diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 61968cf3f..f3aa34610 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -631,6 +631,8 @@ FD3C907127E445E500CD579F /* MessageReceiverDecryptionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.swift */; }; FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */; }; FD42F9A8285064B800A0C77D /* PushNotificationAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */; }; + FD43EE9D297A5190009C87C5 /* SessionUtil+Groups.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD43EE9C297A5190009C87C5 /* SessionUtil+Groups.swift */; }; + FD43EE9F297E2EE0009C87C5 /* SessionUtil+ConvoInfoVolatile.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD43EE9E297E2EE0009C87C5 /* SessionUtil+ConvoInfoVolatile.swift */; }; FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200D283492210034334B /* InsetLockableTableView.swift */; }; FD52090028AF6153006098F6 /* OWSBackgroundTask.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */; }; FD52090128AF61BA006098F6 /* OWSBackgroundTask.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -1751,6 +1753,8 @@ FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReceiverDecryptionSpec.swift; sourceTree = ""; }; FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = ""; }; FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModel.swift; sourceTree = ""; }; + FD43EE9C297A5190009C87C5 /* SessionUtil+Groups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionUtil+Groups.swift"; sourceTree = ""; }; + FD43EE9E297E2EE0009C87C5 /* SessionUtil+ConvoInfoVolatile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionUtil+ConvoInfoVolatile.swift"; sourceTree = ""; }; FD4B200D283492210034334B /* InsetLockableTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetLockableTableView.swift; sourceTree = ""; }; FD52090228B4680F006098F6 /* RadioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioButton.swift; sourceTree = ""; }; FD52090428B4915F006098F6 /* PrivacySettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettingsViewModel.swift; sourceTree = ""; }; @@ -4039,6 +4043,8 @@ children = ( FD8ECF8F29381FC200C0D1BB /* SessionUtil+UserProfile.swift */, FD2B4AFC294688D000AB4848 /* SessionUtil+Contacts.swift */, + FD43EE9E297E2EE0009C87C5 /* SessionUtil+ConvoInfoVolatile.swift */, + FD43EE9C297A5190009C87C5 /* SessionUtil+Groups.swift */, ); path = "Config Handling"; sourceTree = ""; @@ -5674,6 +5680,7 @@ C3C2A7852553AAF300C340D1 /* SessionProtos.pb.swift in Sources */, FDF0B74F28079E5E004C14C5 /* SendReadReceiptsJob.swift in Sources */, FDF0B7422804EA4F004C14C5 /* _002_SetupStandardJobs.swift in Sources */, + FD43EE9F297E2EE0009C87C5 /* SessionUtil+ConvoInfoVolatile.swift in Sources */, B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */, FDF8487F29405994007DCAE5 /* HTTPHeader+OpenGroup.swift in Sources */, FD8ECF7D2934293A00C0D1BB /* _011_SharedUtilChanges.swift in Sources */, @@ -5688,6 +5695,7 @@ FD245C692850666800B966DD /* ExpirationTimerUpdate.swift in Sources */, FD42F9A8285064B800A0C77D /* PushNotificationAPI.swift in Sources */, FD245C6C2850669200B966DD /* MessageReceiveJob.swift in Sources */, + FD43EE9D297A5190009C87C5 /* SessionUtil+Groups.swift in Sources */, FD83B9CC27D179BC005E1583 /* FSEndpoint.swift in Sources */, FD7115F228C6CB3900B47552 /* _010_AddThreadIdToFTS.swift in Sources */, FD716E6428502DDD00C96BF4 /* CallManagerProtocol.swift in Sources */, diff --git a/Session/Calls/Call Management/SessionCall.swift b/Session/Calls/Call Management/SessionCall.swift index 07b01a114..3c4901fc7 100644 --- a/Session/Calls/Call Management/SessionCall.swift +++ b/Session/Calls/Call Management/SessionCall.swift @@ -324,12 +324,20 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { } }() - guard shouldMarkAsRead else { return } + guard + shouldMarkAsRead, + let threadVariant: SessionThread.Variant = try SessionThread + .filter(id: interaction.threadId) + .select(.variant) + .asRequest(of: SessionThread.Variant.self) + .fetchOne(db) + else { return } try Interaction.markAsRead( db, interactionId: interaction.id, threadId: interaction.threadId, + threadVariant: threadVariant, includingOlder: false, trySendReadReceipt: false ) diff --git a/Session/Calls/CallVC.swift b/Session/Calls/CallVC.swift index 49152e981..a568f4088 100644 --- a/Session/Calls/CallVC.swift +++ b/Session/Calls/CallVC.swift @@ -398,7 +398,8 @@ final class CallVC: UIViewController, VideoPreviewDelegate { view.addSubview(titleLabel) titleLabel.translatesAutoresizingMaskIntoConstraints = false titleLabel.center(.vertical, in: minimizeButton) - titleLabel.center(.horizontal, in: view) + titleLabel.pin(.left, to: .left, of: view, withInset: Values.largeSpacing) + titleLabel.pin(.right, to: .right, of: view, withInset: Values.largeSpacing) // Response Panel view.addSubview(responsePanel) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 531d3bb7d..bfe97c1dc 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1108,6 +1108,7 @@ extension ConversationVC: guard cellViewModel.reactionInfo?.isEmpty == false && ( + self.viewModel.threadData.threadVariant == .legacyClosedGroup || self.viewModel.threadData.threadVariant == .closedGroup || self.viewModel.threadData.threadVariant == .openGroup ), @@ -1797,7 +1798,7 @@ extension ConversationVC: self?.showInputAccessoryView() } - case .contact, .closedGroup: + case .contact, .legacyClosedGroup, .closedGroup: let serverHash: String? = Storage.shared.read { db -> String? in try Interaction .select(.serverHash) @@ -1856,7 +1857,7 @@ extension ConversationVC: }) actionSheet.addAction(UIAlertAction( - title: (cellViewModel.threadVariant == .closedGroup ? + title: (cellViewModel.threadVariant == .legacyClosedGroup || cellViewModel.threadVariant == .closedGroup ? "delete_message_for_everyone".localized() : String(format: "delete_message_for_me_and_recipient".localized(), threadName) ), diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 113a6b148..9cf2ba174 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -105,7 +105,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { threadId: self.threadId, threadVariant: self.initialThreadVariant, threadIsNoteToSelf: (self.threadId == getUserHexEncodedPublicKey()), - currentUserIsClosedGroupMember: (self.initialThreadVariant != .closedGroup ? + currentUserIsClosedGroupMember: ((self.initialThreadVariant != .legacyClosedGroup && self.initialThreadVariant != .closedGroup) ? nil : Storage.shared.read { db in GroupMember @@ -406,6 +406,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { 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 @@ -414,6 +415,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { db, interactionId: targetInteractionId, threadId: threadId, + threadVariant: threadVariant, includingOlder: true, trySendReadReceipt: trySendReadReceipt ) diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index 2ee2f9068..c3730fe65 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -269,7 +269,7 @@ final class QuoteView: UIView { contentView.addSubview(mainStackView) mainStackView.pin(to: contentView) - if threadVariant != .openGroup && threadVariant != .closedGroup { + if threadVariant == .contact { bodyLabel.set(.width, to: bodyLabelSize.width) } diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 728a98d55..49f688752 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -305,7 +305,11 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { cellViewModel.isOnlyMessageInCluster ) ) - let isGroupThread: Bool = (cellViewModel.threadVariant == .openGroup || cellViewModel.threadVariant == .closedGroup) + let isGroupThread: Bool = ( + cellViewModel.threadVariant == .openGroup || + cellViewModel.threadVariant == .legacyClosedGroup || + cellViewModel.threadVariant == .closedGroup + ) // Profile picture view profilePictureViewLeadingConstraint.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : 0) @@ -706,6 +710,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { maxWidth: maxWidth, showingAllReactions: showExpandedReactions, showNumbers: ( + cellViewModel.threadVariant == .legacyClosedGroup || cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup ) @@ -1066,6 +1071,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { case .standardIncoming, .standardIncomingDeleted: let isGroupThread = ( cellViewModel.threadVariant == .openGroup || + cellViewModel.threadVariant == .legacyClosedGroup || cellViewModel.threadVariant == .closedGroup ) let leftGutterSize = (isGroupThread ? leftGutterSize : contactThreadHSpacing) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 8ecf5ef77..612bfb602 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -180,7 +180,7 @@ class ThreadSettingsViewModel: SessionTableViewModel (thread.mutedUntilTimestamp ?? 0) else { return } - guard thread.variant != .closedGroup && thread.variant != .openGroup else { return } + guard + thread.variant != .legacyClosedGroup && + thread.variant != .closedGroup && + thread.variant != .openGroup + else { return } guard interaction.variant == .infoCall, let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8), @@ -342,7 +346,11 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { // No reaction notifications for muted, group threads or message requests guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } - guard thread.variant != .closedGroup && thread.variant != .openGroup else { return } + guard + thread.variant != .legacyClosedGroup && + thread.variant != .closedGroup && + thread.variant != .openGroup + else { return } guard !isMessageRequest else { return } let senderName: String = Profile.displayName(db, id: reaction.authorId, threadVariant: thread.variant) @@ -551,6 +559,7 @@ class NotificationActionHandler { db, interactionId: interaction.id, threadId: thread.id, + threadVariant: thread.variant, includingOlder: true, trySendReadReceipt: true ) @@ -607,6 +616,7 @@ class NotificationActionHandler { .asRequest(of: Int64.self) .fetchOne(db), threadId: thread.id, + threadVariant: thread.variant, includingOlder: true, trySendReadReceipt: true ) diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index e14d31b6a..01197d547 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -308,12 +308,12 @@ public final class FullConversationCell: UITableViewCell { switch cellViewModel.threadVariant { case .contact, .openGroup: bottomLabelStackView.isHidden = true - case .closedGroup: + case .legacyClosedGroup, .closedGroup: bottomLabelStackView.isHidden = (cellViewModel.threadMemberNames ?? "").isEmpty ThemeManager.onThemeChange(observer: displayNameLabel) { [weak self, weak snippetLabel] theme, _ in guard let textColor: UIColor = theme.color(for: .textPrimary) else { return } - if cellViewModel.threadVariant == .closedGroup { + if cellViewModel.threadVariant == .legacyClosedGroup || cellViewModel.threadVariant == .closedGroup { snippetLabel?.attributedText = self?.getHighlightedSnippet( content: (cellViewModel.threadMemberNames ?? ""), currentUserPublicKey: cellViewModel.currentUserPublicKey, @@ -354,8 +354,11 @@ public final class FullConversationCell: UITableViewCell { ofSize: (unreadCount < 10000 ? Values.verySmallFontSize : 8) ) hasMentionView.isHidden = !( - ((cellViewModel.threadUnreadMentionCount ?? 0) > 0) && - (cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup) + ((cellViewModel.threadUnreadMentionCount ?? 0) > 0) && ( + cellViewModel.threadVariant == .legacyClosedGroup || + cellViewModel.threadVariant == .closedGroup || + cellViewModel.threadVariant == .openGroup + ) ) profilePictureView.update( publicKey: cellViewModel.threadId, @@ -454,7 +457,7 @@ public final class FullConversationCell: UITableViewCell { )) } - if cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup { + if cellViewModel.threadVariant == .legacyClosedGroup || cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup { let authorName: String = cellViewModel.authorName(for: cellViewModel.threadVariant) result.append(NSAttributedString( diff --git a/Session/Shared/SessionTableViewController.swift b/Session/Shared/SessionTableViewController.swift index 81669ce34..665a14db4 100644 --- a/Session/Shared/SessionTableViewController.swift +++ b/Session/Shared/SessionTableViewController.swift @@ -609,6 +609,7 @@ class SessionTableViewController? = try SessionUtil.loadState( + for: .convoInfoVolatile, + secretKey: secretKey, + cachedData: nil + ) + let convoInfoVolatileConfResult: SessionUtil.ConfResult = try SessionUtil.upsert( + convoInfoVolatileChanges: volatileThreadInfo, + in: Atomic(convoInfoVolatileConf) + ) + + if convoInfoVolatileConfResult.needsDump { + try SessionUtil + .createDump( + conf: contactsConf, + for: .convoInfoVolatile, + publicKey: userPublicKey, + messageHashes: nil + )? + .save(db) + } Storage.update(progress: 1, for: self, in: target) // In case this is the last migration } } diff --git a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift index 30ccf219c..5c18b9417 100644 --- a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift +++ b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift @@ -17,11 +17,17 @@ public struct DisappearingMessagesConfiguration: Codable, Identifiable, Equatabl case durationSeconds } + public enum DisappearingMessageType: Int, Codable, Hashable, DatabaseValueConvertible { + case disappearAfterRead + case disappearAfterSend + } + public var id: String { threadId } // Identifiable public let threadId: String public let isEnabled: Bool public let durationSeconds: TimeInterval + public var type: DisappearingMessageType? { return nil } // TODO: Add as part of Disappearing Message Rebuild // MARK: - Relationships @@ -45,7 +51,8 @@ public extension DisappearingMessagesConfiguration { func with( isEnabled: Bool? = nil, - durationSeconds: TimeInterval? = nil + durationSeconds: TimeInterval? = nil, + type: DisappearingMessageType? = nil ) -> DisappearingMessagesConfiguration { return DisappearingMessagesConfiguration( threadId: threadId, diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 83782c4b7..f90b7ae9d 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -85,6 +85,10 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu // MARK: - Convenience + public static let variantsToIncrementUnreadCount: [Variant] = [ + .standardIncoming, .infoCall + ] + public var isInfoMessage: Bool { switch self { case .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft, @@ -349,7 +353,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu state: .sending ).insert(db) - case .closedGroup: + case .legacyClosedGroup, .closedGroup: let closedGroupMemberIds: Set = (try? GroupMember .select(.profileId) .filter(GroupMember.Columns.groupId == threadId) @@ -445,13 +449,28 @@ public extension Interaction { _ db: Database, interactionId: Int64?, threadId: String, + threadVariant: SessionThread.Variant, includingOlder: Bool, trySendReadReceipt: Bool ) throws { guard let interactionId: Int64 = interactionId else { return } // Once all of the below is done schedule the jobs - func scheduleJobs(interactionIds: [Int64]) { + func scheduleJobs( + _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, + interactionIds: [Int64], + lastReadTimestampMs: Int64 + ) throws { + // Update the last read timestamp if needed + try SessionUtil.syncThreadLastReadIfNeeded( + db, + threadId: threadId, + threadVariant: threadVariant, + lastReadTimestampMs: lastReadTimestampMs + ) + // Add the 'DisappearingMessagesJob' if needed - this will update any expiring // messages `expiresStartedAtMs` values JobRunner.upsert( @@ -510,13 +529,22 @@ public extension Interaction { guard includingOlder, let interactionInfo: InteractionReadInfo = maybeInteractionInfo else { // Only mark as read and trigger the subsequent jobs if the interaction is // actually not read (no point updating and triggering db changes otherwise) - guard maybeInteractionInfo?.wasRead == false else { return } + guard + maybeInteractionInfo?.wasRead == false, + let timestampMs: Int64 = maybeInteractionInfo?.timestampMs + else { return } _ = try Interaction .filter(id: interactionId) .updateAll(db, Columns.wasRead.set(to: true)) - scheduleJobs(interactionIds: [interactionId]) + try scheduleJobs( + db, + threadId: threadId, + threadVariant: threadVariant, + interactionIds: [interactionId], + lastReadTimestampMs: timestampMs + ) return } @@ -533,7 +561,13 @@ public extension Interaction { // for this interaction (need to ensure the disapeparing messages run for sync'ed // outgoing messages which will always have 'wasRead' as false) guard !interactionIdsToMarkAsRead.isEmpty else { - scheduleJobs(interactionIds: [interactionId]) + try scheduleJobs( + db, + threadId: threadId, + threadVariant: threadVariant, + interactionIds: [interactionId], + lastReadTimestampMs: interactionInfo.timestampMs + ) return } @@ -541,13 +575,19 @@ public extension Interaction { try interactionQuery.updateAll(db, Columns.wasRead.set(to: true)) // Retrieve the interaction ids we want to update - scheduleJobs(interactionIds: interactionIdsToMarkAsRead) + try scheduleJobs( + db, + threadId: threadId, + threadVariant: threadVariant, + interactionIds: interactionIdsToMarkAsRead, + lastReadTimestampMs: interactionInfo.timestampMs + ) } /// This method flags sent messages as read for the specified recipients /// /// **Note:** This method won't update the 'wasRead' flag (it will be updated via the above method) - static func markAsRead(_ db: Database, recipientId: String, timestampMsValues: [Double], readTimestampMs: Double) throws { + static func markAsRecipientRead(_ db: Database, recipientId: String, timestampMsValues: [Double], readTimestampMs: Double) throws { guard db[.areReadReceiptsEnabled] == true else { return } try RecipientState diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 533c3657f..737d78cd6 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -324,7 +324,7 @@ public extension Profile { } switch threadVariant { - case .contact, .closedGroup: return name + case .contact, .legacyClosedGroup, .closedGroup: return name case .openGroup: // In open groups, where it's more likely that multiple users have the same name, diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index ca08aa182..b5ddd28be 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -32,12 +32,14 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, case notificationSound case mutedUntilTimestamp case onlyNotifyForMentions + case markedAsUnread } public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible { case contact - case closedGroup + case legacyClosedGroup case openGroup + case closedGroup } /// Unique identifier for a thread (formerly known as uniqueId) @@ -74,6 +76,9 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, /// A flag indicating whether the thread should only notify for mentions public let onlyNotifyForMentions: Bool + /// A flag indicating whether this thread has been manually marked as unread by the user + public let markedAsUnread: Bool? + // MARK: - Relationships public var contact: QueryInterfaceRequest { @@ -111,7 +116,8 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, messageDraft: String? = nil, notificationSound: Preferences.Sound? = nil, mutedUntilTimestamp: TimeInterval? = nil, - onlyNotifyForMentions: Bool = false + onlyNotifyForMentions: Bool = false, + markedAsUnread: Bool? = false ) { self.id = id self.variant = variant @@ -122,6 +128,7 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, self.notificationSound = notificationSound self.mutedUntilTimestamp = mutedUntilTimestamp self.onlyNotifyForMentions = onlyNotifyForMentions + self.markedAsUnread = markedAsUnread } // MARK: - Custom Database Interaction @@ -147,7 +154,8 @@ public extension SessionThread { messageDraft: messageDraft, notificationSound: notificationSound, mutedUntilTimestamp: mutedUntilTimestamp, - onlyNotifyForMentions: onlyNotifyForMentions + onlyNotifyForMentions: onlyNotifyForMentions, + markedAsUnread: markedAsUnread ) } } @@ -304,7 +312,7 @@ public extension SessionThread { profile: Profile? = nil ) -> String { switch variant { - case .closedGroup: return (closedGroupName ?? "Unknown Group") + case .legacyClosedGroup, .closedGroup: return (closedGroupName ?? "Unknown Group") case .openGroup: return (openGroupName ?? "Unknown Group") case .contact: guard !isNoteToSelf else { return "NOTE_TO_SELF".localized() } diff --git a/SessionMessagingKit/Database/Models/SharedConfigDump.swift b/SessionMessagingKit/Database/Models/SharedConfigDump.swift index 90b92befb..5b424eb8d 100644 --- a/SessionMessagingKit/Database/Models/SharedConfigDump.swift +++ b/SessionMessagingKit/Database/Models/SharedConfigDump.swift @@ -19,6 +19,8 @@ public struct ConfigDump: Codable, Equatable, Hashable, FetchableRecord, Persist public enum Variant: String, Codable, DatabaseValueConvertible { case userProfile case contacts + case convoInfoVolatile + case groups } /// The type of config this dump is for @@ -64,12 +66,14 @@ public extension ConfigDump { } public extension ConfigDump.Variant { - static let userVariants: [ConfigDump.Variant] = [ .userProfile, .contacts ] + static let userVariants: [ConfigDump.Variant] = [ .userProfile, .contacts, .convoInfoVolatile, .groups ] var configMessageKind: SharedConfigMessage.Kind { switch self { case .userProfile: return .userProfile case .contacts: return .contacts + case .convoInfoVolatile: return .convoInfoVolatile + case .groups: return .groups } } @@ -77,6 +81,8 @@ public extension ConfigDump.Variant { switch self { case .userProfile: return SnodeAPI.Namespace.configUserProfile case .contacts: return SnodeAPI.Namespace.configContacts + case .convoInfoVolatile: return SnodeAPI.Namespace.configConvoInfoVolatile + case .groups: return SnodeAPI.Namespace.configGroups } } } diff --git a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift index 020aa27fc..0bbbc6bc8 100644 --- a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift @@ -120,6 +120,7 @@ public extension SendReadReceiptsJob { .joining( // Don't send read receipts in group threads required: Interaction.thread + .filter(SessionThread.Columns.variant != SessionThread.Variant.legacyClosedGroup) .filter(SessionThread.Columns.variant != SessionThread.Variant.closedGroup) .filter(SessionThread.Columns.variant != SessionThread.Variant.openGroup) ) diff --git a/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+Contacts.swift b/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+Contacts.swift index 703d6fe63..b9cf7951f 100644 --- a/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+Contacts.swift +++ b/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+Contacts.swift @@ -11,11 +11,17 @@ internal extension SessionUtil { static func handleContactsUpdate( _ db: Database, in atomicConf: Atomic?>, - needsDump: Bool - ) throws { - typealias ContactData = [String: (contact: Contact, profile: Profile)] + mergeResult: ConfResult + ) throws -> ConfResult { + typealias ContactData = [ + String: ( + contact: Contact, + profile: Profile, + isHiddenConversation: Bool + ) + ] - guard needsDump else { return } + guard mergeResult.needsDump else { return mergeResult } guard atomicConf.wrappedValue != nil else { throw SessionUtilError.nilConfigObject } // Since we are doing direct memory manipulation we are using an `Atomic` type which has @@ -47,7 +53,11 @@ internal extension SessionUtil { ) ) - contactData[contactId] = (contactResult, profileResult) + contactData[contactId] = ( + contactResult, + profileResult, + false + ) contacts_iterator_advance(contactIterator) } contacts_iterator_free(contactIterator) // Need to free the iterator @@ -61,7 +71,7 @@ internal extension SessionUtil { let targetContactData: ContactData = contactData.filter { $0.key != userPublicKey } // If we only updated the current user contact then no need to continue - guard !targetContactData.isEmpty else { return } + guard !targetContactData.isEmpty else { return mergeResult } // Since we don't sync 100% of the data stored against the contact and profile objects we // need to only update the data we do have to ensure we don't overwrite anything that doesn't @@ -107,8 +117,8 @@ internal extension SessionUtil { /// swapping `isApproved` and `didApproveMe` to `false` if (contact.isApproved != data.contact.isApproved) || - (contact.isBlocked != data.contact.isBlocked) || - (contact.didApproveMe != data.contact.didApproveMe) + (contact.isBlocked != data.contact.isBlocked) || + (contact.didApproveMe != data.contact.didApproveMe) { try contact.save(db) try Contact @@ -116,17 +126,21 @@ internal extension SessionUtil { .updateAll( // Handling a config update so don't use `updateAllAndConfig` db, [ - (!data.contact.isApproved ? nil : + (!data.contact.isApproved || contact.isApproved == data.contact.isApproved ? nil : Contact.Columns.isApproved.set(to: true) ), - Contact.Columns.isBlocked.set(to: data.contact.isBlocked), - (!data.contact.didApproveMe ? nil : + (contact.isBlocked == data.contact.isBlocked ? nil : + Contact.Columns.isBlocked.set(to: data.contact.isBlocked) + ), + (!data.contact.didApproveMe || contact.didApproveMe == data.contact.didApproveMe ? nil : Contact.Columns.didApproveMe.set(to: true) ) ].compactMap { $0 } ) } } + + return mergeResult } // MARK: - Outgoing Changes diff --git a/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+ConvoInfoVolatile.swift b/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+ConvoInfoVolatile.swift new file mode 100644 index 000000000..8119f3b24 --- /dev/null +++ b/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+ConvoInfoVolatile.swift @@ -0,0 +1,614 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtil +import SessionUtilitiesKit + +internal extension SessionUtil { + // MARK: - Incoming Changes + + static func handleConvoInfoVolatileUpdate( + _ db: Database, + in atomicConf: Atomic?>, + mergeResult: ConfResult + ) throws -> ConfResult { + guard mergeResult.needsDump else { return mergeResult } + guard atomicConf.wrappedValue != nil else { throw SessionUtilError.nilConfigObject } + + // Since we are doing direct memory manipulation we are using an `Atomic` type which has + // blocking access in it's `mutate` closure + let volatileThreadInfo: [VolatileThreadInfo] = atomicConf.mutate { conf -> [VolatileThreadInfo] in + var volatileThreadInfo: [VolatileThreadInfo] = [] + var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1() + var openGroup: convo_info_volatile_open = convo_info_volatile_open() + var legacyClosedGroup: convo_info_volatile_legacy_closed = convo_info_volatile_legacy_closed() + let convoIterator: OpaquePointer = convo_info_volatile_iterator_new(conf) + + while !convo_info_volatile_iterator_done(convoIterator) { + if convo_info_volatile_it_is_1to1(convoIterator, &oneToOne) { + let sessionId: String = String(cString: withUnsafeBytes(of: oneToOne.session_id) { [UInt8]($0) } + .map { CChar($0) } + .nullTerminated() + ) + + volatileThreadInfo.append( + VolatileThreadInfo( + threadId: sessionId, + variant: .contact, + changes: [ + .markedAsUnread(oneToOne.unread), + .lastReadTimestampMs(oneToOne.last_read) + ] + ) + ) + } + else if convo_info_volatile_it_is_open(convoIterator, &openGroup) { + let server: String = String(cString: withUnsafeBytes(of: openGroup.base_url) { [UInt8]($0) } + .map { CChar($0) } + .nullTerminated() + ) + let roomToken: String = String(cString: withUnsafeBytes(of: openGroup.room) { [UInt8]($0) } + .map { CChar($0) } + .nullTerminated() + ) + let publicKey: String = String(cString: withUnsafeBytes(of: openGroup.pubkey) { [UInt8]($0) } + .map { CChar($0) } + .nullTerminated() + ) + + // 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), + variant: .openGroup, + openGroupUrlInfo: VolatileThreadInfo.OpenGroupUrlInfo( + threadId: OpenGroup.idFor(roomToken: roomToken, server: server), + server: server, + roomToken: roomToken, + publicKey: publicKey + ), + changes: [ + .markedAsUnread(openGroup.unread), + .lastReadTimestampMs(openGroup.last_read) + ] + ) + ) + } + else if convo_info_volatile_it_is_legacy_closed(convoIterator, &legacyClosedGroup) { + let groupId: String = String(cString: withUnsafeBytes(of: legacyClosedGroup.group_id) { [UInt8]($0) } + .map { CChar($0) } + .nullTerminated() + ) + + volatileThreadInfo.append( + VolatileThreadInfo( + threadId: groupId, + variant: .legacyClosedGroup, + changes: [ + .markedAsUnread(legacyClosedGroup.unread), + .lastReadTimestampMs(legacyClosedGroup.last_read) + ] + ) + ) + } + else { + SNLog("Ignoring unknown conversation type when iterating through volatile conversation info update") + } + + convo_info_volatile_iterator_advance(convoIterator) + } + convo_info_volatile_iterator_free(convoIterator) // Need to free the iterator + + return volatileThreadInfo + } + + // If we don't have any conversations then no need to continue + guard !volatileThreadInfo.isEmpty else { return mergeResult } + + // Get the local volatile thread info from all conversations + let localVolatileThreadInfo: [String: VolatileThreadInfo] = VolatileThreadInfo.fetchAll(db) + .reduce(into: [:]) { result, next in result[next.threadId] = next } + + // Map the volatileThreadInfo, upserting any changes and returning a list of local changes + // 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) + guard + let threadId: String = try? SessionThread + .select(.id) + .filter(SessionThread.Columns.id.lowercased == threadInfo.threadId) + .asRequest(of: String.self) + .fetchOne(db) + else { return nil } + + + // Get the existing local state for the thread + let localThreadInfo: VolatileThreadInfo? = localVolatileThreadInfo[threadId] + + // Update the thread 'markedAsUnread' state + if + let markedAsUnread: Bool = threadInfo.changes.markedAsUnread, + markedAsUnread != (localThreadInfo?.changes.markedAsUnread ?? false) + { + try SessionThread + .filter(id: threadId) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + SessionThread.Columns.markedAsUnread.set(to: markedAsUnread) + ) + } + + // If the device has a more recent read interaction then return the info so we can + // update the cached config state accordingly + guard + let lastReadTimestampMs: Int64 = threadInfo.changes.lastReadTimestampMs, + lastReadTimestampMs > (localThreadInfo?.changes.lastReadTimestampMs ?? 0) + else { + // We only want to return the 'lastReadTimestampMs' change, since the local state + // should win in that case, so ignore all others + return localThreadInfo? + .filterChanges { change in + switch change { + case .lastReadTimestampMs: return true + default: return false + } + } + } + + // Mark all older interactions as read + try Interaction + .filter( + Interaction.Columns.threadId == threadId && + Interaction.Columns.timestampMs <= lastReadTimestampMs + ) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + Interaction.Columns.wasRead.set(to: true) + ) + return nil + } + + // If there are no newer local last read timestamps then just return the mergeResult + guard !newerLocalChanges.isEmpty else { return mergeResult } + + return try upsert( + convoInfoVolatileChanges: newerLocalChanges, + in: atomicConf + ) + } + + static func upsert( + convoInfoVolatileChanges: [VolatileThreadInfo], + in atomicConf: Atomic?> + ) throws -> ConfResult { + // Since we are doing direct memory manipulation we are using an `Atomic` type which has + // blocking access in it's `mutate` closure + return atomicConf.mutate { conf in + convoInfoVolatileChanges.forEach { threadInfo in + var cThreadId: [CChar] = threadInfo.cThreadId + + switch threadInfo.variant { + case .contact: + var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1() + + guard convo_info_volatile_get_or_construct_1to1(conf, &oneToOne, &cThreadId) else { + SNLog("Unable to create contact conversation when updating last read timestamp") + return + } + + threadInfo.changes.forEach { change in + switch change { + case .lastReadTimestampMs(let lastReadMs): + oneToOne.last_read = lastReadMs + + case .markedAsUnread(let unread): + oneToOne.unread = unread + } + } + convo_info_volatile_set_1to1(conf, &oneToOne) + + case .legacyClosedGroup: + var legacyClosedGroup: convo_info_volatile_legacy_closed = convo_info_volatile_legacy_closed() + + guard convo_info_volatile_get_or_construct_legacy_closed(conf, &legacyClosedGroup, &cThreadId) else { + SNLog("Unable to create legacy group conversation when updating last read timestamp") + return + } + + threadInfo.changes.forEach { change in + switch change { + case .lastReadTimestampMs(let lastReadMs): + legacyClosedGroup.last_read = lastReadMs + + case .markedAsUnread(let unread): + legacyClosedGroup.unread = unread + } + } + convo_info_volatile_set_legacy_closed(conf, &legacyClosedGroup) + + case .openGroup: + guard + var cBaseUrl: [CChar] = threadInfo.cBaseUrl, + var cRoomToken: [CChar] = threadInfo.cRoomToken, + var cPubkey: [CChar] = threadInfo.cPubkey + else { + SNLog("Unable to create community conversation when updating last read timestamp due to missing URL info") + return + } + + var openGroup: convo_info_volatile_open = convo_info_volatile_open() + + guard convo_info_volatile_get_or_construct_open(conf, &openGroup, &cBaseUrl, &cRoomToken, &cPubkey) else { + SNLog("Unable to create legacy group conversation when updating last read timestamp") + return + } + + threadInfo.changes.forEach { change in + switch change { + case .lastReadTimestampMs(let lastReadMs): + openGroup.last_read = lastReadMs + + case .markedAsUnread(let unread): + openGroup.unread = unread + } + } + convo_info_volatile_set_open(conf, &openGroup) + + case .closedGroup: return // TODO: Need to add when the type is added to the lib + } + } + + return ConfResult( + needsPush: config_needs_push(conf), + needsDump: config_needs_dump(conf) + ) + } + } +} + +// MARK: - Convenience + +internal extension SessionUtil { + static func updatingThreads(_ db: Database, _ updated: [T]) throws -> [T] { + guard let updatedThreads: [SessionThread] = updated as? [SessionThread] else { + throw StorageError.generic + } + + // If we have no updated threads then no need to continue + guard !updatedThreads.isEmpty else { return updated } + + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let changes: [VolatileThreadInfo] = try updatedThreads.map { thread in + VolatileThreadInfo( + threadId: thread.id, + variant: thread.variant, + openGroupUrlInfo: (thread.variant != .openGroup ? nil : + try VolatileThreadInfo.OpenGroupUrlInfo.fetchOne(db, id: thread.id) + ), + changes: [.markedAsUnread(thread.markedAsUnread ?? false)] + ) + } + + db.afterNextTransaction { db in + do { + let atomicConf: Atomic?> = SessionUtil.config( + for: .convoInfoVolatile, + publicKey: userPublicKey + ) + let result: ConfResult = try upsert( + convoInfoVolatileChanges: changes, + in: atomicConf + ) + + // If we don't need to dump the data the we can finish early + guard result.needsDump else { return } + + try SessionUtil.saveState( + db, + keepingExistingMessageHashes: true, + configDump: try atomicConf.mutate { conf in + try SessionUtil.createDump( + conf: conf, + for: .convoInfoVolatile, + publicKey: userPublicKey, + messageHashes: nil + ) + } + ) + } + catch { + SNLog("[libSession-util] Failed to dump updated data") + } + } + + return updated + } + + static func syncThreadLastReadIfNeeded( + _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, + lastReadTimestampMs: Int64 + ) throws { + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let atomicConf: Atomic?> = SessionUtil.config( + for: .convoInfoVolatile, + publicKey: userPublicKey + ) + let change: VolatileThreadInfo = VolatileThreadInfo( + threadId: threadId, + variant: threadVariant, + openGroupUrlInfo: (threadVariant != .openGroup ? nil : + try VolatileThreadInfo.OpenGroupUrlInfo.fetchOne(db, id: threadId) + ), + changes: [.lastReadTimestampMs(lastReadTimestampMs)] + ) + + // Update the conf + let result: ConfResult = try upsert( + convoInfoVolatileChanges: [change], + in: atomicConf + ) + + // If we need to dump then do so here + if result.needsDump { + try SessionUtil.saveState( + db, + keepingExistingMessageHashes: true, + configDump: try atomicConf.mutate { conf in + try SessionUtil.createDump( + conf: conf, + for: .contacts, + publicKey: userPublicKey, + messageHashes: nil + ) + } + ) + } + + // If we need to push then enqueue a 'ConfigurationSyncJob' + if result.needsPush { + ConfigurationSyncJob.enqueue(db) + } + } + + static func timestampAlreadyRead( + threadId: String, + threadVariant: SessionThread.Variant, + timestampMs: Int64, + userPublicKey: String, + openGroup: OpenGroup? + ) -> Bool { + let atomicConf: Atomic?> = SessionUtil.config( + for: .convoInfoVolatile, + publicKey: userPublicKey + ) + + // If we don't have a config then just assume it's unread + guard atomicConf.wrappedValue != nil else { return false } + + // Since we are doing direct memory manipulation we are using an `Atomic` type which has + // blocking access in it's `mutate` closure + return atomicConf.mutate { conf in + switch threadVariant { + case .contact: + var cThreadId: [CChar] = threadId + .bytes + .map { CChar(bitPattern: $0) } + var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1() + guard convo_info_volatile_get_1to1(conf, &oneToOne, &cThreadId) else { return false } + + return (oneToOne.last_read > timestampMs) + + case .legacyClosedGroup: + var cThreadId: [CChar] = threadId + .bytes + .map { CChar(bitPattern: $0) } + var legacyClosedGroup: convo_info_volatile_legacy_closed = convo_info_volatile_legacy_closed() + + guard convo_info_volatile_get_legacy_closed(conf, &legacyClosedGroup, &cThreadId) else { + return false + } + + return (legacyClosedGroup.last_read > timestampMs) + + case .openGroup: + guard let openGroup: OpenGroup = openGroup else { return false } + + var cBaseUrl: [CChar] = openGroup.server + .bytes + .map { CChar(bitPattern: $0) } + var cRoomToken: [CChar] = openGroup.roomToken + .bytes + .map { CChar(bitPattern: $0) } + var cPubKey: [CChar] = openGroup.publicKey + .bytes + .map { CChar(bitPattern: $0) } + var convoOpenGroup: convo_info_volatile_open = convo_info_volatile_open() + + guard convo_info_volatile_get_open(conf, &convoOpenGroup, &cBaseUrl, &cRoomToken, &cPubKey) else { + return false + } + + return (convoOpenGroup.last_read > timestampMs) + + case .closedGroup: return false // TODO: Need to add when the type is added to the lib + } + } + } +} + +// MARK: - VolatileThreadInfo + +public extension SessionUtil { + struct VolatileThreadInfo { + enum Change { + case markedAsUnread(Bool) + case lastReadTimestampMs(Int64) + } + + fileprivate struct OpenGroupUrlInfo: FetchableRecord, Codable, Hashable { + let threadId: String + let server: String + let roomToken: String + let publicKey: String + + static func fetchOne(_ db: Database, id: String) throws -> OpenGroupUrlInfo? { + return try OpenGroup + .filter(id: id) + .select(.threadId, .server, .roomToken, .publicKey) + .asRequest(of: OpenGroupUrlInfo.self) + .fetchOne(db) + } + } + + let threadId: String + let variant: SessionThread.Variant + private let openGroupUrlInfo: OpenGroupUrlInfo? + let changes: [Change] + + var cThreadId: [CChar] { + threadId.bytes.map { CChar(bitPattern: $0) } + } + var cBaseUrl: [CChar]? { + (openGroupUrlInfo?.server).map { + $0.bytes.map { CChar(bitPattern: $0) } + } + } + var cRoomToken: [CChar]? { + (openGroupUrlInfo?.roomToken).map { + $0.bytes.map { CChar(bitPattern: $0) } + } + } + var cPubkey: [CChar]? { + (openGroupUrlInfo?.publicKey).map { + $0.bytes.map { CChar(bitPattern: $0) } + } + } + + fileprivate init( + threadId: String, + variant: SessionThread.Variant, + openGroupUrlInfo: OpenGroupUrlInfo? = nil, + changes: [Change] + ) { + self.threadId = threadId + self.variant = variant + self.openGroupUrlInfo = openGroupUrlInfo + self.changes = changes + } + + // MARK: - Convenience + + func filterChanges(isIncluded: (Change) -> Bool) -> VolatileThreadInfo { + return VolatileThreadInfo( + threadId: threadId, + variant: variant, + openGroupUrlInfo: openGroupUrlInfo, + changes: changes.filter(isIncluded) + ) + } + + static func fetchAll(_ db: Database? = nil, ids: [String]? = nil) -> [VolatileThreadInfo] { + guard let db: Database = db else { + return Storage.shared + .read { db in fetchAll(db, ids: ids) } + .defaulting(to: []) + } + + struct FetchedInfo: FetchableRecord, Codable, Hashable { + let id: String + let variant: SessionThread.Variant + let markedAsUnread: Bool? + let timestampMs: Int64? + let server: String? + let roomToken: String? + let publicKey: String? + } + + let thread: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + let openGroup: TypedTableAlias = TypedTableAlias() + let request: SQLRequest = """ + SELECT + \(thread[.id]), + \(thread[.variant]), + \(thread[.markedAsUnread]), + MAX(\(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 \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) + \(ids == nil ? SQL("") : + "WHERE \(SQL("\(thread[.id]) IN \(ids ?? [])"))" + ) + """ + + return ((try? request.fetchAll(db)) ?? []) + .map { threadInfo in + VolatileThreadInfo( + threadId: threadInfo.id, + variant: threadInfo.variant, + openGroupUrlInfo: { + guard + let server: String = threadInfo.server, + let roomToken: String = threadInfo.roomToken, + let publicKey: String = threadInfo.publicKey + else { return nil } + + return VolatileThreadInfo.OpenGroupUrlInfo( + threadId: threadInfo.id, + server: server, + roomToken: roomToken, + publicKey: publicKey + ) + }(), + changes: [ + .markedAsUnread(threadInfo.markedAsUnread ?? false), + .lastReadTimestampMs(threadInfo.timestampMs ?? 0) + ] + ) + } + } + } +} + +fileprivate extension [SessionUtil.VolatileThreadInfo.Change] { + var markedAsUnread: Bool? { + for change in self { + switch change { + case .markedAsUnread(let value): return value + default: continue + } + } + + return nil + } + + var lastReadTimestampMs: Int64? { + for change in self { + switch change { + case .lastReadTimestampMs(let value): return value + default: continue + } + } + + return nil + } +} diff --git a/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+Groups.swift b/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+Groups.swift new file mode 100644 index 000000000..76cbbcd6f --- /dev/null +++ b/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+Groups.swift @@ -0,0 +1,19 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtil +import SessionUtilitiesKit + +internal extension SessionUtil { + // MARK: - Incoming Changes + + static func handleGroupsUpdate( + _ db: Database, + in atomicConf: Atomic?>, + mergeResult: ConfResult + ) throws -> ConfResult { + // TODO: This + return mergeResult + } +} diff --git a/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+UserProfile.swift b/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+UserProfile.swift index d89132c58..8b21c6372 100644 --- a/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+UserProfile.swift +++ b/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+UserProfile.swift @@ -11,12 +11,12 @@ internal extension SessionUtil { static func handleUserProfileUpdate( _ db: Database, in atomicConf: Atomic?>, - needsDump: Bool, + mergeResult: ConfResult, latestConfigUpdateSentTimestamp: TimeInterval - ) throws { + ) throws -> ConfResult { typealias ProfileData = (profileName: String, profilePictureUrl: String?, profilePictureKey: Data?) - guard needsDump else { return } + guard mergeResult.needsDump else { return mergeResult } guard atomicConf.wrappedValue != nil else { throw SessionUtilError.nilConfigObject } let userPublicKey: String = getUserHexEncodedPublicKey(db) @@ -52,7 +52,7 @@ internal extension SessionUtil { } // Only save the data in the database if it's valid - guard let profileData: ProfileData = maybeProfileData else { return } + guard let profileData: ProfileData = maybeProfileData else { return mergeResult } // Handle user profile changes try ProfileManager.updateProfileIfNeeded( @@ -90,6 +90,8 @@ internal extension SessionUtil { Contact.Columns.didApproveMe.set(to: true) ) } + + return mergeResult } // MARK: - Outgoing Changes diff --git a/SessionMessagingKit/LibSessionUtil/Database/QueryInterfaceRequest+Utilities.swift b/SessionMessagingKit/LibSessionUtil/Database/QueryInterfaceRequest+Utilities.swift index e186e0027..0c4b7acdb 100644 --- a/SessionMessagingKit/LibSessionUtil/Database/QueryInterfaceRequest+Utilities.swift +++ b/SessionMessagingKit/LibSessionUtil/Database/QueryInterfaceRequest+Utilities.swift @@ -26,6 +26,10 @@ public extension QueryInterfaceRequest { case let profileRequest as QueryInterfaceRequest: return try profileRequest.updateAndFetchAllAndUpdateConfig(db, assignments).count + + case let threadRequest as QueryInterfaceRequest: + return try threadRequest.updateAndFetchAllAndUpdateConfig(db, assignments).count + default: return try self.updateAll(db, assignments) } @@ -73,6 +77,9 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table case is QueryInterfaceRequest: return try SessionUtil.updatingProfiles(db, try updateAndFetchAll(db, assignments)) + case is QueryInterfaceRequest: + return try SessionUtil.updatingThreads(db, try updateAndFetchAll(db, assignments)) + default: return try self.updateAndFetchAll(db, assignments) } } diff --git a/SessionMessagingKit/LibSessionUtil/SessionUtil.swift b/SessionMessagingKit/LibSessionUtil/SessionUtil.swift index 6d95d9a5a..44ba49187 100644 --- a/SessionMessagingKit/LibSessionUtil/SessionUtil.swift +++ b/SessionMessagingKit/LibSessionUtil/SessionUtil.swift @@ -17,6 +17,8 @@ public enum SessionUtil { let needsDump: Bool let messageHashes: [String] let latestSentTimestamp: TimeInterval + + var result: ConfResult { ConfResult(needsPush: needsPush, needsDump: needsDump) } } public struct OutgoingConfResult { @@ -46,7 +48,11 @@ public enum SessionUtil { public static var needsSync: Bool { return configStore .wrappedValue - .contains { _, atomicConf in config_needs_push(atomicConf.wrappedValue) } + .contains { _, atomicConf in + guard atomicConf.wrappedValue != nil else { return false } + + return config_needs_push(atomicConf.wrappedValue) + } } // MARK: - Loading @@ -121,9 +127,12 @@ public enum SessionUtil { switch variant { case .userProfile: return user_profile_init(&conf, &secretKey, cachedDump?.data, (cachedDump?.length ?? 0), error) - + case .contacts: return contacts_init(&conf, &secretKey, cachedDump?.data, (cachedDump?.length ?? 0), error) + + case .convoInfoVolatile: + return convo_info_volatile_init(&conf, &secretKey, cachedDump?.data, (cachedDump?.length ?? 0), error) } }() @@ -229,7 +238,10 @@ public enum SessionUtil { ) // Check if the config needs to be pushed - guard config_needs_push(atomicConf.wrappedValue) else { return nil } + guard + atomicConf.wrappedValue != nil && + config_needs_push(atomicConf.wrappedValue) + else { return nil } var toPush: UnsafeMutablePointer? = nil var toPushLen: Int = 0 @@ -266,6 +278,8 @@ public enum SessionUtil { Atomic(nil) ) + guard atomicConf.wrappedValue != nil else { return false } + // Mark the config as pushed config_confirm_pushed(atomicConf.wrappedValue, message.seqNo) @@ -289,7 +303,7 @@ public enum SessionUtil { .grouped(by: \.kind) // Merge the config messages into the current state - let results: [ConfigDump.Variant: IncomingConfResult] = groupedMessages + let mergeResults: [ConfigDump.Variant: IncomingConfResult] = groupedMessages .reduce(into: [:]) { result, next in let key: ConfigKey = ConfigKey(variant: next.key.configDumpVariant, publicKey: publicKey) let atomicConf: Atomic?> = ( @@ -327,28 +341,43 @@ public enum SessionUtil { } // Process the results from the merging - try results.forEach { variant, result in + let finalResults: [ConfResult] = try mergeResults.map { variant, mergeResult in let key: ConfigKey = ConfigKey(variant: variant, publicKey: publicKey) let atomicConf: Atomic?> = ( SessionUtil.configStore.wrappedValue[key] ?? Atomic(nil) ) + var finalResult: ConfResult = mergeResult.result // Apply the updated states to the database switch variant { case .userProfile: - try SessionUtil.handleUserProfileUpdate( + finalResult = try SessionUtil.handleUserProfileUpdate( db, in: atomicConf, - needsDump: result.needsDump, - latestConfigUpdateSentTimestamp: result.latestSentTimestamp + mergeResult: mergeResult.result, + latestConfigUpdateSentTimestamp: mergeResult.latestSentTimestamp ) case .contacts: - try SessionUtil.handleContactsUpdate( + finalResult = try SessionUtil.handleContactsUpdate( + db, + in: atomicConf, + mergeResult: mergeResult.result + ) + + case .convoInfoVolatile: + finalResult = try SessionUtil.handleConvoInfoVolatileUpdate( db, in: atomicConf, - needsDump: result.needsDump + mergeResult: mergeResult.result + ) + + case .groups: + finalResult = try SessionUtil.handleGroupsUpdate( + db, + in: atomicConf, + mergeResult: mergeResult.result ) } @@ -366,11 +395,11 @@ public enum SessionUtil { .defaulting(to: []) .asSet() let allMessageHashes: [String] = Array(oldMessageHashes - .inserting(contentsOf: result.messageHashes.asSet())) - let messageHashesChanged: Bool = (oldMessageHashes != result.messageHashes.asSet()) + .inserting(contentsOf: mergeResult.messageHashes.asSet())) + let messageHashesChanged: Bool = (oldMessageHashes != mergeResult.messageHashes.asSet()) // Now that the changes are applied, update the cached dumps - switch (result.needsDump, messageHashesChanged) { + switch (finalResult.needsDump, messageHashesChanged) { case (true, _): // The config data had changes so regenerate the dump and save it try atomicConf @@ -400,13 +429,15 @@ public enum SessionUtil { default: break } + + return finalResult + } // Now that the local state has been updated, trigger a config sync (this will push any // pending updates and properly update the state) - if results.contains(where: { $0.value.needsPush }) { + if finalResults.contains(where: { $0.needsPush }) { ConfigurationSyncJob.enqueue(db) } - } } diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/Info.plist b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/Info.plist index c9a2d9b3e..97310ed4d 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/Info.plist +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/Info.plist @@ -6,30 +6,30 @@ LibraryIdentifier - ios-arm64 + ios-arm64_x86_64-simulator LibraryPath libsession-util.a SupportedArchitectures arm64 + x86_64 SupportedPlatform ios + SupportedPlatformVariant + simulator LibraryIdentifier - ios-arm64_x86_64-simulator + ios-arm64 LibraryPath libsession-util.a SupportedArchitectures arm64 - x86_64 SupportedPlatform ios - SupportedPlatformVariant - simulator CFBundlePackageType diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64/libsession-util.a b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64/libsession-util.a index 16f752d7c..ba172f550 100644 Binary files a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64/libsession-util.a and b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64/libsession-util.a differ diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64_x86_64-simulator/libsession-util.a b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64_x86_64-simulator/libsession-util.a index 3c72d9390..65cfc8dcf 100644 Binary files a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64_x86_64-simulator/libsession-util.a and b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64_x86_64-simulator/libsession-util.a differ diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/module.modulemap b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/module.modulemap index d5540495e..37ffdf02d 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/module.modulemap +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/module.modulemap @@ -3,7 +3,9 @@ module SessionUtil { header "session/export.h" header "session/config.h" header "session/config/error.h" + header "session/config/convo_info_volatile.h" header "session/config/user_profile.h" + header "session/config/util.h" header "session/config/contacts.h" header "session/config/encrypt.h" header "session/config/base.h" diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/base.hpp b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/base.hpp index be3a4f9cb..86cdeab24 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/base.hpp +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/base.hpp @@ -103,39 +103,47 @@ class ConfigBase { // See if we can find the key without needing to create anything, so that we can attempt to // access values without mutating anything (which allows, among other things, for assigning - // of the existing value to not dirty anything). Returns nullptr if the value or something + // of the existing value to not dirty anything). Returns nullptrs if the value or something // along its path would need to be created, or has the wrong type; otherwise a const pointer - // to the value. The templated type, if provided, can be one of the types a dict_value can - // hold to also check that the returned value has a particular type; if omitted you get back - // the dict_value pointer itself. + // to the key and the value. The templated type, if provided, can be one of the types a + // dict_value can hold to also check that the returned value has a particular type; if + // omitted you get back the dict_value pointer itself. If the field exists but is not the + // requested `T` type, you get back the key string pointer with a nullptr value. template >> - const T* get_clean() const { + std::pair get_clean_pair() const { const config::dict* data = &_conf._config->data(); // All but the last need to be dicts: for (const auto& key : _inter_keys) { auto it = data->find(key); data = it != data->end() ? std::get_if(&it->second) : nullptr; if (!data) - return nullptr; + return {nullptr, nullptr}; } + const std::string* key; const dict_value* val; // The last can be any value type: - if (auto it = data->find(_last_key); it != data->end()) + if (auto it = data->find(_last_key); it != data->end()) { + key = &it->first; val = &it->second; - else - return nullptr; + } else + return {nullptr, nullptr}; if constexpr (std::is_same_v) - return val; + return {key, val}; else if constexpr (is_dict_subtype) { - if (auto* v = std::get_if(val)) - return v; + return {key, std::get_if(val)}; } else { // int64 or std::string, i.e. the config::scalar sub-types. if (auto* scalar = std::get_if(val)) - return std::get_if(scalar); + return {key, std::get_if(scalar)}; + return {key, nullptr}; } - return nullptr; + } + + // Same as above but just gives back the value, not the key + template >> + const T* get_clean() const { + return get_clean_pair().second; } // Returns a lvalue reference to the value, stomping its way through the dict as it goes to @@ -233,6 +241,11 @@ class ConfigBase { return std::move(*this); } + /// Returns a pointer to the (deepest level) key for this dict pair *if* a pair exists at + /// the given location, nullptr otherwise. This allows a caller to get a reference to the + /// actual key, rather than an ephemeral copy of the current key value. + const std::string* key() const { return get_clean_pair().first; } + /// Returns a const pointer to the string if one exists at the given location, nullptr /// otherwise. const std::string* string() const { return get_clean(); } diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/contacts.h b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/contacts.h index ba27f79b9..0046bfd4e 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/contacts.h +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/contacts.h @@ -6,6 +6,7 @@ extern "C" { #include "base.h" #include "profile_pic.h" +#include "util.h" typedef struct contacts_contact { char session_id[67]; // in hex; 66 hex chars + null terminator. @@ -48,11 +49,6 @@ int contacts_init( size_t dumplen, char* error) __attribute__((warn_unused_result)); -/// Returns true if session_id has the right form (66 hex digits). This is a quick check, not a -/// robust one: it does not check the leading byte prefix, nor the cryptographic properties of the -/// pubkey for actual validity. -bool session_id_is_valid(const char* session_id); - /// Fills `contact` with the contact info given a session ID (specified as a null-terminated hex /// string), if the contact exists, and returns true. If the contact does not exist then `contact` /// is left unchanged and false is returned. diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/contacts.hpp b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/contacts.hpp index f97199c53..a4d33234a 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/contacts.hpp +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/contacts.hpp @@ -43,15 +43,26 @@ struct contact_info { bool approved_me = false; bool blocked = false; - contact_info(std::string sid); + explicit contact_info(std::string sid); // Internal ctor/method for C API implementations: contact_info(const struct contacts_contact& c); // From c struct - void into(contacts_contact& c); // Into c struct + void into(contacts_contact& c) const; // Into c struct + + // Sets a name, storing the name internally in the object. This is intended for use where the + // source string is a temporary may not outlive the `contact_info` object: the name is first + // copied into an internal std::string, and then the name string_view references that. + void set_name(std::string name); + + // Same as above, but for nickname. + void set_nickname(std::string nickname); private: friend class Contacts; + std::string name_; + std::string nickname_; + void load(const dict& info_dict); }; @@ -101,8 +112,8 @@ class Contacts : public ConfigBase { void set(const contact_info& contact); /// Alternative to `set()` for setting individual fields. - void set_name(std::string_view session_id, std::string_view name); - void set_nickname(std::string_view session_id, std::string_view nickname); + void set_name(std::string_view session_id, std::string name); + void set_nickname(std::string_view session_id, std::string nickname); void set_profile_pic(std::string_view session_id, profile_pic pic); void set_approved(std::string_view session_id, bool approved); void set_approved_me(std::string_view session_id, bool approved_me); diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/convo_info_volatile.h b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/convo_info_volatile.h new file mode 100644 index 000000000..9ec098ece --- /dev/null +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/convo_info_volatile.h @@ -0,0 +1,219 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#include "base.h" +#include "profile_pic.h" + +typedef struct convo_info_volatile_1to1 { + char session_id[67]; // in hex; 66 hex chars + null terminator. + + int64_t last_read; // milliseconds since unix epoch + bool unread; // true if the conversation is explicitly marked unread +} convo_info_volatile_1to1; + +typedef struct convo_info_volatile_open { + char base_url[268]; // null-terminated (max length 267), normalized (i.e. always lower-case, + // only has port if non-default, has trailing / removed) + char room[65]; // null-terminated (max length 64), normalized (always lower-case) + unsigned char pubkey[32]; // 32 bytes (not terminated, can contain nulls) + + int64_t last_read; // ms since unix epoch + bool unread; // true if marked unread +} convo_info_volatile_open; + +typedef struct convo_info_volatile_legacy_closed { + char group_id[67]; // in hex; 66 hex chars + null terminator. Looks just like a Session ID, + // though isn't really one. + + int64_t last_read; // ms since unix epoch + bool unread; // true if marked unread +} convo_info_volatile_legacy_closed; + +/// Constructs a conversations config object and sets a pointer to it in `conf`. +/// +/// \param ed25519_secretkey must be the 32-byte secret key seed value. (You can also pass the +/// pointer to the beginning of the 64-byte value libsodium calls the "secret key" as the first 32 +/// bytes of that are the seed). This field cannot be null. +/// +/// \param dump - if non-NULL this restores the state from the dumped byte string produced by a past +/// instantiation's call to `dump()`. To construct a new, empty object this should be NULL. +/// +/// \param dumplen - the length of `dump` when restoring from a dump, or 0 when `dump` is NULL. +/// +/// \param error - the pointer to a buffer in which we will write an error string if an error +/// occurs; error messages are discarded if this is given as NULL. If non-NULL this must be a +/// buffer of at least 256 bytes. +/// +/// Returns 0 on success; returns a non-zero error code and write the exception message as a +/// C-string into `error` (if not NULL) on failure. +/// +/// When done with the object the `config_object` must be destroyed by passing the pointer to +/// config_free() (in `session/config/base.h`). +int convo_info_volatile_init( + config_object** conf, + const unsigned char* ed25519_secretkey, + const unsigned char* dump, + size_t dumplen, + char* error) __attribute__((warn_unused_result)); + +/// Fills `convo` with the conversation info given a session ID (specified as a null-terminated hex +/// string), if the conversation exists, and returns true. If the conversation does not exist then +/// `convo` is left unchanged and false is returned. +bool convo_info_volatile_get_1to1( + const config_object* conf, convo_info_volatile_1to1* convo, const char* session_id) + __attribute__((warn_unused_result)); + +/// Same as the above except that when the conversation does not exist, this sets all the convo +/// fields to defaults and loads it with the given session_id. +/// +/// Returns true as long as it is given a valid session_id. A false return is considered an error, +/// and means the session_id was not a valid session_id. +/// +/// This is the method that should usually be used to create or update a conversation, followed by +/// setting fields in the convo, and then giving it to convo_info_volatile_set(). +bool convo_info_volatile_get_or_construct_1to1( + const config_object* conf, convo_info_volatile_1to1* convo, const char* session_id) + __attribute__((warn_unused_result)); + +/// open-group versions of the 1-to-1 functions: +/// +/// Gets an open group convo info. `base_url` and `room` are null-terminated c strings; pubkey is +/// 32 bytes. base_url and room will always be lower-cased (if not already). +bool convo_info_volatile_get_open( + const config_object* conf, + convo_info_volatile_open* og, + const char* base_url, + const char* room, + unsigned const char* pubkey) __attribute__((warn_unused_result)); +bool convo_info_volatile_get_or_construct_open( + const config_object* conf, + convo_info_volatile_open* convo, + const char* base_url, + const char* room, + unsigned const char* pubkey) __attribute__((warn_unused_result)); + +/// Fills `convo` with the conversation info given a legacy closed group ID (specified as a +/// null-terminated hex string), if the conversation exists, and returns true. If the conversation +/// does not exist then `convo` is left unchanged and false is returned. +bool convo_info_volatile_get_legacy_closed( + const config_object* conf, convo_info_volatile_legacy_closed* convo, const char* id) + __attribute__((warn_unused_result)); + +/// Same as the above except that when the conversation does not exist, this sets all the convo +/// fields to defaults and loads it with the given id. +/// +/// Returns true as long as it is given a valid legacy closed group id (i.e. same format as a +/// session id). A false return is considered an error, and means the id was not a valid session +/// id. +/// +/// This is the method that should usually be used to create or update a conversation, followed by +/// setting fields in the convo, and then giving it to convo_info_volatile_set(). +bool convo_info_volatile_get_or_construct_legacy_closed( + const config_object* conf, convo_info_volatile_legacy_closed* convo, const char* id) + __attribute__((warn_unused_result)); + +/// Adds or updates a conversation from the given convo info +void convo_info_volatile_set_1to1(config_object* conf, const convo_info_volatile_1to1* convo); +void convo_info_volatile_set_open(config_object* conf, const convo_info_volatile_open* convo); +void convo_info_volatile_set_legacy_closed( + config_object* conf, const convo_info_volatile_legacy_closed* convo); + +/// Erases a conversation from the conversation list. Returns true if the conversation was found +/// and removed, false if the conversation was not present. You must not call this during +/// iteration; see details below. +bool convo_info_volatile_erase_1to1(config_object* conf, const char* session_id); +bool convo_info_volatile_erase_open( + config_object* conf, const char* base_url, const char* room, unsigned const char* pubkey); +bool convo_info_volatile_erase_legacy_closed(config_object* conf, const char* group_id); + +/// Returns the number of conversations. +size_t convo_info_volatile_size(const config_object* conf); +/// Returns the number of conversations of the specific type. +size_t convo_info_volatile_size_1to1(const config_object* conf); +size_t convo_info_volatile_size_open(const config_object* conf); +size_t convo_info_volatile_size_legacy_closed(const config_object* conf); + +/// Functions for iterating through the entire conversation list. Intended use is: +/// +/// convo_info_volatile_1to1 c1; +/// convo_info_volatile_open c2; +/// convo_info_volatile_legacy_closed c3; +/// convo_info_volatile_iterator *it = convo_info_volatile_iterator_new(my_convos); +/// for (; !convo_info_volatile_iterator_done(it); convo_info_volatile_iterator_advance(it)) { +/// if (convo_info_volatile_it_is_1to1(it, &c1)) { +/// // use c1.whatever +/// } else if (convo_info_volatile_it_is_open(it, &c2)) { +/// // use c2.whatever +/// } else if (convo_info_volatile_it_is_legacy_closed(it, &c3)) { +/// // use c3.whatever +/// } +/// } +/// convo_info_volatile_iterator_free(it); +/// +/// It is permitted to modify records (e.g. with a call to one of the `convo_info_volatile_set_*` +/// functions) and add records while iterating. +/// +/// If you need to remove while iterating then usage is slightly different: you must advance the +/// iteration by calling either convo_info_volatile_iterator_advance if not deleting, or +/// convo_info_volatile_iterator_erase to erase and advance. Usage looks like this: +/// +/// convo_info_volatile_1to1 c1; +/// convo_info_volatile_iterator *it = convo_info_volatile_iterator_new(my_convos); +/// while (!convo_info_volatile_iterator_done(it)) { +/// if (convo_it_is_1to1(it, &c1)) { +/// bool should_delete = /* ... */; +/// if (should_delete) +/// convo_info_volatile_iterator_erase(it); +/// else +/// convo_info_volatile_iterator_advance(it); +/// } +/// } +/// convo_info_volatile_iterator_free(it); +/// + +typedef struct convo_info_volatile_iterator convo_info_volatile_iterator; + +// Starts a new iterator that iterates over all conversations. +convo_info_volatile_iterator* convo_info_volatile_iterator_new(const config_object* conf); + +// The same as `convo_info_volatile_iterator_new` except that this iterates *only* over one type of +// conversation. You still need to use `convo_info_volatile_it_is_1to1` (or the alternatives) to +// load the data in each pass of the loop. (You can, however, safely ignore the bool return value +// of the `it_is_whatever` function: it will always be true for the particular type being iterated +// over). +convo_info_volatile_iterator* convo_info_volatile_iterator_new_1to1(const config_object* conf); +convo_info_volatile_iterator* convo_info_volatile_iterator_new_open(const config_object* conf); +convo_info_volatile_iterator* convo_info_volatile_iterator_new_legacy_closed( + const config_object* conf); + +// Frees an iterator once no longer needed. +void convo_info_volatile_iterator_free(convo_info_volatile_iterator* it); + +// Returns true if iteration has reached the end. +bool convo_info_volatile_iterator_done(convo_info_volatile_iterator* it); + +// Advances the iterator. +void convo_info_volatile_iterator_advance(convo_info_volatile_iterator* it); + +// If the current iterator record is a 1-to-1 conversation this sets the details into `c` and +// returns true. Otherwise it returns false. +bool convo_info_volatile_it_is_1to1(convo_info_volatile_iterator* it, convo_info_volatile_1to1* c); + +// If the current iterator record is an open group conversation this sets the details into `c` and +// returns true. Otherwise it returns false. +bool convo_info_volatile_it_is_open(convo_info_volatile_iterator* it, convo_info_volatile_open* c); + +// If the current iterator record is a legacy closed group conversation this sets the details into +// `c` and returns true. Otherwise it returns false. +bool convo_info_volatile_it_is_legacy_closed( + convo_info_volatile_iterator* it, convo_info_volatile_legacy_closed* c); + +// Erases the current convo while advancing the iterator to the next convo in the iteration. +void convo_info_volatile_iterator_erase(config_object* conf, convo_info_volatile_iterator* it); + +#ifdef __cplusplus +} // extern "C" +#endif diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/convo_info_volatile.hpp b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/convo_info_volatile.hpp new file mode 100644 index 000000000..f6de101e3 --- /dev/null +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/convo_info_volatile.hpp @@ -0,0 +1,380 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "base.hpp" + +extern "C" { +struct convo_info_volatile_1to1; +struct convo_info_volatile_open; +struct convo_info_volatile_legacy_closed; +} + +namespace session::config { + +class ConvoInfoVolatile; + +/// keys used in this config, either currently or in the past (so that we don't reuse): +/// +/// Note that this is a high-frequency object, intended only for properties that change frequently ( +/// (currently just the read timestamp for each conversation). +/// +/// 1 - dict of one-to-one conversations. Each key is the Session ID of the contact (in hex). +/// Values are dicts with keys: +/// r - the unix timestamp (in integer milliseconds) of the last-read message. Always +/// included, but will be 0 if no messages are read. +/// u - will be present and set to 1 if this conversation is specifically marked unread. +/// +/// o - open group conversations. Each key is: BASE_URL + '\0' + LC_ROOM_NAME + '\0' + +/// SERVER_PUBKEY (in bytes). Note that room name is *always* lower-cased here (so that clients +/// with the same room but with different cases will always set the same key). Values are dicts +/// with keys: +/// r - the unix timestamp (in integer milliseconds) of the last-read message. Always included, +/// but will be 0 if no messages are read. +/// u - will be present and set to 1 if this conversation is specifically marked unread. +/// +/// C - legacy closed group conversations. The key is the closed group identifier (which looks +/// indistinguishable from a Session ID, but isn't really a proper Session ID). Values are +/// dicts with keys: +/// r - the unix timestamp (integer milliseconds) of the last-read message. Always included, +/// but will be 0 if no messages are read. +/// u - will be present and set to 1 if this conversation is specifically marked unread. +/// +/// c - reserved for future tracking of new closed group conversations. + +namespace convo { + + struct base { + int64_t last_read = 0; + bool unread = false; + + protected: + void load(const dict& info_dict); + }; + + struct one_to_one : base { + std::string session_id; // in hex + + // Constructs an empty one_to_one from a session_id. Session ID can be either bytes (33) or + // hex (66). + explicit one_to_one(std::string&& session_id); + explicit one_to_one(std::string_view session_id); + + // Internal ctor/method for C API implementations: + one_to_one(const struct convo_info_volatile_1to1& c); // From c struct + void into(convo_info_volatile_1to1& c) const; // Into c struct + + friend class session::config::ConvoInfoVolatile; + }; + + struct open_group : base { + // 267 = len('https://') + 253 (max valid DNS name length) + len(':XXXXX') + static constexpr size_t MAX_URL = 267, MAX_ROOM = 64; + + std::string_view base_url() const; // Accesses the base url (i.e. not including room or + // pubkey). Always lower-case. + std::string_view room() + const; // Accesses the room name, always in lower-case. (Note that the + // actual open group info might not be lower-case; it is just in + // the open group convo where we force it lower-case). + ustring_view pubkey() const; // Accesses the server pubkey (32 bytes). + std::string pubkey_hex() const; // Accesses the server pubkey as hex (64 hex digits). + + open_group() = default; + + // Constructs an empty open_group convo struct from url, room, and pubkey. `base_url` and + // `room` will be lower-cased if not already (they do not have to be passed lower-case). + // pubkey is 32 bytes. + open_group(std::string_view base_url, std::string_view room, ustring_view pubkey); + + // Same as above, but takes pubkey as a hex string. + open_group(std::string_view base_url, std::string_view room, std::string_view pubkey_hex); + + // Takes a combined room URL (e.g. https://whatever.com/r/Room?public_key=01234....), either + // new style (with /r/) or old style (without /r/). Note that the URL gets canonicalized so + // the resulting `base_url()` and `room()` values may not be exactly equal to what is given. + // + // See also `parse_full_url` which does the same thing but returns it in pieces rather than + // constructing a new `open_group` object. + explicit open_group(std::string_view full_url); + + // Internal ctor/method for C API implementations: + open_group(const struct convo_info_volatile_open& c); // From c struct + void into(convo_info_volatile_open& c) const; // Into c struct + + // Replaces the baseurl/room/pubkey of this object. Note that changing this and then giving + // it to `set` will end up inserting a *new* record but not removing the *old* one (you need + // to erase first to do that). + void set_server(std::string_view base_url, std::string_view room, ustring_view pubkey); + void set_server( + std::string_view base_url, std::string_view room, std::string_view pubkey_hex); + void set_server(std::string_view full_url); + + // Loads the baseurl/room/pubkey of this object from an encoded key. Throws + // std::invalid_argument if the encoded key does not look right. + void load_encoded_key(std::string key); + + // Takes a base URL as input and returns it in canonical form. This involves doing things + // like lower casing it and removing redundant ports (e.g. :80 when using http://). + static std::string canonical_url(std::string_view url); + + // Takes a full room URL, splits it up into canonical url (see above), lower-case room + // token, and server pubkey. We take both the deprecated form (e.g. + // https://example.com/SomeRoom?public_key=...) and new form + // (https://example.com/r/SomeRoom?public_key=...). The public_key is typically specified + // in hex (64 digits), but we also accept unpadded base64 (43 chars) and base32z (52 chars) + // encodings (for slightly shorter URLs). + static std::tuple parse_full_url( + std::string_view full_url); + + private: + std::string key; + size_t url_size = 0; + + friend class session::config::ConvoInfoVolatile; + + // Returns the key value we use in the stored dict for this open group, i.e. + // lc(URL) + lc(NAME) + PUBKEY_BYTES. + static std::string make_key( + std::string_view base_url, std::string_view room, std::string_view pubkey_hex); + static std::string make_key( + std::string_view base_url, std::string_view room, ustring_view pubkey); + }; + + struct legacy_closed_group : base { + std::string id; // in hex, indistinguishable from a Session ID + + // Constructs an empty legacy_closed_group from a quasi-session_id + explicit legacy_closed_group(std::string&& group_id); + explicit legacy_closed_group(std::string_view group_id); + + // Internal ctor/method for C API implementations: + legacy_closed_group(const struct convo_info_volatile_legacy_closed& c); // From c struct + void into(convo_info_volatile_legacy_closed& c) const; // Into c struct + + private: + friend class session::config::ConvoInfoVolatile; + }; + + using any = std::variant; +} // namespace convo + +class ConvoInfoVolatile : public ConfigBase { + + public: + // No default constructor + ConvoInfoVolatile() = delete; + + /// Constructs a conversation list from existing data (stored from `dump()`) and the user's + /// secret key for generating the data encryption key. To construct a blank list (i.e. with no + /// pre-existing dumped data to load) pass `std::nullopt` as the second argument. + /// + /// \param ed25519_secretkey - contains the libsodium secret key used to encrypt/decrypt the + /// data when pushing/pulling from the swarm. This can either be the full 64-byte value (which + /// is technically the 32-byte seed followed by the 32-byte pubkey), or just the 32-byte seed of + /// the secret key. + /// + /// \param dumped - either `std::nullopt` to construct a new, empty object; or binary state data + /// that was previously dumped from an instance of this class by calling `dump()`. + ConvoInfoVolatile(ustring_view ed25519_secretkey, std::optional dumped); + + Namespace storage_namespace() const override { return Namespace::ConvoInfoVolatile; } + + const char* encryption_domain() const override { return "ConvoInfoVolatile"; } + + /// Looks up and returns a contact by session ID (hex). Returns nullopt if the session ID was + /// not found, otherwise returns a filled out `convo::one_to_one`. + std::optional get_1to1(std::string_view session_id) const; + + /// Looks up and returns an open group conversation. Takes the base URL, room name (case + /// insensitive), and pubkey (in hex). Retuns nullopt if the open group was not found, + /// otherwise a filled out `convo::open_group`. + std::optional get_open( + std::string_view base_url, std::string_view room, std::string_view pubkey_hex) const; + + /// Same as above, but takes the pubkey as bytes instead of hex + std::optional get_open( + std::string_view base_url, std::string_view room, ustring_view pubkey) const; + + /// Looks up and returns a legacy closed group conversation by ID. The ID looks like a hex + /// Session ID, but isn't really a Session ID. Returns nullopt if there is no record of the + /// closed group conversation. + std::optional get_legacy_closed(std::string_view pubkey_hex) const; + + /// These are the same as the above methods (without "_or_construct" in the name), except that + /// when the conversation doesn't exist a new one is created, prefilled with the pubkey/url/etc. + convo::one_to_one get_or_construct_1to1(std::string_view session_id) const; + convo::open_group get_or_construct_open( + std::string_view base_url, std::string_view room, std::string_view pubkey_hex) const; + convo::open_group get_or_construct_open( + std::string_view base_url, std::string_view room, ustring_view pubkey) const; + convo::legacy_closed_group get_or_construct_legacy_closed(std::string_view pubkey_hex) const; + + /// Inserts or replaces existing conversation info. For example, to update a 1-to-1 + /// conversation last read time you would do: + /// + /// auto info = conversations.get_or_construct_1to1(some_session_id); + /// info.last_read = new_unix_timestamp; + /// conversations.set(info); + /// + void set(const convo::one_to_one& c); + void set(const convo::legacy_closed_group& c); + void set(const convo::open_group& c); + + void set(const convo::any& c); // Variant which can be any of the above + + protected: + void set_base(const convo::base& c, DictFieldProxy& info); + + public: + /// Removes a one-to-one conversation. Returns true if found and removed, false if not present. + bool erase_1to1(std::string_view pubkey); + + /// Removes an open group conversation record. Returns true if found and removed, false if not + /// present. Arguments are the same as `get_open`. + bool erase_open(std::string_view base_url, std::string_view room, std::string_view pubkey_hex); + bool erase_open(std::string_view base_url, std::string_view room, ustring_view pubkey); + + /// Removes a legacy closed group conversation. Returns true if found and removed, false if not + /// present. + bool erase_legacy_closed(std::string_view pubkey_hex); + + /// Removes a conversation taking the convo::whatever record (rather than the pubkey/url). + bool erase(const convo::one_to_one& c); + bool erase(const convo::open_group& c); + bool erase(const convo::legacy_closed_group& c); + + bool erase(const convo::any& c); // Variant of any of them + + struct iterator; + + /// This works like erase, but takes an iterator to the conversation to remove. The element is + /// removed and the iterator to the next element after the removed one is returned. This is + /// intended for use where elements are to be removed during iteration: see below for an + /// example. + iterator erase(iterator it); + + /// Returns the number of conversations (of any type). + size_t size() const; + + /// Returns the number of 1-to-1, open group, and legacy closed group conversations, + /// respectively. + size_t size_1to1() const; + size_t size_open() const; + size_t size_legacy_closed() const; + + /// Returns true if the conversation list is empty. + bool empty() const { return size() == 0; } + + /// Iterators for iterating through all conversations. Typically you access this implicit via a + /// for loop over the `ConvoInfoVolatile` object: + /// + /// for (auto& convo : conversations) { + /// if (auto* dm = std::get_if(&convo)) { + /// // use dm->session_id, dm->last_read, etc. + /// } else if (auto* og = std::get_if(&convo)) { + /// // use og->base_url, og->room, om->last_read, etc. + /// } else if (auto* lcg = std::get_if(&convo)) { + /// // use lcg->id, lcg->last_read + /// } + /// } + /// + /// This iterates through all conversations in sorted order (sorted first by convo type, then by + /// id within the type). + /// + /// It is permitted to modify and add records while iterating (e.g. by modifying one of the + /// `dm`/`og`/`lcg` and then calling set()). + /// + /// If you need to erase the current conversation during iteration then care is required: you + /// need to advance the iterator via the iterator version of erase when erasing an element + /// rather than incrementing it regularly. For example: + /// + /// for (auto it = conversations.begin(); it != conversations.end(); ) { + /// if (should_remove(*it)) + /// it = converations.erase(it); + /// else + /// ++it; + /// } + /// + /// Alternatively, you can use the first version with two loops: the first loop through all + /// converations doesn't erase but just builds a vector of IDs to erase, then the second loops + /// through that vector calling `erase_1to1()`/`erase_open()`/`erase_legacy_closed()` for each + /// one. + /// + iterator begin() const { return iterator{data}; } + iterator end() const { return iterator{}; } + + template + struct subtype_iterator; + + /// Returns an iterator that iterates only through one type of conversations + subtype_iterator begin_1to1() const { return {data}; } + subtype_iterator begin_open() const { return {data}; } + subtype_iterator begin_legacy_closed() const { return {data}; } + + using iterator_category = std::input_iterator_tag; + using value_type = + std::variant; + using reference = value_type&; + using pointer = value_type*; + using difference_type = std::ptrdiff_t; + + struct iterator { + protected: + std::shared_ptr _val; + std::optional _it_11, _end_11, _it_open, _end_open, _it_lclosed, + _end_lclosed; + void _load_val(); + iterator() = default; // Constructs an end tombstone + explicit iterator( + const DictFieldRoot& data, + bool oneto1 = true, + bool open = true, + bool closed = true); + friend class ConvoInfoVolatile; + + public: + bool operator==(const iterator& other) const; + bool operator!=(const iterator& other) const { return !(*this == other); } + bool done() const; // Equivalent to comparing against the end iterator + convo::any& operator*() const { return *_val; } + convo::any* operator->() const { return _val.get(); } + iterator& operator++(); + iterator operator++(int) { + auto copy{*this}; + ++*this; + return copy; + } + }; + + template + struct subtype_iterator : iterator { + protected: + subtype_iterator(const DictFieldRoot& data) : + iterator( + data, + std::is_same_v, + std::is_same_v, + std::is_same_v) {} + friend class ConvoInfoVolatile; + + public: + ConvoType& operator*() const { return std::get(*_val); } + ConvoType* operator->() const { return &std::get(*_val); } + subtype_iterator& operator++() { + iterator::operator++(); + return *this; + } + subtype_iterator operator++(int) { + auto copy{*this}; + ++*this; + return copy; + } + }; +}; + +} // namespace session::config diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/namespaces.hpp b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/namespaces.hpp index 1ba0226ca..e96a12ab0 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/namespaces.hpp +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/namespaces.hpp @@ -7,6 +7,7 @@ namespace session::config { enum class Namespace : std::int16_t { UserProfile = 2, Contacts = 3, + ConvoInfoVolatile = 4, ClosedGroupInfo = 11, }; diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/profile_pic.hpp b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/profile_pic.hpp index 00cd90062..0a076ab97 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/profile_pic.hpp +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/profile_pic.hpp @@ -3,24 +3,37 @@ #include "session/types.hpp" namespace session::config { + // Profile pic info. Note that `url` is null terminated (though the null lies just beyond the end // of the string view: that is, it views into a full std::string). struct profile_pic { + private: + std::string url_; + ustring key_; + + public: std::string_view url; ustring_view key; + // Default constructor, makes an empty profile pic + profile_pic() = default; + + // Constructs from string views: the values must stay alive for the duration of the profile_pic + // instance. (If not, use `set_url`/`set_key` or the rvalue-argument constructor instead). profile_pic(std::string_view url, ustring_view key) : url{url}, key{key} {} + // Constructs from temporary strings; the strings are stored/managed internally + profile_pic(std::string&& url, ustring&& key) : + url_{std::move(url)}, key_{std::move(key)}, url{url_}, key{key_} {} + // Returns true if either url or key are empty bool empty() const { return url.empty() || key.empty(); } - // Guard against accidentally passing in a temporary string or ustring: - template < - typename UrlType, - typename KeyType, - std::enable_if_t< - std::is_same_v || std::is_same_v>> - profile_pic(UrlType&& url, KeyType&& key) = delete; + // Sets the url or key to a temporary value that needs to be copied and owned by this + // profile_pic object. (This is only needed when the source string may not outlive the + // profile_pic object; if it does, the `url` or `key` can be assigned to directly). + void set_url(std::string url); + void set_key(ustring key); }; } // namespace session::config diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/util.h b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/util.h new file mode 100644 index 000000000..6ae2c890b --- /dev/null +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/util.h @@ -0,0 +1,16 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +/// Returns true if session_id has the right form (66 hex digits). This is a quick check, not a +/// robust one: it does not check the leading byte prefix, nor the cryptographic properties of the +/// pubkey for actual validity. +bool session_id_is_valid(const char* session_id); + +#ifdef __cplusplus +} +#endif diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/util.hpp b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/util.hpp index 8348d908e..6cfa77e2b 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/util.hpp +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/util.hpp @@ -24,4 +24,21 @@ inline std::string_view from_unsigned_sv(ustring_view v) { return {from_unsigned(v.data()), v.size()}; } +/// Returns true if the first string is equal to the second string, compared case-insensitively. +inline bool string_iequal(std::string_view s1, std::string_view s2) { + return std::equal(s1.begin(), s1.end(), s2.begin(), s2.end(), [](char a, char b) { + return std::tolower(static_cast(a)) == + std::tolower(static_cast(b)); + }); +} + +// C++20 starts_/ends_with backport +inline constexpr bool starts_with(std::string_view str, std::string_view prefix) { + return str.size() >= prefix.size() && str.substr(prefix.size()) == prefix; +} + +inline constexpr bool end_with(std::string_view str, std::string_view suffix) { + return str.size() >= suffix.size() && str.substr(str.size() - suffix.size()) == suffix; +} + } // namespace session diff --git a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift index 4331ba7a4..7ac0ad123 100644 --- a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift @@ -373,7 +373,7 @@ public extension ClosedGroupControlMessage.Kind { let addedMemberNames: [String] = memberIds .map { knownMemberNameMap[$0] ?? - Profile.truncated(id: $0, threadVariant: .closedGroup) + Profile.truncated(id: $0, threadVariant: .legacyClosedGroup) } return String( @@ -396,7 +396,7 @@ public extension ClosedGroupControlMessage.Kind { let removedMemberNames: [String] = memberIds.removing(userPublicKey) .map { knownMemberNameMap[$0] ?? - Profile.truncated(id: $0, threadVariant: .closedGroup) + Profile.truncated(id: $0, threadVariant: .legacyClosedGroup) } let format: String = (removedMemberNames.count > 1 ? "GROUP_MEMBERS_REMOVED".localized() : diff --git a/SessionMessagingKit/Messages/Control Messages/SharedConfigMessage.swift b/SessionMessagingKit/Messages/Control Messages/SharedConfigMessage.swift index 96bb65f4c..c6172c79e 100644 --- a/SessionMessagingKit/Messages/Control Messages/SharedConfigMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/SharedConfigMessage.swift @@ -22,11 +22,15 @@ public final class SharedConfigMessage: ControlMessage { public enum Kind: CustomStringConvertible, Codable { case userProfile case contacts + case convoInfoVolatile + case groups public var description: String { switch self { case .userProfile: return "userProfile" case .contacts: return "contacts" + case .convoInfoVolatile: return "convoInfoVolatile" + case .groups: return "groups" } } } @@ -77,6 +81,8 @@ public final class SharedConfigMessage: ControlMessage { switch sharedConfigMessage.kind { case .userProfile: return .userProfile case .contacts: return .contacts + case .convoInfoVolatile: return .convoInfoVolatile + case .groups: return .groups } }(), seqNo: sharedConfigMessage.seqno, @@ -91,6 +97,8 @@ public final class SharedConfigMessage: ControlMessage { switch self.kind { case .userProfile: return .userProfile case .contacts: return .contacts + case .convoInfoVolatile: return .convoInfoVolatile + case .groups: return .groups } }(), seqno: self.seqNo, @@ -126,6 +134,8 @@ public extension SharedConfigMessage.Kind { switch self { case .userProfile: return .userProfile case .contacts: return .contacts + case .convoInfoVolatile: return .convoInfoVolatile + case .groups: return .groups } } } diff --git a/SessionMessagingKit/Messages/Message+Destination.swift b/SessionMessagingKit/Messages/Message+Destination.swift index 3714b06c6..476b868f4 100644 --- a/SessionMessagingKit/Messages/Message+Destination.swift +++ b/SessionMessagingKit/Messages/Message+Destination.swift @@ -39,7 +39,7 @@ public extension Message { return .contact(publicKey: thread.id) - case .closedGroup: + case .legacyClosedGroup, .closedGroup: return .closedGroup(groupPublicKey: thread.id) case .openGroup: diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift index 8bd727094..20b01e9e1 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift @@ -217,7 +217,10 @@ public extension VisibleMessage { sentTimestamp: UInt64(interaction.timestampMs), recipient: (try? interaction.recipientStates.fetchOne(db))?.recipientId, groupPublicKey: try? interaction.thread - .filter(SessionThread.Columns.variant == SessionThread.Variant.closedGroup) + .filter( + SessionThread.Columns.variant == SessionThread.Variant.legacyClosedGroup || + SessionThread.Columns.variant == SessionThread.Variant.closedGroup + ) .select(.id) .asRequest(of: String.self) .fetchOne(db), diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 0f48ab8d8..d6d2eca27 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -504,7 +504,7 @@ public final class OpenGroupManager { /// Start downloading the room image (if we don't have one or it's been updated) if - let imageId: String = pollInfo.details?.imageId, + let imageId: String = (pollInfo.details?.imageId ?? openGroup.imageId), ( openGroup.imageData == nil || openGroup.imageId != imageId @@ -883,12 +883,11 @@ public final class OpenGroupManager { return dependencies.storage .read { db in - let isDirectModOrAdmin: Bool = (try? GroupMember + let isDirectModOrAdmin: Bool = GroupMember .filter(GroupMember.Columns.groupId == groupId) .filter(GroupMember.Columns.profileId == publicKey) .filter(targetRoles.contains(GroupMember.Columns.role)) - .isNotEmpty(db)) - .defaulting(to: false) + .isNotEmpty(db) // If the publicKey provided matches a mod or admin directly then just return immediately if isDirectModOrAdmin { return true } @@ -942,12 +941,11 @@ public final class OpenGroupManager { SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString ]) - return (try? GroupMember + return GroupMember .filter(GroupMember.Columns.groupId == groupId) .filter(possibleKeys.contains(GroupMember.Columns.profileId)) .filter(targetRoles.contains(GroupMember.Columns.role)) - .isNotEmpty(db)) - .defaulting(to: false) + .isNotEmpty(db) } } .defaulting(to: false) diff --git a/SessionMessagingKit/Protos/Generated/SNProto.swift b/SessionMessagingKit/Protos/Generated/SNProto.swift index b13cf6585..b10ef5fac 100644 --- a/SessionMessagingKit/Protos/Generated/SNProto.swift +++ b/SessionMessagingKit/Protos/Generated/SNProto.swift @@ -3715,12 +3715,16 @@ extension SNProtoAttachmentPointer.SNProtoAttachmentPointerBuilder { @objc public enum SNProtoSharedConfigMessageKind: Int32 { case userProfile = 1 case contacts = 2 + case convoInfoVolatile = 3 + case groups = 4 } private class func SNProtoSharedConfigMessageKindWrap(_ value: SessionProtos_SharedConfigMessage.Kind) -> SNProtoSharedConfigMessageKind { switch value { case .userProfile: return .userProfile case .contacts: return .contacts + case .convoInfoVolatile: return .convoInfoVolatile + case .groups: return .groups } } @@ -3728,6 +3732,8 @@ extension SNProtoAttachmentPointer.SNProtoAttachmentPointerBuilder { switch value { case .userProfile: return .userProfile case .contacts: return .contacts + case .convoInfoVolatile: return .convoInfoVolatile + case .groups: return .groups } } diff --git a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift index 3dd86bfb7..3b649effc 100644 --- a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift +++ b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift @@ -1622,6 +1622,8 @@ struct SessionProtos_SharedConfigMessage { typealias RawValue = Int case userProfile // = 1 case contacts // = 2 + case convoInfoVolatile // = 3 + case groups // = 4 init() { self = .userProfile @@ -1631,6 +1633,8 @@ struct SessionProtos_SharedConfigMessage { switch rawValue { case 1: self = .userProfile case 2: self = .contacts + case 3: self = .convoInfoVolatile + case 4: self = .groups default: return nil } } @@ -1639,6 +1643,8 @@ struct SessionProtos_SharedConfigMessage { switch self { case .userProfile: return 1 case .contacts: return 2 + case .convoInfoVolatile: return 3 + case .groups: return 4 } } @@ -3336,5 +3342,7 @@ extension SessionProtos_SharedConfigMessage.Kind: SwiftProtobuf._ProtoNameProvid static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "USER_PROFILE"), 2: .same(proto: "CONTACTS"), + 3: .same(proto: "CONVO_INFO_VOLATILE"), + 4: .same(proto: "GROUPS"), ] } diff --git a/SessionMessagingKit/Protos/SessionProtos.proto b/SessionMessagingKit/Protos/SessionProtos.proto index 839dd78b6..2014274b0 100644 --- a/SessionMessagingKit/Protos/SessionProtos.proto +++ b/SessionMessagingKit/Protos/SessionProtos.proto @@ -276,6 +276,8 @@ message SharedConfigMessage { enum Kind { USER_PROFILE = 1; CONTACTS = 2; + CONVO_INFO_VOLATILE = 3; + GROUPS = 4; } // @required diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift index 13f42dbf0..3b11e0003 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift @@ -70,7 +70,7 @@ extension MessageReceiver { // Create the group let groupAlreadyExisted: Bool = ((try? SessionThread.exists(db, id: groupPublicKey)) ?? false) let thread: SessionThread = try SessionThread - .fetchOrCreate(db, id: groupPublicKey, variant: .closedGroup) + .fetchOrCreate(db, id: groupPublicKey, variant: .legacyClosedGroup) .with(shouldBeVisible: true) .saved(db) let closedGroup: ClosedGroup = try ClosedGroup( diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift index c3304e9ea..467a41930 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift @@ -168,7 +168,7 @@ extension MessageReceiver { // past two weeks) if isInitialSync { let existingClosedGroupsIds: [String] = (try? SessionThread - .filter(SessionThread.Columns.variant == SessionThread.Variant.closedGroup) + .filter(SessionThread.Columns.variant == SessionThread.Variant.legacyClosedGroup) .fetchAll(db)) .defaulting(to: []) .map { $0.id } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ReadReceipts.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ReadReceipts.swift index 913610e83..79557468b 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ReadReceipts.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ReadReceipts.swift @@ -9,7 +9,7 @@ extension MessageReceiver { guard let timestampMsValues: [Double] = message.timestamps?.map({ Double($0) }) else { return } guard let readTimestampMs: Double = message.receivedTimestamp.map({ Double($0) }) else { return } - try Interaction.markAsRead( + try Interaction.markAsRecipientRead( db, recipientId: sender, timestampMsValues: timestampMsValues, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift index 277f26000..f09b3f487 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift @@ -19,7 +19,12 @@ extension MessageReceiver { guard let interactionId: Int64 = maybeInteraction?.id, - let interaction: Interaction = maybeInteraction + let interaction: Interaction = maybeInteraction, + let threadVariant: SessionThread.Variant = try SessionThread + .filter(id: interaction.threadId) + .select(.variant) + .asRequest(of: SessionThread.Variant.self) + .fetchOne(db) else { return } // Mark incoming messages as read and remove any of their notifications @@ -28,6 +33,7 @@ extension MessageReceiver { db, interactionId: interactionId, threadId: interaction.threadId, + threadVariant: threadVariant, includingOlder: false, trySendReadReceipt: false ) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 31045cd8d..c69eed681 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -54,11 +54,11 @@ extension MessageReceiver { let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) let thread: SessionThread = try SessionThread .fetchOrCreate(db, id: threadInfo.id, variant: threadInfo.variant) + let maybeOpenGroup: OpenGroup? = openGroupId.map { try? OpenGroup.fetchOne(db, id: $0) } let variant: Interaction.Variant = { guard - let openGroupId: String = openGroupId, let senderSessionId: SessionId = SessionId(from: sender), - let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: openGroupId) + let openGroup: OpenGroup = maybeOpenGroup else { return (sender == currentUserPublicKey ? .standardOutgoing : @@ -118,7 +118,17 @@ extension MessageReceiver { variant: variant, body: message.text, timestampMs: Int64(messageSentTimestamp * 1000), - wasRead: (variant == .standardOutgoing), // Auto-mark sent messages as read + wasRead: ( + // Auto-mark sent messages or messages older than the 'lastReadTimestampMs' as read + variant == .standardOutgoing || + SessionUtil.timestampAlreadyRead( + threadId: thread.id, + threadVariant: thread.variant, + timestampMs: Int64(messageSentTimestamp * 1000), + userPublicKey: currentUserPublicKey, + openGroup: maybeOpenGroup + ) + ), hasMention: Interaction.isUserMentioned( db, threadId: thread.id, @@ -383,7 +393,7 @@ extension MessageReceiver { ).save(db) } - case .closedGroup: + case .legacyClosedGroup, .closedGroup: try GroupMember .filter(GroupMember.Columns.groupId == thread.id) .fetchAll(db) @@ -410,6 +420,7 @@ extension MessageReceiver { db, interactionId: interactionId, threadId: thread.id, + threadVariant: thread.variant, includingOlder: true, trySendReadReceipt: true ) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift index 331118c74..a8b71888b 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift @@ -40,7 +40,7 @@ extension MessageSender { do { // Create the relevant objects in the database thread = try SessionThread - .fetchOrCreate(db, id: groupPublicKey, variant: .closedGroup) + .fetchOrCreate(db, id: groupPublicKey, variant: .legacyClosedGroup) try ClosedGroup( threadId: groupPublicKey, name: name, @@ -81,7 +81,7 @@ extension MessageSender { threadId: thread.id, authorId: userPublicKey, variant: .infoClosedGroupCreated, - timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + timestampMs: SnodeAPI.currentOffsetTimestampMs() ).inserted(db) memberSendData = try members @@ -173,7 +173,7 @@ extension MessageSender { threadId: closedGroup.threadId, publicKey: legacyNewKeyPair.publicKey, secretKey: legacyNewKeyPair.privateKey, - receivedTimestamp: Date().timeIntervalSince1970 + receivedTimestamp: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) ) // Distribute it diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 64722160e..82557517d 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -289,7 +289,7 @@ public enum MessageReceiver { // Note: We don't want to create a thread for a closed group if it doesn't exist if (try? SessionThread.exists(db, id: groupPublicKey)) != true { return nil } - return (groupPublicKey, .closedGroup) + return (groupPublicKey, .legacyClosedGroup) } // Extract the 'syncTarget' value if there is one diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 72a73da04..754ecb2e6 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -974,7 +974,7 @@ public final class MessageSender { to: .contact(publicKey: userPublicKey), interactionId: interactionId, userPublicKey: userPublicKey, - messageSendTimestamp: Int64(floor(Date().timeIntervalSince1970 * 1000)), + messageSendTimestamp: SnodeAPI.currentOffsetTimestampMs(), isSyncMessage: true ), using: dependencies diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift index ed88e7edc..d420e709f 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift @@ -8,7 +8,9 @@ import SessionSnodeKit import SessionUtilitiesKit public final class CurrentUserPoller: Poller { - public static var namespaces: [SnodeAPI.Namespace] = [.default, .configUserProfile, .configContacts] + public static var namespaces: [SnodeAPI.Namespace] = [ + .default, .configUserProfile, .configContacts, .configConvoInfoVolatile, .configGroups + ] private var targetSnode: Atomic = Atomic(nil) private var usedSnodes: Atomic> = Atomic([]) diff --git a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift index afebe2c10..97d5957cb 100644 --- a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift +++ b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift @@ -38,7 +38,11 @@ public class TypingIndicators { } // Don't send typing indicators in group threads - guard threadVariant != .closedGroup && threadVariant != .openGroup else { return nil } + guard + threadVariant != .legacyClosedGroup && + threadVariant != .closedGroup && + threadVariant != .openGroup + else { return nil } self.threadId = threadId self.direction = direction diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index 99664cdeb..129553a42 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -303,6 +303,11 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, case (false, true): return (.bottom, isOnlyMessageInCluster) } }() + let isGroupThread: Bool = ( + self.threadVariant == .openGroup || + self.threadVariant == .legacyClosedGroup || + self.threadVariant == .closedGroup + ) return ViewModel( threadId: self.threadId, @@ -363,9 +368,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, authorName: authorDisplayName, senderName: { // Only show for group threads - guard self.threadVariant == .openGroup || self.threadVariant == .closedGroup else { - return nil - } + guard isGroupThread else { return nil } // Only show for incoming messages guard self.variant == .standardIncoming || self.variant == .standardIncomingDeleted else { @@ -381,7 +384,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, }(), shouldShowProfile: ( // Only group threads - (self.threadVariant == .openGroup || self.threadVariant == .closedGroup) && + isGroupThread && // Only incoming messages (self.variant == .standardIncoming || self.variant == .standardIncomingDeleted) && diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 616b7c15a..ff45afd9f 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -100,7 +100,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public var canWrite: Bool { switch threadVariant { case .contact: return true - case .closedGroup: return currentUserIsClosedGroupMember == true + case .legacyClosedGroup, .closedGroup: return currentUserIsClosedGroupMember == true case .openGroup: return openGroupPermissions?.contains(.write) ?? false } } @@ -158,14 +158,15 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public var profile: Profile? { switch threadVariant { case .contact: return contactProfile - case .closedGroup: return (closedGroupProfileBack ?? closedGroupProfileBackFallback) + case .legacyClosedGroup, .closedGroup: + return (closedGroupProfileBack ?? closedGroupProfileBackFallback) case .openGroup: return nil } } public var additionalProfile: Profile? { switch threadVariant { - case .closedGroup: return closedGroupProfileFront + case .legacyClosedGroup, .closedGroup: return closedGroupProfileFront default: return nil } } @@ -190,7 +191,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public var userCount: Int? { switch threadVariant { case .contact: return nil - case .closedGroup: return closedGroupUserCount + case .legacyClosedGroup, .closedGroup: return closedGroupUserCount case .openGroup: return openGroupUserCount } } @@ -1256,7 +1257,10 @@ public extension SessionThreadViewModel { LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON false LEFT JOIN \(OpenGroup.self) ON false - WHERE \(SQL("\(thread[.variant]) = \(SessionThread.Variant.closedGroup)")) + WHERE ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.legacyClosedGroup)")) OR + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.closedGroup)")) + ) GROUP BY \(thread[.id]) """ diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index eb74f0f77..63a6916a1 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -26,7 +26,7 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { ) var notificationTitle: String = senderName - if thread.variant == .closedGroup || thread.variant == .openGroup { + if thread.variant == .legacyClosedGroup || thread.variant == .closedGroup || thread.variant == .openGroup { if thread.onlyNotifyForMentions && !interaction.hasMention { // Ignore PNs if the group is set to only notify for mentions return @@ -127,7 +127,11 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) { // No call notifications for muted or group threads guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } - guard thread.variant != .closedGroup && thread.variant != .openGroup else { return } + guard + thread.variant != .legacyClosedGroup && + thread.variant != .closedGroup && + thread.variant != .openGroup + else { return } guard interaction.variant == .infoCall, let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8), @@ -181,7 +185,11 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { // No reaction notifications for muted, group threads or message requests guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } - guard thread.variant != .closedGroup && thread.variant != .openGroup else { return } + guard + thread.variant != .legacyClosedGroup && + thread.variant != .closedGroup && + thread.variant != .openGroup + else { return } guard !isMessageRequest else { return } let senderName: String = Profile.displayName(db, id: reaction.authorId, threadVariant: thread.variant) diff --git a/SessionSnodeKit/Types/SnodeAPINamespace.swift b/SessionSnodeKit/Types/SnodeAPINamespace.swift index 17fc93056..d5f559217 100644 --- a/SessionSnodeKit/Types/SnodeAPINamespace.swift +++ b/SessionSnodeKit/Types/SnodeAPINamespace.swift @@ -8,6 +8,8 @@ public extension SnodeAPI { case configUserProfile = 2 case configContacts = 3 + case configConvoInfoVolatile = 4 + case configGroups = 5 case configClosedGroupInfo = 11 case legacyClosedGroup = -10 diff --git a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift index 02b7f9817..e88705002 100644 --- a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift @@ -429,14 +429,14 @@ class ThreadSettingsViewModelSpec: QuickSpec { try SessionThread( id: "TestId", - variant: .closedGroup + variant: .legacyClosedGroup ).insert(db) } viewModel = ThreadSettingsViewModel( dependencies: dependencies, threadId: "TestId", - threadVariant: .closedGroup, + threadVariant: .legacyClosedGroup, didTriggerSearch: { didTriggerSearchCallbackTriggered = true } diff --git a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift index 6348bbdf0..e4250cb4d 100644 --- a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift +++ b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift @@ -219,7 +219,7 @@ public final class ProfilePictureView: UIView { imageViewHeightConstraint.constant = self.size imageContainerView.layer.cornerRadius = (self.size / 2) - case .closedGroup: + case .legacyClosedGroup, .closedGroup: guard !publicKey.isEmpty else { return } // If the `publicKey` we were given matches the first profile id then we have