mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
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:
parent
4f8fb63f2c
commit
07046db4b6
67 changed files with 1716 additions and 155 deletions
|
@ -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 */,
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
),
|
),
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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")"
|
||||||
)
|
)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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() }
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -27,6 +27,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
@ -124,6 +130,9 @@ public enum SessionUtil {
|
||||||
|
|
||||||
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
|
||||||
|
@ -401,12 +430,14 @@ public enum SessionUtil {
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now that the local state has been updated, trigger a config sync (this will push any
|
return finalResult
|
||||||
// pending updates and properly update the state)
|
|
||||||
if results.contains(where: { $0.value.needsPush }) {
|
|
||||||
ConfigurationSyncJob.enqueue(db)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Now that the local state has been updated, trigger a config sync (this will push any
|
||||||
|
// pending updates and properly update the state)
|
||||||
|
if finalResults.contains(where: { $0.needsPush }) {
|
||||||
|
ConfigurationSyncJob.enqueue(db)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Binary file not shown.
Binary file not shown.
|
@ -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"
|
||||||
|
|
|
@ -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>(); }
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
@ -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() :
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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([])
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) &&
|
||||||
|
|
|
@ -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])
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue