Fixed additional QA issues and bugs found while testing

• Added a unit test to validate the GroupMember sorting continues to work as expected
• Updated the AppSetup process to be simpler (no need to check if it had previously run anymore)
• Removed some state management code from the NotificationServiceExtension (no longer needed now that state is properly managed via the Dependencies)
• Fixed an issue where if you had updated another client, gotten updated groups in your config, and then update the iOS client then it wouldn't create the updated groups until a UserGroups config change occurred
• Fixed a bug where we would incorrectly try to retrieve the disappearing messages settings for V2 Groups from the UserGroups config instead of the GroupInfo one
• Fixed an issue where the updated groups poller might not get started correctly in some cases
• Fixed an issue where we could incorrectly add a "you were invited..." control message on linked devices when creating an updated group
• Fixed an issue where the "open url" modal wouldn't be dismissed when copying the url
• Fixed an issue where reactions could appear on locally deleted community messages
• Fixed an issue where the "Note to Self" conversation could be mislabelled in the share extension
• Fixed an issue where sharing a url with a preview would fail
• Fixed an issue where a quote for an attachment wouldn't show the thumbnail if the conversation was open when the quote message was received
• Fixed an issue where the background colour of the display picture could be incorrect when in a multi-avatar for a group conversation
pull/894/head
Morgan Pretty 4 months ago
parent 6a4fa224ac
commit 8d4365d89c

@ -704,6 +704,7 @@
FD5D201E27B0D87C00FEA984 /* SessionId.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201D27B0D87C00FEA984 /* SessionId.swift */; };
FD5E93D12C100FD70038C25A /* FileUploadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387127B5BB3B00C60D73 /* FileUploadResponse.swift */; };
FD5E93D22C12B0580038C25A /* AppVersionResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383727B3863200C60D73 /* AppVersionResponse.swift */; };
FD61FCF92D308CC9005752DE /* GroupMemberSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD61FCF82D308CC5005752DE /* GroupMemberSpec.swift */; };
FD65318A2AA025C500DFEEAA /* TestDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6531892AA025C500DFEEAA /* TestDependencies.swift */; };
FD65318B2AA025C500DFEEAA /* TestDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6531892AA025C500DFEEAA /* TestDependencies.swift */; };
FD65318C2AA025C500DFEEAA /* TestDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6531892AA025C500DFEEAA /* TestDependencies.swift */; };
@ -1913,6 +1914,7 @@
FD5C7308285007920029977D /* BlindedIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindedIdLookup.swift; sourceTree = "<group>"; };
FD5CE3442A3C5D96001A6DE3 /* DecryptExportedKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecryptExportedKey.swift; sourceTree = "<group>"; };
FD5D201D27B0D87C00FEA984 /* SessionId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionId.swift; sourceTree = "<group>"; };
FD61FCF82D308CC5005752DE /* GroupMemberSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberSpec.swift; sourceTree = "<group>"; };
FD6531892AA025C500DFEEAA /* TestDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestDependencies.swift; sourceTree = "<group>"; };
FD66CB272BF3449B00268FAB /* SessionSnodeKit.xctestplan */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = SessionSnodeKit.xctestplan; sourceTree = "<group>"; };
FD66CB2B2BF344C600268FAB /* SessionMessageKit.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = SessionMessageKit.xctestplan; sourceTree = "<group>"; };
@ -4058,6 +4060,22 @@
path = "Shared Models";
sourceTree = "<group>";
};
FD61FCF62D308CAE005752DE /* Database */ = {
isa = PBXGroup;
children = (
FD61FCF72D308CC0005752DE /* Models */,
);
path = Database;
sourceTree = "<group>";
};
FD61FCF72D308CC0005752DE /* Models */ = {
isa = PBXGroup;
children = (
FD61FCF82D308CC5005752DE /* GroupMemberSpec.swift */,
);
path = Models;
sourceTree = "<group>";
};
FD7115F528C8150600B47552 /* Combine */ = {
isa = PBXGroup;
children = (
@ -4500,6 +4518,7 @@
FDC4389B27BA01E300C60D73 /* _TestUtilities */,
FD3C906527E416A200CD579F /* Contacts */,
FD72BDA22BE368FA00CF6CF6 /* Crypto */,
FD61FCF62D308CAE005752DE /* Database */,
FD96F3A229DBC3BA00401309 /* Jobs */,
FDC4389827BA001800C60D73 /* Open Groups */,
FDE754A72C9B964D002A2623 /* Sending & Receiving */,
@ -6671,6 +6690,7 @@
FD23EA6228ED0B260058676E /* CombineExtensions.swift in Sources */,
FD83B9C527CF3E2A005E1583 /* OpenGroupSpec.swift in Sources */,
FD23CE342A67C4D90000B97C /* MockNetwork.swift in Sources */,
FD61FCF92D308CC9005752DE /* GroupMemberSpec.swift in Sources */,
FDC2908B27D707F3005DAE71 /* SendMessageRequestSpec.swift in Sources */,
FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */,
FD7692F72A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift in Sources */,
@ -7892,7 +7912,7 @@
CLANG_WARN__ARC_BRIDGE_CAST_NONARC = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
CURRENT_PROJECT_VERSION = 520;
CURRENT_PROJECT_VERSION = 525;
ENABLE_BITCODE = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@ -7968,7 +7988,7 @@
CLANG_WARN__ARC_BRIDGE_CAST_NONARC = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Distribution";
CURRENT_PROJECT_VERSION = 520;
CURRENT_PROJECT_VERSION = 525;
ENABLE_BITCODE = NO;
ENABLE_MODULE_VERIFIER = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;

@ -1300,13 +1300,17 @@ extension ConversationVC:
cancelTitle: "urlCopy".localized(),
cancelStyle: .alert_text,
hasCloseButton: true,
onConfirm: { [weak self] _ in
onConfirm: { [weak self] modal in
UIApplication.shared.open(url, options: [:], completionHandler: nil)
self?.showInputAccessoryView()
modal.dismiss(animated: true)
},
onCancel: { [weak self] _ in
onCancel: { [weak self] modal in
UIPasteboard.general.string = url.absoluteString
self?.showInputAccessoryView()
modal.dismiss(animated: true)
}
)
)

@ -512,32 +512,16 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold
joinToPagedType: MessageViewModel.TypingIndicatorInfo.joinToViewModelQuerySQL,
associateData: MessageViewModel.TypingIndicatorInfo.createAssociateDataClosure()
),
AssociatedRecord<MessageViewModel.QuoteAttachmentInfo, MessageViewModel>(
trackedAgainst: Attachment.self,
observedChanges: [
PagedData.ObservedChanges(
table: Attachment.self,
columns: [.state]
)
],
dataQuery: MessageViewModel.QuoteAttachmentInfo.baseQuery(
userSessionId: userSessionId,
blinded15SessionId: blinded15SessionId,
blinded25SessionId: blinded25SessionId
),
joinToPagedType: MessageViewModel.QuoteAttachmentInfo.joinToViewModelQuerySQL(
userSessionId: userSessionId,
blinded15SessionId: blinded15SessionId,
blinded25SessionId: blinded25SessionId
),
associateData: MessageViewModel.QuoteAttachmentInfo.createAssociateDataClosure()
),
AssociatedRecord<MessageViewModel.QuotedInfo, MessageViewModel>(
trackedAgainst: Quote.self,
observedChanges: [
PagedData.ObservedChanges(
table: Interaction.self,
columns: [.variant]
),
PagedData.ObservedChanges(
table: Attachment.self,
columns: [.state]
)
],
dataQuery: MessageViewModel.QuotedInfo.baseQuery(

@ -50,17 +50,48 @@ enum _022_GroupsRebuildChanges: Migration {
.defaults(to: GroupMember.RoleStatus.accepted)
}
let userSessionId: SessionId = dependencies[cache: .general].sessionId
// Update existing groups where the current user is a member to have `shouldPoll` as `true`
try ClosedGroup
.joining(
required: ClosedGroup.members
.filter(GroupMember.Columns.profileId == dependencies[cache: .general].sessionId.hexString)
.filter(GroupMember.Columns.profileId == userSessionId.hexString)
)
.updateAll(
db,
ClosedGroup.Columns.shouldPoll.set(to: true)
)
// If a user had upgraded a different device their config could already contain V2 groups
// so we should check and, if so, create those
try dependencies.mutate(cache: .libSession) { cache in
guard case .userGroups(let conf) = cache.config(for: .userGroups, sessionId: userSessionId) else {
return
}
// Extract all of the user group info
let extractedUserGroups: LibSession.ExtractedUserGroups = try LibSession.extractUserGroups(
from: conf,
using: dependencies
)
try extractedUserGroups.groups.forEach { group in
// Add a new group if it doesn't already exist
try MessageReceiver.handleNewGroup(
db,
groupSessionId: group.groupSessionId,
groupIdentityPrivateKey: group.groupIdentityPrivateKey,
name: group.name,
authData: group.authData,
joinedAt: group.joinedAt,
invited: group.invited,
forceMarkAsInvited: false,
using: dependencies
)
}
}
// Move the `imageData` out of the `OpenGroup` table and on to disk to be consistent with
// the other display picture logic
let existingImageInfo: [OpenGroupImageInfo] = try OpenGroup

@ -1108,7 +1108,7 @@ extension Attachment {
public func preparedUpload(
_ db: Database,
threadId: String,
logCategory cat: Log.Category,
logCategory cat: Log.Category?,
using dependencies: Dependencies
) throws -> Network.PreparedRequest<String> {
typealias UploadInfo = (
@ -1177,7 +1177,7 @@ extension Attachment {
// Get the raw attachment data
guard let rawData: Data = try? readDataFromFile(using: dependencies) else {
Log.error(cat, "Couldn't read attachment from disk.")
Log.error([cat].compactMap { $0 }, "Couldn't read attachment from disk.")
throw AttachmentError.noAttachment
}
@ -1193,7 +1193,7 @@ extension Attachment {
.encryptAttachment(plaintext: rawData, using: dependencies)
)
else {
Log.error(cat, "Couldn't encrypt attachment.")
Log.error([cat].compactMap { $0 }, "Couldn't encrypt attachment.")
throw AttachmentError.encryptionFailed
}
@ -1203,7 +1203,7 @@ extension Attachment {
}
// Ensure the file size is smaller than our upload limit
Log.info(cat, "File size: \(finalData.count) bytes.")
Log.info([cat].compactMap { $0 }, "File size: \(finalData.count) bytes.")
guard finalData.count <= Network.maxFileSize else { throw NetworkError.maxFileSizeExceeded }
// Generate the request

@ -191,7 +191,31 @@ public extension ClosedGroup {
throw MessageReceiverError.noUserED25519KeyPair
}
/// Update the database state
/// Update the `USER_GROUPS` config
try? LibSession.update(
db,
groupSessionId: group.id,
invited: false,
using: dependencies
)
/// If we don't have auth data for the group then no need to make any other changes (this can happen if we
/// have synced an updated group that the user was kicked from or was already destroyed)
guard group.authData != nil || group.groupIdentityPrivateKey != nil else {
/// Update the database state before we finish up
if group.invited == true {
try ClosedGroup
.filter(id: group.id)
.updateAllAndConfig(
db,
ClosedGroup.Columns.invited.set(to: false),
using: dependencies
)
}
return
}
/// Update the database state if needed
if group.invited == true || group.shouldPoll != true {
try ClosedGroup
.filter(id: group.id)
@ -203,34 +227,23 @@ public extension ClosedGroup {
)
}
/// Wait until after the transaction completes before creating the group state if needed (this is needed as it's possible that
/// we are already mutating the `libSessionCache` when this function gets called)
db.afterNextTransaction { db in
dependencies.mutate(cache: .libSession) { cache in
let groupSessionId: SessionId = .init(.group, hex: group.id)
guard
!cache.hasConfig(for: .groupKeys, sessionId: groupSessionId) ||
!cache.hasConfig(for: .groupInfo, sessionId: groupSessionId) ||
!cache.hasConfig(for: .groupMembers, sessionId: groupSessionId)
else { return }
_ = try? cache.createAndLoadGroupState(
groupSessionId: groupSessionId,
userED25519KeyPair: userED25519KeyPair,
groupIdentityPrivateKey: group.groupIdentityPrivateKey
)
}
/// Load the group state into the `LibSession.Cache` if needed
dependencies.mutate(cache: .libSession) { cache in
let groupSessionId: SessionId = .init(.group, hex: group.id)
guard
!cache.hasConfig(for: .groupKeys, sessionId: groupSessionId) ||
!cache.hasConfig(for: .groupInfo, sessionId: groupSessionId) ||
!cache.hasConfig(for: .groupMembers, sessionId: groupSessionId)
else { return }
_ = try? cache.createAndLoadGroupState(
groupSessionId: groupSessionId,
userED25519KeyPair: userED25519KeyPair,
groupIdentityPrivateKey: group.groupIdentityPrivateKey
)
}
/// Update the `USER_GROUPS` config
try? LibSession.update(
db,
groupSessionId: group.id,
invited: false,
using: dependencies
)
/// Start the poller
dependencies.mutate(cache: .groupPollers) { $0.getOrCreatePoller(for: group.id).startIfNeeded() }

@ -31,7 +31,7 @@ public struct GroupMember: Codable, Equatable, Hashable, FetchableRecord, Persis
public static func < (lhs: Role, rhs: Role) -> Bool { lhs.rawValue < rhs.rawValue }
}
public enum RoleStatus: Int, Codable, DatabaseValueConvertible {
public enum RoleStatus: Int, Codable, CaseIterable, DatabaseValueConvertible {
case accepted
case pending
case failed
@ -184,6 +184,9 @@ extension GroupMember: ProfileAssociated {
/// If the role and status match then we want to sort by current user, no-name members by id, then by name
guard lhs.value.role != rhs.value.role || lhs.value.roleStatus != rhs.value.roleStatus else {
switch (lhs.profileId, rhs.profileId, lhs.profile?.name, rhs.profile?.name) {
case (userSessionId.hexString, userSessionId.hexString, _, _):
/// This case shouldn't be possible and is more to make the unit tests a bit nicer to read
return (lhsDisplayName.lowercased() < rhsDisplayName.lowercased())
case (userSessionId.hexString, _, _, _): return true
case (_, userSessionId.hexString, _, _): return false
case (_, _, .none, .some): return true

@ -57,64 +57,24 @@ public enum MessageSendJob: JobExecutor {
else { return failure(job, JobRunnerError.missingRequiredDetails, true) }
// Retrieve the current attachment state
typealias AttachmentState = (error: Error?, pendingUploadAttachmentIds: [String], preparedFileIds: [String])
let attachmentState: AttachmentState = dependencies[singleton: .storage]
.read { db in
// If the original interaction no longer exists then don't bother sending the message (ie. the
// message was deleted before it even got sent)
guard try Interaction.exists(db, id: interactionId) else {
Log.warn(.cat, "Failing \(messageType) (\(job.id ?? -1)) due to missing interaction")
return (StorageError.objectNotFound, [], [])
}
// Get the current state of the attachments
let allAttachmentStateInfo: [Attachment.StateInfo] = try Attachment
.stateInfo(interactionId: interactionId)
.fetchAll(db)
let maybeFileIds: [String?] = allAttachmentStateInfo
.sorted { lhs, rhs in lhs.albumIndex < rhs.albumIndex }
.map { Attachment.fileId(for: $0.downloadUrl) }
let fileIds: [String] = maybeFileIds.compactMap { $0 }
// If there were failed attachments then this job should fail (can't send a
// message which has associated attachments if the attachments fail to upload)
guard !allAttachmentStateInfo.contains(where: { $0.state == .failedDownload }) else {
Log.info(.cat, "Failing \(messageType) (\(job.id ?? -1)) due to failed attachment upload")
return (AttachmentError.notUploaded, [], fileIds)
}
/// Find all attachmentIds for attachments which need to be uploaded
///
/// **Note:** If there are any 'downloaded' attachments then they also need to be uploaded (as a
/// 'downloaded' attachment will be on the current users device but not on the message recipients
/// device - both `LinkPreview` and `Quote` can have this case)
let pendingUploadAttachmentIds: [String] = allAttachmentStateInfo
.filter { attachment -> Bool in
// Non-media quotes won't have thumbnails so so don't try to upload them
guard attachment.downloadUrl != Attachment.nonMediaQuoteFileId else { return false }
switch attachment.state {
case .uploading, .pendingDownload, .downloading, .failedUpload, .downloaded:
return true
// If we've somehow got an attachment that is in an 'uploaded' state but doesn't
// have a 'downloadUrl' then it's invalid and needs to be re-uploaded
case .uploaded: return (attachment.downloadUrl == nil)
default: return false
}
}
.map { $0.attachmentId }
return (nil, pendingUploadAttachmentIds, fileIds)
}
.defaulting(to: (MessageSenderError.invalidMessage, [], []))
.read { db in try MessageSendJob.fetchAttachmentState(db, interactionId: interactionId) }
.defaulting(to: AttachmentState(error: MessageSenderError.invalidMessage))
/// If we got an error when trying to retrieve the attachment state then this job is actually invalid so it
/// should permanently fail
guard attachmentState.error == nil else {
Log.error(.cat, "Failed \(messageType) (\(job.id ?? -1)) due to invalid attachment state")
switch (attachmentState.error ?? NetworkError.unknown) {
case StorageError.objectNotFound:
Log.warn(.cat, "Failing \(messageType) (\(job.id ?? -1)) due to missing interaction")
case AttachmentError.notUploaded:
Log.info(.cat, "Failing \(messageType) (\(job.id ?? -1)) due to failed attachment upload")
default:
Log.error(.cat, "Failed \(messageType) (\(job.id ?? -1)) due to invalid attachment state")
}
return failure(job, (attachmentState.error ?? MessageSenderError.invalidMessage), true)
}
@ -276,6 +236,88 @@ public enum MessageSendJob: JobExecutor {
}
}
// MARK: - Convenience
public extension MessageSendJob {
struct AttachmentState {
public let error: Error?
public let pendingUploadAttachmentIds: [String]
public let preparedFileIds: [String]
public let allAttachmentIds: [String]
init(
error: Error? = nil,
pendingUploadAttachmentIds: [String] = [],
preparedFileIds: [String] = [],
allAttachmentIds: [String] = []
) {
self.error = error
self.pendingUploadAttachmentIds = pendingUploadAttachmentIds
self.preparedFileIds = preparedFileIds
self.allAttachmentIds = allAttachmentIds
}
}
static func fetchAttachmentState(
_ db: Database,
interactionId: Int64
) throws -> AttachmentState {
// If the original interaction no longer exists then don't bother sending the message (ie. the
// message was deleted before it even got sent)
guard try Interaction.exists(db, id: interactionId) else {
return AttachmentState(error: StorageError.objectNotFound)
}
// Get the current state of the attachments
let allAttachmentStateInfo: [Attachment.StateInfo] = try Attachment
.stateInfo(interactionId: interactionId)
.fetchAll(db)
let maybeFileIds: [String?] = allAttachmentStateInfo
.sorted { lhs, rhs in lhs.albumIndex < rhs.albumIndex }
.map { Attachment.fileId(for: $0.downloadUrl) }
let fileIds: [String] = maybeFileIds.compactMap { $0 }
// If there were failed attachments then this job should fail (can't send a
// message which has associated attachments if the attachments fail to upload)
guard !allAttachmentStateInfo.contains(where: { $0.state == .failedDownload }) else {
return AttachmentState(
error: AttachmentError.notUploaded,
preparedFileIds: fileIds,
allAttachmentIds: allAttachmentStateInfo.map(\.attachmentId)
)
}
/// Find all attachmentIds for attachments which need to be uploaded
///
/// **Note:** If there are any 'downloaded' attachments then they also need to be uploaded (as a
/// 'downloaded' attachment will be on the current users device but not on the message recipients
/// device - both `LinkPreview` and `Quote` can have this case)
let pendingUploadAttachmentIds: [String] = allAttachmentStateInfo
.filter { attachment -> Bool in
// Non-media quotes won't have thumbnails so so don't try to upload them
guard attachment.downloadUrl != Attachment.nonMediaQuoteFileId else { return false }
switch attachment.state {
case .uploading, .pendingDownload, .downloading, .failedUpload, .downloaded:
return true
// If we've somehow got an attachment that is in an 'uploaded' state but doesn't
// have a 'downloadUrl' then it's invalid and needs to be re-uploaded
case .uploaded: return (attachment.downloadUrl == nil)
default: return false
}
}
.map { $0.attachmentId }
return AttachmentState(
pendingUploadAttachmentIds: pendingUploadAttachmentIds,
preparedFileIds: fileIds,
allAttachmentIds: allAttachmentStateInfo.map(\.attachmentId)
)
}
}
// MARK: - MessageSendJob.Details
extension MessageSendJob {

@ -475,7 +475,7 @@ public extension LibSessionCacheType {
// MARK: - VolatileThreadInfo
public extension LibSession {
internal struct OpenGroupUrlInfo: FetchableRecord, Codable, Hashable {
struct OpenGroupUrlInfo: FetchableRecord, Codable, Hashable {
let threadId: String
let server: String
let roomToken: String

@ -47,103 +47,11 @@ internal extension LibSessionCacheType {
guard configNeedsDump(config) else { return }
guard case .userGroups(let conf) = config else { throw LibSessionError.invalidConfigObject }
var infiniteLoopGuard: Int = 0
var communities: [PrioritisedData<LibSession.OpenGroupUrlInfo>] = []
var legacyGroups: [LibSession.LegacyGroupInfo] = []
var groups: [LibSession.GroupInfo] = []
var community: ugroups_community_info = ugroups_community_info()
var legacyGroup: ugroups_legacy_group_info = ugroups_legacy_group_info()
var group: ugroups_group_info = ugroups_group_info()
let groupsIterator: OpaquePointer = user_groups_iterator_new(conf)
while !user_groups_iterator_done(groupsIterator) {
try LibSession.checkLoopLimitReached(&infiniteLoopGuard, for: .userGroups)
if user_groups_it_is_community(groupsIterator, &community) {
let server: String = community.get(\.base_url)
let roomToken: String = community.get(\.room)
communities.append(
PrioritisedData(
data: LibSession.OpenGroupUrlInfo(
threadId: OpenGroup.idFor(roomToken: roomToken, server: server),
server: server,
roomToken: roomToken,
publicKey: community.getHex(\.pubkey)
),
priority: community.priority
)
)
}
else if user_groups_it_is_legacy_group(groupsIterator, &legacyGroup) {
let groupId: String = legacyGroup.get(\.session_id)
let members: [String: Bool] = LibSession.memberInfo(in: &legacyGroup)
legacyGroups.append(
LibSession.LegacyGroupInfo(
id: groupId,
name: legacyGroup.get(\.name),
lastKeyPair: ClosedGroupKeyPair(
threadId: groupId,
publicKey: legacyGroup.get(\.enc_pubkey),
secretKey: legacyGroup.get(\.enc_seckey),
receivedTimestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000)
),
disappearingConfig: DisappearingMessagesConfiguration
.defaultWith(groupId)
.with(
isEnabled: (legacyGroup.disappearing_timer > 0),
durationSeconds: TimeInterval(legacyGroup.disappearing_timer),
type: .disappearAfterSend
),
groupMembers: members
.filter { _, isAdmin in !isAdmin }
.map { memberId, _ in
GroupMember(
groupId: groupId,
profileId: memberId,
role: .standard,
roleStatus: .accepted, // Legacy group members don't have role statuses
isHidden: false
)
},
groupAdmins: members
.filter { _, isAdmin in isAdmin }
.map { memberId, _ in
GroupMember(
groupId: groupId,
profileId: memberId,
role: .admin,
roleStatus: .accepted, // Legacy group members don't have role statuses
isHidden: false
)
},
priority: legacyGroup.priority,
joinedAt: TimeInterval(legacyGroup.joined_at)
)
)
}
else if user_groups_it_is_group(groupsIterator, &group) {
groups.append(
LibSession.GroupInfo(
groupSessionId: group.get(\.id),
groupIdentityPrivateKey: (!group.have_secretkey ? nil : group.get(\.secretkey, nullIfEmpty: true)),
name: group.get(\.name),
authData: (!group.have_auth_data ? nil : group.get(\.auth_data, nullIfEmpty: true)),
priority: group.priority,
joinedAt: TimeInterval(group.joined_at),
invited: group.invited,
wasKickedFromGroup: ugroups_group_is_kicked(&group)
)
)
}
else {
Log.warn(.libSession, "Ignoring unknown conversation type when iterating through volatile conversation info update")
}
user_groups_iterator_advance(groupsIterator)
}
user_groups_iterator_free(groupsIterator) // Need to free the iterator
// Extract all of the user group info
let extractedUserGroups: LibSession.ExtractedUserGroups = try LibSession.extractUserGroups(
from: conf,
using: dependencies
)
// Extract all community/legacyGroup/group thread priorities
let existingThreadInfo: [String: LibSession.PriorityVisibilityInfo] = (try? SessionThread
@ -163,7 +71,7 @@ internal extension LibSessionCacheType {
// MARK: -- Handle Community Changes
// Add any new communities (via the OpenGroupManager)
communities.forEach { community in
extractedUserGroups.communities.forEach { community in
let successfullyAddedGroup: Bool = dependencies[singleton: .openGroupManager].add(
db,
roomToken: community.data.roomToken,
@ -203,7 +111,7 @@ internal extension LibSessionCacheType {
let communityIdsToRemove: Set<String> = Set(existingThreadInfo
.filter { $0.value.variant == .community }
.keys)
.subtracting(communities.map { $0.data.threadId })
.subtracting(extractedUserGroups.communities.map { $0.data.threadId })
if !communityIdsToRemove.isEmpty {
LibSession.kickFromConversationUIIfNeeded(removedThreadIds: Array(communityIdsToRemove), using: dependencies)
@ -232,7 +140,7 @@ internal extension LibSessionCacheType {
.defaulting(to: [])
.grouped(by: \.groupId)
try legacyGroups.forEach { group in
try extractedUserGroups.legacyGroups.forEach { group in
guard
let name: String = group.name,
let lastKeyPair: ClosedGroupKeyPair = group.lastKeyPair
@ -398,7 +306,7 @@ internal extension LibSessionCacheType {
// Remove any legacy groups which are no longer in the config
let legacyGroupIdsToRemove: Set<String> = existingLegacyGroupIds
.subtracting(legacyGroups.map { $0.id })
.subtracting(extractedUserGroups.legacyGroups.map { $0.id })
if !legacyGroupIdsToRemove.isEmpty {
LibSession.kickFromConversationUIIfNeeded(removedThreadIds: Array(legacyGroupIdsToRemove), using: dependencies)
@ -423,7 +331,7 @@ internal extension LibSessionCacheType {
.defaulting(to: [])
.reduce(into: [:]) { result, next in result[next.id] = next }
try groups.forEach { group in
try extractedUserGroups.groups.forEach { group in
switch (existingGroups[group.groupSessionId], existingGroupSessionIds.contains(group.groupSessionId)) {
case (.none, _), (_, false):
// Add a new group if it doesn't already exist
@ -439,46 +347,6 @@ internal extension LibSessionCacheType {
using: dependencies
)
/// If the thread didn't already exist, or the user had previously been kicked but has since been re-added to the group, then insert
/// a fallback 'invited' info message
if
(group.authData != nil || group.groupIdentityPrivateKey != nil) &&
(existingGroups[group.groupSessionId] == nil || group.wasKickedFromGroup == true)
{
/// **Note:** In `MessageReceiver+Groups` we don't apply the disappearing message config settings to the
/// invitation control message because we wouldn't have them at that point - technically we might have them here
/// since the user was already part of the group, but they _may_ be stale so it'd be better to just behave consistently
/// and not disappear like the original one
_ = try Interaction(
threadId: group.groupSessionId,
threadVariant: .group,
authorId: group.groupSessionId,
variant: .infoGroupInfoInvited,
body: {
switch group.groupIdentityPrivateKey {
case .none:
return ClosedGroup.MessageInfo
.invitedFallback(group.name)
.infoString(using: dependencies)
case .some:
return ClosedGroup.MessageInfo
.invitedAdminFallback(group.name)
.infoString(using: dependencies)
}
}(),
timestampMs: Int64(group.joinedAt * 1000),
wasRead: timestampAlreadyRead(
threadId: group.groupSessionId,
threadVariant: .group,
timestampMs: Int64(group.joinedAt * 1000),
userSessionId: userSessionId,
openGroup: nil
),
using: dependencies
).inserted(db)
}
case (.some(let existingGroup), _):
/// Otherwise update the existing group
///
@ -539,7 +407,7 @@ internal extension LibSessionCacheType {
// Remove any groups which are no longer in the config
let groupSessionIdsToRemove: Set<String> = existingGroupSessionIds
.subtracting(groups.map { $0.groupSessionId })
.subtracting(extractedUserGroups.groups.map { $0.groupSessionId })
if !groupSessionIdsToRemove.isEmpty {
LibSession.kickFromConversationUIIfNeeded(removedThreadIds: Array(groupSessionIdsToRemove), using: dependencies)
@ -1249,12 +1117,128 @@ public extension LibSession {
}
}
// MARK: - Convenience
public extension LibSession {
typealias ExtractedUserGroups = (
communities: [PrioritisedData<LibSession.OpenGroupUrlInfo>],
legacyGroups: [LibSession.LegacyGroupInfo],
groups: [LibSession.GroupInfo]
)
static func extractUserGroups(
from conf: UnsafeMutablePointer<config_object>?,
using dependencies: Dependencies
) throws -> ExtractedUserGroups {
var infiniteLoopGuard: Int = 0
var communities: [PrioritisedData<LibSession.OpenGroupUrlInfo>] = []
var legacyGroups: [LibSession.LegacyGroupInfo] = []
var groups: [LibSession.GroupInfo] = []
var community: ugroups_community_info = ugroups_community_info()
var legacyGroup: ugroups_legacy_group_info = ugroups_legacy_group_info()
var group: ugroups_group_info = ugroups_group_info()
let groupsIterator: OpaquePointer = user_groups_iterator_new(conf)
while !user_groups_iterator_done(groupsIterator) {
try LibSession.checkLoopLimitReached(&infiniteLoopGuard, for: .userGroups)
if user_groups_it_is_community(groupsIterator, &community) {
let server: String = community.get(\.base_url)
let roomToken: String = community.get(\.room)
communities.append(
PrioritisedData(
data: LibSession.OpenGroupUrlInfo(
threadId: OpenGroup.idFor(roomToken: roomToken, server: server),
server: server,
roomToken: roomToken,
publicKey: community.getHex(\.pubkey)
),
priority: community.priority
)
)
}
else if user_groups_it_is_legacy_group(groupsIterator, &legacyGroup) {
let groupId: String = legacyGroup.get(\.session_id)
let members: [String: Bool] = LibSession.memberInfo(in: &legacyGroup)
legacyGroups.append(
LibSession.LegacyGroupInfo(
id: groupId,
name: legacyGroup.get(\.name),
lastKeyPair: ClosedGroupKeyPair(
threadId: groupId,
publicKey: legacyGroup.get(\.enc_pubkey),
secretKey: legacyGroup.get(\.enc_seckey),
receivedTimestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000)
),
disappearingConfig: DisappearingMessagesConfiguration
.defaultWith(groupId)
.with(
isEnabled: (legacyGroup.disappearing_timer > 0),
durationSeconds: TimeInterval(legacyGroup.disappearing_timer),
type: .disappearAfterSend
),
groupMembers: members
.filter { _, isAdmin in !isAdmin }
.map { memberId, _ in
GroupMember(
groupId: groupId,
profileId: memberId,
role: .standard,
roleStatus: .accepted, // Legacy group members don't have role statuses
isHidden: false
)
},
groupAdmins: members
.filter { _, isAdmin in isAdmin }
.map { memberId, _ in
GroupMember(
groupId: groupId,
profileId: memberId,
role: .admin,
roleStatus: .accepted, // Legacy group members don't have role statuses
isHidden: false
)
},
priority: legacyGroup.priority,
joinedAt: TimeInterval(legacyGroup.joined_at)
)
)
}
else if user_groups_it_is_group(groupsIterator, &group) {
groups.append(
LibSession.GroupInfo(
groupSessionId: group.get(\.id),
groupIdentityPrivateKey: (!group.have_secretkey ? nil : group.get(\.secretkey, nullIfEmpty: true)),
name: group.get(\.name),
authData: (!group.have_auth_data ? nil : group.get(\.auth_data, nullIfEmpty: true)),
priority: group.priority,
joinedAt: TimeInterval(group.joined_at),
invited: group.invited,
wasKickedFromGroup: ugroups_group_is_kicked(&group),
wasGroupDestroyed: ugroups_group_is_destroyed(&group)
)
)
}
else {
Log.warn(.libSession, "Ignoring unknown conversation type when iterating through volatile conversation info update")
}
user_groups_iterator_advance(groupsIterator)
}
user_groups_iterator_free(groupsIterator) // Need to free the iterator
return (communities, legacyGroups, groups)
}
}
// MARK: - LegacyGroupInfo
extension LibSession {
public extension LibSession {
struct LegacyGroupInfo: Decodable, FetchableRecord, ColumnExpressible {
typealias Columns = CodingKeys
enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable {
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable {
case threadId
case name
case lastKeyPair
@ -1379,7 +1363,7 @@ extension LibSession {
// MARK: - GroupInfo
extension LibSession {
public extension LibSession {
struct GroupInfo {
let groupSessionId: String
let groupIdentityPrivateKey: Data?
@ -1389,6 +1373,7 @@ extension LibSession {
let joinedAt: TimeInterval
let invited: Bool
let wasKickedFromGroup: Bool
let wasGroupDestroyed: Bool
}
struct GroupUpdateInfo {
@ -1400,6 +1385,7 @@ extension LibSession {
let joinedAt: TimeInterval?
let invited: Bool?
let wasKickedFromGroup: Bool?
let wasGroupDestroyed: Bool?
init(
groupSessionId: String,
@ -1409,7 +1395,8 @@ extension LibSession {
priority: Int32? = nil,
joinedAt: TimeInterval? = nil,
invited: Bool? = nil,
wasKickedFromGroup: Bool? = nil
wasKickedFromGroup: Bool? = nil,
wasGroupDestroyed: Bool? = nil
) {
self.groupSessionId = groupSessionId
self.groupIdentityPrivateKey = groupIdentityPrivateKey
@ -1419,6 +1406,7 @@ extension LibSession {
self.joinedAt = joinedAt
self.invited = invited
self.wasKickedFromGroup = wasKickedFromGroup
self.wasGroupDestroyed = wasGroupDestroyed
}
}
}
@ -1440,17 +1428,9 @@ extension LibSession {
}
}
// MARK: - GroupThreadData
fileprivate struct GroupThreadData {
let communities: [PrioritisedData<LibSession.OpenGroupUrlInfo>]
let legacyGroups: [PrioritisedData<LibSession.LegacyGroupInfo>]
let groups: [PrioritisedData<LibSession.GroupInfo>]
}
// MARK: - PrioritisedData
fileprivate struct PrioritisedData<T> {
public struct PrioritisedData<T> {
let data: T
let priority: Int32
}

@ -840,11 +840,22 @@ public extension LibSession {
threadVariant: threadVariant
)
case .community, .group, .legacyGroup:
case .community, .legacyGroup:
return configStore[userSessionId, .userGroups]?.disappearingMessagesConfig(
threadId: threadId,
threadVariant: threadVariant
)
case .group:
guard
let groupSessionId: SessionId = try? SessionId(from: threadId),
groupSessionId.prefix == .group
else { return nil }
return configStore[groupSessionId, .groupInfo]?.disappearingMessagesConfig(
threadId: threadId,
threadVariant: threadVariant
)
}
}

@ -543,22 +543,28 @@ public enum MessageReceiver {
openGroupMessageServerId: Int64,
openGroupReactions: [Reaction]
) throws {
guard let interactionId: Int64 = try? Interaction
.select(.id)
struct Info: Decodable, FetchableRecord {
let id: Int64
let variant: Interaction.Variant
}
guard let interactionInfo: Info = try? Interaction
.select(.id, .variant)
.filter(Interaction.Columns.threadId == threadId)
.filter(Interaction.Columns.openGroupServerMessageId == openGroupMessageServerId)
.asRequest(of: Int64.self)
.asRequest(of: Info.self)
.fetchOne(db)
else {
throw MessageReceiverError.invalidMessage
}
else { throw MessageReceiverError.invalidMessage }
// If the user locally deleted the message then we don't want to process reactions for it
guard !interactionInfo.variant.isDeletedMessage else { return }
_ = try Reaction
.filter(Reaction.Columns.interactionId == interactionId)
.filter(Reaction.Columns.interactionId == interactionInfo.id)
.deleteAll(db)
for reaction in openGroupReactions {
try reaction.with(interactionId: interactionId).insert(db)
try reaction.with(interactionId: interactionInfo.id).insert(db)
}
}

@ -12,7 +12,6 @@ fileprivate typealias ViewModel = MessageViewModel
fileprivate typealias AttachmentInteractionInfo = MessageViewModel.AttachmentInteractionInfo
fileprivate typealias ReactionInfo = MessageViewModel.ReactionInfo
fileprivate typealias TypingIndicatorInfo = MessageViewModel.TypingIndicatorInfo
fileprivate typealias QuoteAttachmentInfo = MessageViewModel.QuoteAttachmentInfo
fileprivate typealias QuotedInfo = MessageViewModel.QuotedInfo
public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable, ColumnExpressible {
@ -674,27 +673,6 @@ public extension MessageViewModel {
}
}
// MARK: - QuoteAttachmentInfo
public extension MessageViewModel {
struct QuoteAttachmentInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, ColumnExpressible {
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable {
case rowId
case attachment
case quote
}
public let rowId: Int64
public let attachment: Attachment
public let quote: Quote
// MARK: - Identifiable
public var id: String { "\(quote.interactionId)-\(attachment.id)" }
}
}
// MARK: - QuotedInfo
public extension MessageViewModel {
@ -703,18 +681,20 @@ public extension MessageViewModel {
public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable {
case rowId
case quote
case attachment
case quotedInteractionId
case quotedInteractionVariant
}
public let rowId: Int64
public let quote: Quote
public let attachment: Attachment?
public let quotedInteractionId: Int64
public let quotedInteractionVariant: Interaction.Variant
// MARK: - Identifiable
public var id: String { "quote-\(quote.interactionId)" }
public var id: String { "quote-\(quote.interactionId)-attachment_\(attachment?.id ?? "None")" }
}
}
@ -1337,6 +1317,11 @@ public extension MessageViewModel.QuotedInfo {
return { additionalFilters -> AdaptedFetchRequest<SQLRequest<QuotedInfo>> in
let quote: TypedTableAlias<Quote> = TypedTableAlias()
let quoteInteraction: TypedTableAlias<Interaction> = TypedTableAlias(name: "quoteInteraction")
let quoteInteractionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias(
name: "quoteInteractionAttachment"
)
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
let finalFilterSQL: SQL = {
guard let additionalFilters: SQL = additionalFilters else {
@ -1353,6 +1338,7 @@ public extension MessageViewModel.QuotedInfo {
SELECT
\(quote[.rowId]) AS \(QuotedInfo.Columns.rowId),
\(quote.allColumns),
\(attachment.allColumns),
\(quoteInteraction[.id]) AS \(QuotedInfo.Columns.quotedInteractionId),
\(quoteInteraction[.variant]) AS \(QuotedInfo.Columns.quotedInteractionVariant)
FROM \(Quote.self)
@ -1368,6 +1354,14 @@ public extension MessageViewModel.QuotedInfo {
)
)
)
)
LEFT JOIN \(quoteInteractionAttachment) ON (
\(quoteInteractionAttachment[.interactionId]) = \(quoteInteraction[.id]) AND
\(quoteInteractionAttachment[.albumIndex]) = 0
)
LEFT JOIN \(Attachment.self) ON (
\(attachment[.id]) = \(quoteInteractionAttachment[.attachmentId]) OR
\(attachment[.id]) = \(quote[.attachmentId])
)
\(finalFilterSQL)
"""
@ -1375,11 +1369,13 @@ public extension MessageViewModel.QuotedInfo {
return request.adapted { db in
let adapters = try splittingRowAdapters(columnCounts: [
numColumnsBeforeLinkedRecords,
Quote.numberOfSelectedColumns(db)
Quote.numberOfSelectedColumns(db),
Attachment.numberOfSelectedColumns(db)
])
return ScopeAdapter.with(QuotedInfo.self, [
.quote: adapters[1]
.quote: adapters[1],
.attachment: adapters[2]
])
}
}
@ -1398,12 +1394,6 @@ public extension MessageViewModel.QuotedInfo {
"""
}
static func createCustomPagedRowIdValidator() -> ((Int64, DataCache<MessageViewModel.QuotedInfo>) -> Bool) {
return { pagedRowId, dataCache -> Bool in
dataCache.values.contains(where: { $0.quotedInteractionId == pagedRowId })
}
}
static func createReferencedRowIdsRetriever() -> (([Int64], DataCache<MessageViewModel.QuotedInfo>) -> [Int64]) {
return { pagedRowIds, dataCache -> [Int64] in
dataCache.values.compactMap { quotedInfo in
@ -1424,173 +1414,39 @@ public extension MessageViewModel.QuotedInfo {
// Update changed records
dataCache.values.forEach { quoteInfo in
guard
quoteInfo.quotedInteractionVariant.isDeletedMessage,
let dataToUpdate: ViewModel = updatedPagedDataCache.data[quoteInfo.quote.interactionId],
(
dataToUpdate.quote?.body != nil ||
dataToUpdate.quote?.attachmentId != nil ||
dataToUpdate.quoteAttachment != nil
)
let interactionRowId: Int64 = updatedPagedDataCache.lookup[quoteInfo.quote.interactionId],
let dataToUpdate: ViewModel = updatedPagedDataCache.data[interactionRowId]
else { return }
updatedPagedDataCache = updatedPagedDataCache.upserting(
dataToUpdate.with(
quote: quoteInfo.quote.withOriginalMessageDeleted()
)
)
}
return updatedPagedDataCache
}
}
}
// MARK: --QuoteAttachmentInfo
public extension MessageViewModel.QuoteAttachmentInfo {
static func baseQuery(
userSessionId: SessionId,
blinded15SessionId: SessionId?,
blinded25SessionId: SessionId?
) -> ((SQL?) -> AdaptedFetchRequest<SQLRequest<MessageViewModel.QuoteAttachmentInfo>>) {
return { additionalFilters -> AdaptedFetchRequest<SQLRequest<QuoteAttachmentInfo>> in
let quote: TypedTableAlias<Quote> = TypedTableAlias()
let quoteInteraction: TypedTableAlias<Interaction> = TypedTableAlias(name: "quoteInteraction")
// let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
// let quoteAttachment: TypedTableAlias<Attachment> = TypedTableAlias(name: ViewModel.CodingKeys.quoteAttachment.stringValue)
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
let finalFilterSQL: SQL = {
guard let additionalFilters: SQL = additionalFilters else {
return SQL(stringLiteral: "")
}
return """
WHERE \(additionalFilters)
"""
}()
let numColumnsBeforeLinkedRecords: Int = 1
let request: SQLRequest<QuoteAttachmentInfo> = """
SELECT
\(attachment[.rowId]) AS \(QuoteAttachmentInfo.Columns.rowId),
\(attachment.allColumns),
\(quote.allColumns)
FROM \(Attachment.self)
LEFT JOIN \(InteractionAttachment.self) ON (
\(interactionAttachment[.attachmentId]) = \(attachment[.id]) AND
\(interactionAttachment[.albumIndex]) = 0
)
LEFT JOIN \(quoteInteraction) ON \(quoteInteraction[.id]) = \(interactionAttachment[.interactionId])
JOIN \(Quote.self) ON (
\(quote[.attachmentId]) = \(attachment[.id]) OR (
\(quoteInteraction[.timestampMs]) = \(quote[.timestampMs]) AND (
\(quoteInteraction[.authorId]) = \(quote[.authorId]) OR (
-- A users outgoing message is stored in some cases using their standard id
-- but the quote will use their blinded id so handle that case
\(quoteInteraction[.authorId]) = \(userSessionId.hexString) AND
(
\(quote[.authorId]) = \(blinded15SessionId?.hexString ?? "''") OR
\(quote[.authorId]) = \(blinded25SessionId?.hexString ?? "''")
)
switch quoteInfo.quotedInteractionVariant.isDeletedMessage {
// If the original message wasn't deleted and the quote contains some of it's content
// then remove that content from the quote
case false:
updatedPagedDataCache = updatedPagedDataCache.upserting(
dataToUpdate.with(
quoteAttachment: quoteInfo.attachment.map { [$0] }
)
)
)
)
\(finalFilterSQL)
"""
return request.adapted { db in
let adapters = try splittingRowAdapters(columnCounts: [
numColumnsBeforeLinkedRecords,
Attachment.numberOfSelectedColumns(db),
Quote.numberOfSelectedColumns(db)
])
return ScopeAdapter.with(QuoteAttachmentInfo.self, [
.attachment: adapters[1],
.quote: adapters[2]
])
}
}
}
static func joinToViewModelQuerySQL(
userSessionId: SessionId,
blinded15SessionId: SessionId?,
blinded25SessionId: SessionId?
) -> SQL {
let quote: TypedTableAlias<Quote> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let quoteInteraction: TypedTableAlias<Interaction> = TypedTableAlias(name: "quoteInteraction")
let quoteInteractionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias(
name: "quoteInteractionAttachment"
)
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
return """
JOIN \(Quote.self) ON \(quote[.interactionId]) = \(interaction[.id])
JOIN \(quoteInteraction) ON (
\(quoteInteraction[.timestampMs]) = \(quote[.timestampMs]) AND (
\(quoteInteraction[.authorId]) = \(quote[.authorId]) OR (
-- A users outgoing message is stored in some cases using their standard id
-- but the quote will use their blinded id so handle that case
\(quoteInteraction[.authorId]) = \(userSessionId.hexString) AND
(
\(quote[.authorId]) = \(blinded15SessionId?.hexString ?? "''") OR
\(quote[.authorId]) = \(blinded25SessionId?.hexString ?? "''")
)
)
)
)
JOIN \(quoteInteractionAttachment) ON (
\(quoteInteractionAttachment[.interactionId]) = \(quoteInteraction[.id]) AND
\(quoteInteractionAttachment[.albumIndex]) = 0
)
JOIN \(Attachment.self) ON (
\(attachment[.id]) = \(quoteInteractionAttachment[.attachmentId]) OR
\(attachment[.id]) = \(quote[.attachmentId])
)
"""
}
static func createAssociateDataClosure() -> (DataCache<MessageViewModel.QuoteAttachmentInfo>, DataCache<MessageViewModel>) -> DataCache<MessageViewModel> {
return { dataCache, pagedDataCache -> DataCache<MessageViewModel> in
var updatedPagedDataCache: DataCache<MessageViewModel> = pagedDataCache
// Update changed records
dataCache
.values
.grouped(by: \.quote.interactionId)
.forEach { (interactionId: Int64, attachments: [MessageViewModel.QuoteAttachmentInfo]) in
guard
let interactionRowId: Int64 = updatedPagedDataCache.lookup[interactionId],
let dataToUpdate: ViewModel = updatedPagedDataCache.data[interactionRowId]
else { return }
updatedPagedDataCache = updatedPagedDataCache.upserting(
dataToUpdate.with(
quoteAttachment: attachments.map { $0.attachment }
)
)
}
// Remove records that no longer exist
pagedDataCache
.values
.filter { $0.quoteAttachment != nil }
.forEach { model in
guard !dataCache.data.contains(where: { _, value in value.quote.interactionId == model.id }) else {
return
}
updatedPagedDataCache = updatedPagedDataCache.upserting(
model.with(
quoteAttachment: []
// If the original message was deleted and the quote contains some of it's content
// then remove that content from the quote
case true:
guard
(
dataToUpdate.quote?.body != nil ||
dataToUpdate.quote?.attachmentId != nil ||
dataToUpdate.quoteAttachment != nil
)
else { return }
updatedPagedDataCache = updatedPagedDataCache.upserting(
dataToUpdate.with(
quote: quoteInfo.quote.withOriginalMessageDeleted(),
quoteAttachment: []
)
)
)
}
}
return updatedPagedDataCache
}

@ -90,7 +90,7 @@ public extension ProfilePictureView {
imageData: (
profile.map { DisplayPictureManager.displayPicture(owner: .user($0), using: dependencies) } ??
PlaceholderIcon.generate(
seed: publicKey,
seed: (profile?.id ?? publicKey),
text: (profile?.displayName(for: threadVariant))
.defaulting(to: publicKey),
size: (additionalProfile != nil ?
@ -142,10 +142,7 @@ public extension ProfilePictureView {
seed: publicKey,
text: (profile?.displayName(for: threadVariant))
.defaulting(to: publicKey),
size: (additionalProfile != nil ?
size.multiImageSize :
size.viewSize
)
size: size.viewSize
).pngData()
),
icon: profileIcon

@ -0,0 +1,202 @@
// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import SessionUtilitiesKit
import Quick
import Nimble
@testable import SessionMessagingKit
class GroupMemberSpec: QuickSpec {
override class func spec() {
// MARK: - a GroupMember
describe("a GroupMember") {
// MARK: -- when ProfileAssociated
context("when ProfileAssociated") {
// MARK: ---- is sorted correctly
it("is sorted correctly") {
let userSessionId: SessionId = SessionId(.standard, hex: TestConstants.publicKey)
var members: [WithProfile<GroupMember>] = (0..<100).map { index in
WithProfile(
value: GroupMember(
groupId: "TestGroupId",
profileId: "05_(Id\(index < 10 ? "0" : "")\(index))",
role: .standard,
roleStatus: .accepted,
isHidden: false
),
profile: Profile(
id: "05_(Id\(index < 10 ? "0" : "")\(index))",
name: "Name\(index < 10 ? "0" : "")\(index)"
),
currentUserSessionId: userSessionId
)
}
// Update some names so that we can test the name sorting (case, special chars
// non-english, etc.)
members.with(1, name: "Test3")
members.with(2, name: "zName")
members.with(3, name: "test2")
members.with(4, name: "$#@$Name")
members.with(5, name: "TeSt1")
members.with(6, name: "BName")
members.with(7, name: "")
members.with(8, name: "")
// Provide a bunch of different statuses
var statusRandomGenerator: ARC4RandomNumberGenerator = ARC4RandomNumberGenerator(seed: 1234)
let remainingMembers: Double = Double(members.count - 10)
let numberOfStatuses: Int = Int(floor(remainingMembers / Double(GroupMember.RoleStatus.allCases.count)))
let allStatuses: [GroupMember.RoleStatus] = GroupMember.RoleStatus.allCases
.duplicated(count: numberOfStatuses)
.shuffled(using: &statusRandomGenerator)
allStatuses.enumerated().forEach { index, status in
members.with(10 + index, roleStatus: status)
}
// Make a bunch of the users admins
(40..<80).forEach { index in
members.with(index, role: .admin)
}
// Remove some profiles so we can check those
(80..<90).forEach { index in
members.with(index, removedProfile: true)
}
// Make a few of them the current user to check where they get placed
members.with(20, profileId: userSessionId.hexString, name: "You1")
members.with(50, profileId: userSessionId.hexString, name: "You2")
members.with(70, profileId: userSessionId.hexString, name: "You3")
members.with(95, profileId: userSessionId.hexString, name: "You4")
members.with(98, profileId: userSessionId.hexString, name: "You5")
// Sort the members and check that the values are in the expected orders
let sortedMembers: [WithProfile<GroupMember>] = members.sorted()
expect(sortedMembers.map { $0.profile?.name ?? $0.profileId }).to(equal([
"05_(Id87)", "Name15", "Name18", "05_(Id81)", "05_(Id84)",
"Name91", "Name93", "05_(Id80)", "05_(Id82)", "05_(Id89)",
"05_(Id86)", "05_(Id83)", "05_(Id88)", "Name90", "Name40",
"Name44", "Name45", "Name48", "Name49", "Name57",
"Name60", "Name61", "Name67", "Name10", "Name17",
"Name19", "Name23", "Name26", "Name33", "Name39",
"Name63", "Name64", "Name68", "Name72", "Name76",
"Name32", "Name38", "Name43", "Name55", "Name59",
"Name73", "Name75", "Name79", "Name11", "Name16",
"Name22", "Name28", "Name31", "Name36", "Name53",
"Name54", "Name77", "You1", "Name21", "Name25",
"Name30", "Name34", "Name41", "Name51", "Name62",
"Name65", "Name66", "Name74", "Name12", "Name24",
"Name27", "Name29", "Name35", "You2", "Name56",
"Name58", "Name71", "You3", "Name42", "Name46",
"Name47", "Name52", "Name69", "Name78", "You4",
"You5", "05_(Id85)", "$#@$Name", "BName", "Name00",
"Name09", "Name13", "Name14", "Name37", "Name92",
"Name94", "Name96", "Name97", "Name99", "TeSt1",
"test2", "Test3", "zName", "", ""
]))
expect(sortedMembers.map { $0.value.role }).to(equal([
.standard, .standard, .standard, .standard, .standard,
.standard, .standard, .standard, .standard, .standard,
.standard, .standard, .standard, .standard, .admin,
.admin, .admin, .admin, .admin, .admin,
.admin, .admin, .admin, .standard, .standard,
.standard, .standard, .standard, .standard, .standard,
.admin, .admin, .admin, .admin, .admin,
.standard, .standard, .admin, .admin, .admin,
.admin, .admin, .admin, .standard, .standard,
.standard, .standard, .standard, .standard, .admin,
.admin, .admin, .standard, .standard, .standard,
.standard, .standard, .admin, .admin, .admin,
.admin, .admin, .admin, .standard, .standard,
.standard, .standard, .standard, .admin, .admin,
.admin, .admin, .admin, .admin, .admin,
.admin, .admin, .admin, .admin, .standard,
.standard, .standard, .standard, .standard, .standard,
.standard, .standard, .standard, .standard, .standard,
.standard, .standard, .standard, .standard, .standard,
.standard, .standard, .standard, .standard, .standard
]))
expect(sortedMembers.map { $0.value.roleStatus }).to(equal([
.failed, .failed, .failed, .sending, .sending,
.sending, .sending, .pending, .pending, .pending,
.unknown, .pendingRemoval, .pendingRemoval, .pendingRemoval, .failed,
.failed, .failed, .failed, .failed, .failed,
.failed, .failed, .failed, .notSentYet, .notSentYet,
.notSentYet, .notSentYet, .notSentYet, .notSentYet, .notSentYet,
.notSentYet, .notSentYet, .notSentYet, .notSentYet, .notSentYet,
.sending, .sending, .sending, .sending, .sending,
.sending, .sending, .sending, .pending, .pending,
.pending, .pending, .pending, .pending, .pending,
.pending, .pending, .unknown, .unknown, .unknown,
.unknown, .unknown, .unknown, .unknown, .unknown,
.unknown, .unknown, .unknown, .pendingRemoval, .pendingRemoval,
.pendingRemoval, .pendingRemoval, .pendingRemoval, .pendingRemoval, .pendingRemoval,
.pendingRemoval, .pendingRemoval, .accepted, .accepted, .accepted,
.accepted, .accepted, .accepted, .accepted, .accepted,
.accepted, .accepted, .accepted, .accepted, .accepted,
.accepted, .accepted, .accepted, .accepted, .accepted,
.accepted, .accepted, .accepted, .accepted, .accepted,
.accepted, .accepted, .accepted, .accepted, .accepted
]))
let indexesOfCurrentUser: [Int] = sortedMembers.enumerated().reduce(into: []) { result, next in
guard next.element.profileId == userSessionId.hexString else { return }
result.append(next.offset)
}
expect(indexesOfCurrentUser).to(equal([
52, 68, 72, 79, 80
]))
}
}
}
}
}
// MARK: - Convenience
private extension Array {
func duplicated(count: Int) -> [Element] {
guard count > 1 else { return self }
var updated: [Element] = self
(0..<(count - 1)).forEach { _ in updated += self }
return updated
}
}
private extension Array where Element == WithProfile<GroupMember> {
mutating func with(
_ index: Int,
profileId: String? = nil,
name: String? = nil,
removedProfile: Bool = false,
role: GroupMember.Role? = nil,
roleStatus: GroupMember.RoleStatus? = nil
) {
let current: WithProfile<GroupMember> = self[index]
self[index] = WithProfile(
value: GroupMember(
groupId: "TestGroupId",
profileId: (profileId ?? current.profileId),
role: (role ?? current.value.role),
roleStatus: (roleStatus ?? current.value.roleStatus),
isHidden: false
),
profile: (removedProfile ? nil :
current.profile.map { currentProfile in
Profile(
id: (profileId ?? current.profileId),
name: (name ?? currentProfile.name)
)
}
),
currentUserSessionId: current.currentUserSessionId
)
}
}

@ -297,45 +297,8 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension
Log.info("Performing setup.")
dependencies.warmCache(cache: .appVersion)
// FIXME: Remove these once the database instance is fully managed via `Dependencies`
if AppSetup.hasRun {
dependencies[singleton: .storage].resumeDatabaseAccess()
dependencies[singleton: .storage].reconfigureDatabase()
/// If we had already done a setup then `libSession` won't have been re-setup so
/// we need to do so now (this ensures it has the correct user keys as well)
dependencies.remove(cache: .libSession)
dependencies[singleton: .storage].read { [dependencies] db in
guard let userKeyPair: KeyPair = Identity.fetchUserKeyPair(db) else {
dependencies.mutate(cache: .general) { $0.setCachedSessionId(sessionId: .invalid) }
dependencies.set(
cache: .libSession,
to: LibSession.Cache(
userSessionId: .invalid,
using: dependencies
)
)
return
}
dependencies.mutate(cache: .general) {
$0.setCachedSessionId(sessionId: SessionId(.standard, publicKey: userKeyPair.publicKey))
}
dependencies.set(
cache: .libSession,
to: LibSession.Cache(
userSessionId: SessionId(.standard, publicKey: userKeyPair.publicKey),
using: dependencies
)
)
dependencies.mutate(cache: .libSession) { $0.loadState(db) }
}
}
AppSetup.setupEnvironment(
retrySetupIfDatabaseInvalid: true,
appSpecificBlock: { [dependencies] in
// stringlint:ignore_start
Log.setup(with: Logger(

@ -335,16 +335,29 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView
).insert(db)
}
// Prepare any attachments
let preparedAttachments: [Attachment] = Attachment.prepare(attachments: finalAttachments, using: dependencies)
try Attachment.process(db, attachments: preparedAttachments, for: interactionId)
// Process any attachments
try Attachment.process(
db,
attachments: Attachment.prepare(attachments: finalAttachments, using: dependencies),
for: interactionId
)
return (
interaction,
try preparedAttachments.map {
try $0.preparedUpload(db, threadId: threadId, logCategory: .messageSender, using: dependencies)
// Using the same logic as the `MessageSendJob` retrieve
let attachmentState: MessageSendJob.AttachmentState = try MessageSendJob
.fetchAttachmentState(db, interactionId: interactionId)
let preparedUploads: [Network.PreparedRequest<String>] = try Attachment
.filter(ids: attachmentState.allAttachmentIds)
.fetchAll(db)
.map { attachment in
try attachment.preparedUpload(
db,
threadId: threadId,
logCategory: nil,
using: dependencies
)
}
)
return (interaction, preparedUploads)
}
.flatMap { (interaction: Interaction, preparedUploads: [Network.PreparedRequest<String>]) -> AnyPublisher<(interaction: Interaction, fileIds: [String]), Error> in
guard !preparedUploads.isEmpty else {

@ -69,7 +69,7 @@ public class ThreadPickerViewModel {
threads.filter { $0.threadCanWrite == true } // Exclude unwritable threads
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[ThreadPickerViewModel] Observation failed with error: \($0)") })
.handleEvents(didFail: { Log.error("Observation failed with error: \($0)") })
// MARK: - Functions

@ -8,44 +8,13 @@ import SessionMessagingKit
import SessionUtilitiesKit
public enum AppSetup {
@ThreadSafe private static var cachedHasRun: Bool = false
public static var hasRun: Bool { cachedHasRun }
public static func setupEnvironment(
additionalMigrationTargets: [MigratableTarget.Type] = [],
retrySetupIfDatabaseInvalid: Bool = false,
appSpecificBlock: (() -> ())? = nil,
migrationProgressChanged: ((CGFloat, TimeInterval) -> ())? = nil,
migrationsCompletion: @escaping (Result<Void, Error>, Bool) -> (),
using dependencies: Dependencies
) {
// If we've already run the app setup then only continue under certain circumstances
guard !AppSetup.cachedHasRun else {
let storageIsValid: Bool = dependencies[singleton: .storage].isValid
switch (retrySetupIfDatabaseInvalid, storageIsValid) {
case (true, false):
dependencies[singleton: .storage].reconfigureDatabase()
AppSetup.cachedHasRun = false
AppSetup.setupEnvironment(
retrySetupIfDatabaseInvalid: false, // Don't want to get stuck in a loop
appSpecificBlock: appSpecificBlock,
migrationProgressChanged: migrationProgressChanged,
migrationsCompletion: migrationsCompletion,
using: dependencies
)
default:
migrationsCompletion(
(storageIsValid ? .success(()) : .failure(StorageError.startupFailed)),
false
)
}
return
}
AppSetup.cachedHasRun = true
var backgroundTask: SessionBackgroundTask? = SessionBackgroundTask(label: #function, using: dependencies)
DispatchQueue.global(qos: .userInitiated).async {

Loading…
Cancel
Save