Breaking GroupsKeys change, QA issue fixes

• Added updated member statuses
• Updated to latest libSession (includes group status changes and breaking GroupKeys change)
• Fixed an issue where starting a new conversation on a new account could result in being kicked out of the conversation after a couple of seconds
• Fixed an issue where blocking a group invite would result in the senders contact conversation reverting to a message request
• Fixed an issue where deleting all attachments before now wouldn't leave behind the "message was deleted" artifact
• Fixed an issue where deleting all attachments before now was incorrectly deleting voice messages
• Fixed an issue where deleting all attachments before now wasn't deleting quote thumbnails
• Fixed an issue where you could never delete a group after being kicked
• Fixed an issue where some updated group control messages would be dropped by linked devices
pull/894/head
Morgan Pretty 5 months ago
parent a5df142c69
commit 162f2c823c

@ -1 +1 @@
Subproject commit 50585142beaa65cfb80c1dee353c5271cb46c974
Subproject commit 774985d6e2f8bad4b4e1b6f7be81c47ae7076023

@ -7888,7 +7888,7 @@
CLANG_WARN__ARC_BRIDGE_CAST_NONARC = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
CURRENT_PROJECT_VERSION = 515;
CURRENT_PROJECT_VERSION = 518;
ENABLE_BITCODE = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@ -7964,7 +7964,7 @@
CLANG_WARN__ARC_BRIDGE_CAST_NONARC = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Distribution";
CURRENT_PROJECT_VERSION = 515;
CURRENT_PROJECT_VERSION = 518;
ENABLE_BITCODE = NO;
ENABLE_MODULE_VERIFIER = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;

@ -317,8 +317,13 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Editabl
)),
trailingAccessory: {
switch (memberInfo.value.role, memberInfo.value.roleStatus) {
case (.admin, _), (.moderator, _): return nil
case (.standard, .failed), (.standard, .notSentYet), (.standard, .pending):
case (.admin, _), (.moderator, _), (_, .pendingRemoval): return nil
case (.standard, .accepted), (.zombie, _):
return .radio(
isSelected: selectedIdsSubject.value.ids.contains(memberInfo.profileId)
)
case (.standard, _):
return .highlightingBackgroundLabelAndRadio(
title: "resend".localized(),
isSelected: selectedIdsSubject.value.ids.contains(memberInfo.profileId),
@ -331,11 +336,6 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Editabl
label: "Select contact"
)
)
case (.standard, .accepted), (.zombie, _):
return .radio(
isSelected: selectedIdsSubject.value.ids.contains(memberInfo.profileId)
)
}
}(),
styling: SessionCell.StyleInfo(
@ -351,18 +351,17 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Editabl
let didTapResend: Bool = (targetView is SessionHighlightingBackgroundLabel)
switch (memberInfo.value.role, memberInfo.value.roleStatus, didTapResend) {
case (.moderator, _, _): return
case (.admin, _, _):
case (_, .pendingRemoval, _): return
case (.moderator, _, _), (.admin, _, _):
self?.showToast(
text: "adminCannotBeRemoved".localized(),
backgroundColor: .backgroundSecondary
)
case (.standard, .failed, true), (.standard, .notSentYet, true), (.standard, .pending, true):
case (.standard, _, true):
self?.resendInvitation(memberId: memberInfo.profileId)
case (.standard, .failed, _), (.standard, .notSentYet, _), (.standard, .pending, _),
(.standard, .accepted, _), (.zombie, _, _):
case (.standard, _, false), (.zombie, _, _):
if !selectedIdsSubject.value.ids.contains(memberInfo.profileId) {
selectedIdsSubject.send((
state.group.name,

@ -601,6 +601,7 @@ extension ConversationVC:
db,
SessionThread.Columns.shouldBeVisible.set(to: true),
SessionThread.Columns.pinnedPriority.set(to: LibSession.visiblePriority),
SessionThread.Columns.isDraft.set(to: false),
calledFromConfig: nil,
using: dependencies
)
@ -1335,7 +1336,7 @@ extension ConversationVC:
openGroupServer: String?,
openGroupPublicKey: String?
) {
guard viewModel.threadData.canWrite(using: viewModel.dependencies) else { return }
guard viewModel.threadData.threadCanWrite == true else { return }
// FIXME: Add in support for starting a thread with a 'blinded25' id (disabled until we support this decoding)
guard (try? SessionId.Prefix(from: sessionId)) != .blinded25 else { return }
guard (try? SessionId.Prefix(from: sessionId)) == .blinded15 else {

@ -75,7 +75,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa
}
override var inputAccessoryView: UIView? {
return (viewModel.threadData.canWrite(using: viewModel.dependencies) && isShowingSearchUI ?
return (viewModel.threadData.threadCanWrite == true && isShowingSearchUI ?
searchController.resultsBar :
snInputView
)
@ -150,7 +150,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa
result.contentInset = UIEdgeInsets(
top: 0,
leading: 0,
bottom: (viewModel.threadData.canWrite(using: viewModel.dependencies) ?
bottom: (viewModel.threadData.threadCanWrite == true ?
Values.mediumSpacing :
(Values.mediumSpacing + (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0))
),
@ -323,7 +323,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa
lazy var messageRequestFooterView: MessageRequestFooterView = MessageRequestFooterView(
threadVariant: self.viewModel.threadData.threadVariant,
canWrite: self.viewModel.threadData.canWrite(using: self.viewModel.dependencies),
canWrite: (self.viewModel.threadData.threadCanWrite == true),
threadIsMessageRequest: (self.viewModel.threadData.threadIsMessageRequest == true),
threadRequiresApproval: (self.viewModel.threadData.threadRequiresApproval == true),
closedGroupAdminProfile: self.viewModel.threadData.closedGroupAdminProfile,
@ -537,9 +537,9 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa
hasReloadedThreadDataAfterDisappearance = false
viewIsDisappearing = false
// If the user just created this thread but didn't send a message then we want to delete the
// "shadow" thread since it's not actually in use (this is to prevent it from taking up database
// space or unintentionally getting synced via libSession in the future)
/// If the user just created this thread but didn't send a message or the conversation is marked as hidden then we want to delete the
/// "shadow" thread since it's not actually in use (this is to prevent it from taking up database space or unintentionally getting synced
/// via `libSession` in the future)
let threadId: String = viewModel.threadData.threadId
if
@ -548,13 +548,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa
self.navigationController?.viewControllers.contains(self) == false
) &&
viewModel.threadData.threadIsNoteToSelf == false &&
viewModel.threadData.threadShouldBeVisible == false &&
!LibSession.conversationInConfig(
threadId: threadId,
threadVariant: viewModel.threadData.threadVariant,
visibleOnly: false,
using: viewModel.dependencies
)
viewModel.threadData.threadIsDraft == true
{
viewModel.dependencies[singleton: .storage].writeAsync { db in
_ = try SessionThread // Intentionally use `deleteAll` here instead of `deleteOrLeave`
@ -733,13 +727,13 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa
if
initialLoad ||
viewModel.threadData.canWrite(using: viewModel.dependencies) != updatedThreadData.canWrite(using: viewModel.dependencies) ||
viewModel.threadData.threadCanWrite != updatedThreadData.threadCanWrite ||
viewModel.threadData.threadVariant != updatedThreadData.threadVariant ||
viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest ||
viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval ||
viewModel.threadData.closedGroupAdminProfile != updatedThreadData.closedGroupAdminProfile
{
if updatedThreadData.canWrite(using: viewModel.dependencies) {
if updatedThreadData.threadCanWrite == true {
self.showInputAccessoryView()
} else {
self.hideInputAccessoryView()
@ -750,7 +744,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa
UIView.animate(withDuration: 0.3) { [weak self, dependencies = viewModel.dependencies] in
self?.messageRequestFooterView.update(
threadVariant: updatedThreadData.threadVariant,
canWrite: updatedThreadData.canWrite(using: dependencies),
canWrite: (updatedThreadData.threadCanWrite == true),
threadIsMessageRequest: (updatedThreadData.threadIsMessageRequest == true),
threadRequiresApproval: (updatedThreadData.threadRequiresApproval == true),
closedGroupAdminProfile: updatedThreadData.closedGroupAdminProfile
@ -810,12 +804,11 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa
}
// Now we have done all the needed diffs update the viewModel with the latest data
let oldCanWrite: Bool = viewModel.threadData.canWrite(using: viewModel.dependencies)
self.viewModel.updateThreadData(updatedThreadData)
/// **Note:** This needs to happen **after** we have update the viewModel's thread data (otherwise the `inputAccessoryView`
/// won't be generated correctly)
if initialLoad || oldCanWrite != updatedThreadData.canWrite(using: viewModel.dependencies) {
if initialLoad || viewModel.threadData.threadCanWrite != updatedThreadData.threadCanWrite {
if !self.isFirstResponder {
self.becomeFirstResponder()
}

@ -239,6 +239,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold
threadVariant == .group &&
LibSession.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadId), using: dependencies)
),
threadCanWrite: true, // Assume true
using: dependencies
)
)
@ -326,6 +327,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold
currentUserBlinded25SessionIdForThisThread: self?.threadData.currentUserBlinded25SessionId,
wasKickedFromGroup: wasKickedFromGroup,
groupIsDestroyed: groupIsDestroyed,
threadCanWrite: viewModel.determineInitialCanWriteFlag(using: dependencies),
using: dependencies
)
}
@ -376,7 +378,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold
LibSession.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadData.threadId), using: dependencies)
)
switch (threadData.threadIsNoteToSelf, threadData.canWrite(using: dependencies), blocksCommunityMessageRequests, wasKickedFromGroup, groupIsDestroyed) {
switch (threadData.threadIsNoteToSelf, threadData.threadCanWrite == true, blocksCommunityMessageRequests, wasKickedFromGroup, groupIsDestroyed) {
case (true, _, _, _, _): return "noteToSelfEmpty".localized()
case (_, false, true, _, _):
return "messageRequestsTurnedOff"
@ -519,6 +521,26 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold
dataQuery: MessageViewModel.TypingIndicatorInfo.baseQuery,
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()
)
],
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in

@ -376,6 +376,7 @@ public class HomeViewModel: NavigatableStateHolder {
using: dependencies
)
),
threadCanWrite: false, // Irrelevant for the HomeViewModel
using: dependencies
)
}

@ -168,6 +168,7 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O
using: dependencies
)
),
threadCanWrite: false, // Irrelevant for the MessageRequestsViewModel
using: dependencies
),
accessibility: Accessibility(

@ -189,7 +189,10 @@ public class SessionApp: SessionAppType {
db,
id: threadId,
variant: variant,
values: .existingOrDefault,
values: SessionThread.TargetValues(
shouldBeVisible: .useLibSession,
isDraft: (threadExists ? .useExisting : .setTo(true))
),
calledFromConfig: nil,
using: dependencies
)

@ -359,15 +359,18 @@ public extension UIContextualAction {
tableView: tableView
) { [weak viewController] _, _, completionHandler in
let threadIsBlocked: Bool = (threadViewModel.threadIsBlocked == true)
let threadIsMessageRequest: Bool = (threadViewModel.threadIsMessageRequest == true)
let threadIsContactMessageRequest: Bool = (
threadViewModel.threadVariant == .contact &&
threadViewModel.threadIsMessageRequest == true
)
let contactChanges: [ConfigColumnAssignment] = [
Contact.Columns.isBlocked.set(to: !threadIsBlocked),
/// **Note:** We set `didApproveMe` to `true` so the current user will be able to send a
/// message to the person who originally sent them the message request in the future if they
/// unblock them
(!threadIsMessageRequest ? nil : Contact.Columns.didApproveMe.set(to: true)),
(!threadIsMessageRequest ? nil : Contact.Columns.isApproved.set(to: false))
(!threadIsContactMessageRequest ? nil : Contact.Columns.didApproveMe.set(to: true)),
(!threadIsContactMessageRequest ? nil : Contact.Columns.isApproved.set(to: false))
].compactMap { $0 }
let performBlock: (UIViewController?) -> () = { viewController in
@ -413,7 +416,7 @@ public extension UIContextualAction {
}
// Blocked message requests should be deleted
if threadIsMessageRequest {
if threadViewModel.threadIsMessageRequest == true {
try SessionThread.deleteOrLeave(
db,
type: .deleteContactConversationAndMarkHidden,
@ -429,7 +432,7 @@ public extension UIContextualAction {
}
}
switch threadIsMessageRequest {
switch threadViewModel.threadIsMessageRequest == true {
case false: performBlock(nil)
case true:
let nameToUse: String = {

@ -244,13 +244,33 @@ enum _013_SessionUtilChanges: Migration {
/// counts so don't do this when running tests (this logic is the same as in `MainAppContext.isRunningTests`
if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil {
if (try SessionThread.exists(db, id: userSessionId.hexString)) == false {
try SessionThread.upsert(
db,
id: userSessionId.hexString,
variant: .contact,
values: SessionThread.TargetValues(shouldBeVisible: .setTo(false)),
calledFromConfig: nil,
using: dependencies
try db.execute(
sql: """
INSERT INTO \(SessionThread.databaseTableName) (
\(SessionThread.Columns.id.name),
\(SessionThread.Columns.variant.name),
\(SessionThread.Columns.creationDateTimestamp.name),
\(SessionThread.Columns.shouldBeVisible.name),
"isPinned",
\(SessionThread.Columns.messageDraft.name),
\(SessionThread.Columns.notificationSound.name),
\(SessionThread.Columns.mutedUntilTimestamp.name),
\(SessionThread.Columns.onlyNotifyForMentions.name),
\(SessionThread.Columns.markedAsUnread.name),
\(SessionThread.Columns.pinnedPriority.name)
)
VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?)
""",
arguments: [
userSessionId.hexString,
SessionThread.Variant.contact,
(dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000),
LibSession.shouldBeVisible(priority: LibSession.hiddenPriority),
false,
false,
false,
LibSession.hiddenPriority
]
)
}
}

@ -18,11 +18,15 @@ enum _022_GroupsRebuildChanges: Migration {
Identity.self, OpenGroup.self
]
static var createdOrAlteredTables: [(FetchableRecord & TableRecord).Type] = [
ClosedGroup.self, OpenGroup.self, GroupMember.self
SessionThread.self, ClosedGroup.self, OpenGroup.self, GroupMember.self
]
static let droppedTables: [(TableRecord & FetchableRecord).Type] = []
static func migrate(_ db: Database, using dependencies: Dependencies) throws {
try db.alter(table: SessionThread.self) { t in
t.add(.isDraft, .boolean).defaults(to: false)
}
try db.alter(table: ClosedGroup.self) { t in
t.add(.groupDescription, .text)
t.add(.displayPictureUrl, .text)

@ -36,6 +36,10 @@ public struct GroupMember: Codable, Equatable, Hashable, FetchableRecord, Persis
case pending
case failed
case notSentYet
case sending
case pendingRemoval
case unknown = 100
}
public let groupId: String
@ -152,15 +156,25 @@ extension GroupMember: ProfileAssociated {
}
/// We want to sort the member list so the most important info is at the top of the list, this means that we want to prioritise
/// Failed invitations
/// Pending invitations
/// Failed promotions
/// Pending promotions
/// Admins
/// Members
/// Invite failed, sorted as NameSortingOrder
/// Invite not sent, sorted as NameSortingOrder
/// Sending invite, sorted as NameSortingOrder
/// Invite sent, sorted as NameSortingOrder
/// Invite status unknown, sorted as NameSortingOrder
/// Pending removal, sorted as NameSortingOrder
/// Admin promotion failed, sorted as NameSortingOrder
/// Admin promotion not sent, sorted as NameSortingOrder
/// Sending admin promotion, sorted as NameSortingOrder
/// Admin promotion sent, sorted as NameSortingOrder
/// Admin promotion status unknown, sorted as NameSortingOrder
/// Admin, sorted as NameSortingOrder
/// Member, sorted as NameSortingOrder
///
/// And the current user should appear at the top of their respective group
let userSessionId: SessionId = lhs.currentUserSessionId
let desiredStatusOrder: [RoleStatus] = [
.failed, .notSentYet, .sending, .pending, .unknown, .pendingRemoval
]
/// 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 {
@ -176,24 +190,26 @@ extension GroupMember: ProfileAssociated {
switch (lhs.value.role, lhs.value.roleStatus, rhs.value.role, rhs.value.roleStatus) {
/// Non-accepted standard before admin
case (.standard, .failed, .admin, _), (.standard, .notSentYet, .admin, _), (.standard, .pending, .admin, _):
case (.standard, .failed, .admin, _), (.standard, .notSentYet, .admin, _),
(.standard, .sending, .admin, _), (.standard, .pending, .admin, _),
(.standard, .unknown, .admin, _), (.standard, .pendingRemoval, .admin, _):
return true
/// Non-accepted admin before accepted standard
case (.standard, _, .admin, .failed), (.standard, _, .admin, .notSentYet), (.standard, _, .admin, .pending):
case (.admin, .failed, .standard, .accepted), (.admin, .notSentYet, .standard, .accepted),
(.admin, .sending, .standard, .accepted), (.admin, .pending, .standard, .accepted),
(.admin, .unknown, .standard, .accepted), (.admin, .pendingRemoval, .standard, .accepted):
return true
/// Failed before sending, sending before pending
case (_, .failed, _, .notSentYet), (_, .failed, _, .pending), (_, .notSentYet, _, .pending): return true
/// Other statuses before accepted
case (_, .failed, _, .accepted), (_, .notSentYet, _, .accepted), (_, .pending, _, .accepted): return true
/// Accepted admin before accepted standard
case (.admin, .accepted, .standard, .accepted): return true
/// All other cases are in the wrong order
default: return false
/// Otherwise we should order based on the status position in `desiredStatusOrder`
default:
let lhsIndex = desiredStatusOrder.firstIndex(of: lhs.value.roleStatus)
let rhsIndex = desiredStatusOrder.firstIndex(of: rhs.value.roleStatus)
return ((lhsIndex ?? desiredStatusOrder.endIndex) < rhsIndex ?? desiredStatusOrder.endIndex)
}
}
}

@ -1334,14 +1334,79 @@ public extension Interaction {
.joining(required: Attachment.interaction.filter(interactionIds.contains(Interaction.Columns.id)))
.fetchAll(db)
/// If attachments were removed then we also need to tetrieve any quotes of the interactions which had attachments and
/// remove their thumbnails
///
/// **Note:** THis needs to happen before the attachments are deleted otherwise the joins in the query will fail
if !attachments.isEmpty {
let quote: TypedTableAlias<Quote> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
var blinded15SessionIdHexString: String = ""
var blinded25SessionIdHexString: String = ""
/// If it's a `community` conversation then we need to get the blinded ids
if threadVariant == .community {
blinded15SessionIdHexString = (SessionThread.getCurrentUserBlindedSessionId(
db,
threadId: threadId,
threadVariant: threadVariant,
blindingPrefix: .blinded15,
using: dependencies
)?.hexString).defaulting(to: "")
blinded25SessionIdHexString = (SessionThread.getCurrentUserBlindedSessionId(
db,
threadId: threadId,
threadVariant: threadVariant,
blindingPrefix: .blinded25,
using: dependencies
)?.hexString).defaulting(to: "")
}
/// Construct a request which gets the `quote.attachmentId` for any `Quote` entries related
/// to the removed `interactionIds`
let request: SQLRequest<String> = """
SELECT \(quote[.attachmentId])
FROM \(Quote.self)
JOIN \(Interaction.self) ON (
\(interaction[.timestampMs]) = \(quote[.timestampMs]) AND (
\(interaction[.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
\(interaction[.authorId]) = \(dependencies[cache: .general].sessionId.hexString) AND
(
\(quote[.authorId]) = \(blinded15SessionIdHexString) OR
\(quote[.authorId]) = \(blinded25SessionIdHexString)
)
)
)
)
JOIN \(InteractionAttachment.self) ON (
\(interactionAttachment[.interactionId]) = \(interaction[.id]) AND
\(interactionAttachment[.attachmentId]) IN \(attachments.map { $0.id })
)
WHERE (
\(quote[.attachmentId]) IS NOT NULL AND
\(interaction[.id]) IN \(interactionIds)
)
"""
let quoteAttachmentIds: [String] = try request.fetchAll(db)
_ = try Attachment
.filter(ids: quoteAttachmentIds)
.deleteAll(db)
}
/// Delete any attachments from the database
try attachments.forEach { try $0.delete(db) }
/// Delete the reactions from the database
_ = try Reaction
.filter(interactionIds.contains(Reaction.Columns.interactionId))
.deleteAll(db)
/// Delete any attachments from the database
try attachments.forEach { try $0.delete(db) }
/// Remove the `SnodeReceivedMessageInfo` records (otherwise we might try to poll for a hash which no longer exists, resulting
/// in fetching the last 14 days of messages)
let serverHashes: Set<String> = interactionInfo.compactMap(\.serverHash).asSet()

@ -33,6 +33,7 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord,
case onlyNotifyForMentions
case markedAsUnread
case pinnedPriority
case isDraft
}
public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible, CaseIterable {
@ -83,6 +84,9 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord,
/// A value indicating the priority of this conversation within the pinned conversations
public let pinnedPriority: Int32?
/// A value indicating whether this conversation is a draft conversation (ie. hasn't sent a message yet and should auto-delete)
public let isDraft: Bool?
// MARK: - Relationships
public var contact: QueryInterfaceRequest<Contact> {
@ -123,6 +127,7 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord,
onlyNotifyForMentions: Bool = false,
markedAsUnread: Bool? = false,
pinnedPriority: Int32? = nil,
isDraft: Bool? = nil,
using dependencies: Dependencies
) {
self.id = id
@ -134,6 +139,7 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord,
self.mutedUntilTimestamp = mutedUntilTimestamp
self.onlyNotifyForMentions = onlyNotifyForMentions
self.markedAsUnread = markedAsUnread
self.isDraft = isDraft
self.pinnedPriority = ((pinnedPriority ?? 0) > 0 ? pinnedPriority :
(isPinned ? 1 : 0)
)
@ -175,6 +181,7 @@ public extension SessionThread {
let creationDateTimestamp: Value<TimeInterval>
let shouldBeVisible: Value<Bool>
let pinnedPriority: Value<Int32>
let isDraft: Value<Bool>
let disappearingMessagesConfig: Value<DisappearingMessagesConfiguration>
// MARK: - Convenience
@ -189,11 +196,13 @@ public extension SessionThread {
creationDateTimestamp: Value<TimeInterval> = .useExisting,
shouldBeVisible: Value<Bool>,
pinnedPriority: Value<Int32> = .useLibSession,
isDraft: Value<Bool> = .useExisting,
disappearingMessagesConfig: Value<DisappearingMessagesConfiguration> = .useLibSession
) {
self.creationDateTimestamp = creationDateTimestamp
self.shouldBeVisible = shouldBeVisible
self.pinnedPriority = pinnedPriority
self.isDraft = isDraft
self.disappearingMessagesConfig = disappearingMessagesConfig
}
}
@ -229,6 +238,7 @@ public extension SessionThread {
),
shouldBeVisible: LibSession.shouldBeVisible(priority: targetPriority),
pinnedPriority: targetPriority,
isDraft: (values.isDraft.valueOrNull == true),
using: dependencies
).upserted(db)
}
@ -275,6 +285,7 @@ public extension SessionThread {
var finalCreationDateTimestamp: TimeInterval = result.creationDateTimestamp
var finalShouldBeVisible: Bool = result.shouldBeVisible
var finalPinnedPriority: Int32? = result.pinnedPriority
var finalIsDraft: Bool? = result.isDraft
/// The `shouldBeVisible` flag is based on `pinnedPriority` so we need to check these two together if they
/// should both be sourced from `libSession`
@ -315,6 +326,11 @@ public extension SessionThread {
finalShouldBeVisible = value
}
if case .setTo(let value) = values.isDraft, value != result.isDraft {
requiredChanges.append(SessionThread.Columns.isDraft.set(to: value))
finalIsDraft = value
}
/// If no changes were needed we can just return the existing/default thread
guard !requiredChanges.isEmpty else { return result }
@ -341,6 +357,7 @@ public extension SessionThread {
creationDateTimestamp: finalCreationDateTimestamp,
shouldBeVisible: finalShouldBeVisible,
pinnedPriority: finalPinnedPriority,
isDraft: finalIsDraft,
using: dependencies
).upserted(db)
)

@ -65,9 +65,20 @@ public enum GroupLeavingJob: JobExecutor {
.distinct()
.fetchCount(db))
.defaulting(to: 0)
let finalBehaviour: GroupLeavingJob.Details.Behaviour = {
guard
threadVariant == .group,
LibSession.wasKickedFromGroup(
groupSessionId: SessionId(.group, hex: threadId),
using: dependencies
)
else { return details.behaviour }
return .delete
}()
switch (threadVariant, details.behaviour, (isAdminUser && numAdminUsers == 1)) {
case (.legacyGroup, _, _):
switch (threadVariant, finalBehaviour, isAdminUser, (isAdminUser && numAdminUsers == 1)) {
case (.legacyGroup, _, _, _):
// Legacy group only supports the 'leave' behaviour so don't bother checking
return .leave(
try MessageSender.preparedSend(
@ -81,7 +92,7 @@ public enum GroupLeavingJob: JobExecutor {
)
)
case (.group, .leave, false):
case (.group, .leave, _, false):
return .leave(
try SnodeAPI
.preparedBatch(
@ -112,7 +123,7 @@ public enum GroupLeavingJob: JobExecutor {
.map { _, _ in () }
)
case (.group, .delete, _), (.group, .leave, true):
case (.group, .delete, true, _), (.group, .leave, true, true):
try LibSession.deleteGroupForEveryone(
db,
groupSessionId: SessionId(.group, hex: threadId),
@ -120,6 +131,8 @@ public enum GroupLeavingJob: JobExecutor {
)
return .delete
case (.group, .delete, false, _): return .delete
default: throw MessageSenderError.invalidClosedGroupUpdate
}

@ -226,9 +226,14 @@ internal extension LibSessionCacheType {
if attachDeleteBeforeTimestamp > 0 {
let interactionInfo: [InteractionInfo] = (try? Interaction
.filter(Interaction.Columns.threadId == groupSessionId.hexString)
.filter(Interaction.Columns.timestampMs < (TimeInterval(deleteBeforeTimestamp) * 1000))
.filter(Interaction.Columns.timestampMs < (TimeInterval(attachDeleteBeforeTimestamp) * 1000))
.filter(Interaction.Columns.serverHash != nil)
.joining(required: Interaction.interactionAttachments)
.joining(
required: Interaction.interactionAttachments.joining(
required: InteractionAttachment.attachment
.filter(Attachment.Columns.variant != Attachment.Variant.voiceMessage)
)
)
.select(.id, .serverHash)
.asRequest(of: InteractionInfo.self)
.fetchAll(db))
@ -241,11 +246,14 @@ internal extension LibSessionCacheType {
.asRequest(of: String.self)
.fetchSet(db)
let deletionCount: Int = try Interaction
.filter(Interaction.Columns.threadId == groupSessionId.hexString)
.filter(Interaction.Columns.timestampMs < (TimeInterval(attachDeleteBeforeTimestamp) * 1000))
.joining(required: Interaction.interactionAttachments)
.deleteAll(db)
try Interaction.markAsDeleted(
db,
threadId: groupSessionId.hexString,
threadVariant: .group,
interactionIds: Set(interactionIdsToRemove),
localOnly: false,
using: dependencies
)
if !interactionInfo.isEmpty {
Log.info(.libSession, "Deleted \(interactionInfo.count) message(s) with attachments from \(groupSessionId.hexString) due to 'attach_delete_before' value.")

@ -267,12 +267,13 @@ internal extension LibSession {
case (.admin, .accepted): groups_members_set_promotion_accepted(conf, &cMemberId)
case (.admin, .failed): groups_members_set_promotion_failed(conf, &cMemberId)
case (.admin, .pending): groups_members_set_promotion_sent(conf, &cMemberId)
case (.admin, .notSentYet): groups_members_set_promoted(conf, &cMemberId)
case (.admin, .notSentYet), (.admin, .sending): groups_members_set_promoted(conf, &cMemberId)
case (_, .accepted): groups_members_set_invite_accepted(conf, &cMemberId)
case (_, .failed): groups_members_set_invite_failed(conf, &cMemberId)
case (_, .pending): groups_members_set_invite_sent(conf, &cMemberId)
case (_, .notSentYet): break // Default state (can't return to this after creation)
case (_, .notSentYet), (_, .sending): break // Default state (can't return to this after creation)
case (_, .pendingRemoval), (_, .unknown): break // Unknown or permanent states
}
try LibSessionError.throwIfNeeded(conf)
@ -420,7 +421,7 @@ internal extension LibSession {
while !groups_members_iterator_done(membersIterator, &member) {
try LibSession.checkLoopLimitReached(&infiniteLoopGuard, for: .groupMembers)
let status: GROUP_MEMBER_STATUS = group_member_status(&member);
let status: GROUP_MEMBER_STATUS = groups_members_get_status(conf, &member)
// Ignore members pending removal
guard !status.isRemoveStatus else { continue }
@ -453,7 +454,7 @@ internal extension LibSession {
while !groups_members_iterator_done(membersIterator, &member) {
try LibSession.checkLoopLimitReached(&infiniteLoopGuard, for: .groupMembers)
let status: GROUP_MEMBER_STATUS = group_member_status(&member);
let status: GROUP_MEMBER_STATUS = groups_members_get_status(conf, &member)
guard status.isRemoveStatus else {
groups_members_iterator_advance(membersIterator)
@ -507,17 +508,6 @@ internal extension LibSession {
}
}
fileprivate extension GroupMember.RoleStatus {
var libSessionValue: Int32 {
switch self {
case .accepted: return 0
case .pending: return Int32(INVITE_SENT.rawValue)
case .failed: return Int32(INVITE_FAILED.rawValue)
case .notSentYet: return Int32(INVITE_NOT_SENT.rawValue)
}
}
}
fileprivate extension GROUP_MEMBER_STATUS {
func isAdmin(_ memberAdminFlag: Bool) -> Bool {
switch self {
@ -534,6 +524,9 @@ fileprivate extension GROUP_MEMBER_STATUS {
switch self {
case GROUP_MEMBER_STATUS_INVITE_NOT_SENT, GROUP_MEMBER_STATUS_PROMOTION_NOT_SENT:
return .notSentYet
case GROUP_MEMBER_STATUS_INVITE_SENDING, GROUP_MEMBER_STATUS_PROMOTION_SENDING:
return .sending
case GROUP_MEMBER_STATUS_INVITE_ACCEPTED, GROUP_MEMBER_STATUS_PROMOTION_ACCEPTED:
return .accepted
@ -543,6 +536,13 @@ fileprivate extension GROUP_MEMBER_STATUS {
case GROUP_MEMBER_STATUS_INVITE_SENT, GROUP_MEMBER_STATUS_PROMOTION_SENT:
return .pending
case GROUP_MEMBER_STATUS_REMOVED, GROUP_MEMBER_STATUS_REMOVED_MEMBER_AND_MESSAGES,
GROUP_MEMBER_STATUS_REMOVED_UNKNOWN:
return .pendingRemoval
case GROUP_MEMBER_STATUS_INVITE_UNKNOWN, GROUP_MEMBER_STATUS_PROMOTION_UNKNOWN:
return .unknown
// Default to "accepted" as that's what the `libSession.groups.member.status()` function does
default: return .accepted

@ -69,15 +69,21 @@ internal extension LibSession {
// If we have no updated threads then no need to continue
guard !updatedThreads.isEmpty else { return updated }
// Exclude any "draft" conversations from updating `libSession` (we don't want them to be
// synced until they turn into "real" conversations)
let targetThreads: [SessionThread] = updatedThreads.filter {
$0.isDraft != true
}
let userSessionId: SessionId = dependencies[cache: .general].sessionId
let groupedThreads: [SessionThread.Variant: [SessionThread]] = updatedThreads
let groupedThreads: [SessionThread.Variant: [SessionThread]] = targetThreads
.grouped(by: \.variant)
let urlInfo: [String: OpenGroupUrlInfo] = try OpenGroupUrlInfo
.fetchAll(db, ids: updatedThreads.map { $0.id })
.fetchAll(db, ids: targetThreads.map { $0.id })
.reduce(into: [:]) { result, next in result[next.threadId] = next }
// Update the unread state for the threads first (just in case that's what changed)
try LibSession.updateMarkedAsUnreadState(db, threads: updatedThreads, using: dependencies)
try LibSession.updateMarkedAsUnreadState(db, threads: targetThreads, using: dependencies)
// Then update the `hidden` and `priority` values
try groupedThreads.forEach { variant, threads in

@ -17,6 +17,8 @@ public final class GroupUpdateDeleteMemberContentMessage: ControlMessage {
public var messageHashes: [String]
public var adminSignature: Authentication.Signature?
public override var isSelfSendValid: Bool { true }
override public var processWithBlockedSender: Bool { true }
// MARK: - Initialization

@ -25,6 +25,8 @@ public final class GroupUpdateInfoChangeMessage: ControlMessage {
public var updatedExpiration: UInt32?
public var adminSignature: Authentication.Signature
public override var isSelfSendValid: Bool { true }
override public var processWithBlockedSender: Bool { true }
// MARK: - Initialization

@ -25,6 +25,8 @@ public final class GroupUpdateMemberChangeMessage: ControlMessage {
public var historyShared: Bool
public var adminSignature: Authentication.Signature
public override var isSelfSendValid: Bool { true }
override public var processWithBlockedSender: Bool { true }
// MARK: - Initialization

@ -16,6 +16,8 @@ public final class GroupUpdateMemberLeftMessage: ControlMessage {
/// to having the same keys
private let memberLeftCodableId: UUID = UUID()
public override var isSelfSendValid: Bool { true }
override public var processWithBlockedSender: Bool { true }
// MARK: - Proto Conversion

@ -531,6 +531,7 @@ public enum MessageReceiver {
db,
SessionThread.Columns.shouldBeVisible.set(to: true),
SessionThread.Columns.pinnedPriority.set(to: LibSession.visiblePriority),
SessionThread.Columns.isDraft.set(to: false),
calledFromConfig: nil,
using: dependencies
)

@ -12,6 +12,7 @@ fileprivate typealias ViewModel = MessageViewModel
fileprivate typealias AttachmentInteractionInfo = MessageViewModel.AttachmentInteractionInfo
fileprivate typealias ReactionInfo = MessageViewModel.ReactionInfo
fileprivate typealias TypingIndicatorInfo = MessageViewModel.TypingIndicatorInfo
fileprivate typealias QuoteAttachmentInfo = MessageViewModel.QuoteAttachmentInfo
public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable, ColumnExpressible {
public typealias Columns = CodingKeys
@ -207,8 +208,9 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
// MARK: - Mutation
public func with(
state: Interaction.State? = nil, // Optimistic outgoing messages
state: Interaction.State? = nil, // Optimistic outgoing messages
mostRecentFailureText: String? = nil, // Optimistic outgoing messages
quoteAttachment: [Attachment]? = nil, // Pass an empty array to clear
attachments: [Attachment]? = nil,
reactionInfo: [ReactionInfo]? = nil
) -> MessageViewModel {
@ -241,7 +243,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
isTypingIndicator: self.isTypingIndicator,
profile: self.profile,
quote: self.quote,
quoteAttachment: self.quoteAttachment,
quoteAttachment: (quoteAttachment ?? self.quoteAttachment.map { [$0] })?.first, // Only contains one
linkPreview: self.linkPreview,
linkPreviewAttachment: self.linkPreviewAttachment,
currentUserSessionId: self.currentUserSessionId,
@ -266,6 +268,69 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
)
}
public func removingQuoteAttachmentsIfNeeded(
validAttachments: [Attachment]
) -> MessageViewModel {
guard
let quote: Quote = self.quote,
let quoteAttachment: Attachment = self.quoteAttachment,
!validAttachments.contains(quoteAttachment)
else { return self }
return ViewModel(
threadId: self.threadId,
threadVariant: self.threadVariant,
threadIsTrusted: self.threadIsTrusted,
threadExpirationType: self.threadExpirationType,
threadExpirationTimer: self.threadExpirationTimer,
threadOpenGroupServer: self.threadOpenGroupServer,
threadOpenGroupPublicKey: self.threadOpenGroupPublicKey,
threadContactNameInternal: self.threadContactNameInternal,
rowId: self.rowId,
id: self.id,
serverHash: self.serverHash,
openGroupServerMessageId: self.openGroupServerMessageId,
variant: self.variant,
timestampMs: self.timestampMs,
receivedAtTimestampMs: self.receivedAtTimestampMs,
authorId: self.authorId,
authorNameInternal: self.authorNameInternal,
body: self.body,
rawBody: self.body,
expiresStartedAtMs: self.expiresStartedAtMs,
expiresInSeconds: self.expiresInSeconds,
state: self.state,
hasBeenReadByRecipient: self.hasBeenReadByRecipient,
mostRecentFailureText: self.mostRecentFailureText,
isSenderOpenGroupModerator: self.isSenderOpenGroupModerator,
isTypingIndicator: self.isTypingIndicator,
profile: self.profile,
quote: self.quote,
quoteAttachment: nil,
linkPreview: self.linkPreview,
linkPreviewAttachment: self.linkPreviewAttachment,
currentUserSessionId: self.currentUserSessionId,
attachments: self.attachments,
reactionInfo: self.reactionInfo,
cellType: self.cellType,
authorName: self.authorName,
senderName: self.senderName,
canHaveProfile: self.canHaveProfile,
shouldShowProfile: self.shouldShowProfile,
shouldShowDateHeader: self.shouldShowDateHeader,
containsOnlyEmoji: self.containsOnlyEmoji,
glyphCount: self.glyphCount,
previousVariant: self.previousVariant,
positionInCluster: self.positionInCluster,
isOnlyMessageInCluster: self.isOnlyMessageInCluster,
isLast: self.isLast,
isLastOutgoing: self.isLastOutgoing,
currentUserBlinded15SessionId: self.currentUserBlinded15SessionId,
currentUserBlinded25SessionId: self.currentUserBlinded25SessionId,
optimisticMessageId: self.optimisticMessageId
)
}
public func withClusteringChanges(
prevModel: MessageViewModel?,
nextModel: MessageViewModel?,
@ -610,6 +675,27 @@ 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: - Convenience Initialization
public extension MessageViewModel {
@ -1217,3 +1303,154 @@ public extension MessageViewModel.TypingIndicatorInfo {
}
}
}
// 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 ?? "''")
)
)
)
)
)
\(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: []
)
)
}
return updatedPagedDataCache
}
}
}

@ -35,12 +35,14 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat
case threadMutedUntilTimestamp
case threadOnlyNotifyForMentions
case threadMessageDraft
case threadIsDraft
case threadContactIsTyping
case threadWasMarkedUnread
case threadUnreadCount
case threadUnreadMentionCount
case threadHasUnreadMessagesOfAnyKind
case threadCanWrite
// Thread display info
@ -135,42 +137,14 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat
public let threadMutedUntilTimestamp: TimeInterval?
public let threadOnlyNotifyForMentions: Bool?
public let threadMessageDraft: String?
public let threadIsDraft: Bool?
public let threadContactIsTyping: Bool?
public let threadWasMarkedUnread: Bool?
public let threadUnreadCount: UInt?
public let threadUnreadMentionCount: UInt?
public let threadHasUnreadMessagesOfAnyKind: Bool?
public func canWrite(using dependencies: Dependencies) -> Bool {
switch threadVariant {
case .contact:
guard threadIsMessageRequest == true else { return true }
return (profile?.blocksCommunityMessageRequests != true)
case .legacyGroup:
guard threadIsMessageRequest == false else { return true }
return (
currentUserIsClosedGroupMember == true &&
interactionVariant?.isGroupLeavingStatus != true
)
case .group:
guard groupIsDestroyed != true else { return false }
guard wasKickedFromGroup != true else { return false }
guard threadIsMessageRequest == false else { return true }
return (
currentUserIsClosedGroupMember == true &&
interactionVariant?.isGroupLeavingStatus != true
)
case .community:
return (openGroupPermissions?.contains(.write) ?? false)
}
}
public let threadCanWrite: Bool?
// Thread display info
@ -420,6 +394,45 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat
)
}
}
// MARK: - Functions
/// This function should only be called when initially creating/populating the `SessionThreadViewModel`, instead use
/// `threadCanWrite == true` to determine whether the user should be able to write to a thread, this function uses
/// external data to determine if the user can write so the result might differ from the original value when the
/// `SessionThreadViewModel` was created
public func determineInitialCanWriteFlag(using dependencies: Dependencies) -> Bool {
switch threadVariant {
case .contact:
guard threadIsMessageRequest == true else { return true }
return (profile?.blocksCommunityMessageRequests != true)
case .legacyGroup:
guard threadIsMessageRequest == false else { return true }
return (
currentUserIsClosedGroupMember == true &&
interactionVariant?.isGroupLeavingStatus != true
)
case .group:
guard groupIsDestroyed != true else { return false }
guard wasKickedFromGroup != true else { return false }
guard threadIsMessageRequest == false else { return true }
guard
!LibSession.wasKickedFromGroup(
groupSessionId: SessionId(.group, hex: threadId),
using: dependencies
)
else { return false }
return interactionVariant?.isGroupLeavingStatus != true
case .community:
return (openGroupPermissions?.contains(.write) ?? false)
}
}
}
// MARK: - Convenience Initialization
@ -442,6 +455,7 @@ public extension SessionThreadViewModel {
openGroupPermissions: OpenGroup.Permissions? = nil,
unreadCount: UInt = 0,
hasUnreadMessagesOfAnyKind: Bool = false,
threadCanWrite: Bool = true,
disappearingMessagesConfiguration: DisappearingMessagesConfiguration? = nil,
using dependencies: Dependencies
) {
@ -461,12 +475,14 @@ public extension SessionThreadViewModel {
self.threadMutedUntilTimestamp = nil
self.threadOnlyNotifyForMentions = nil
self.threadMessageDraft = nil
self.threadIsDraft = nil
self.threadContactIsTyping = nil
self.threadWasMarkedUnread = nil
self.threadUnreadCount = unreadCount
self.threadUnreadMentionCount = nil
self.threadHasUnreadMessagesOfAnyKind = hasUnreadMessagesOfAnyKind
self.threadCanWrite = threadCanWrite
// Thread display info
@ -538,11 +554,13 @@ public extension SessionThreadViewModel {
threadMutedUntilTimestamp: self.threadMutedUntilTimestamp,
threadOnlyNotifyForMentions: self.threadOnlyNotifyForMentions,
threadMessageDraft: self.threadMessageDraft,
threadIsDraft: self.threadIsDraft,
threadContactIsTyping: self.threadContactIsTyping,
threadWasMarkedUnread: self.threadWasMarkedUnread,
threadUnreadCount: self.threadUnreadCount,
threadUnreadMentionCount: self.threadUnreadMentionCount,
threadHasUnreadMessagesOfAnyKind: self.threadHasUnreadMessagesOfAnyKind,
threadCanWrite: self.threadCanWrite,
disappearingMessagesConfiguration: self.disappearingMessagesConfiguration,
contactLastKnownClientVersion: self.contactLastKnownClientVersion,
displayPictureFilename: self.displayPictureFilename,
@ -590,6 +608,7 @@ public extension SessionThreadViewModel {
currentUserBlinded25SessionIdForThisThread: String?,
wasKickedFromGroup: Bool,
groupIsDestroyed: Bool,
threadCanWrite: Bool,
using dependencies: Dependencies
) -> SessionThreadViewModel {
return SessionThreadViewModel(
@ -608,11 +627,13 @@ public extension SessionThreadViewModel {
threadMutedUntilTimestamp: self.threadMutedUntilTimestamp,
threadOnlyNotifyForMentions: self.threadOnlyNotifyForMentions,
threadMessageDraft: self.threadMessageDraft,
threadIsDraft: self.threadIsDraft,
threadContactIsTyping: self.threadContactIsTyping,
threadWasMarkedUnread: self.threadWasMarkedUnread,
threadUnreadCount: self.threadUnreadCount,
threadUnreadMentionCount: self.threadUnreadMentionCount,
threadHasUnreadMessagesOfAnyKind: self.threadHasUnreadMessagesOfAnyKind,
threadCanWrite: threadCanWrite,
disappearingMessagesConfiguration: self.disappearingMessagesConfiguration,
contactLastKnownClientVersion: self.contactLastKnownClientVersion,
displayPictureFilename: self.displayPictureFilename,
@ -1052,7 +1073,7 @@ public extension SessionThreadViewModel {
/// the `disappearingMessageSConfiguration` entry below otherwise the query will fail to parse and might throw
///
/// Explicitly set default values for the fields ignored for search results
let numColumnsBeforeProfiles: Int = 17
let numColumnsBeforeProfiles: Int = 18
let request: SQLRequest<ViewModel> = """
SELECT
\(thread[.rowId]) AS \(ViewModel.Columns.rowId),
@ -1091,6 +1112,7 @@ public extension SessionThreadViewModel {
\(thread[.mutedUntilTimestamp]) AS \(ViewModel.Columns.threadMutedUntilTimestamp),
\(thread[.onlyNotifyForMentions]) AS \(ViewModel.Columns.threadOnlyNotifyForMentions),
\(thread[.messageDraft]) AS \(ViewModel.Columns.threadMessageDraft),
\(thread[.isDraft]) AS \(ViewModel.Columns.threadIsDraft),
\(thread[.markedAsUnread]) AS \(ViewModel.Columns.threadWasMarkedUnread),
\(aggregateInteraction[.threadUnreadCount]),

@ -280,6 +280,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView
db,
SessionThread.Columns.shouldBeVisible.set(to: true),
SessionThread.Columns.pinnedPriority.set(to: LibSession.visiblePriority),
SessionThread.Columns.isDraft.set(to: false),
calledFromConfig: nil,
using: dependencies
)

@ -40,7 +40,7 @@ public class ThreadPickerViewModel {
.fetchAll(db)
}
.map { [dependencies] threads -> [SessionThreadViewModel] in
threads.filter { $0.canWrite(using: dependencies) } // Exclude unwritable threads
threads.filter { $0.threadCanWrite == true } // Exclude unwritable threads
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[ThreadPickerViewModel] Observation failed with error: \($0)") })

Loading…
Cancel
Save