Updated to the latest libSession, fixed a few bugs

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

View File

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

View File

@ -324,12 +324,20 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
}
}()
guard shouldMarkAsRead else { return }
guard
shouldMarkAsRead,
let threadVariant: SessionThread.Variant = try SessionThread
.filter(id: interaction.threadId)
.select(.variant)
.asRequest(of: SessionThread.Variant.self)
.fetchOne(db)
else { return }
try Interaction.markAsRead(
db,
interactionId: interaction.id,
threadId: interaction.threadId,
threadVariant: threadVariant,
includingOlder: false,
trySendReadReceipt: false
)

View File

@ -398,7 +398,8 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
view.addSubview(titleLabel)
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.center(.vertical, in: minimizeButton)
titleLabel.center(.horizontal, in: view)
titleLabel.pin(.left, to: .left, of: view, withInset: Values.largeSpacing)
titleLabel.pin(.right, to: .right, of: view, withInset: Values.largeSpacing)
// Response Panel
view.addSubview(responsePanel)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,6 +14,12 @@ enum _011_SharedUtilChanges: Migration {
static let minExpectedRunDuration: TimeInterval = 0.1
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
t.column(.variant, .text)
.notNull()
@ -94,6 +100,28 @@ enum _011_SharedUtilChanges: Migration {
.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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,614 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtil
import SessionUtilitiesKit
internal extension SessionUtil {
// MARK: - Incoming Changes
static func handleConvoInfoVolatileUpdate(
_ db: Database,
in atomicConf: Atomic<UnsafeMutablePointer<config_object>?>,
mergeResult: ConfResult
) throws -> ConfResult {
guard mergeResult.needsDump else { return mergeResult }
guard atomicConf.wrappedValue != nil else { throw SessionUtilError.nilConfigObject }
// Since we are doing direct memory manipulation we are using an `Atomic` type which has
// blocking access in it's `mutate` closure
let volatileThreadInfo: [VolatileThreadInfo] = atomicConf.mutate { conf -> [VolatileThreadInfo] in
var volatileThreadInfo: [VolatileThreadInfo] = []
var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1()
var openGroup: convo_info_volatile_open = convo_info_volatile_open()
var legacyClosedGroup: convo_info_volatile_legacy_closed = convo_info_volatile_legacy_closed()
let convoIterator: OpaquePointer = convo_info_volatile_iterator_new(conf)
while !convo_info_volatile_iterator_done(convoIterator) {
if convo_info_volatile_it_is_1to1(convoIterator, &oneToOne) {
let sessionId: String = String(cString: withUnsafeBytes(of: oneToOne.session_id) { [UInt8]($0) }
.map { CChar($0) }
.nullTerminated()
)
volatileThreadInfo.append(
VolatileThreadInfo(
threadId: sessionId,
variant: .contact,
changes: [
.markedAsUnread(oneToOne.unread),
.lastReadTimestampMs(oneToOne.last_read)
]
)
)
}
else if convo_info_volatile_it_is_open(convoIterator, &openGroup) {
let server: String = String(cString: withUnsafeBytes(of: openGroup.base_url) { [UInt8]($0) }
.map { CChar($0) }
.nullTerminated()
)
let roomToken: String = String(cString: withUnsafeBytes(of: openGroup.room) { [UInt8]($0) }
.map { CChar($0) }
.nullTerminated()
)
let publicKey: String = String(cString: withUnsafeBytes(of: openGroup.pubkey) { [UInt8]($0) }
.map { CChar($0) }
.nullTerminated()
)
// Note: A normal 'openGroupId' isn't lowercased but the volatile conversation
// info will always be lowercase so we force everything to lowercase to simplify
// the code
volatileThreadInfo.append(
VolatileThreadInfo(
threadId: OpenGroup.idFor(roomToken: roomToken, server: server),
variant: .openGroup,
openGroupUrlInfo: VolatileThreadInfo.OpenGroupUrlInfo(
threadId: OpenGroup.idFor(roomToken: roomToken, server: server),
server: server,
roomToken: roomToken,
publicKey: publicKey
),
changes: [
.markedAsUnread(openGroup.unread),
.lastReadTimestampMs(openGroup.last_read)
]
)
)
}
else if convo_info_volatile_it_is_legacy_closed(convoIterator, &legacyClosedGroup) {
let groupId: String = String(cString: withUnsafeBytes(of: legacyClosedGroup.group_id) { [UInt8]($0) }
.map { CChar($0) }
.nullTerminated()
)
volatileThreadInfo.append(
VolatileThreadInfo(
threadId: groupId,
variant: .legacyClosedGroup,
changes: [
.markedAsUnread(legacyClosedGroup.unread),
.lastReadTimestampMs(legacyClosedGroup.last_read)
]
)
)
}
else {
SNLog("Ignoring unknown conversation type when iterating through volatile conversation info update")
}
convo_info_volatile_iterator_advance(convoIterator)
}
convo_info_volatile_iterator_free(convoIterator) // Need to free the iterator
return volatileThreadInfo
}
// If we don't have any conversations then no need to continue
guard !volatileThreadInfo.isEmpty else { return mergeResult }
// Get the local volatile thread info from all conversations
let localVolatileThreadInfo: [String: VolatileThreadInfo] = VolatileThreadInfo.fetchAll(db)
.reduce(into: [:]) { result, next in result[next.threadId] = next }
// Map the volatileThreadInfo, upserting any changes and returning a list of local changes
// which should override any synced changes (eg. 'lastReadTimestampMs')
let newerLocalChanges: [VolatileThreadInfo] = try volatileThreadInfo
.compactMap { threadInfo -> VolatileThreadInfo? in
// Fetch the "proper" threadId (we need the correct casing for updating the database)
guard
let threadId: String = try? SessionThread
.select(.id)
.filter(SessionThread.Columns.id.lowercased == threadInfo.threadId)
.asRequest(of: String.self)
.fetchOne(db)
else { return nil }
// Get the existing local state for the thread
let localThreadInfo: VolatileThreadInfo? = localVolatileThreadInfo[threadId]
// Update the thread 'markedAsUnread' state
if
let markedAsUnread: Bool = threadInfo.changes.markedAsUnread,
markedAsUnread != (localThreadInfo?.changes.markedAsUnread ?? false)
{
try SessionThread
.filter(id: threadId)
.updateAll( // Handling a config update so don't use `updateAllAndConfig`
db,
SessionThread.Columns.markedAsUnread.set(to: markedAsUnread)
)
}
// If the device has a more recent read interaction then return the info so we can
// update the cached config state accordingly
guard
let lastReadTimestampMs: Int64 = threadInfo.changes.lastReadTimestampMs,
lastReadTimestampMs > (localThreadInfo?.changes.lastReadTimestampMs ?? 0)
else {
// We only want to return the 'lastReadTimestampMs' change, since the local state
// should win in that case, so ignore all others
return localThreadInfo?
.filterChanges { change in
switch change {
case .lastReadTimestampMs: return true
default: return false
}
}
}
// Mark all older interactions as read
try Interaction
.filter(
Interaction.Columns.threadId == threadId &&
Interaction.Columns.timestampMs <= lastReadTimestampMs
)
.updateAll( // Handling a config update so don't use `updateAllAndConfig`
db,
Interaction.Columns.wasRead.set(to: true)
)
return nil
}
// If there are no newer local last read timestamps then just return the mergeResult
guard !newerLocalChanges.isEmpty else { return mergeResult }
return try upsert(
convoInfoVolatileChanges: newerLocalChanges,
in: atomicConf
)
}
static func upsert(
convoInfoVolatileChanges: [VolatileThreadInfo],
in atomicConf: Atomic<UnsafeMutablePointer<config_object>?>
) throws -> ConfResult {
// Since we are doing direct memory manipulation we are using an `Atomic` type which has
// blocking access in it's `mutate` closure
return atomicConf.mutate { conf in
convoInfoVolatileChanges.forEach { threadInfo in
var cThreadId: [CChar] = threadInfo.cThreadId
switch threadInfo.variant {
case .contact:
var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1()
guard convo_info_volatile_get_or_construct_1to1(conf, &oneToOne, &cThreadId) else {
SNLog("Unable to create contact conversation when updating last read timestamp")
return
}
threadInfo.changes.forEach { change in
switch change {
case .lastReadTimestampMs(let lastReadMs):
oneToOne.last_read = lastReadMs
case .markedAsUnread(let unread):
oneToOne.unread = unread
}
}
convo_info_volatile_set_1to1(conf, &oneToOne)
case .legacyClosedGroup:
var legacyClosedGroup: convo_info_volatile_legacy_closed = convo_info_volatile_legacy_closed()
guard convo_info_volatile_get_or_construct_legacy_closed(conf, &legacyClosedGroup, &cThreadId) else {
SNLog("Unable to create legacy group conversation when updating last read timestamp")
return
}
threadInfo.changes.forEach { change in
switch change {
case .lastReadTimestampMs(let lastReadMs):
legacyClosedGroup.last_read = lastReadMs
case .markedAsUnread(let unread):
legacyClosedGroup.unread = unread
}
}
convo_info_volatile_set_legacy_closed(conf, &legacyClosedGroup)
case .openGroup:
guard
var cBaseUrl: [CChar] = threadInfo.cBaseUrl,
var cRoomToken: [CChar] = threadInfo.cRoomToken,
var cPubkey: [CChar] = threadInfo.cPubkey
else {
SNLog("Unable to create community conversation when updating last read timestamp due to missing URL info")
return
}
var openGroup: convo_info_volatile_open = convo_info_volatile_open()
guard convo_info_volatile_get_or_construct_open(conf, &openGroup, &cBaseUrl, &cRoomToken, &cPubkey) else {
SNLog("Unable to create legacy group conversation when updating last read timestamp")
return
}
threadInfo.changes.forEach { change in
switch change {
case .lastReadTimestampMs(let lastReadMs):
openGroup.last_read = lastReadMs
case .markedAsUnread(let unread):
openGroup.unread = unread
}
}
convo_info_volatile_set_open(conf, &openGroup)
case .closedGroup: return // TODO: Need to add when the type is added to the lib
}
}
return ConfResult(
needsPush: config_needs_push(conf),
needsDump: config_needs_dump(conf)
)
}
}
}
// MARK: - Convenience
internal extension SessionUtil {
static func updatingThreads<T>(_ db: Database, _ updated: [T]) throws -> [T] {
guard let updatedThreads: [SessionThread] = updated as? [SessionThread] else {
throw StorageError.generic
}
// If we have no updated threads then no need to continue
guard !updatedThreads.isEmpty else { return updated }
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let changes: [VolatileThreadInfo] = try updatedThreads.map { thread in
VolatileThreadInfo(
threadId: thread.id,
variant: thread.variant,
openGroupUrlInfo: (thread.variant != .openGroup ? nil :
try VolatileThreadInfo.OpenGroupUrlInfo.fetchOne(db, id: thread.id)
),
changes: [.markedAsUnread(thread.markedAsUnread ?? false)]
)
}
db.afterNextTransaction { db in
do {
let atomicConf: Atomic<UnsafeMutablePointer<config_object>?> = SessionUtil.config(
for: .convoInfoVolatile,
publicKey: userPublicKey
)
let result: ConfResult = try upsert(
convoInfoVolatileChanges: changes,
in: atomicConf
)
// If we don't need to dump the data the we can finish early
guard result.needsDump else { return }
try SessionUtil.saveState(
db,
keepingExistingMessageHashes: true,
configDump: try atomicConf.mutate { conf in
try SessionUtil.createDump(
conf: conf,
for: .convoInfoVolatile,
publicKey: userPublicKey,
messageHashes: nil
)
}
)
}
catch {
SNLog("[libSession-util] Failed to dump updated data")
}
}
return updated
}
static func syncThreadLastReadIfNeeded(
_ db: Database,
threadId: String,
threadVariant: SessionThread.Variant,
lastReadTimestampMs: Int64
) throws {
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let atomicConf: Atomic<UnsafeMutablePointer<config_object>?> = SessionUtil.config(
for: .convoInfoVolatile,
publicKey: userPublicKey
)
let change: VolatileThreadInfo = VolatileThreadInfo(
threadId: threadId,
variant: threadVariant,
openGroupUrlInfo: (threadVariant != .openGroup ? nil :
try VolatileThreadInfo.OpenGroupUrlInfo.fetchOne(db, id: threadId)
),
changes: [.lastReadTimestampMs(lastReadTimestampMs)]
)
// Update the conf
let result: ConfResult = try upsert(
convoInfoVolatileChanges: [change],
in: atomicConf
)
// If we need to dump then do so here
if result.needsDump {
try SessionUtil.saveState(
db,
keepingExistingMessageHashes: true,
configDump: try atomicConf.mutate { conf in
try SessionUtil.createDump(
conf: conf,
for: .contacts,
publicKey: userPublicKey,
messageHashes: nil
)
}
)
}
// If we need to push then enqueue a 'ConfigurationSyncJob'
if result.needsPush {
ConfigurationSyncJob.enqueue(db)
}
}
static func timestampAlreadyRead(
threadId: String,
threadVariant: SessionThread.Variant,
timestampMs: Int64,
userPublicKey: String,
openGroup: OpenGroup?
) -> Bool {
let atomicConf: Atomic<UnsafeMutablePointer<config_object>?> = SessionUtil.config(
for: .convoInfoVolatile,
publicKey: userPublicKey
)
// If we don't have a config then just assume it's unread
guard atomicConf.wrappedValue != nil else { return false }
// Since we are doing direct memory manipulation we are using an `Atomic` type which has
// blocking access in it's `mutate` closure
return atomicConf.mutate { conf in
switch threadVariant {
case .contact:
var cThreadId: [CChar] = threadId
.bytes
.map { CChar(bitPattern: $0) }
var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1()
guard convo_info_volatile_get_1to1(conf, &oneToOne, &cThreadId) else { return false }
return (oneToOne.last_read > timestampMs)
case .legacyClosedGroup:
var cThreadId: [CChar] = threadId
.bytes
.map { CChar(bitPattern: $0) }
var legacyClosedGroup: convo_info_volatile_legacy_closed = convo_info_volatile_legacy_closed()
guard convo_info_volatile_get_legacy_closed(conf, &legacyClosedGroup, &cThreadId) else {
return false
}
return (legacyClosedGroup.last_read > timestampMs)
case .openGroup:
guard let openGroup: OpenGroup = openGroup else { return false }
var cBaseUrl: [CChar] = openGroup.server
.bytes
.map { CChar(bitPattern: $0) }
var cRoomToken: [CChar] = openGroup.roomToken
.bytes
.map { CChar(bitPattern: $0) }
var cPubKey: [CChar] = openGroup.publicKey
.bytes
.map { CChar(bitPattern: $0) }
var convoOpenGroup: convo_info_volatile_open = convo_info_volatile_open()
guard convo_info_volatile_get_open(conf, &convoOpenGroup, &cBaseUrl, &cRoomToken, &cPubKey) else {
return false
}
return (convoOpenGroup.last_read > timestampMs)
case .closedGroup: return false // TODO: Need to add when the type is added to the lib
}
}
}
}
// MARK: - VolatileThreadInfo
public extension SessionUtil {
struct VolatileThreadInfo {
enum Change {
case markedAsUnread(Bool)
case lastReadTimestampMs(Int64)
}
fileprivate struct OpenGroupUrlInfo: FetchableRecord, Codable, Hashable {
let threadId: String
let server: String
let roomToken: String
let publicKey: String
static func fetchOne(_ db: Database, id: String) throws -> OpenGroupUrlInfo? {
return try OpenGroup
.filter(id: id)
.select(.threadId, .server, .roomToken, .publicKey)
.asRequest(of: OpenGroupUrlInfo.self)
.fetchOne(db)
}
}
let threadId: String
let variant: SessionThread.Variant
private let openGroupUrlInfo: OpenGroupUrlInfo?
let changes: [Change]
var cThreadId: [CChar] {
threadId.bytes.map { CChar(bitPattern: $0) }
}
var cBaseUrl: [CChar]? {
(openGroupUrlInfo?.server).map {
$0.bytes.map { CChar(bitPattern: $0) }
}
}
var cRoomToken: [CChar]? {
(openGroupUrlInfo?.roomToken).map {
$0.bytes.map { CChar(bitPattern: $0) }
}
}
var cPubkey: [CChar]? {
(openGroupUrlInfo?.publicKey).map {
$0.bytes.map { CChar(bitPattern: $0) }
}
}
fileprivate init(
threadId: String,
variant: SessionThread.Variant,
openGroupUrlInfo: OpenGroupUrlInfo? = nil,
changes: [Change]
) {
self.threadId = threadId
self.variant = variant
self.openGroupUrlInfo = openGroupUrlInfo
self.changes = changes
}
// MARK: - Convenience
func filterChanges(isIncluded: (Change) -> Bool) -> VolatileThreadInfo {
return VolatileThreadInfo(
threadId: threadId,
variant: variant,
openGroupUrlInfo: openGroupUrlInfo,
changes: changes.filter(isIncluded)
)
}
static func fetchAll(_ db: Database? = nil, ids: [String]? = nil) -> [VolatileThreadInfo] {
guard let db: Database = db else {
return Storage.shared
.read { db in fetchAll(db, ids: ids) }
.defaulting(to: [])
}
struct FetchedInfo: FetchableRecord, Codable, Hashable {
let id: String
let variant: SessionThread.Variant
let markedAsUnread: Bool?
let timestampMs: Int64?
let server: String?
let roomToken: String?
let publicKey: String?
}
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
let request: SQLRequest<FetchedInfo> = """
SELECT
\(thread[.id]),
\(thread[.variant]),
\(thread[.markedAsUnread]),
MAX(\(interaction[.timestampMs])),
\(openGroup[.server]),
\(openGroup[.roomToken]),
\(openGroup[.publicKey])
FROM \(SessionThread.self)
LEFT JOIN \(Interaction.self) ON (
\(interaction[.threadId]) = \(thread[.id]) AND
\(interaction[.wasRead]) = true AND
-- Note: Due to the complexity of how call messages are handled and the short
-- duration they exist in the swarm, we have decided to exclude trying to
-- include them when syncing the read status of conversations (they are also
-- implemented differently between platforms so including them could be a
-- significant amount of work)
\(SQL("\(interaction[.variant]) IN \(Interaction.Variant.variantsToIncrementUnreadCount.filter { $0 != .infoCall })"))
)
LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])
\(ids == nil ? SQL("") :
"WHERE \(SQL("\(thread[.id]) IN \(ids ?? [])"))"
)
"""
return ((try? request.fetchAll(db)) ?? [])
.map { threadInfo in
VolatileThreadInfo(
threadId: threadInfo.id,
variant: threadInfo.variant,
openGroupUrlInfo: {
guard
let server: String = threadInfo.server,
let roomToken: String = threadInfo.roomToken,
let publicKey: String = threadInfo.publicKey
else { return nil }
return VolatileThreadInfo.OpenGroupUrlInfo(
threadId: threadInfo.id,
server: server,
roomToken: roomToken,
publicKey: publicKey
)
}(),
changes: [
.markedAsUnread(threadInfo.markedAsUnread ?? false),
.lastReadTimestampMs(threadInfo.timestampMs ?? 0)
]
)
}
}
}
}
fileprivate extension [SessionUtil.VolatileThreadInfo.Change] {
var markedAsUnread: Bool? {
for change in self {
switch change {
case .markedAsUnread(let value): return value
default: continue
}
}
return nil
}
var lastReadTimestampMs: Int64? {
for change in self {
switch change {
case .lastReadTimestampMs(let value): return value
default: continue
}
}
return nil
}
}

View File

@ -0,0 +1,19 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtil
import SessionUtilitiesKit
internal extension SessionUtil {
// MARK: - Incoming Changes
static func handleGroupsUpdate(
_ db: Database,
in atomicConf: Atomic<UnsafeMutablePointer<config_object>?>,
mergeResult: ConfResult
) throws -> ConfResult {
// TODO: This
return mergeResult
}
}

View File

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

View File

@ -26,6 +26,10 @@ public extension QueryInterfaceRequest {
case let profileRequest as QueryInterfaceRequest<Profile>:
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)
}
@ -73,6 +77,9 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table
case is QueryInterfaceRequest<Profile>:
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)
}
}

View File

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

View File

@ -4,18 +4,6 @@
<dict>
<key>AvailableLibraries</key>
<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>
<key>LibraryIdentifier</key>
<string>ios-arm64_x86_64-simulator</string>
@ -31,6 +19,18 @@
<key>SupportedPlatformVariant</key>
<string>simulator</string>
</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>
<key>CFBundlePackageType</key>
<string>XFWK</string>

View File

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

View File

@ -103,39 +103,47 @@ class ConfigBase {
// See if we can find the key without needing to create anything, so that we can attempt to
// access values without mutating anything (which allows, among other things, for assigning
// of the existing value to not dirty anything). Returns nullptr if the value or something
// of the existing value to not dirty anything). Returns nullptrs if the value or something
// along its path would need to be created, or has the wrong type; otherwise a const pointer
// to the value. The templated type, if provided, can be one of the types a dict_value can
// hold to also check that the returned value has a particular type; if omitted you get back
// the dict_value pointer itself.
// to the key and the value. The templated type, if provided, can be one of the types a
// dict_value can hold to also check that the returned value has a particular type; if
// omitted you get back the dict_value pointer itself. If the field exists but is not the
// requested `T` type, you get back the key string pointer with a nullptr value.
template <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();
// All but the last need to be dicts:
for (const auto& key : _inter_keys) {
auto it = data->find(key);
data = it != data->end() ? std::get_if<config::dict>(&it->second) : nullptr;
if (!data)
return nullptr;
return {nullptr, nullptr};
}
const std::string* key;
const dict_value* val;
// The last can be any value type:
if (auto it = data->find(_last_key); it != data->end())
if (auto it = data->find(_last_key); it != data->end()) {
key = &it->first;
val = &it->second;
else
return nullptr;
} else
return {nullptr, nullptr};
if constexpr (std::is_same_v<T, dict_value>)
return val;
return {key, val};
else if constexpr (is_dict_subtype<T>) {
if (auto* v = std::get_if<T>(val))
return v;
return {key, std::get_if<T>(val)};
} else { // int64 or std::string, i.e. the config::scalar sub-types.
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
@ -233,6 +241,11 @@ class ConfigBase {
return std::move(*this);
}
/// Returns a pointer to the (deepest level) key for this dict pair *if* a pair exists at
/// the given location, nullptr otherwise. This allows a caller to get a reference to the
/// actual key, rather than an ephemeral copy of the current key value.
const std::string* key() const { return get_clean_pair().first; }
/// Returns a const pointer to the string if one exists at the given location, nullptr
/// otherwise.
const std::string* string() const { return get_clean<std::string>(); }

View File

@ -6,6 +6,7 @@ extern "C" {
#include "base.h"
#include "profile_pic.h"
#include "util.h"
typedef struct contacts_contact {
char session_id[67]; // in hex; 66 hex chars + null terminator.
@ -48,11 +49,6 @@ int contacts_init(
size_t dumplen,
char* error) __attribute__((warn_unused_result));
/// Returns true if session_id has the right form (66 hex digits). This is a quick check, not a
/// robust one: it does not check the leading byte prefix, nor the cryptographic properties of the
/// pubkey for actual validity.
bool session_id_is_valid(const char* session_id);
/// Fills `contact` with the contact info given a session ID (specified as a null-terminated hex
/// string), if the contact exists, and returns true. If the contact does not exist then `contact`
/// is left unchanged and false is returned.

View File

@ -43,15 +43,26 @@ struct contact_info {
bool approved_me = false;
bool blocked = false;
contact_info(std::string sid);
explicit contact_info(std::string sid);
// Internal ctor/method for C API implementations:
contact_info(const struct contacts_contact& c); // From c struct
void into(contacts_contact& c); // Into c struct
void into(contacts_contact& c) const; // Into c struct
// Sets a name, storing the name internally in the object. This is intended for use where the
// source string is a temporary may not outlive the `contact_info` object: the name is first
// copied into an internal std::string, and then the name string_view references that.
void set_name(std::string name);
// Same as above, but for nickname.
void set_nickname(std::string nickname);
private:
friend class Contacts;
std::string name_;
std::string nickname_;
void load(const dict& info_dict);
};
@ -101,8 +112,8 @@ class Contacts : public ConfigBase {
void set(const contact_info& contact);
/// Alternative to `set()` for setting individual fields.
void set_name(std::string_view session_id, std::string_view name);
void set_nickname(std::string_view session_id, std::string_view nickname);
void set_name(std::string_view session_id, std::string name);
void set_nickname(std::string_view session_id, std::string nickname);
void set_profile_pic(std::string_view session_id, profile_pic pic);
void set_approved(std::string_view session_id, bool approved);
void set_approved_me(std::string_view session_id, bool approved_me);

View File

@ -0,0 +1,219 @@
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
#include "base.h"
#include "profile_pic.h"
typedef struct convo_info_volatile_1to1 {
char session_id[67]; // in hex; 66 hex chars + null terminator.
int64_t last_read; // milliseconds since unix epoch
bool unread; // true if the conversation is explicitly marked unread
} convo_info_volatile_1to1;
typedef struct convo_info_volatile_open {
char base_url[268]; // null-terminated (max length 267), normalized (i.e. always lower-case,
// only has port if non-default, has trailing / removed)
char room[65]; // null-terminated (max length 64), normalized (always lower-case)
unsigned char pubkey[32]; // 32 bytes (not terminated, can contain nulls)
int64_t last_read; // ms since unix epoch
bool unread; // true if marked unread
} convo_info_volatile_open;
typedef struct convo_info_volatile_legacy_closed {
char group_id[67]; // in hex; 66 hex chars + null terminator. Looks just like a Session ID,
// though isn't really one.
int64_t last_read; // ms since unix epoch
bool unread; // true if marked unread
} convo_info_volatile_legacy_closed;
/// Constructs a conversations config object and sets a pointer to it in `conf`.
///
/// \param ed25519_secretkey must be the 32-byte secret key seed value. (You can also pass the
/// pointer to the beginning of the 64-byte value libsodium calls the "secret key" as the first 32
/// bytes of that are the seed). This field cannot be null.
///
/// \param dump - if non-NULL this restores the state from the dumped byte string produced by a past
/// instantiation's call to `dump()`. To construct a new, empty object this should be NULL.
///
/// \param dumplen - the length of `dump` when restoring from a dump, or 0 when `dump` is NULL.
///
/// \param error - the pointer to a buffer in which we will write an error string if an error
/// occurs; error messages are discarded if this is given as NULL. If non-NULL this must be a
/// buffer of at least 256 bytes.
///
/// Returns 0 on success; returns a non-zero error code and write the exception message as a
/// C-string into `error` (if not NULL) on failure.
///
/// When done with the object the `config_object` must be destroyed by passing the pointer to
/// config_free() (in `session/config/base.h`).
int convo_info_volatile_init(
config_object** conf,
const unsigned char* ed25519_secretkey,
const unsigned char* dump,
size_t dumplen,
char* error) __attribute__((warn_unused_result));
/// Fills `convo` with the conversation info given a session ID (specified as a null-terminated hex
/// string), if the conversation exists, and returns true. If the conversation does not exist then
/// `convo` is left unchanged and false is returned.
bool convo_info_volatile_get_1to1(
const config_object* conf, convo_info_volatile_1to1* convo, const char* session_id)
__attribute__((warn_unused_result));
/// Same as the above except that when the conversation does not exist, this sets all the convo
/// fields to defaults and loads it with the given session_id.
///
/// Returns true as long as it is given a valid session_id. A false return is considered an error,
/// and means the session_id was not a valid session_id.
///
/// This is the method that should usually be used to create or update a conversation, followed by
/// setting fields in the convo, and then giving it to convo_info_volatile_set().
bool convo_info_volatile_get_or_construct_1to1(
const config_object* conf, convo_info_volatile_1to1* convo, const char* session_id)
__attribute__((warn_unused_result));
/// open-group versions of the 1-to-1 functions:
///
/// Gets an open group convo info. `base_url` and `room` are null-terminated c strings; pubkey is
/// 32 bytes. base_url and room will always be lower-cased (if not already).
bool convo_info_volatile_get_open(
const config_object* conf,
convo_info_volatile_open* og,
const char* base_url,
const char* room,
unsigned const char* pubkey) __attribute__((warn_unused_result));
bool convo_info_volatile_get_or_construct_open(
const config_object* conf,
convo_info_volatile_open* convo,
const char* base_url,
const char* room,
unsigned const char* pubkey) __attribute__((warn_unused_result));
/// Fills `convo` with the conversation info given a legacy closed group ID (specified as a
/// null-terminated hex string), if the conversation exists, and returns true. If the conversation
/// does not exist then `convo` is left unchanged and false is returned.
bool convo_info_volatile_get_legacy_closed(
const config_object* conf, convo_info_volatile_legacy_closed* convo, const char* id)
__attribute__((warn_unused_result));
/// Same as the above except that when the conversation does not exist, this sets all the convo
/// fields to defaults and loads it with the given id.
///
/// Returns true as long as it is given a valid legacy closed group id (i.e. same format as a
/// session id). A false return is considered an error, and means the id was not a valid session
/// id.
///
/// This is the method that should usually be used to create or update a conversation, followed by
/// setting fields in the convo, and then giving it to convo_info_volatile_set().
bool convo_info_volatile_get_or_construct_legacy_closed(
const config_object* conf, convo_info_volatile_legacy_closed* convo, const char* id)
__attribute__((warn_unused_result));
/// Adds or updates a conversation from the given convo info
void convo_info_volatile_set_1to1(config_object* conf, const convo_info_volatile_1to1* convo);
void convo_info_volatile_set_open(config_object* conf, const convo_info_volatile_open* convo);
void convo_info_volatile_set_legacy_closed(
config_object* conf, const convo_info_volatile_legacy_closed* convo);
/// Erases a conversation from the conversation list. Returns true if the conversation was found
/// and removed, false if the conversation was not present. You must not call this during
/// iteration; see details below.
bool convo_info_volatile_erase_1to1(config_object* conf, const char* session_id);
bool convo_info_volatile_erase_open(
config_object* conf, const char* base_url, const char* room, unsigned const char* pubkey);
bool convo_info_volatile_erase_legacy_closed(config_object* conf, const char* group_id);
/// Returns the number of conversations.
size_t convo_info_volatile_size(const config_object* conf);
/// Returns the number of conversations of the specific type.
size_t convo_info_volatile_size_1to1(const config_object* conf);
size_t convo_info_volatile_size_open(const config_object* conf);
size_t convo_info_volatile_size_legacy_closed(const config_object* conf);
/// Functions for iterating through the entire conversation list. Intended use is:
///
/// convo_info_volatile_1to1 c1;
/// convo_info_volatile_open c2;
/// convo_info_volatile_legacy_closed c3;
/// convo_info_volatile_iterator *it = convo_info_volatile_iterator_new(my_convos);
/// for (; !convo_info_volatile_iterator_done(it); convo_info_volatile_iterator_advance(it)) {
/// if (convo_info_volatile_it_is_1to1(it, &c1)) {
/// // use c1.whatever
/// } else if (convo_info_volatile_it_is_open(it, &c2)) {
/// // use c2.whatever
/// } else if (convo_info_volatile_it_is_legacy_closed(it, &c3)) {
/// // use c3.whatever
/// }
/// }
/// convo_info_volatile_iterator_free(it);
///
/// It is permitted to modify records (e.g. with a call to one of the `convo_info_volatile_set_*`
/// functions) and add records while iterating.
///
/// If you need to remove while iterating then usage is slightly different: you must advance the
/// iteration by calling either convo_info_volatile_iterator_advance if not deleting, or
/// convo_info_volatile_iterator_erase to erase and advance. Usage looks like this:
///
/// convo_info_volatile_1to1 c1;
/// convo_info_volatile_iterator *it = convo_info_volatile_iterator_new(my_convos);
/// while (!convo_info_volatile_iterator_done(it)) {
/// if (convo_it_is_1to1(it, &c1)) {
/// bool should_delete = /* ... */;
/// if (should_delete)
/// convo_info_volatile_iterator_erase(it);
/// else
/// convo_info_volatile_iterator_advance(it);
/// }
/// }
/// convo_info_volatile_iterator_free(it);
///
typedef struct convo_info_volatile_iterator convo_info_volatile_iterator;
// Starts a new iterator that iterates over all conversations.
convo_info_volatile_iterator* convo_info_volatile_iterator_new(const config_object* conf);
// The same as `convo_info_volatile_iterator_new` except that this iterates *only* over one type of
// conversation. You still need to use `convo_info_volatile_it_is_1to1` (or the alternatives) to
// load the data in each pass of the loop. (You can, however, safely ignore the bool return value
// of the `it_is_whatever` function: it will always be true for the particular type being iterated
// over).
convo_info_volatile_iterator* convo_info_volatile_iterator_new_1to1(const config_object* conf);
convo_info_volatile_iterator* convo_info_volatile_iterator_new_open(const config_object* conf);
convo_info_volatile_iterator* convo_info_volatile_iterator_new_legacy_closed(
const config_object* conf);
// Frees an iterator once no longer needed.
void convo_info_volatile_iterator_free(convo_info_volatile_iterator* it);
// Returns true if iteration has reached the end.
bool convo_info_volatile_iterator_done(convo_info_volatile_iterator* it);
// Advances the iterator.
void convo_info_volatile_iterator_advance(convo_info_volatile_iterator* it);
// If the current iterator record is a 1-to-1 conversation this sets the details into `c` and
// returns true. Otherwise it returns false.
bool convo_info_volatile_it_is_1to1(convo_info_volatile_iterator* it, convo_info_volatile_1to1* c);
// If the current iterator record is an open group conversation this sets the details into `c` and
// returns true. Otherwise it returns false.
bool convo_info_volatile_it_is_open(convo_info_volatile_iterator* it, convo_info_volatile_open* c);
// If the current iterator record is a legacy closed group conversation this sets the details into
// `c` and returns true. Otherwise it returns false.
bool convo_info_volatile_it_is_legacy_closed(
convo_info_volatile_iterator* it, convo_info_volatile_legacy_closed* c);
// Erases the current convo while advancing the iterator to the next convo in the iteration.
void convo_info_volatile_iterator_erase(config_object* conf, convo_info_volatile_iterator* it);
#ifdef __cplusplus
} // extern "C"
#endif

View File

@ -0,0 +1,380 @@
#pragma once
#include <chrono>
#include <cstddef>
#include <iterator>
#include <memory>
#include <session/config.hpp>
#include "base.hpp"
extern "C" {
struct convo_info_volatile_1to1;
struct convo_info_volatile_open;
struct convo_info_volatile_legacy_closed;
}
namespace session::config {
class ConvoInfoVolatile;
/// keys used in this config, either currently or in the past (so that we don't reuse):
///
/// Note that this is a high-frequency object, intended only for properties that change frequently (
/// (currently just the read timestamp for each conversation).
///
/// 1 - dict of one-to-one conversations. Each key is the Session ID of the contact (in hex).
/// Values are dicts with keys:
/// r - the unix timestamp (in integer milliseconds) of the last-read message. Always
/// included, but will be 0 if no messages are read.
/// u - will be present and set to 1 if this conversation is specifically marked unread.
///
/// o - open group conversations. Each key is: BASE_URL + '\0' + LC_ROOM_NAME + '\0' +
/// SERVER_PUBKEY (in bytes). Note that room name is *always* lower-cased here (so that clients
/// with the same room but with different cases will always set the same key). Values are dicts
/// with keys:
/// r - the unix timestamp (in integer milliseconds) of the last-read message. Always included,
/// but will be 0 if no messages are read.
/// u - will be present and set to 1 if this conversation is specifically marked unread.
///
/// C - legacy closed group conversations. The key is the closed group identifier (which looks
/// indistinguishable from a Session ID, but isn't really a proper Session ID). Values are
/// dicts with keys:
/// r - the unix timestamp (integer milliseconds) of the last-read message. Always included,
/// but will be 0 if no messages are read.
/// u - will be present and set to 1 if this conversation is specifically marked unread.
///
/// c - reserved for future tracking of new closed group conversations.
namespace convo {
struct base {
int64_t last_read = 0;
bool unread = false;
protected:
void load(const dict& info_dict);
};
struct one_to_one : base {
std::string session_id; // in hex
// Constructs an empty one_to_one from a session_id. Session ID can be either bytes (33) or
// hex (66).
explicit one_to_one(std::string&& session_id);
explicit one_to_one(std::string_view session_id);
// Internal ctor/method for C API implementations:
one_to_one(const struct convo_info_volatile_1to1& c); // From c struct
void into(convo_info_volatile_1to1& c) const; // Into c struct
friend class session::config::ConvoInfoVolatile;
};
struct open_group : base {
// 267 = len('https://') + 253 (max valid DNS name length) + len(':XXXXX')
static constexpr size_t MAX_URL = 267, MAX_ROOM = 64;
std::string_view base_url() const; // Accesses the base url (i.e. not including room or
// pubkey). Always lower-case.
std::string_view room()
const; // Accesses the room name, always in lower-case. (Note that the
// actual open group info might not be lower-case; it is just in
// the open group convo where we force it lower-case).
ustring_view pubkey() const; // Accesses the server pubkey (32 bytes).
std::string pubkey_hex() const; // Accesses the server pubkey as hex (64 hex digits).
open_group() = default;
// Constructs an empty open_group convo struct from url, room, and pubkey. `base_url` and
// `room` will be lower-cased if not already (they do not have to be passed lower-case).
// pubkey is 32 bytes.
open_group(std::string_view base_url, std::string_view room, ustring_view pubkey);
// Same as above, but takes pubkey as a hex string.
open_group(std::string_view base_url, std::string_view room, std::string_view pubkey_hex);
// Takes a combined room URL (e.g. https://whatever.com/r/Room?public_key=01234....), either
// new style (with /r/) or old style (without /r/). Note that the URL gets canonicalized so
// the resulting `base_url()` and `room()` values may not be exactly equal to what is given.
//
// See also `parse_full_url` which does the same thing but returns it in pieces rather than
// constructing a new `open_group` object.
explicit open_group(std::string_view full_url);
// Internal ctor/method for C API implementations:
open_group(const struct convo_info_volatile_open& c); // From c struct
void into(convo_info_volatile_open& c) const; // Into c struct
// Replaces the baseurl/room/pubkey of this object. Note that changing this and then giving
// it to `set` will end up inserting a *new* record but not removing the *old* one (you need
// to erase first to do that).
void set_server(std::string_view base_url, std::string_view room, ustring_view pubkey);
void set_server(
std::string_view base_url, std::string_view room, std::string_view pubkey_hex);
void set_server(std::string_view full_url);
// Loads the baseurl/room/pubkey of this object from an encoded key. Throws
// std::invalid_argument if the encoded key does not look right.
void load_encoded_key(std::string key);
// Takes a base URL as input and returns it in canonical form. This involves doing things
// like lower casing it and removing redundant ports (e.g. :80 when using http://).
static std::string canonical_url(std::string_view url);
// Takes a full room URL, splits it up into canonical url (see above), lower-case room
// token, and server pubkey. We take both the deprecated form (e.g.
// https://example.com/SomeRoom?public_key=...) and new form
// (https://example.com/r/SomeRoom?public_key=...). The public_key is typically specified
// in hex (64 digits), but we also accept unpadded base64 (43 chars) and base32z (52 chars)
// encodings (for slightly shorter URLs).
static std::tuple<std::string, std::string, ustring> parse_full_url(
std::string_view full_url);
private:
std::string key;
size_t url_size = 0;
friend class session::config::ConvoInfoVolatile;
// Returns the key value we use in the stored dict for this open group, i.e.
// lc(URL) + lc(NAME) + PUBKEY_BYTES.
static std::string make_key(
std::string_view base_url, std::string_view room, std::string_view pubkey_hex);
static std::string make_key(
std::string_view base_url, std::string_view room, ustring_view pubkey);
};
struct legacy_closed_group : base {
std::string id; // in hex, indistinguishable from a Session ID
// Constructs an empty legacy_closed_group from a quasi-session_id
explicit legacy_closed_group(std::string&& group_id);
explicit legacy_closed_group(std::string_view group_id);
// Internal ctor/method for C API implementations:
legacy_closed_group(const struct convo_info_volatile_legacy_closed& c); // From c struct
void into(convo_info_volatile_legacy_closed& c) const; // Into c struct
private:
friend class session::config::ConvoInfoVolatile;
};
using any = std::variant<one_to_one, open_group, legacy_closed_group>;
} // namespace convo
class ConvoInfoVolatile : public ConfigBase {
public:
// No default constructor
ConvoInfoVolatile() = delete;
/// Constructs a conversation list from existing data (stored from `dump()`) and the user's
/// secret key for generating the data encryption key. To construct a blank list (i.e. with no
/// pre-existing dumped data to load) pass `std::nullopt` as the second argument.
///
/// \param ed25519_secretkey - contains the libsodium secret key used to encrypt/decrypt the
/// data when pushing/pulling from the swarm. This can either be the full 64-byte value (which
/// is technically the 32-byte seed followed by the 32-byte pubkey), or just the 32-byte seed of
/// the secret key.
///
/// \param dumped - either `std::nullopt` to construct a new, empty object; or binary state data
/// that was previously dumped from an instance of this class by calling `dump()`.
ConvoInfoVolatile(ustring_view ed25519_secretkey, std::optional<ustring_view> dumped);
Namespace storage_namespace() const override { return Namespace::ConvoInfoVolatile; }
const char* encryption_domain() const override { return "ConvoInfoVolatile"; }
/// Looks up and returns a contact by session ID (hex). Returns nullopt if the session ID was
/// not found, otherwise returns a filled out `convo::one_to_one`.
std::optional<convo::one_to_one> get_1to1(std::string_view session_id) const;
/// Looks up and returns an open group conversation. Takes the base URL, room name (case
/// insensitive), and pubkey (in hex). Retuns nullopt if the open group was not found,
/// otherwise a filled out `convo::open_group`.
std::optional<convo::open_group> get_open(
std::string_view base_url, std::string_view room, std::string_view pubkey_hex) const;
/// Same as above, but takes the pubkey as bytes instead of hex
std::optional<convo::open_group> get_open(
std::string_view base_url, std::string_view room, ustring_view pubkey) const;
/// Looks up and returns a legacy closed group conversation by ID. The ID looks like a hex
/// Session ID, but isn't really a Session ID. Returns nullopt if there is no record of the
/// closed group conversation.
std::optional<convo::legacy_closed_group> get_legacy_closed(std::string_view pubkey_hex) const;
/// These are the same as the above methods (without "_or_construct" in the name), except that
/// when the conversation doesn't exist a new one is created, prefilled with the pubkey/url/etc.
convo::one_to_one get_or_construct_1to1(std::string_view session_id) const;
convo::open_group get_or_construct_open(
std::string_view base_url, std::string_view room, std::string_view pubkey_hex) const;
convo::open_group get_or_construct_open(
std::string_view base_url, std::string_view room, ustring_view pubkey) const;
convo::legacy_closed_group get_or_construct_legacy_closed(std::string_view pubkey_hex) const;
/// Inserts or replaces existing conversation info. For example, to update a 1-to-1
/// conversation last read time you would do:
///
/// auto info = conversations.get_or_construct_1to1(some_session_id);
/// info.last_read = new_unix_timestamp;
/// conversations.set(info);
///
void set(const convo::one_to_one& c);
void set(const convo::legacy_closed_group& c);
void set(const convo::open_group& c);
void set(const convo::any& c); // Variant which can be any of the above
protected:
void set_base(const convo::base& c, DictFieldProxy& info);
public:
/// Removes a one-to-one conversation. Returns true if found and removed, false if not present.
bool erase_1to1(std::string_view pubkey);
/// Removes an open group conversation record. Returns true if found and removed, false if not
/// present. Arguments are the same as `get_open`.
bool erase_open(std::string_view base_url, std::string_view room, std::string_view pubkey_hex);
bool erase_open(std::string_view base_url, std::string_view room, ustring_view pubkey);
/// Removes a legacy closed group conversation. Returns true if found and removed, false if not
/// present.
bool erase_legacy_closed(std::string_view pubkey_hex);
/// Removes a conversation taking the convo::whatever record (rather than the pubkey/url).
bool erase(const convo::one_to_one& c);
bool erase(const convo::open_group& c);
bool erase(const convo::legacy_closed_group& c);
bool erase(const convo::any& c); // Variant of any of them
struct iterator;
/// This works like erase, but takes an iterator to the conversation to remove. The element is
/// removed and the iterator to the next element after the removed one is returned. This is
/// intended for use where elements are to be removed during iteration: see below for an
/// example.
iterator erase(iterator it);
/// Returns the number of conversations (of any type).
size_t size() const;
/// Returns the number of 1-to-1, open group, and legacy closed group conversations,
/// respectively.
size_t size_1to1() const;
size_t size_open() const;
size_t size_legacy_closed() const;
/// Returns true if the conversation list is empty.
bool empty() const { return size() == 0; }
/// Iterators for iterating through all conversations. Typically you access this implicit via a
/// for loop over the `ConvoInfoVolatile` object:
///
/// for (auto& convo : conversations) {
/// if (auto* dm = std::get_if<convo::one_to_one>(&convo)) {
/// // use dm->session_id, dm->last_read, etc.
/// } else if (auto* og = std::get_if<convo::open_group>(&convo)) {
/// // use og->base_url, og->room, om->last_read, etc.
/// } else if (auto* lcg = std::get_if<convo::legacy_closed_group>(&convo)) {
/// // use lcg->id, lcg->last_read
/// }
/// }
///
/// This iterates through all conversations in sorted order (sorted first by convo type, then by
/// id within the type).
///
/// It is permitted to modify and add records while iterating (e.g. by modifying one of the
/// `dm`/`og`/`lcg` and then calling set()).
///
/// If you need to erase the current conversation during iteration then care is required: you
/// need to advance the iterator via the iterator version of erase when erasing an element
/// rather than incrementing it regularly. For example:
///
/// for (auto it = conversations.begin(); it != conversations.end(); ) {
/// if (should_remove(*it))
/// it = converations.erase(it);
/// else
/// ++it;
/// }
///
/// Alternatively, you can use the first version with two loops: the first loop through all
/// converations doesn't erase but just builds a vector of IDs to erase, then the second loops
/// through that vector calling `erase_1to1()`/`erase_open()`/`erase_legacy_closed()` for each
/// one.
///
iterator begin() const { return iterator{data}; }
iterator end() const { return iterator{}; }
template <typename ConvoType>
struct subtype_iterator;
/// Returns an iterator that iterates only through one type of conversations
subtype_iterator<convo::one_to_one> begin_1to1() const { return {data}; }
subtype_iterator<convo::open_group> begin_open() const { return {data}; }
subtype_iterator<convo::legacy_closed_group> begin_legacy_closed() const { return {data}; }
using iterator_category = std::input_iterator_tag;
using value_type =
std::variant<convo::one_to_one, convo::open_group, convo::legacy_closed_group>;
using reference = value_type&;
using pointer = value_type*;
using difference_type = std::ptrdiff_t;
struct iterator {
protected:
std::shared_ptr<convo::any> _val;
std::optional<dict::const_iterator> _it_11, _end_11, _it_open, _end_open, _it_lclosed,
_end_lclosed;
void _load_val();
iterator() = default; // Constructs an end tombstone
explicit iterator(
const DictFieldRoot& data,
bool oneto1 = true,
bool open = true,
bool closed = true);
friend class ConvoInfoVolatile;
public:
bool operator==(const iterator& other) const;
bool operator!=(const iterator& other) const { return !(*this == other); }
bool done() const; // Equivalent to comparing against the end iterator
convo::any& operator*() const { return *_val; }
convo::any* operator->() const { return _val.get(); }
iterator& operator++();
iterator operator++(int) {
auto copy{*this};
++*this;
return copy;
}
};
template <typename ConvoType>
struct subtype_iterator : iterator {
protected:
subtype_iterator(const DictFieldRoot& data) :
iterator(
data,
std::is_same_v<convo::one_to_one, ConvoType>,
std::is_same_v<convo::open_group, ConvoType>,
std::is_same_v<convo::legacy_closed_group, ConvoType>) {}
friend class ConvoInfoVolatile;
public:
ConvoType& operator*() const { return std::get<ConvoType>(*_val); }
ConvoType* operator->() const { return &std::get<ConvoType>(*_val); }
subtype_iterator& operator++() {
iterator::operator++();
return *this;
}
subtype_iterator operator++(int) {
auto copy{*this};
++*this;
return copy;
}
};
};
} // namespace session::config

View File

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

View File

@ -3,24 +3,37 @@
#include "session/types.hpp"
namespace session::config {
// Profile pic info. Note that `url` is null terminated (though the null lies just beyond the end
// of the string view: that is, it views into a full std::string).
struct profile_pic {
private:
std::string url_;
ustring key_;
public:
std::string_view url;
ustring_view key;
// Default constructor, makes an empty profile pic
profile_pic() = default;
// Constructs from string views: the values must stay alive for the duration of the profile_pic
// instance. (If not, use `set_url`/`set_key` or the rvalue-argument constructor instead).
profile_pic(std::string_view url, ustring_view key) : url{url}, key{key} {}
// Constructs from temporary strings; the strings are stored/managed internally
profile_pic(std::string&& url, ustring&& key) :
url_{std::move(url)}, key_{std::move(key)}, url{url_}, key{key_} {}
// Returns true if either url or key are empty
bool empty() const { return url.empty() || key.empty(); }
// Guard against accidentally passing in a temporary string or ustring:
template <
typename UrlType,
typename KeyType,
std::enable_if_t<
std::is_same_v<UrlType, std::string> || std::is_same_v<KeyType, ustring>>>
profile_pic(UrlType&& url, KeyType&& key) = delete;
// Sets the url or key to a temporary value that needs to be copied and owned by this
// profile_pic object. (This is only needed when the source string may not outlive the
// profile_pic object; if it does, the `url` or `key` can be assigned to directly).
void set_url(std::string url);
void set_key(ustring key);
};
} // namespace session::config

View File

@ -0,0 +1,16 @@
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
#include <stdbool.h>
/// Returns true if session_id has the right form (66 hex digits). This is a quick check, not a
/// robust one: it does not check the leading byte prefix, nor the cryptographic properties of the
/// pubkey for actual validity.
bool session_id_is_valid(const char* session_id);
#ifdef __cplusplus
}
#endif

View File

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

View File

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

View File

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

View File

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

View File

@ -217,7 +217,10 @@ public extension VisibleMessage {
sentTimestamp: UInt64(interaction.timestampMs),
recipient: (try? interaction.recipientStates.fetchOne(db))?.recipientId,
groupPublicKey: try? interaction.thread
.filter(SessionThread.Columns.variant == SessionThread.Variant.closedGroup)
.filter(
SessionThread.Columns.variant == SessionThread.Variant.legacyClosedGroup ||
SessionThread.Columns.variant == SessionThread.Variant.closedGroup
)
.select(.id)
.asRequest(of: String.self)
.fetchOne(db),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,7 +19,12 @@ extension MessageReceiver {
guard
let interactionId: Int64 = maybeInteraction?.id,
let interaction: Interaction = maybeInteraction
let interaction: Interaction = maybeInteraction,
let threadVariant: SessionThread.Variant = try SessionThread
.filter(id: interaction.threadId)
.select(.variant)
.asRequest(of: SessionThread.Variant.self)
.fetchOne(db)
else { return }
// Mark incoming messages as read and remove any of their notifications
@ -28,6 +33,7 @@ extension MessageReceiver {
db,
interactionId: interactionId,
threadId: interaction.threadId,
threadVariant: threadVariant,
includingOlder: false,
trySendReadReceipt: false
)

View File

@ -54,11 +54,11 @@ extension MessageReceiver {
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies)
let thread: SessionThread = try SessionThread
.fetchOrCreate(db, id: threadInfo.id, variant: threadInfo.variant)
let maybeOpenGroup: OpenGroup? = openGroupId.map { try? OpenGroup.fetchOne(db, id: $0) }
let variant: Interaction.Variant = {
guard
let openGroupId: String = openGroupId,
let senderSessionId: SessionId = SessionId(from: sender),
let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: openGroupId)
let openGroup: OpenGroup = maybeOpenGroup
else {
return (sender == currentUserPublicKey ?
.standardOutgoing :
@ -118,7 +118,17 @@ extension MessageReceiver {
variant: variant,
body: message.text,
timestampMs: Int64(messageSentTimestamp * 1000),
wasRead: (variant == .standardOutgoing), // Auto-mark sent messages as read
wasRead: (
// Auto-mark sent messages or messages older than the 'lastReadTimestampMs' as read
variant == .standardOutgoing ||
SessionUtil.timestampAlreadyRead(
threadId: thread.id,
threadVariant: thread.variant,
timestampMs: Int64(messageSentTimestamp * 1000),
userPublicKey: currentUserPublicKey,
openGroup: maybeOpenGroup
)
),
hasMention: Interaction.isUserMentioned(
db,
threadId: thread.id,
@ -383,7 +393,7 @@ extension MessageReceiver {
).save(db)
}
case .closedGroup:
case .legacyClosedGroup, .closedGroup:
try GroupMember
.filter(GroupMember.Columns.groupId == thread.id)
.fetchAll(db)
@ -410,6 +420,7 @@ extension MessageReceiver {
db,
interactionId: interactionId,
threadId: thread.id,
threadVariant: thread.variant,
includingOlder: true,
trySendReadReceipt: true
)

View File

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

View File

@ -289,7 +289,7 @@ public enum MessageReceiver {
// Note: We don't want to create a thread for a closed group if it doesn't exist
if (try? SessionThread.exists(db, id: groupPublicKey)) != true { return nil }
return (groupPublicKey, .closedGroup)
return (groupPublicKey, .legacyClosedGroup)
}
// Extract the 'syncTarget' value if there is one

View File

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

View File

@ -8,7 +8,9 @@ import SessionSnodeKit
import SessionUtilitiesKit
public final class CurrentUserPoller: Poller {
public static var namespaces: [SnodeAPI.Namespace] = [.default, .configUserProfile, .configContacts]
public static var namespaces: [SnodeAPI.Namespace] = [
.default, .configUserProfile, .configContacts, .configConvoInfoVolatile, .configGroups
]
private var targetSnode: Atomic<Snode?> = Atomic(nil)
private var usedSnodes: Atomic<Set<Snode>> = Atomic([])

View File

@ -38,7 +38,11 @@ public class TypingIndicators {
}
// Don't send typing indicators in group threads
guard threadVariant != .closedGroup && threadVariant != .openGroup else { return nil }
guard
threadVariant != .legacyClosedGroup &&
threadVariant != .closedGroup &&
threadVariant != .openGroup
else { return nil }
self.threadId = threadId
self.direction = direction

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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