Updated to the latest libSession, fixed a few bugs

Added the logic to sync the last read state for a conversation
Added the legacyClosedGroup thread variant
Updated the config handling to be able to update the 'mergeResult' and require a dump/push due to local changes
Fixed an issue where the name on the CallVC could go off the screen
Fixed an issue where OpenGroup info could sometimes incorrectly get deleted
Fixed an issue where the ConfirmationModal on a SessionTableViewController wouldn't trigger it's action
Fixed an issue where the config handling could incorrectly trigger a contacts update when there were no changes
This commit is contained in:
Morgan Pretty 2023-01-27 14:51:04 +11:00
parent 4f8fb63f2c
commit 07046db4b6
67 changed files with 1716 additions and 155 deletions

View File

@ -631,6 +631,8 @@
FD3C907127E445E500CD579F /* MessageReceiverDecryptionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.swift */; }; FD3C907127E445E500CD579F /* MessageReceiverDecryptionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.swift */; };
FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */; }; FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */; };
FD42F9A8285064B800A0C77D /* PushNotificationAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBDE255A581900E217F9 /* PushNotificationAPI.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 */; }; FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200D283492210034334B /* InsetLockableTableView.swift */; };
FD52090028AF6153006098F6 /* OWSBackgroundTask.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */; }; 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, ); }; }; 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 = "<group>"; }; FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReceiverDecryptionSpec.swift; sourceTree = "<group>"; };
FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = "<group>"; }; FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = "<group>"; };
FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModel.swift; sourceTree = "<group>"; }; FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModel.swift; sourceTree = "<group>"; };
FD43EE9C297A5190009C87C5 /* SessionUtil+Groups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionUtil+Groups.swift"; sourceTree = "<group>"; };
FD43EE9E297E2EE0009C87C5 /* SessionUtil+ConvoInfoVolatile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionUtil+ConvoInfoVolatile.swift"; sourceTree = "<group>"; };
FD4B200D283492210034334B /* InsetLockableTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetLockableTableView.swift; sourceTree = "<group>"; }; FD4B200D283492210034334B /* InsetLockableTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetLockableTableView.swift; sourceTree = "<group>"; };
FD52090228B4680F006098F6 /* RadioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioButton.swift; sourceTree = "<group>"; }; FD52090228B4680F006098F6 /* RadioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioButton.swift; sourceTree = "<group>"; };
FD52090428B4915F006098F6 /* PrivacySettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettingsViewModel.swift; sourceTree = "<group>"; }; FD52090428B4915F006098F6 /* PrivacySettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettingsViewModel.swift; sourceTree = "<group>"; };
@ -4039,6 +4043,8 @@
children = ( children = (
FD8ECF8F29381FC200C0D1BB /* SessionUtil+UserProfile.swift */, FD8ECF8F29381FC200C0D1BB /* SessionUtil+UserProfile.swift */,
FD2B4AFC294688D000AB4848 /* SessionUtil+Contacts.swift */, FD2B4AFC294688D000AB4848 /* SessionUtil+Contacts.swift */,
FD43EE9E297E2EE0009C87C5 /* SessionUtil+ConvoInfoVolatile.swift */,
FD43EE9C297A5190009C87C5 /* SessionUtil+Groups.swift */,
); );
path = "Config Handling"; path = "Config Handling";
sourceTree = "<group>"; sourceTree = "<group>";
@ -5674,6 +5680,7 @@
C3C2A7852553AAF300C340D1 /* SessionProtos.pb.swift in Sources */, C3C2A7852553AAF300C340D1 /* SessionProtos.pb.swift in Sources */,
FDF0B74F28079E5E004C14C5 /* SendReadReceiptsJob.swift in Sources */, FDF0B74F28079E5E004C14C5 /* SendReadReceiptsJob.swift in Sources */,
FDF0B7422804EA4F004C14C5 /* _002_SetupStandardJobs.swift in Sources */, FDF0B7422804EA4F004C14C5 /* _002_SetupStandardJobs.swift in Sources */,
FD43EE9F297E2EE0009C87C5 /* SessionUtil+ConvoInfoVolatile.swift in Sources */,
B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */, B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */,
FDF8487F29405994007DCAE5 /* HTTPHeader+OpenGroup.swift in Sources */, FDF8487F29405994007DCAE5 /* HTTPHeader+OpenGroup.swift in Sources */,
FD8ECF7D2934293A00C0D1BB /* _011_SharedUtilChanges.swift in Sources */, FD8ECF7D2934293A00C0D1BB /* _011_SharedUtilChanges.swift in Sources */,
@ -5688,6 +5695,7 @@
FD245C692850666800B966DD /* ExpirationTimerUpdate.swift in Sources */, FD245C692850666800B966DD /* ExpirationTimerUpdate.swift in Sources */,
FD42F9A8285064B800A0C77D /* PushNotificationAPI.swift in Sources */, FD42F9A8285064B800A0C77D /* PushNotificationAPI.swift in Sources */,
FD245C6C2850669200B966DD /* MessageReceiveJob.swift in Sources */, FD245C6C2850669200B966DD /* MessageReceiveJob.swift in Sources */,
FD43EE9D297A5190009C87C5 /* SessionUtil+Groups.swift in Sources */,
FD83B9CC27D179BC005E1583 /* FSEndpoint.swift in Sources */, FD83B9CC27D179BC005E1583 /* FSEndpoint.swift in Sources */,
FD7115F228C6CB3900B47552 /* _010_AddThreadIdToFTS.swift in Sources */, FD7115F228C6CB3900B47552 /* _010_AddThreadIdToFTS.swift in Sources */,
FD716E6428502DDD00C96BF4 /* CallManagerProtocol.swift in Sources */, FD716E6428502DDD00C96BF4 /* CallManagerProtocol.swift in Sources */,

View File

@ -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( try Interaction.markAsRead(
db, db,
interactionId: interaction.id, interactionId: interaction.id,
threadId: interaction.threadId, threadId: interaction.threadId,
threadVariant: threadVariant,
includingOlder: false, includingOlder: false,
trySendReadReceipt: false trySendReadReceipt: false
) )

View File

@ -398,7 +398,8 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
view.addSubview(titleLabel) view.addSubview(titleLabel)
titleLabel.translatesAutoresizingMaskIntoConstraints = false titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.center(.vertical, in: minimizeButton) 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 // Response Panel
view.addSubview(responsePanel) view.addSubview(responsePanel)

View File

@ -1108,6 +1108,7 @@ extension ConversationVC:
guard guard
cellViewModel.reactionInfo?.isEmpty == false && cellViewModel.reactionInfo?.isEmpty == false &&
( (
self.viewModel.threadData.threadVariant == .legacyClosedGroup ||
self.viewModel.threadData.threadVariant == .closedGroup || self.viewModel.threadData.threadVariant == .closedGroup ||
self.viewModel.threadData.threadVariant == .openGroup self.viewModel.threadData.threadVariant == .openGroup
), ),
@ -1797,7 +1798,7 @@ extension ConversationVC:
self?.showInputAccessoryView() self?.showInputAccessoryView()
} }
case .contact, .closedGroup: case .contact, .legacyClosedGroup, .closedGroup:
let serverHash: String? = Storage.shared.read { db -> String? in let serverHash: String? = Storage.shared.read { db -> String? in
try Interaction try Interaction
.select(.serverHash) .select(.serverHash)
@ -1856,7 +1857,7 @@ extension ConversationVC:
}) })
actionSheet.addAction(UIAlertAction( actionSheet.addAction(UIAlertAction(
title: (cellViewModel.threadVariant == .closedGroup ? title: (cellViewModel.threadVariant == .legacyClosedGroup || cellViewModel.threadVariant == .closedGroup ?
"delete_message_for_everyone".localized() : "delete_message_for_everyone".localized() :
String(format: "delete_message_for_me_and_recipient".localized(), threadName) String(format: "delete_message_for_me_and_recipient".localized(), threadName)
), ),

View File

@ -105,7 +105,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
threadId: self.threadId, threadId: self.threadId,
threadVariant: self.initialThreadVariant, threadVariant: self.initialThreadVariant,
threadIsNoteToSelf: (self.threadId == getUserHexEncodedPublicKey()), threadIsNoteToSelf: (self.threadId == getUserHexEncodedPublicKey()),
currentUserIsClosedGroupMember: (self.initialThreadVariant != .closedGroup ? currentUserIsClosedGroupMember: ((self.initialThreadVariant != .legacyClosedGroup && self.initialThreadVariant != .closedGroup) ?
nil : nil :
Storage.shared.read { db in Storage.shared.read { db in
GroupMember GroupMember
@ -406,6 +406,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
else { return } else { return }
let threadId: String = self.threadData.threadId let threadId: String = self.threadData.threadId
let threadVariant: SessionThread.Variant = self.threadData.threadVariant
let trySendReadReceipt: Bool = (self.threadData.threadIsMessageRequest == false) let trySendReadReceipt: Bool = (self.threadData.threadIsMessageRequest == false)
self.lastInteractionIdMarkedAsRead = targetInteractionId self.lastInteractionIdMarkedAsRead = targetInteractionId
@ -414,6 +415,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
db, db,
interactionId: targetInteractionId, interactionId: targetInteractionId,
threadId: threadId, threadId: threadId,
threadVariant: threadVariant,
includingOlder: true, includingOlder: true,
trySendReadReceipt: trySendReadReceipt trySendReadReceipt: trySendReadReceipt
) )

View File

@ -269,7 +269,7 @@ final class QuoteView: UIView {
contentView.addSubview(mainStackView) contentView.addSubview(mainStackView)
mainStackView.pin(to: contentView) mainStackView.pin(to: contentView)
if threadVariant != .openGroup && threadVariant != .closedGroup { if threadVariant == .contact {
bodyLabel.set(.width, to: bodyLabelSize.width) bodyLabel.set(.width, to: bodyLabelSize.width)
} }

View File

@ -305,7 +305,11 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
cellViewModel.isOnlyMessageInCluster 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 // Profile picture view
profilePictureViewLeadingConstraint.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : 0) profilePictureViewLeadingConstraint.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : 0)
@ -706,6 +710,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
maxWidth: maxWidth, maxWidth: maxWidth,
showingAllReactions: showExpandedReactions, showingAllReactions: showExpandedReactions,
showNumbers: ( showNumbers: (
cellViewModel.threadVariant == .legacyClosedGroup ||
cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .closedGroup ||
cellViewModel.threadVariant == .openGroup cellViewModel.threadVariant == .openGroup
) )
@ -1066,6 +1071,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
case .standardIncoming, .standardIncomingDeleted: case .standardIncoming, .standardIncomingDeleted:
let isGroupThread = ( let isGroupThread = (
cellViewModel.threadVariant == .openGroup || cellViewModel.threadVariant == .openGroup ||
cellViewModel.threadVariant == .legacyClosedGroup ||
cellViewModel.threadVariant == .closedGroup cellViewModel.threadVariant == .closedGroup
) )
let leftGutterSize = (isGroupThread ? leftGutterSize : contactThreadHSpacing) let leftGutterSize = (isGroupThread ? leftGutterSize : contactThreadHSpacing)

View File

@ -180,7 +180,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
override var title: String { override var title: String {
switch threadVariant { switch threadVariant {
case .contact: return "vc_settings_title".localized() case .contact: return "vc_settings_title".localized()
case .closedGroup, .openGroup: return "vc_group_settings_title".localized() case .legacyClosedGroup, .closedGroup, .openGroup: return "vc_group_settings_title".localized()
} }
} }
@ -215,7 +215,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
.fetchOne(db, id: threadId) .fetchOne(db, id: threadId)
.defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId)) .defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId))
let currentUserIsClosedGroupMember: Bool = ( let currentUserIsClosedGroupMember: Bool = (
threadVariant == .closedGroup && (
threadVariant == .legacyClosedGroup ||
threadVariant == .closedGroup
) &&
threadViewModel.currentUserIsClosedGroupMember == true threadViewModel.currentUserIsClosedGroupMember == true
) )
let editIcon: UIImage? = UIImage(named: "icon_edit") let editIcon: UIImage? = UIImage(named: "icon_edit")
@ -304,7 +307,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
SectionModel( SectionModel(
model: .content, model: .content,
elements: [ elements: [
(threadVariant == .closedGroup ? nil : (threadVariant == .legacyClosedGroup || threadVariant == .closedGroup ? nil :
SessionCell.Info( SessionCell.Info(
id: .copyThreadId, id: .copyThreadId,
leftAccessory: .icon( leftAccessory: .icon(
@ -321,7 +324,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
), ),
onTap: { onTap: {
switch threadVariant { switch threadVariant {
case .contact, .closedGroup: case .contact, .legacyClosedGroup, .closedGroup:
UIPasteboard.general.string = threadId UIPasteboard.general.string = threadId
case .openGroup: case .openGroup:
@ -534,7 +537,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
.boolValue(threadViewModel.threadOnlyNotifyForMentions == true) .boolValue(threadViewModel.threadOnlyNotifyForMentions == true)
), ),
isEnabled: ( isEnabled: (
threadViewModel.threadVariant != .closedGroup || (
threadViewModel.threadVariant != .legacyClosedGroup &&
threadViewModel.threadVariant != .closedGroup
) ||
currentUserIsClosedGroupMember currentUserIsClosedGroupMember
), ),
accessibility: SessionCell.Accessibility( accessibility: SessionCell.Accessibility(
@ -569,7 +575,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
.boolValue(threadViewModel.threadMutedUntilTimestamp != nil) .boolValue(threadViewModel.threadMutedUntilTimestamp != nil)
), ),
isEnabled: ( isEnabled: (
threadViewModel.threadVariant != .closedGroup || (
threadViewModel.threadVariant != .legacyClosedGroup &&
threadViewModel.threadVariant != .closedGroup
) ||
currentUserIsClosedGroupMember currentUserIsClosedGroupMember
), ),
accessibility: SessionCell.Accessibility( accessibility: SessionCell.Accessibility(

View File

@ -168,7 +168,7 @@ final class ConversationTitleView: UIView {
switch threadVariant { switch threadVariant {
case .contact: break case .contact: break
case .closedGroup: case .legacyClosedGroup, .closedGroup:
subtitleLabel?.attributedText = NSAttributedString( subtitleLabel?.attributedText = NSAttributedString(
string: "\(userCount) member\(userCount == 1 ? "" : "s")" string: "\(userCount) member\(userCount == 1 ? "" : "s")"
) )

View File

@ -304,7 +304,7 @@ public class HomeViewModel {
public func delete(threadId: String, threadVariant: SessionThread.Variant) { public func delete(threadId: String, threadVariant: SessionThread.Variant) {
Storage.shared.writeAsync { db in Storage.shared.writeAsync { db in
switch threadVariant { switch threadVariant {
case .closedGroup: case .legacyClosedGroup, .closedGroup:
MessageSender MessageSender
.leave(db, groupPublicKey: threadId) .leave(db, groupPublicKey: threadId)
.sinkUntilComplete() .sinkUntilComplete()

View File

@ -427,7 +427,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
return UISwipeActionsConfiguration(actions: [ delete, block ]) return UISwipeActionsConfiguration(actions: [ delete, block ])
case .closedGroup, .openGroup: case .legacyClosedGroup, .closedGroup, .openGroup:
return UISwipeActionsConfiguration(actions: [ delete ]) return UISwipeActionsConfiguration(actions: [ delete ])
} }
@ -451,7 +451,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
let closedGroupThreadIds: [String] = (viewModel.threadData let closedGroupThreadIds: [String] = (viewModel.threadData
.first { $0.model == .threads }? .first { $0.model == .threads }?
.elements .elements
.filter { $0.threadVariant == .closedGroup } .filter { $0.threadVariant == .legacyClosedGroup || $0.threadVariant == .closedGroup }
.map { $0.threadId }) .map { $0.threadId })
.defaulting(to: []) .defaulting(to: [])
let alertVC: UIAlertController = UIAlertController( let alertVC: UIAlertController = UIAlertController(

View File

@ -188,7 +188,7 @@ public class MessageRequestsViewModel {
.filter(id: threadId) .filter(id: threadId)
.deleteAll(db) .deleteAll(db)
case .closedGroup: case .legacyClosedGroup, .closedGroup:
try ClosedGroup.removeKeysAndUnsubscribe( try ClosedGroup.removeKeysAndUnsubscribe(
db, db,
threadId: threadId, threadId: threadId,

View File

@ -203,7 +203,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
case .contact: case .contact:
notificationTitle = (isMessageRequest ? "Session" : senderName) notificationTitle = (isMessageRequest ? "Session" : senderName)
case .closedGroup, .openGroup: case .legacyClosedGroup, .closedGroup, .openGroup:
notificationTitle = String( notificationTitle = String(
format: NotificationStrings.incomingGroupMessageTitleFormat, format: NotificationStrings.incomingGroupMessageTitleFormat,
senderName, senderName,
@ -274,7 +274,11 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) { public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) {
// No call notifications for muted or group threads // No call notifications for muted or group threads
guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } 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 guard
interaction.variant == .infoCall, interaction.variant == .infoCall,
let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8), 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 // No reaction notifications for muted, group threads or message requests
guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } 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 } guard !isMessageRequest else { return }
let senderName: String = Profile.displayName(db, id: reaction.authorId, threadVariant: thread.variant) let senderName: String = Profile.displayName(db, id: reaction.authorId, threadVariant: thread.variant)
@ -551,6 +559,7 @@ class NotificationActionHandler {
db, db,
interactionId: interaction.id, interactionId: interaction.id,
threadId: thread.id, threadId: thread.id,
threadVariant: thread.variant,
includingOlder: true, includingOlder: true,
trySendReadReceipt: true trySendReadReceipt: true
) )
@ -607,6 +616,7 @@ class NotificationActionHandler {
.asRequest(of: Int64.self) .asRequest(of: Int64.self)
.fetchOne(db), .fetchOne(db),
threadId: thread.id, threadId: thread.id,
threadVariant: thread.variant,
includingOlder: true, includingOlder: true,
trySendReadReceipt: true trySendReadReceipt: true
) )

View File

@ -308,12 +308,12 @@ public final class FullConversationCell: UITableViewCell {
switch cellViewModel.threadVariant { switch cellViewModel.threadVariant {
case .contact, .openGroup: bottomLabelStackView.isHidden = true case .contact, .openGroup: bottomLabelStackView.isHidden = true
case .closedGroup: case .legacyClosedGroup, .closedGroup:
bottomLabelStackView.isHidden = (cellViewModel.threadMemberNames ?? "").isEmpty bottomLabelStackView.isHidden = (cellViewModel.threadMemberNames ?? "").isEmpty
ThemeManager.onThemeChange(observer: displayNameLabel) { [weak self, weak snippetLabel] theme, _ in ThemeManager.onThemeChange(observer: displayNameLabel) { [weak self, weak snippetLabel] theme, _ in
guard let textColor: UIColor = theme.color(for: .textPrimary) else { return } 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( snippetLabel?.attributedText = self?.getHighlightedSnippet(
content: (cellViewModel.threadMemberNames ?? ""), content: (cellViewModel.threadMemberNames ?? ""),
currentUserPublicKey: cellViewModel.currentUserPublicKey, currentUserPublicKey: cellViewModel.currentUserPublicKey,
@ -354,8 +354,11 @@ public final class FullConversationCell: UITableViewCell {
ofSize: (unreadCount < 10000 ? Values.verySmallFontSize : 8) ofSize: (unreadCount < 10000 ? Values.verySmallFontSize : 8)
) )
hasMentionView.isHidden = !( hasMentionView.isHidden = !(
((cellViewModel.threadUnreadMentionCount ?? 0) > 0) && ((cellViewModel.threadUnreadMentionCount ?? 0) > 0) && (
(cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup) cellViewModel.threadVariant == .legacyClosedGroup ||
cellViewModel.threadVariant == .closedGroup ||
cellViewModel.threadVariant == .openGroup
)
) )
profilePictureView.update( profilePictureView.update(
publicKey: cellViewModel.threadId, 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) let authorName: String = cellViewModel.authorName(for: cellViewModel.threadVariant)
result.append(NSAttributedString( result.append(NSAttributedString(

View File

@ -609,6 +609,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
let confirmationModal: ConfirmationModal = ConfirmationModal( let confirmationModal: ConfirmationModal = ConfirmationModal(
targetView: tappedView, targetView: tappedView,
info: confirmationInfo info: confirmationInfo
.with(onConfirm: { _ in performAction() })
) )
present(confirmationModal, animated: true, completion: nil) present(confirmationModal, animated: true, completion: nil)
} }

View File

@ -241,7 +241,7 @@ enum MockDataGenerator {
} }
let thread: SessionThread = try! SessionThread let thread: SessionThread = try! SessionThread
.fetchOrCreate(db, id: randomGroupPublicKey, variant: .closedGroup) .fetchOrCreate(db, id: randomGroupPublicKey, variant: .legacyClosedGroup)
.with(shouldBeVisible: true) .with(shouldBeVisible: true)
.saved(db) .saved(db)
_ = try! ClosedGroup( _ = try! ClosedGroup(

View File

@ -565,7 +565,7 @@ enum _003_YDBToGRDBMigration: Migration {
switch legacyThread { switch legacyThread {
case let groupThread as SMKLegacy._GroupThread: case let groupThread as SMKLegacy._GroupThread:
threadVariant = (groupThread.isOpenGroup ? .openGroup : .closedGroup) threadVariant = (groupThread.isOpenGroup ? .openGroup : .legacyClosedGroup)
onlyNotifyForMentions = groupThread.isOnlyNotifyingForMentions onlyNotifyForMentions = groupThread.isOnlyNotifyingForMentions
default: default:

View File

@ -14,6 +14,12 @@ enum _011_SharedUtilChanges: Migration {
static let minExpectedRunDuration: TimeInterval = 0.1 static let minExpectedRunDuration: TimeInterval = 0.1
static func migrate(_ db: Database) throws { static func migrate(_ db: Database) throws {
// Add `markedAsUnread` to the thread table
try db.alter(table: SessionThread.self) { t in
t.add(.markedAsUnread, .boolean)
}
// New table for storing the latest config dump for each type
try db.create(table: ConfigDump.self) { t in try db.create(table: ConfigDump.self) { t in
t.column(.variant, .text) t.column(.variant, .text)
.notNull() .notNull()
@ -94,6 +100,28 @@ enum _011_SharedUtilChanges: Migration {
.save(db) .save(db)
} }
// Create a dump for the convoInfoVolatile data
let volatileThreadInfo: [SessionUtil.VolatileThreadInfo] = SessionUtil.VolatileThreadInfo.fetchAll(db)
let convoInfoVolatileConf: UnsafeMutablePointer<config_object>? = 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 Storage.update(progress: 1, for: self, in: target) // In case this is the last migration
} }
} }

View File

@ -17,11 +17,17 @@ public struct DisappearingMessagesConfiguration: Codable, Identifiable, Equatabl
case durationSeconds case durationSeconds
} }
public enum DisappearingMessageType: Int, Codable, Hashable, DatabaseValueConvertible {
case disappearAfterRead
case disappearAfterSend
}
public var id: String { threadId } // Identifiable public var id: String { threadId } // Identifiable
public let threadId: String public let threadId: String
public let isEnabled: Bool public let isEnabled: Bool
public let durationSeconds: TimeInterval public let durationSeconds: TimeInterval
public var type: DisappearingMessageType? { return nil } // TODO: Add as part of Disappearing Message Rebuild
// MARK: - Relationships // MARK: - Relationships
@ -45,7 +51,8 @@ public extension DisappearingMessagesConfiguration {
func with( func with(
isEnabled: Bool? = nil, isEnabled: Bool? = nil,
durationSeconds: TimeInterval? = nil durationSeconds: TimeInterval? = nil,
type: DisappearingMessageType? = nil
) -> DisappearingMessagesConfiguration { ) -> DisappearingMessagesConfiguration {
return DisappearingMessagesConfiguration( return DisappearingMessagesConfiguration(
threadId: threadId, threadId: threadId,

View File

@ -85,6 +85,10 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu
// MARK: - Convenience // MARK: - Convenience
public static let variantsToIncrementUnreadCount: [Variant] = [
.standardIncoming, .infoCall
]
public var isInfoMessage: Bool { public var isInfoMessage: Bool {
switch self { switch self {
case .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft, case .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft,
@ -349,7 +353,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu
state: .sending state: .sending
).insert(db) ).insert(db)
case .closedGroup: case .legacyClosedGroup, .closedGroup:
let closedGroupMemberIds: Set<String> = (try? GroupMember let closedGroupMemberIds: Set<String> = (try? GroupMember
.select(.profileId) .select(.profileId)
.filter(GroupMember.Columns.groupId == threadId) .filter(GroupMember.Columns.groupId == threadId)
@ -445,13 +449,28 @@ public extension Interaction {
_ db: Database, _ db: Database,
interactionId: Int64?, interactionId: Int64?,
threadId: String, threadId: String,
threadVariant: SessionThread.Variant,
includingOlder: Bool, includingOlder: Bool,
trySendReadReceipt: Bool trySendReadReceipt: Bool
) throws { ) throws {
guard let interactionId: Int64 = interactionId else { return } guard let interactionId: Int64 = interactionId else { return }
// Once all of the below is done schedule the jobs // 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 // Add the 'DisappearingMessagesJob' if needed - this will update any expiring
// messages `expiresStartedAtMs` values // messages `expiresStartedAtMs` values
JobRunner.upsert( JobRunner.upsert(
@ -510,13 +529,22 @@ public extension Interaction {
guard includingOlder, let interactionInfo: InteractionReadInfo = maybeInteractionInfo else { guard includingOlder, let interactionInfo: InteractionReadInfo = maybeInteractionInfo else {
// Only mark as read and trigger the subsequent jobs if the interaction is // Only mark as read and trigger the subsequent jobs if the interaction is
// actually not read (no point updating and triggering db changes otherwise) // 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 _ = try Interaction
.filter(id: interactionId) .filter(id: interactionId)
.updateAll(db, Columns.wasRead.set(to: true)) .updateAll(db, Columns.wasRead.set(to: true))
scheduleJobs(interactionIds: [interactionId]) try scheduleJobs(
db,
threadId: threadId,
threadVariant: threadVariant,
interactionIds: [interactionId],
lastReadTimestampMs: timestampMs
)
return return
} }
@ -533,7 +561,13 @@ public extension Interaction {
// for this interaction (need to ensure the disapeparing messages run for sync'ed // for this interaction (need to ensure the disapeparing messages run for sync'ed
// outgoing messages which will always have 'wasRead' as false) // outgoing messages which will always have 'wasRead' as false)
guard !interactionIdsToMarkAsRead.isEmpty else { guard !interactionIdsToMarkAsRead.isEmpty else {
scheduleJobs(interactionIds: [interactionId]) try scheduleJobs(
db,
threadId: threadId,
threadVariant: threadVariant,
interactionIds: [interactionId],
lastReadTimestampMs: interactionInfo.timestampMs
)
return return
} }
@ -541,13 +575,19 @@ public extension Interaction {
try interactionQuery.updateAll(db, Columns.wasRead.set(to: true)) try interactionQuery.updateAll(db, Columns.wasRead.set(to: true))
// Retrieve the interaction ids we want to update // 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 /// 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) /// **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 } guard db[.areReadReceiptsEnabled] == true else { return }
try RecipientState try RecipientState

View File

@ -324,7 +324,7 @@ public extension Profile {
} }
switch threadVariant { switch threadVariant {
case .contact, .closedGroup: return name case .contact, .legacyClosedGroup, .closedGroup: return name
case .openGroup: case .openGroup:
// In open groups, where it's more likely that multiple users have the same name, // In open groups, where it's more likely that multiple users have the same name,

View File

@ -32,12 +32,14 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord,
case notificationSound case notificationSound
case mutedUntilTimestamp case mutedUntilTimestamp
case onlyNotifyForMentions case onlyNotifyForMentions
case markedAsUnread
} }
public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible { public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible {
case contact case contact
case closedGroup case legacyClosedGroup
case openGroup case openGroup
case closedGroup
} }
/// Unique identifier for a thread (formerly known as uniqueId) /// 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 /// A flag indicating whether the thread should only notify for mentions
public let onlyNotifyForMentions: Bool 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 // MARK: - Relationships
public var contact: QueryInterfaceRequest<Contact> { public var contact: QueryInterfaceRequest<Contact> {
@ -111,7 +116,8 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord,
messageDraft: String? = nil, messageDraft: String? = nil,
notificationSound: Preferences.Sound? = nil, notificationSound: Preferences.Sound? = nil,
mutedUntilTimestamp: TimeInterval? = nil, mutedUntilTimestamp: TimeInterval? = nil,
onlyNotifyForMentions: Bool = false onlyNotifyForMentions: Bool = false,
markedAsUnread: Bool? = false
) { ) {
self.id = id self.id = id
self.variant = variant self.variant = variant
@ -122,6 +128,7 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord,
self.notificationSound = notificationSound self.notificationSound = notificationSound
self.mutedUntilTimestamp = mutedUntilTimestamp self.mutedUntilTimestamp = mutedUntilTimestamp
self.onlyNotifyForMentions = onlyNotifyForMentions self.onlyNotifyForMentions = onlyNotifyForMentions
self.markedAsUnread = markedAsUnread
} }
// MARK: - Custom Database Interaction // MARK: - Custom Database Interaction
@ -147,7 +154,8 @@ public extension SessionThread {
messageDraft: messageDraft, messageDraft: messageDraft,
notificationSound: notificationSound, notificationSound: notificationSound,
mutedUntilTimestamp: mutedUntilTimestamp, mutedUntilTimestamp: mutedUntilTimestamp,
onlyNotifyForMentions: onlyNotifyForMentions onlyNotifyForMentions: onlyNotifyForMentions,
markedAsUnread: markedAsUnread
) )
} }
} }
@ -304,7 +312,7 @@ public extension SessionThread {
profile: Profile? = nil profile: Profile? = nil
) -> String { ) -> String {
switch variant { switch variant {
case .closedGroup: return (closedGroupName ?? "Unknown Group") case .legacyClosedGroup, .closedGroup: return (closedGroupName ?? "Unknown Group")
case .openGroup: return (openGroupName ?? "Unknown Group") case .openGroup: return (openGroupName ?? "Unknown Group")
case .contact: case .contact:
guard !isNoteToSelf else { return "NOTE_TO_SELF".localized() } guard !isNoteToSelf else { return "NOTE_TO_SELF".localized() }

View File

@ -19,6 +19,8 @@ public struct ConfigDump: Codable, Equatable, Hashable, FetchableRecord, Persist
public enum Variant: String, Codable, DatabaseValueConvertible { public enum Variant: String, Codable, DatabaseValueConvertible {
case userProfile case userProfile
case contacts case contacts
case convoInfoVolatile
case groups
} }
/// The type of config this dump is for /// The type of config this dump is for
@ -64,12 +66,14 @@ public extension ConfigDump {
} }
public extension ConfigDump.Variant { public extension ConfigDump.Variant {
static let userVariants: [ConfigDump.Variant] = [ .userProfile, .contacts ] static let userVariants: [ConfigDump.Variant] = [ .userProfile, .contacts, .convoInfoVolatile, .groups ]
var configMessageKind: SharedConfigMessage.Kind { var configMessageKind: SharedConfigMessage.Kind {
switch self { switch self {
case .userProfile: return .userProfile case .userProfile: return .userProfile
case .contacts: return .contacts case .contacts: return .contacts
case .convoInfoVolatile: return .convoInfoVolatile
case .groups: return .groups
} }
} }
@ -77,6 +81,8 @@ public extension ConfigDump.Variant {
switch self { switch self {
case .userProfile: return SnodeAPI.Namespace.configUserProfile case .userProfile: return SnodeAPI.Namespace.configUserProfile
case .contacts: return SnodeAPI.Namespace.configContacts case .contacts: return SnodeAPI.Namespace.configContacts
case .convoInfoVolatile: return SnodeAPI.Namespace.configConvoInfoVolatile
case .groups: return SnodeAPI.Namespace.configGroups
} }
} }
} }

View File

@ -120,6 +120,7 @@ public extension SendReadReceiptsJob {
.joining( .joining(
// Don't send read receipts in group threads // Don't send read receipts in group threads
required: Interaction.thread required: Interaction.thread
.filter(SessionThread.Columns.variant != SessionThread.Variant.legacyClosedGroup)
.filter(SessionThread.Columns.variant != SessionThread.Variant.closedGroup) .filter(SessionThread.Columns.variant != SessionThread.Variant.closedGroup)
.filter(SessionThread.Columns.variant != SessionThread.Variant.openGroup) .filter(SessionThread.Columns.variant != SessionThread.Variant.openGroup)
) )

View File

@ -11,11 +11,17 @@ internal extension SessionUtil {
static func handleContactsUpdate( static func handleContactsUpdate(
_ db: Database, _ db: Database,
in atomicConf: Atomic<UnsafeMutablePointer<config_object>?>, in atomicConf: Atomic<UnsafeMutablePointer<config_object>?>,
needsDump: Bool mergeResult: ConfResult
) throws { ) throws -> ConfResult {
typealias ContactData = [String: (contact: Contact, profile: Profile)] 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 } guard atomicConf.wrappedValue != nil else { throw SessionUtilError.nilConfigObject }
// Since we are doing direct memory manipulation we are using an `Atomic` type which has // 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_advance(contactIterator)
} }
contacts_iterator_free(contactIterator) // Need to free the iterator contacts_iterator_free(contactIterator) // Need to free the iterator
@ -61,7 +71,7 @@ internal extension SessionUtil {
let targetContactData: ContactData = contactData.filter { $0.key != userPublicKey } let targetContactData: ContactData = contactData.filter { $0.key != userPublicKey }
// If we only updated the current user contact then no need to continue // 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 // 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 // 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` /// swapping `isApproved` and `didApproveMe` to `false`
if if
(contact.isApproved != data.contact.isApproved) || (contact.isApproved != data.contact.isApproved) ||
(contact.isBlocked != data.contact.isBlocked) || (contact.isBlocked != data.contact.isBlocked) ||
(contact.didApproveMe != data.contact.didApproveMe) (contact.didApproveMe != data.contact.didApproveMe)
{ {
try contact.save(db) try contact.save(db)
try Contact try Contact
@ -116,17 +126,21 @@ internal extension SessionUtil {
.updateAll( // Handling a config update so don't use `updateAllAndConfig` .updateAll( // Handling a config update so don't use `updateAllAndConfig`
db, db,
[ [
(!data.contact.isApproved ? nil : (!data.contact.isApproved || contact.isApproved == data.contact.isApproved ? nil :
Contact.Columns.isApproved.set(to: true) Contact.Columns.isApproved.set(to: true)
), ),
Contact.Columns.isBlocked.set(to: data.contact.isBlocked), (contact.isBlocked == data.contact.isBlocked ? nil :
(!data.contact.didApproveMe ? nil : Contact.Columns.isBlocked.set(to: data.contact.isBlocked)
),
(!data.contact.didApproveMe || contact.didApproveMe == data.contact.didApproveMe ? nil :
Contact.Columns.didApproveMe.set(to: true) Contact.Columns.didApproveMe.set(to: true)
) )
].compactMap { $0 } ].compactMap { $0 }
) )
} }
} }
return mergeResult
} }
// MARK: - Outgoing Changes // MARK: - Outgoing Changes

View File

@ -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<UnsafeMutablePointer<config_object>?>,
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<UnsafeMutablePointer<config_object>?>
) 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<T>(_ 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<UnsafeMutablePointer<config_object>?> = 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<UnsafeMutablePointer<config_object>?> = 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<UnsafeMutablePointer<config_object>?> = 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<SessionThread> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
let request: SQLRequest<FetchedInfo> = """
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
}
}

View File

@ -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<UnsafeMutablePointer<config_object>?>,
mergeResult: ConfResult
) throws -> ConfResult {
// TODO: This
return mergeResult
}
}

View File

@ -11,12 +11,12 @@ internal extension SessionUtil {
static func handleUserProfileUpdate( static func handleUserProfileUpdate(
_ db: Database, _ db: Database,
in atomicConf: Atomic<UnsafeMutablePointer<config_object>?>, in atomicConf: Atomic<UnsafeMutablePointer<config_object>?>,
needsDump: Bool, mergeResult: ConfResult,
latestConfigUpdateSentTimestamp: TimeInterval latestConfigUpdateSentTimestamp: TimeInterval
) throws { ) throws -> ConfResult {
typealias ProfileData = (profileName: String, profilePictureUrl: String?, profilePictureKey: Data?) 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 } guard atomicConf.wrappedValue != nil else { throw SessionUtilError.nilConfigObject }
let userPublicKey: String = getUserHexEncodedPublicKey(db) let userPublicKey: String = getUserHexEncodedPublicKey(db)
@ -52,7 +52,7 @@ internal extension SessionUtil {
} }
// Only save the data in the database if it's valid // 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 // Handle user profile changes
try ProfileManager.updateProfileIfNeeded( try ProfileManager.updateProfileIfNeeded(
@ -90,6 +90,8 @@ internal extension SessionUtil {
Contact.Columns.didApproveMe.set(to: true) Contact.Columns.didApproveMe.set(to: true)
) )
} }
return mergeResult
} }
// MARK: - Outgoing Changes // MARK: - Outgoing Changes

View File

@ -26,6 +26,10 @@ public extension QueryInterfaceRequest {
case let profileRequest as QueryInterfaceRequest<Profile>: case let profileRequest as QueryInterfaceRequest<Profile>:
return try profileRequest.updateAndFetchAllAndUpdateConfig(db, assignments).count return try profileRequest.updateAndFetchAllAndUpdateConfig(db, assignments).count
case let threadRequest as QueryInterfaceRequest<SessionThread>:
return try threadRequest.updateAndFetchAllAndUpdateConfig(db, assignments).count
default: return try self.updateAll(db, assignments) default: return try self.updateAll(db, assignments)
} }
@ -73,6 +77,9 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table
case is QueryInterfaceRequest<Profile>: case is QueryInterfaceRequest<Profile>:
return try SessionUtil.updatingProfiles(db, try updateAndFetchAll(db, assignments)) return try SessionUtil.updatingProfiles(db, try updateAndFetchAll(db, assignments))
case is QueryInterfaceRequest<SessionThread>:
return try SessionUtil.updatingThreads(db, try updateAndFetchAll(db, assignments))
default: return try self.updateAndFetchAll(db, assignments) default: return try self.updateAndFetchAll(db, assignments)
} }
} }

View File

@ -17,6 +17,8 @@ public enum SessionUtil {
let needsDump: Bool let needsDump: Bool
let messageHashes: [String] let messageHashes: [String]
let latestSentTimestamp: TimeInterval let latestSentTimestamp: TimeInterval
var result: ConfResult { ConfResult(needsPush: needsPush, needsDump: needsDump) }
} }
public struct OutgoingConfResult { public struct OutgoingConfResult {
@ -46,7 +48,11 @@ public enum SessionUtil {
public static var needsSync: Bool { public static var needsSync: Bool {
return configStore return configStore
.wrappedValue .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 // MARK: - Loading
@ -121,9 +127,12 @@ public enum SessionUtil {
switch variant { switch variant {
case .userProfile: case .userProfile:
return user_profile_init(&conf, &secretKey, cachedDump?.data, (cachedDump?.length ?? 0), error) return user_profile_init(&conf, &secretKey, cachedDump?.data, (cachedDump?.length ?? 0), error)
case .contacts: case .contacts:
return contacts_init(&conf, &secretKey, cachedDump?.data, (cachedDump?.length ?? 0), error) 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 // 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<UInt8>? = nil var toPush: UnsafeMutablePointer<UInt8>? = nil
var toPushLen: Int = 0 var toPushLen: Int = 0
@ -266,6 +278,8 @@ public enum SessionUtil {
Atomic(nil) Atomic(nil)
) )
guard atomicConf.wrappedValue != nil else { return false }
// Mark the config as pushed // Mark the config as pushed
config_confirm_pushed(atomicConf.wrappedValue, message.seqNo) config_confirm_pushed(atomicConf.wrappedValue, message.seqNo)
@ -289,7 +303,7 @@ public enum SessionUtil {
.grouped(by: \.kind) .grouped(by: \.kind)
// Merge the config messages into the current state // 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 .reduce(into: [:]) { result, next in
let key: ConfigKey = ConfigKey(variant: next.key.configDumpVariant, publicKey: publicKey) let key: ConfigKey = ConfigKey(variant: next.key.configDumpVariant, publicKey: publicKey)
let atomicConf: Atomic<UnsafeMutablePointer<config_object>?> = ( let atomicConf: Atomic<UnsafeMutablePointer<config_object>?> = (
@ -327,28 +341,43 @@ public enum SessionUtil {
} }
// Process the results from the merging // 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 key: ConfigKey = ConfigKey(variant: variant, publicKey: publicKey)
let atomicConf: Atomic<UnsafeMutablePointer<config_object>?> = ( let atomicConf: Atomic<UnsafeMutablePointer<config_object>?> = (
SessionUtil.configStore.wrappedValue[key] ?? SessionUtil.configStore.wrappedValue[key] ??
Atomic(nil) Atomic(nil)
) )
var finalResult: ConfResult = mergeResult.result
// Apply the updated states to the database // Apply the updated states to the database
switch variant { switch variant {
case .userProfile: case .userProfile:
try SessionUtil.handleUserProfileUpdate( finalResult = try SessionUtil.handleUserProfileUpdate(
db, db,
in: atomicConf, in: atomicConf,
needsDump: result.needsDump, mergeResult: mergeResult.result,
latestConfigUpdateSentTimestamp: result.latestSentTimestamp latestConfigUpdateSentTimestamp: mergeResult.latestSentTimestamp
) )
case .contacts: case .contacts:
try SessionUtil.handleContactsUpdate( finalResult = try SessionUtil.handleContactsUpdate(
db, db,
in: atomicConf, in: atomicConf,
needsDump: result.needsDump mergeResult: mergeResult.result
)
case .convoInfoVolatile:
finalResult = try SessionUtil.handleConvoInfoVolatileUpdate(
db,
in: atomicConf,
mergeResult: mergeResult.result
)
case .groups:
finalResult = try SessionUtil.handleGroupsUpdate(
db,
in: atomicConf,
mergeResult: mergeResult.result
) )
} }
@ -366,11 +395,11 @@ public enum SessionUtil {
.defaulting(to: []) .defaulting(to: [])
.asSet() .asSet()
let allMessageHashes: [String] = Array(oldMessageHashes let allMessageHashes: [String] = Array(oldMessageHashes
.inserting(contentsOf: result.messageHashes.asSet())) .inserting(contentsOf: mergeResult.messageHashes.asSet()))
let messageHashesChanged: Bool = (oldMessageHashes != result.messageHashes.asSet()) let messageHashesChanged: Bool = (oldMessageHashes != mergeResult.messageHashes.asSet())
// Now that the changes are applied, update the cached dumps // Now that the changes are applied, update the cached dumps
switch (result.needsDump, messageHashesChanged) { switch (finalResult.needsDump, messageHashesChanged) {
case (true, _): case (true, _):
// The config data had changes so regenerate the dump and save it // The config data had changes so regenerate the dump and save it
try atomicConf try atomicConf
@ -400,13 +429,15 @@ public enum SessionUtil {
default: break default: break
} }
return finalResult
}
// Now that the local state has been updated, trigger a config sync (this will push any // Now that the local state has been updated, trigger a config sync (this will push any
// pending updates and properly update the state) // pending updates and properly update the state)
if results.contains(where: { $0.value.needsPush }) { if finalResults.contains(where: { $0.needsPush }) {
ConfigurationSyncJob.enqueue(db) ConfigurationSyncJob.enqueue(db)
} }
} }
} }

View File

@ -4,18 +4,6 @@
<dict> <dict>
<key>AvailableLibraries</key> <key>AvailableLibraries</key>
<array> <array>
<dict>
<key>LibraryIdentifier</key>
<string>ios-arm64</string>
<key>LibraryPath</key>
<string>libsession-util.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
</dict>
<dict> <dict>
<key>LibraryIdentifier</key> <key>LibraryIdentifier</key>
<string>ios-arm64_x86_64-simulator</string> <string>ios-arm64_x86_64-simulator</string>
@ -31,6 +19,18 @@
<key>SupportedPlatformVariant</key> <key>SupportedPlatformVariant</key>
<string>simulator</string> <string>simulator</string>
</dict> </dict>
<dict>
<key>LibraryIdentifier</key>
<string>ios-arm64</string>
<key>LibraryPath</key>
<string>libsession-util.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
</dict>
</array> </array>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>XFWK</string> <string>XFWK</string>

View File

@ -3,7 +3,9 @@ module SessionUtil {
header "session/export.h" header "session/export.h"
header "session/config.h" header "session/config.h"
header "session/config/error.h" header "session/config/error.h"
header "session/config/convo_info_volatile.h"
header "session/config/user_profile.h" header "session/config/user_profile.h"
header "session/config/util.h"
header "session/config/contacts.h" header "session/config/contacts.h"
header "session/config/encrypt.h" header "session/config/encrypt.h"
header "session/config/base.h" header "session/config/base.h"

View File

@ -103,39 +103,47 @@ class ConfigBase {
// See if we can find the key without needing to create anything, so that we can attempt to // 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 // 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 // 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 // to the key and the value. The templated type, if provided, can be one of the types a
// hold to also check that the returned value has a particular type; if omitted you get back // dict_value can hold to also check that the returned value has a particular type; if
// the dict_value pointer itself. // 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 <typename T = dict_value, typename = std::enable_if_t<is_dict_value<T>>> template <typename T = dict_value, typename = std::enable_if_t<is_dict_value<T>>>
const T* get_clean() const { std::pair<const std::string*, const T*> get_clean_pair() const {
const config::dict* data = &_conf._config->data(); const config::dict* data = &_conf._config->data();
// All but the last need to be dicts: // All but the last need to be dicts:
for (const auto& key : _inter_keys) { for (const auto& key : _inter_keys) {
auto it = data->find(key); auto it = data->find(key);
data = it != data->end() ? std::get_if<config::dict>(&it->second) : nullptr; data = it != data->end() ? std::get_if<config::dict>(&it->second) : nullptr;
if (!data) if (!data)
return nullptr; return {nullptr, nullptr};
} }
const std::string* key;
const dict_value* val; const dict_value* val;
// The last can be any value type: // 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; val = &it->second;
else } else
return nullptr; return {nullptr, nullptr};
if constexpr (std::is_same_v<T, dict_value>) if constexpr (std::is_same_v<T, dict_value>)
return val; return {key, val};
else if constexpr (is_dict_subtype<T>) { else if constexpr (is_dict_subtype<T>) {
if (auto* v = std::get_if<T>(val)) return {key, std::get_if<T>(val)};
return v;
} else { // int64 or std::string, i.e. the config::scalar sub-types. } else { // int64 or std::string, i.e. the config::scalar sub-types.
if (auto* scalar = std::get_if<config::scalar>(val)) if (auto* scalar = std::get_if<config::scalar>(val))
return std::get_if<T>(scalar); return {key, std::get_if<T>(scalar)};
return {key, nullptr};
} }
return nullptr; }
// Same as above but just gives back the value, not the key
template <typename T = dict_value, typename = std::enable_if_t<is_dict_value<T>>>
const T* get_clean() const {
return get_clean_pair<T>().second;
} }
// Returns a lvalue reference to the value, stomping its way through the dict as it goes to // 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); 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 /// Returns a const pointer to the string if one exists at the given location, nullptr
/// otherwise. /// otherwise.
const std::string* string() const { return get_clean<std::string>(); } const std::string* string() const { return get_clean<std::string>(); }

View File

@ -6,6 +6,7 @@ extern "C" {
#include "base.h" #include "base.h"
#include "profile_pic.h" #include "profile_pic.h"
#include "util.h"
typedef struct contacts_contact { typedef struct contacts_contact {
char session_id[67]; // in hex; 66 hex chars + null terminator. char session_id[67]; // in hex; 66 hex chars + null terminator.
@ -48,11 +49,6 @@ int contacts_init(
size_t dumplen, size_t dumplen,
char* error) __attribute__((warn_unused_result)); 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 /// 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` /// string), if the contact exists, and returns true. If the contact does not exist then `contact`
/// is left unchanged and false is returned. /// is left unchanged and false is returned.

View File

@ -43,15 +43,26 @@ struct contact_info {
bool approved_me = false; bool approved_me = false;
bool blocked = false; bool blocked = false;
contact_info(std::string sid); explicit contact_info(std::string sid);
// Internal ctor/method for C API implementations: // Internal ctor/method for C API implementations:
contact_info(const struct contacts_contact& c); // From c struct 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: private:
friend class Contacts; friend class Contacts;
std::string name_;
std::string nickname_;
void load(const dict& info_dict); void load(const dict& info_dict);
}; };
@ -101,8 +112,8 @@ class Contacts : public ConfigBase {
void set(const contact_info& contact); void set(const contact_info& contact);
/// Alternative to `set()` for setting individual fields. /// Alternative to `set()` for setting individual fields.
void set_name(std::string_view session_id, std::string_view name); void set_name(std::string_view session_id, std::string name);
void set_nickname(std::string_view session_id, std::string_view nickname); 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_profile_pic(std::string_view session_id, profile_pic pic);
void set_approved(std::string_view session_id, bool approved); void set_approved(std::string_view session_id, bool approved);
void set_approved_me(std::string_view session_id, bool approved_me); void set_approved_me(std::string_view session_id, bool approved_me);

View File

@ -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

View File

@ -0,0 +1,380 @@
#pragma once
#include <chrono>
#include <cstddef>
#include <iterator>
#include <memory>
#include <session/config.hpp>
#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<std::string, std::string, ustring> 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<one_to_one, open_group, legacy_closed_group>;
} // 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<ustring_view> 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<convo::one_to_one> 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<convo::open_group> 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<convo::open_group> 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<convo::legacy_closed_group> 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::one_to_one>(&convo)) {
/// // use dm->session_id, dm->last_read, etc.
/// } else if (auto* og = std::get_if<convo::open_group>(&convo)) {
/// // use og->base_url, og->room, om->last_read, etc.
/// } else if (auto* lcg = std::get_if<convo::legacy_closed_group>(&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 <typename ConvoType>
struct subtype_iterator;
/// Returns an iterator that iterates only through one type of conversations
subtype_iterator<convo::one_to_one> begin_1to1() const { return {data}; }
subtype_iterator<convo::open_group> begin_open() const { return {data}; }
subtype_iterator<convo::legacy_closed_group> begin_legacy_closed() const { return {data}; }
using iterator_category = std::input_iterator_tag;
using value_type =
std::variant<convo::one_to_one, convo::open_group, convo::legacy_closed_group>;
using reference = value_type&;
using pointer = value_type*;
using difference_type = std::ptrdiff_t;
struct iterator {
protected:
std::shared_ptr<convo::any> _val;
std::optional<dict::const_iterator> _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 <typename ConvoType>
struct subtype_iterator : iterator {
protected:
subtype_iterator(const DictFieldRoot& data) :
iterator(
data,
std::is_same_v<convo::one_to_one, ConvoType>,
std::is_same_v<convo::open_group, ConvoType>,
std::is_same_v<convo::legacy_closed_group, ConvoType>) {}
friend class ConvoInfoVolatile;
public:
ConvoType& operator*() const { return std::get<ConvoType>(*_val); }
ConvoType* operator->() const { return &std::get<ConvoType>(*_val); }
subtype_iterator& operator++() {
iterator::operator++();
return *this;
}
subtype_iterator operator++(int) {
auto copy{*this};
++*this;
return copy;
}
};
};
} // namespace session::config

View File

@ -7,6 +7,7 @@ namespace session::config {
enum class Namespace : std::int16_t { enum class Namespace : std::int16_t {
UserProfile = 2, UserProfile = 2,
Contacts = 3, Contacts = 3,
ConvoInfoVolatile = 4,
ClosedGroupInfo = 11, ClosedGroupInfo = 11,
}; };

View File

@ -3,24 +3,37 @@
#include "session/types.hpp" #include "session/types.hpp"
namespace session::config { namespace session::config {
// Profile pic info. Note that `url` is null terminated (though the null lies just beyond the end // 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). // of the string view: that is, it views into a full std::string).
struct profile_pic { struct profile_pic {
private:
std::string url_;
ustring key_;
public:
std::string_view url; std::string_view url;
ustring_view key; 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} {} 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 // Returns true if either url or key are empty
bool empty() const { return url.empty() || key.empty(); } bool empty() const { return url.empty() || key.empty(); }
// Guard against accidentally passing in a temporary string or ustring: // Sets the url or key to a temporary value that needs to be copied and owned by this
template < // profile_pic object. (This is only needed when the source string may not outlive the
typename UrlType, // profile_pic object; if it does, the `url` or `key` can be assigned to directly).
typename KeyType, void set_url(std::string url);
std::enable_if_t< void set_key(ustring key);
std::is_same_v<UrlType, std::string> || std::is_same_v<KeyType, ustring>>>
profile_pic(UrlType&& url, KeyType&& key) = delete;
}; };
} // namespace session::config } // namespace session::config

View File

@ -0,0 +1,16 @@
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
#include <stdbool.h>
/// 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

View File

@ -24,4 +24,21 @@ inline std::string_view from_unsigned_sv(ustring_view v) {
return {from_unsigned(v.data()), v.size()}; 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<unsigned char>(a)) ==
std::tolower(static_cast<unsigned char>(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 } // namespace session

View File

@ -373,7 +373,7 @@ public extension ClosedGroupControlMessage.Kind {
let addedMemberNames: [String] = memberIds let addedMemberNames: [String] = memberIds
.map { .map {
knownMemberNameMap[$0] ?? knownMemberNameMap[$0] ??
Profile.truncated(id: $0, threadVariant: .closedGroup) Profile.truncated(id: $0, threadVariant: .legacyClosedGroup)
} }
return String( return String(
@ -396,7 +396,7 @@ public extension ClosedGroupControlMessage.Kind {
let removedMemberNames: [String] = memberIds.removing(userPublicKey) let removedMemberNames: [String] = memberIds.removing(userPublicKey)
.map { .map {
knownMemberNameMap[$0] ?? knownMemberNameMap[$0] ??
Profile.truncated(id: $0, threadVariant: .closedGroup) Profile.truncated(id: $0, threadVariant: .legacyClosedGroup)
} }
let format: String = (removedMemberNames.count > 1 ? let format: String = (removedMemberNames.count > 1 ?
"GROUP_MEMBERS_REMOVED".localized() : "GROUP_MEMBERS_REMOVED".localized() :

View File

@ -22,11 +22,15 @@ public final class SharedConfigMessage: ControlMessage {
public enum Kind: CustomStringConvertible, Codable { public enum Kind: CustomStringConvertible, Codable {
case userProfile case userProfile
case contacts case contacts
case convoInfoVolatile
case groups
public var description: String { public var description: String {
switch self { switch self {
case .userProfile: return "userProfile" case .userProfile: return "userProfile"
case .contacts: return "contacts" case .contacts: return "contacts"
case .convoInfoVolatile: return "convoInfoVolatile"
case .groups: return "groups"
} }
} }
} }
@ -77,6 +81,8 @@ public final class SharedConfigMessage: ControlMessage {
switch sharedConfigMessage.kind { switch sharedConfigMessage.kind {
case .userProfile: return .userProfile case .userProfile: return .userProfile
case .contacts: return .contacts case .contacts: return .contacts
case .convoInfoVolatile: return .convoInfoVolatile
case .groups: return .groups
} }
}(), }(),
seqNo: sharedConfigMessage.seqno, seqNo: sharedConfigMessage.seqno,
@ -91,6 +97,8 @@ public final class SharedConfigMessage: ControlMessage {
switch self.kind { switch self.kind {
case .userProfile: return .userProfile case .userProfile: return .userProfile
case .contacts: return .contacts case .contacts: return .contacts
case .convoInfoVolatile: return .convoInfoVolatile
case .groups: return .groups
} }
}(), }(),
seqno: self.seqNo, seqno: self.seqNo,
@ -126,6 +134,8 @@ public extension SharedConfigMessage.Kind {
switch self { switch self {
case .userProfile: return .userProfile case .userProfile: return .userProfile
case .contacts: return .contacts case .contacts: return .contacts
case .convoInfoVolatile: return .convoInfoVolatile
case .groups: return .groups
} }
} }
} }

View File

@ -39,7 +39,7 @@ public extension Message {
return .contact(publicKey: thread.id) return .contact(publicKey: thread.id)
case .closedGroup: case .legacyClosedGroup, .closedGroup:
return .closedGroup(groupPublicKey: thread.id) return .closedGroup(groupPublicKey: thread.id)
case .openGroup: case .openGroup:

View File

@ -217,7 +217,10 @@ public extension VisibleMessage {
sentTimestamp: UInt64(interaction.timestampMs), sentTimestamp: UInt64(interaction.timestampMs),
recipient: (try? interaction.recipientStates.fetchOne(db))?.recipientId, recipient: (try? interaction.recipientStates.fetchOne(db))?.recipientId,
groupPublicKey: try? interaction.thread 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) .select(.id)
.asRequest(of: String.self) .asRequest(of: String.self)
.fetchOne(db), .fetchOne(db),

View File

@ -504,7 +504,7 @@ public final class OpenGroupManager {
/// Start downloading the room image (if we don't have one or it's been updated) /// Start downloading the room image (if we don't have one or it's been updated)
if if
let imageId: String = pollInfo.details?.imageId, let imageId: String = (pollInfo.details?.imageId ?? openGroup.imageId),
( (
openGroup.imageData == nil || openGroup.imageData == nil ||
openGroup.imageId != imageId openGroup.imageId != imageId
@ -883,12 +883,11 @@ public final class OpenGroupManager {
return dependencies.storage return dependencies.storage
.read { db in .read { db in
let isDirectModOrAdmin: Bool = (try? GroupMember let isDirectModOrAdmin: Bool = GroupMember
.filter(GroupMember.Columns.groupId == groupId) .filter(GroupMember.Columns.groupId == groupId)
.filter(GroupMember.Columns.profileId == publicKey) .filter(GroupMember.Columns.profileId == publicKey)
.filter(targetRoles.contains(GroupMember.Columns.role)) .filter(targetRoles.contains(GroupMember.Columns.role))
.isNotEmpty(db)) .isNotEmpty(db)
.defaulting(to: false)
// If the publicKey provided matches a mod or admin directly then just return immediately // If the publicKey provided matches a mod or admin directly then just return immediately
if isDirectModOrAdmin { return true } if isDirectModOrAdmin { return true }
@ -942,12 +941,11 @@ public final class OpenGroupManager {
SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString
]) ])
return (try? GroupMember return GroupMember
.filter(GroupMember.Columns.groupId == groupId) .filter(GroupMember.Columns.groupId == groupId)
.filter(possibleKeys.contains(GroupMember.Columns.profileId)) .filter(possibleKeys.contains(GroupMember.Columns.profileId))
.filter(targetRoles.contains(GroupMember.Columns.role)) .filter(targetRoles.contains(GroupMember.Columns.role))
.isNotEmpty(db)) .isNotEmpty(db)
.defaulting(to: false)
} }
} }
.defaulting(to: false) .defaulting(to: false)

View File

@ -3715,12 +3715,16 @@ extension SNProtoAttachmentPointer.SNProtoAttachmentPointerBuilder {
@objc public enum SNProtoSharedConfigMessageKind: Int32 { @objc public enum SNProtoSharedConfigMessageKind: Int32 {
case userProfile = 1 case userProfile = 1
case contacts = 2 case contacts = 2
case convoInfoVolatile = 3
case groups = 4
} }
private class func SNProtoSharedConfigMessageKindWrap(_ value: SessionProtos_SharedConfigMessage.Kind) -> SNProtoSharedConfigMessageKind { private class func SNProtoSharedConfigMessageKindWrap(_ value: SessionProtos_SharedConfigMessage.Kind) -> SNProtoSharedConfigMessageKind {
switch value { switch value {
case .userProfile: return .userProfile case .userProfile: return .userProfile
case .contacts: return .contacts case .contacts: return .contacts
case .convoInfoVolatile: return .convoInfoVolatile
case .groups: return .groups
} }
} }
@ -3728,6 +3732,8 @@ extension SNProtoAttachmentPointer.SNProtoAttachmentPointerBuilder {
switch value { switch value {
case .userProfile: return .userProfile case .userProfile: return .userProfile
case .contacts: return .contacts case .contacts: return .contacts
case .convoInfoVolatile: return .convoInfoVolatile
case .groups: return .groups
} }
} }

View File

@ -1622,6 +1622,8 @@ struct SessionProtos_SharedConfigMessage {
typealias RawValue = Int typealias RawValue = Int
case userProfile // = 1 case userProfile // = 1
case contacts // = 2 case contacts // = 2
case convoInfoVolatile // = 3
case groups // = 4
init() { init() {
self = .userProfile self = .userProfile
@ -1631,6 +1633,8 @@ struct SessionProtos_SharedConfigMessage {
switch rawValue { switch rawValue {
case 1: self = .userProfile case 1: self = .userProfile
case 2: self = .contacts case 2: self = .contacts
case 3: self = .convoInfoVolatile
case 4: self = .groups
default: return nil default: return nil
} }
} }
@ -1639,6 +1643,8 @@ struct SessionProtos_SharedConfigMessage {
switch self { switch self {
case .userProfile: return 1 case .userProfile: return 1
case .contacts: return 2 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 = [ static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
1: .same(proto: "USER_PROFILE"), 1: .same(proto: "USER_PROFILE"),
2: .same(proto: "CONTACTS"), 2: .same(proto: "CONTACTS"),
3: .same(proto: "CONVO_INFO_VOLATILE"),
4: .same(proto: "GROUPS"),
] ]
} }

View File

@ -276,6 +276,8 @@ message SharedConfigMessage {
enum Kind { enum Kind {
USER_PROFILE = 1; USER_PROFILE = 1;
CONTACTS = 2; CONTACTS = 2;
CONVO_INFO_VOLATILE = 3;
GROUPS = 4;
} }
// @required // @required

View File

@ -70,7 +70,7 @@ extension MessageReceiver {
// Create the group // Create the group
let groupAlreadyExisted: Bool = ((try? SessionThread.exists(db, id: groupPublicKey)) ?? false) let groupAlreadyExisted: Bool = ((try? SessionThread.exists(db, id: groupPublicKey)) ?? false)
let thread: SessionThread = try SessionThread let thread: SessionThread = try SessionThread
.fetchOrCreate(db, id: groupPublicKey, variant: .closedGroup) .fetchOrCreate(db, id: groupPublicKey, variant: .legacyClosedGroup)
.with(shouldBeVisible: true) .with(shouldBeVisible: true)
.saved(db) .saved(db)
let closedGroup: ClosedGroup = try ClosedGroup( let closedGroup: ClosedGroup = try ClosedGroup(

View File

@ -168,7 +168,7 @@ extension MessageReceiver {
// past two weeks) // past two weeks)
if isInitialSync { if isInitialSync {
let existingClosedGroupsIds: [String] = (try? SessionThread let existingClosedGroupsIds: [String] = (try? SessionThread
.filter(SessionThread.Columns.variant == SessionThread.Variant.closedGroup) .filter(SessionThread.Columns.variant == SessionThread.Variant.legacyClosedGroup)
.fetchAll(db)) .fetchAll(db))
.defaulting(to: []) .defaulting(to: [])
.map { $0.id } .map { $0.id }

View File

@ -9,7 +9,7 @@ extension MessageReceiver {
guard let timestampMsValues: [Double] = message.timestamps?.map({ Double($0) }) else { return } guard let timestampMsValues: [Double] = message.timestamps?.map({ Double($0) }) else { return }
guard let readTimestampMs: Double = message.receivedTimestamp.map({ Double($0) }) else { return } guard let readTimestampMs: Double = message.receivedTimestamp.map({ Double($0) }) else { return }
try Interaction.markAsRead( try Interaction.markAsRecipientRead(
db, db,
recipientId: sender, recipientId: sender,
timestampMsValues: timestampMsValues, timestampMsValues: timestampMsValues,

View File

@ -19,7 +19,12 @@ extension MessageReceiver {
guard guard
let interactionId: Int64 = maybeInteraction?.id, 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 } else { return }
// Mark incoming messages as read and remove any of their notifications // Mark incoming messages as read and remove any of their notifications
@ -28,6 +33,7 @@ extension MessageReceiver {
db, db,
interactionId: interactionId, interactionId: interactionId,
threadId: interaction.threadId, threadId: interaction.threadId,
threadVariant: threadVariant,
includingOlder: false, includingOlder: false,
trySendReadReceipt: false trySendReadReceipt: false
) )

View File

@ -54,11 +54,11 @@ extension MessageReceiver {
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies)
let thread: SessionThread = try SessionThread let thread: SessionThread = try SessionThread
.fetchOrCreate(db, id: threadInfo.id, variant: threadInfo.variant) .fetchOrCreate(db, id: threadInfo.id, variant: threadInfo.variant)
let maybeOpenGroup: OpenGroup? = openGroupId.map { try? OpenGroup.fetchOne(db, id: $0) }
let variant: Interaction.Variant = { let variant: Interaction.Variant = {
guard guard
let openGroupId: String = openGroupId,
let senderSessionId: SessionId = SessionId(from: sender), let senderSessionId: SessionId = SessionId(from: sender),
let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: openGroupId) let openGroup: OpenGroup = maybeOpenGroup
else { else {
return (sender == currentUserPublicKey ? return (sender == currentUserPublicKey ?
.standardOutgoing : .standardOutgoing :
@ -118,7 +118,17 @@ extension MessageReceiver {
variant: variant, variant: variant,
body: message.text, body: message.text,
timestampMs: Int64(messageSentTimestamp * 1000), 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( hasMention: Interaction.isUserMentioned(
db, db,
threadId: thread.id, threadId: thread.id,
@ -383,7 +393,7 @@ extension MessageReceiver {
).save(db) ).save(db)
} }
case .closedGroup: case .legacyClosedGroup, .closedGroup:
try GroupMember try GroupMember
.filter(GroupMember.Columns.groupId == thread.id) .filter(GroupMember.Columns.groupId == thread.id)
.fetchAll(db) .fetchAll(db)
@ -410,6 +420,7 @@ extension MessageReceiver {
db, db,
interactionId: interactionId, interactionId: interactionId,
threadId: thread.id, threadId: thread.id,
threadVariant: thread.variant,
includingOlder: true, includingOlder: true,
trySendReadReceipt: true trySendReadReceipt: true
) )

View File

@ -40,7 +40,7 @@ extension MessageSender {
do { do {
// Create the relevant objects in the database // Create the relevant objects in the database
thread = try SessionThread thread = try SessionThread
.fetchOrCreate(db, id: groupPublicKey, variant: .closedGroup) .fetchOrCreate(db, id: groupPublicKey, variant: .legacyClosedGroup)
try ClosedGroup( try ClosedGroup(
threadId: groupPublicKey, threadId: groupPublicKey,
name: name, name: name,
@ -81,7 +81,7 @@ extension MessageSender {
threadId: thread.id, threadId: thread.id,
authorId: userPublicKey, authorId: userPublicKey,
variant: .infoClosedGroupCreated, variant: .infoClosedGroupCreated,
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) timestampMs: SnodeAPI.currentOffsetTimestampMs()
).inserted(db) ).inserted(db)
memberSendData = try members memberSendData = try members
@ -173,7 +173,7 @@ extension MessageSender {
threadId: closedGroup.threadId, threadId: closedGroup.threadId,
publicKey: legacyNewKeyPair.publicKey, publicKey: legacyNewKeyPair.publicKey,
secretKey: legacyNewKeyPair.privateKey, secretKey: legacyNewKeyPair.privateKey,
receivedTimestamp: Date().timeIntervalSince1970 receivedTimestamp: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000)
) )
// Distribute it // Distribute it

View File

@ -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 // 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 } if (try? SessionThread.exists(db, id: groupPublicKey)) != true { return nil }
return (groupPublicKey, .closedGroup) return (groupPublicKey, .legacyClosedGroup)
} }
// Extract the 'syncTarget' value if there is one // Extract the 'syncTarget' value if there is one

View File

@ -974,7 +974,7 @@ public final class MessageSender {
to: .contact(publicKey: userPublicKey), to: .contact(publicKey: userPublicKey),
interactionId: interactionId, interactionId: interactionId,
userPublicKey: userPublicKey, userPublicKey: userPublicKey,
messageSendTimestamp: Int64(floor(Date().timeIntervalSince1970 * 1000)), messageSendTimestamp: SnodeAPI.currentOffsetTimestampMs(),
isSyncMessage: true isSyncMessage: true
), ),
using: dependencies using: dependencies

View File

@ -8,7 +8,9 @@ import SessionSnodeKit
import SessionUtilitiesKit import SessionUtilitiesKit
public final class CurrentUserPoller: Poller { 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<Snode?> = Atomic(nil) private var targetSnode: Atomic<Snode?> = Atomic(nil)
private var usedSnodes: Atomic<Set<Snode>> = Atomic([]) private var usedSnodes: Atomic<Set<Snode>> = Atomic([])

View File

@ -38,7 +38,11 @@ public class TypingIndicators {
} }
// Don't send typing indicators in group threads // 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.threadId = threadId
self.direction = direction self.direction = direction

View File

@ -303,6 +303,11 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
case (false, true): return (.bottom, isOnlyMessageInCluster) case (false, true): return (.bottom, isOnlyMessageInCluster)
} }
}() }()
let isGroupThread: Bool = (
self.threadVariant == .openGroup ||
self.threadVariant == .legacyClosedGroup ||
self.threadVariant == .closedGroup
)
return ViewModel( return ViewModel(
threadId: self.threadId, threadId: self.threadId,
@ -363,9 +368,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
authorName: authorDisplayName, authorName: authorDisplayName,
senderName: { senderName: {
// Only show for group threads // Only show for group threads
guard self.threadVariant == .openGroup || self.threadVariant == .closedGroup else { guard isGroupThread else { return nil }
return nil
}
// Only show for incoming messages // Only show for incoming messages
guard self.variant == .standardIncoming || self.variant == .standardIncomingDeleted else { guard self.variant == .standardIncoming || self.variant == .standardIncomingDeleted else {
@ -381,7 +384,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
}(), }(),
shouldShowProfile: ( shouldShowProfile: (
// Only group threads // Only group threads
(self.threadVariant == .openGroup || self.threadVariant == .closedGroup) && isGroupThread &&
// Only incoming messages // Only incoming messages
(self.variant == .standardIncoming || self.variant == .standardIncomingDeleted) && (self.variant == .standardIncoming || self.variant == .standardIncomingDeleted) &&

View File

@ -100,7 +100,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat
public var canWrite: Bool { public var canWrite: Bool {
switch threadVariant { switch threadVariant {
case .contact: return true case .contact: return true
case .closedGroup: return currentUserIsClosedGroupMember == true case .legacyClosedGroup, .closedGroup: return currentUserIsClosedGroupMember == true
case .openGroup: return openGroupPermissions?.contains(.write) ?? false case .openGroup: return openGroupPermissions?.contains(.write) ?? false
} }
} }
@ -158,14 +158,15 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat
public var profile: Profile? { public var profile: Profile? {
switch threadVariant { switch threadVariant {
case .contact: return contactProfile case .contact: return contactProfile
case .closedGroup: return (closedGroupProfileBack ?? closedGroupProfileBackFallback) case .legacyClosedGroup, .closedGroup:
return (closedGroupProfileBack ?? closedGroupProfileBackFallback)
case .openGroup: return nil case .openGroup: return nil
} }
} }
public var additionalProfile: Profile? { public var additionalProfile: Profile? {
switch threadVariant { switch threadVariant {
case .closedGroup: return closedGroupProfileFront case .legacyClosedGroup, .closedGroup: return closedGroupProfileFront
default: return nil default: return nil
} }
} }
@ -190,7 +191,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat
public var userCount: Int? { public var userCount: Int? {
switch threadVariant { switch threadVariant {
case .contact: return nil case .contact: return nil
case .closedGroup: return closedGroupUserCount case .legacyClosedGroup, .closedGroup: return closedGroupUserCount
case .openGroup: return openGroupUserCount case .openGroup: return openGroupUserCount
} }
} }
@ -1256,7 +1257,10 @@ public extension SessionThreadViewModel {
LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON false LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON false
LEFT JOIN \(OpenGroup.self) 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]) GROUP BY \(thread[.id])
""" """

View File

@ -26,7 +26,7 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol {
) )
var notificationTitle: String = senderName 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 { if thread.onlyNotifyForMentions && !interaction.hasMention {
// Ignore PNs if the group is set to only notify for mentions // Ignore PNs if the group is set to only notify for mentions
return return
@ -127,7 +127,11 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol {
public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) { public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) {
// No call notifications for muted or group threads // No call notifications for muted or group threads
guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } 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 guard
interaction.variant == .infoCall, interaction.variant == .infoCall,
let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8), 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 // No reaction notifications for muted, group threads or message requests
guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } 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 } guard !isMessageRequest else { return }
let senderName: String = Profile.displayName(db, id: reaction.authorId, threadVariant: thread.variant) let senderName: String = Profile.displayName(db, id: reaction.authorId, threadVariant: thread.variant)

View File

@ -8,6 +8,8 @@ public extension SnodeAPI {
case configUserProfile = 2 case configUserProfile = 2
case configContacts = 3 case configContacts = 3
case configConvoInfoVolatile = 4
case configGroups = 5
case configClosedGroupInfo = 11 case configClosedGroupInfo = 11
case legacyClosedGroup = -10 case legacyClosedGroup = -10

View File

@ -429,14 +429,14 @@ class ThreadSettingsViewModelSpec: QuickSpec {
try SessionThread( try SessionThread(
id: "TestId", id: "TestId",
variant: .closedGroup variant: .legacyClosedGroup
).insert(db) ).insert(db)
} }
viewModel = ThreadSettingsViewModel( viewModel = ThreadSettingsViewModel(
dependencies: dependencies, dependencies: dependencies,
threadId: "TestId", threadId: "TestId",
threadVariant: .closedGroup, threadVariant: .legacyClosedGroup,
didTriggerSearch: { didTriggerSearch: {
didTriggerSearchCallbackTriggered = true didTriggerSearchCallbackTriggered = true
} }

View File

@ -219,7 +219,7 @@ public final class ProfilePictureView: UIView {
imageViewHeightConstraint.constant = self.size imageViewHeightConstraint.constant = self.size
imageContainerView.layer.cornerRadius = (self.size / 2) imageContainerView.layer.cornerRadius = (self.size / 2)
case .closedGroup: case .legacyClosedGroup, .closedGroup:
guard !publicKey.isEmpty else { return } guard !publicKey.isEmpty else { return }
// If the `publicKey` we were given matches the first profile id then we have // If the `publicKey` we were given matches the first profile id then we have