Files
Morgan Pretty 37e66498fd Merge branch 'master' into dev
# Conflicts:
#	Session/Conversations/ConversationVC+Interaction.swift
#	Session/Conversations/Settings/ThreadSettingsViewModel.swift
#	Session/Home/HomeVC.swift
#	Session/Notifications/NotificationPresenter.swift
#	Session/Onboarding/Onboarding.swift
#	Session/Utilities/UIContextualAction+Utilities.swift
#	SessionMessagingKit/Database/Models/ClosedGroup.swift
#	SessionMessagingKit/Database/Models/Interaction.swift
#	SessionMessagingKit/Database/Models/SessionThread.swift
#	SessionMessagingKit/Open Groups/OpenGroupManager.swift
#	SessionMessagingKit/Sending & Receiving/MessageReceiver.swift
#	SessionMessagingKit/Shared Models/SessionThreadViewModel.swift
#	SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift
#	SessionShareExtension/ThreadPickerVC.swift
2026-01-20 16:33:31 +11:00

2488 lines
116 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import SwiftUI
import Foundation
import PhotosUI
import Combine
import Lucide
import GRDB
import DifferenceKit
import SessionUIKit
import SessionMessagingKit
import SignalUtilitiesKit
import SessionUtilitiesKit
import SessionNetworkingKit
// MARK: - Log.Category
public extension Log.Category {
static let threadSettingsViewModel: Log.Category = .create("ThreadSettingsViewModel", defaultLevel: .warn)
}
// MARK: - ThreadSettingsViewModel
class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, ObservableTableSource {
public let dependencies: Dependencies
public let navigatableState: NavigatableState = NavigatableState()
public let state: TableDataState<Section, TableItem> = TableDataState()
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
private let didTriggerSearch: () -> ()
private var updatedName: String?
private var updatedDescription: String?
private var onDisplayPictureSelected: ((ImageDataManager.DataSource, CGRect?) -> Void)?
private lazy var imagePickerHandler: ImagePickerHandler = ImagePickerHandler(
onTransition: { [weak self] in self?.transitionToScreen($0, transitionType: $1) },
onImagePicked: { [weak self] source, cropRect in
self?.onDisplayPictureSelected?(source, cropRect)
},
using: dependencies
)
/// This value is the current state of the view
@MainActor @Published private(set) var internalState: State
private var observationTask: Task<Void, Never>?
// MARK: - Initialization
@MainActor init(
threadInfo: ConversationInfoViewModel,
didTriggerSearch: @escaping () -> (),
using dependencies: Dependencies
) {
self.dependencies = dependencies
self.didTriggerSearch = didTriggerSearch
self.internalState = State.initialState(threadInfo: threadInfo, using: dependencies)
self.observationTask = nil
/// Bind the state
self.observationTask = ObservationBuilder
.initialValue(self.internalState)
.debounce(for: .milliseconds(10)) /// Changes trigger multiple events at once so debounce them
.using(dependencies: dependencies)
.query(ThreadSettingsViewModel.queryState)
.assign { [weak self] updatedState in
guard let self = self else { return }
// FIXME: To slightly reduce the size of the changes this new observation mechanism is currently wired into the old SessionTableViewController observation mechanism, we should refactor it so everything uses the new mechanism
self.internalState = updatedState
self.pendingTableDataSubject.send(updatedState.sections(viewModel: self))
}
}
// MARK: - Config
enum ProfileImageStatus: Equatable, Hashable {
case image(expanded: Bool)
case qrCode(expanded: Bool)
var isQRCode: Bool {
switch self {
case .image: return false
case .qrCode: return true
}
}
var isExpanded: Bool {
switch self {
case .image(let expanded), .qrCode(let expanded): return expanded
}
}
func toggleState() -> ProfileImageStatus {
switch self {
case .image(let expanded): return .qrCode(expanded: expanded)
case .qrCode(let expanded): return .image(expanded: expanded)
}
}
func toggleExpansion() -> ProfileImageStatus {
switch self {
case .image(let expanded): return .image(expanded: !expanded)
case .qrCode(let expanded): return .qrCode(expanded: !expanded)
}
}
}
enum NavItem: Equatable {
case edit
}
public enum Section: SessionTableSection {
case conversationInfo
case sessionId
case sessionIdNoteToSelf
case content
case adminActions
case destructiveActions
public var title: String? {
switch self {
case .sessionId: return "accountId".localized()
case .sessionIdNoteToSelf: return "accountIdYours".localized()
case .adminActions: return "adminSettings".localized()
default: return nil
}
}
public var style: SessionTableSectionStyle {
switch self {
case .sessionId, .sessionIdNoteToSelf: return .titleSeparator
case .destructiveActions: return .padding
case .adminActions: return .titleRoundedContent
default: return .none
}
}
}
public enum TableItem: Differentiable {
case avatar
case qrCode
case displayName
case contactName
case threadDescription
case sessionId
case copyThreadId
case searchConversation
case disappearingMessages
case pinConversation
case notifications
case addToOpenGroup
case groupMembers
case attachments
case editGroup
case promoteAdmins
case debugSetCustomDisplayPicture
case blockUser
case hideNoteToSelf
case clearAllMessages
case leaveCommunity
case leaveGroup
case deleteConversation
case deleteContact
case debugDeleteAttachmentsBeforeNow
}
public struct ThreadSettingsViewModelEvent: Hashable {
let profileImageStatus: ProfileImageStatus
}
// MARK: - State
public struct State: ObservableKeyProvider {
let profileImageStatus: ProfileImageStatus
let threadInfo: ConversationInfoViewModel
let dataCache: ConversationDataCache
@MainActor public func sections(viewModel: ThreadSettingsViewModel) -> [SectionModel] {
ThreadSettingsViewModel.sections(state: self, viewModel: viewModel)
}
public var observedKeys: Set<ObservableKey> {
var result: Set<ObservableKey> = [
.appLifecycle(.willEnterForeground),
.databaseLifecycle(.resumed),
.updateScreen(ThreadSettingsViewModel.self),
.conversationUpdated(threadInfo.id),
.conversationDeleted(threadInfo.id),
.profile(threadInfo.userSessionId.hexString),
.typingIndicator(threadInfo.id),
.messageCreated(threadId: threadInfo.id),
.recentReactionsUpdated
]
if SessionId.Prefix.isCommunityBlinded(threadInfo.id) {
result.insert(.anyContactUnblinded)
}
result.insert(contentsOf: threadInfo.observedKeys)
return result
}
static func initialState(
threadInfo: ConversationInfoViewModel,
using dependencies: Dependencies
) -> State {
let dataCache: ConversationDataCache = ConversationDataCache(
userSessionId: dependencies[cache: .general].sessionId,
context: ConversationDataCache.Context(
source: .conversationSettings(threadId: threadInfo.id),
requireFullRefresh: false,
requireAuthMethodFetch: false,
requiresMessageRequestCountUpdate: false,
requiresInitialUnreadInteractionInfo: false,
requireRecentReactionEmojiUpdate: false
)
)
return State(
profileImageStatus: .image(expanded: false),
threadInfo: threadInfo,
dataCache: dataCache
)
}
}
lazy var rightNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = $internalState
.map { [weak self] state -> [SessionNavItem<NavItem>] in
let canEditDisplayName: Bool = (
!state.threadInfo.isNoteToSelf &&
(
state.threadInfo.variant == .contact ||
state.threadInfo.groupInfo?.currentUserRole == .admin
)
)
guard canEditDisplayName else { return [] }
return [
SessionNavItem(
id: .edit,
image: Lucide.image(icon: .pencil, size: 22)?
.withRenderingMode(.alwaysTemplate),
style: .plain,
accessibilityIdentifier: "Edit Nickname",
action: { [weak self] in
guard let info: ConfirmationModal.Info = self?.updateDisplayNameModal(state: state) else {
return
}
self?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present)
}
)
]
}
.eraseToAnyPublisher()
// MARK: - Content
@MainActor var title: String {
switch internalState.threadInfo.variant {
case .contact: return "sessionSettings".localized()
case .legacyGroup, .group, .community: return "deleteAfterGroupPR1GroupSettings".localized()
}
}
@Sendable private static func queryState(
previousState: State,
events: [ObservedEvent],
isInitialQuery: Bool,
using dependencies: Dependencies
) async -> State {
var profileImageStatus: ProfileImageStatus = previousState.profileImageStatus
var threadInfo: ConversationInfoViewModel = previousState.threadInfo
var dataCache: ConversationDataCache = previousState.dataCache
/// If there are no events we want to process then just return the current state
guard isInitialQuery || !events.isEmpty else { return previousState }
/// Split the events between those that need database access and those that don't
let changes: EventChangeset = events.split(by: { $0.handlingStrategy })
/// Update the context
dataCache.withContext(
source: .conversationSettings(threadId: threadInfo.id),
requireFullRefresh: (
isInitialQuery ||
changes.containsAny(
.appLifecycle(.willEnterForeground),
.databaseLifecycle(.resumed)
)
)
)
/// Process cache updates first
dataCache = await ConversationDataHelper.applyNonDatabaseEvents(
changes,
currentCache: dataCache,
using: dependencies
)
/// Then determine the fetch requirements
let fetchRequirements: ConversationDataHelper.FetchRequirements = ConversationDataHelper.determineFetchRequirements(
for: changes,
currentCache: dataCache,
itemCache: [threadInfo.id: threadInfo],
loadPageEvent: nil
)
/// Peform any database changes
if !dependencies[singleton: .storage].isSuspended, fetchRequirements.needsAnyFetch {
do {
try await dependencies[singleton: .storage].readAsync { db in
/// Fetch any required data from the cache
dataCache = try ConversationDataHelper.fetchFromDatabase(
db,
requirements: fetchRequirements,
currentCache: dataCache,
using: dependencies
)
}
} catch {
let eventList: String = changes.databaseEvents.map { $0.key.rawValue }.joined(separator: ", ")
Log.critical(.threadSettingsViewModel, "Failed to fetch state for events [\(eventList)], due to error: \(error)")
}
}
else if !changes.databaseEvents.isEmpty {
Log.warn(.threadSettingsViewModel, "Ignored \(changes.databaseEvents.count) database event(s) sent while storage was suspended.")
}
/// Peform any `libSession` changes
if fetchRequirements.needsAnyFetch {
do {
dataCache = try ConversationDataHelper.fetchFromLibSession(
requirements: fetchRequirements,
cache: dataCache,
using: dependencies
)
}
catch {
Log.warn(.threadSettingsViewModel, "Failed to handle \(changes.libSessionEvents.count) libSession event(s) due to error: \(error).")
}
}
if let updatedValue: ThreadSettingsViewModelEvent = changes.latestGeneric(.updateScreen, as: ThreadSettingsViewModelEvent.self) {
profileImageStatus = updatedValue.profileImageStatus
}
/// Regenerate the `threadInfo` now that the `dataCache` is updated
if let thread: SessionThread = dataCache.thread(for: threadInfo.id) {
threadInfo = ConversationInfoViewModel(
thread: thread,
dataCache: dataCache,
using: dependencies
)
}
/// Generate the new state
return State(
profileImageStatus: profileImageStatus,
threadInfo: threadInfo,
dataCache: dataCache
)
}
private static func sections(state: State, viewModel: ThreadSettingsViewModel) -> [SectionModel] {
let threadDisplayName: String = state.threadInfo.displayName.deformatted()
let isThreadHidden: Bool = (
!state.threadInfo.shouldBeVisible ||
state.threadInfo.pinnedPriority == LibSession.hiddenPriority
)
let showThreadPubkey: Bool = (
state.threadInfo.variant == .contact || (
state.threadInfo.variant == .group &&
viewModel.dependencies[feature: .groupsShowPubkeyInConversationSettings]
)
)
// MARK: - Conversation Info
let conversationInfoSection: SectionModel = SectionModel(
model: .conversationInfo,
elements: [
(state.profileImageStatus.isQRCode ?
SessionCell.Info(
id: .qrCode,
accessory: .qrCode(
for: state.threadInfo.qrCodeString,
hasBackground: false,
logo: "SessionWhite40", // stringlint:ignore
themeStyle: ThemeManager.currentTheme.interfaceStyle
),
styling: SessionCell.StyleInfo(
alignment: .centerHugging,
customPadding: SessionCell.Padding(bottom: Values.smallSpacing),
backgroundStyle: .noBackground
),
onTapView: { [weak viewModel, dependencies = viewModel.dependencies] targetView in
let didTapProfileIcon: Bool = !(targetView is UIImageView)
if didTapProfileIcon {
dependencies.notifyAsync(
priority: .immediate,
key: .updateScreen(ThreadSettingsViewModel.self),
value: ThreadSettingsViewModelEvent(
profileImageStatus: state.profileImageStatus.toggleState()
)
)
} else {
viewModel?.showQRCodeLightBox(for: state.threadInfo)
}
}
)
:
SessionCell.Info(
id: .avatar,
accessory: .profile(
id: state.threadInfo.id,
size: (state.profileImageStatus.isExpanded ? .expanded : .hero),
threadVariant: state.threadInfo.variant,
displayPictureUrl: state.threadInfo.displayPictureUrl,
profile: state.threadInfo.profile,
profileIcon: (state.threadInfo.isNoteToSelf || state.threadInfo.variant == .group ? .none : .qrCode),
additionalProfile: state.threadInfo.additionalProfile,
accessibility: nil
),
styling: SessionCell.StyleInfo(
alignment: .centerHugging,
customPadding: SessionCell.Padding(
leading: 0,
bottom: Values.smallSpacing
),
backgroundStyle: .noBackground
),
onTapView: { [dependencies = viewModel.dependencies] targetView in
let didTapQRCodeIcon: Bool = !(targetView is ProfilePictureView)
if didTapQRCodeIcon {
dependencies.notifyAsync(
priority: .immediate,
key: .updateScreen(ThreadSettingsViewModel.self),
value: ThreadSettingsViewModelEvent(
profileImageStatus: state.profileImageStatus.toggleState()
)
)
} else {
dependencies.notifyAsync(
priority: .immediate,
key: .updateScreen(ThreadSettingsViewModel.self),
value: ThreadSettingsViewModelEvent(
profileImageStatus: state.profileImageStatus.toggleExpansion()
)
)
}
}
)
),
SessionCell.Info(
id: .displayName,
title: SessionCell.TextInfo(
threadDisplayName,
font: .titleLarge,
alignment: .center,
trailingImage: {
guard
state.threadInfo.shouldShowProBadge &&
!state.threadInfo.isNoteToSelf
else { return nil }
return SessionProBadge.trailingImage(
size: .medium,
themeBackgroundColor: .primary
)
}()
),
styling: SessionCell.StyleInfo(
alignment: .centerHugging,
customPadding: SessionCell.Padding(
top: Values.smallSpacing,
bottom: {
guard state.threadInfo.variant != .contact else { return Values.mediumSpacing }
guard state.threadInfo.conversationDescription == nil else {
return Values.smallSpacing
}
return Values.largeSpacing
}(),
interItem: 0
),
backgroundStyle: .noBackground
),
accessibility: Accessibility(
identifier: "Username",
label: threadDisplayName
),
onTapView: { [weak viewModel, dependencies = viewModel.dependencies] targetView in
guard
targetView is SessionProBadge,
!dependencies[singleton: .sessionProManager].currentUserIsCurrentlyPro
else {
guard let info: ConfirmationModal.Info = viewModel?.updateDisplayNameModal(state: state) else {
return
}
viewModel?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present)
return
}
let proCTAModalVariant: ProCTAModal.Variant = {
switch state.threadInfo.variant {
case .group:
return .groupLimit(
isAdmin: (state.threadInfo.groupInfo?.currentUserRole == .admin),
isSessionProActivated: (state.threadInfo.groupInfo?.isProGroup == true),
proBadgeImage: UIView.image(
for: .themedKey(
SessionProBadge.Size.mini.cacheKey,
themeBackgroundColor: .primary
),
generator: { SessionProBadge(size: .mini) }
)
)
default:
return .generic(
renew: dependencies[singleton: .sessionProManager]
.currentUserCurrentProState
.status == .expired
)
}
}()
dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded(
proCTAModalVariant,
onConfirm: {
dependencies[singleton: .sessionProManager].showSessionProBottomSheetIfNeeded(
presenting: { [weak viewModel] bottomSheet in
viewModel?.transitionToScreen(bottomSheet, transitionType: .present)
}
)
},
presenting: { [weak viewModel] modal in
viewModel?.transitionToScreen(modal, transitionType: .present)
}
)
}
),
(state.threadInfo.contactInfo == nil || threadDisplayName == state.threadInfo.contactInfo?.displayName ? nil :
SessionCell.Info(
id: .contactName,
subtitle: SessionCell.TextInfo(
"(\(state.threadInfo.contactInfo?.displayName ?? ""))", // stringlint:ignore
font: .subtitle,
alignment: .center
),
styling: SessionCell.StyleInfo(
tintColor: .textSecondary,
customPadding: SessionCell.Padding(
top: 0,
bottom: Values.largeSpacing
),
backgroundStyle: .noBackground
)
)
),
state.threadInfo.conversationDescription.map { conversationDescription in
SessionCell.Info(
id: .threadDescription,
description: SessionCell.TextInfo(
conversationDescription,
font: .subtitle,
alignment: .center,
interaction: .expandable
),
styling: SessionCell.StyleInfo(
tintColor: .textSecondary,
customPadding: SessionCell.Padding(
top: 0,
bottom: (state.threadInfo.variant != .contact ? Values.largeSpacing : nil)
),
backgroundStyle: .noBackground
),
accessibility: Accessibility(
identifier: "Description",
label: conversationDescription
)
)
}
].compactMap { $0 }
)
// MARK: - Session Id
let sessionIdSection: SectionModel = SectionModel(
model: (state.threadInfo.isNoteToSelf ? .sessionIdNoteToSelf : .sessionId),
elements: [
SessionCell.Info(
id: .sessionId,
subtitle: SessionCell.TextInfo(
state.threadInfo.id,
font: .monoLarge,
alignment: .center,
interaction: .copy
),
styling: SessionCell.StyleInfo(
customPadding: SessionCell.Padding(bottom: Values.smallSpacing),
backgroundStyle: .noBackground
),
accessibility: Accessibility(
identifier: "Session ID",
label: state.threadInfo.id
)
)
]
)
// MARK: - Users kicked from groups
guard state.threadInfo.groupInfo?.wasKicked != true else {
return [
conversationInfoSection,
SectionModel(
model: .destructiveActions,
elements: [
SessionCell.Info(
id: .leaveGroup,
leadingAccessory: .icon(.trash2),
title: "groupDelete".localized(),
styling: SessionCell.StyleInfo(tintColor: .danger),
accessibility: Accessibility(
identifier: "Leave group",
label: "Leave group"
),
confirmationInfo: ConfirmationModal.Info(
title: "groupDelete".localized(),
body: .attributedText(
"groupDeleteDescriptionMember"
.put(key: "group_name", value: threadDisplayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
),
confirmTitle: "delete".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text
),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
viewModel?.dismissScreen(type: .popToRoot) {
dependencies[singleton: .storage].writeAsync { db in
try SessionThread.deleteOrLeave(
db,
type: .leaveGroupAsync,
threadId: state.threadInfo.id,
threadVariant: state.threadInfo.variant,
using: dependencies
)
}
}
}
)
]
)
]
}
// MARK: - Standard Actions
let standardActionsSection: SectionModel = SectionModel(
model: .content,
elements: [
(state.threadInfo.variant == .legacyGroup || state.threadInfo.variant == .group ? nil :
SessionCell.Info(
id: .copyThreadId,
leadingAccessory: .icon(.copy),
title: (state.threadInfo.variant == .community ?
"communityUrlCopy".localized() :
"accountIDCopy".localized()
),
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).copy_thread_id",
label: "Copy Session ID"
),
onTap: { [weak viewModel] in
switch state.threadInfo.variant {
case .contact, .legacyGroup, .group:
UIPasteboard.general.string = state.threadInfo.id
case .community:
guard
let communityInfo: ConversationInfoViewModel.CommunityInfo = state.threadInfo.communityInfo,
let urlString: String = LibSession.communityUrlFor(
server: communityInfo.server,
roomToken: communityInfo.roomToken,
publicKey: communityInfo.publicKey
)
else { return }
UIPasteboard.general.string = urlString
}
viewModel?.showToast(
text: "copied".localized(),
backgroundColor: .backgroundSecondary
)
}
)
),
SessionCell.Info(
id: .searchConversation,
leadingAccessory: .icon(.search),
title: "searchConversation".localized(),
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).search",
label: "Search"
),
onTap: { [weak viewModel] in viewModel?.didTriggerSearch() }
),
(
state.threadInfo.variant == .community ||
state.threadInfo.isBlocked ||
state.threadInfo.groupInfo?.currentUserRole == .admin ? nil :
SessionCell.Info(
id: .disappearingMessages,
leadingAccessory: .icon(.timer),
title: "disappearingMessages".localized(),
subtitle: {
guard
let config: DisappearingMessagesConfiguration = state.threadInfo.disappearingMessagesConfiguration,
config.isEnabled
else { return "off".localized() }
return (config.type ?? .unknown).localizedState(
durationString: config.durationString
)
}(),
accessibility: Accessibility(
identifier: "Disappearing messages",
label: "\(ThreadSettingsViewModel.self).disappearing_messages"
),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
viewModel?.transitionToScreen(
SessionTableViewController(
viewModel: ThreadDisappearingMessagesSettingsViewModel(
threadId: state.threadInfo.id,
threadVariant: state.threadInfo.variant,
currentUserRole: state.threadInfo.groupInfo?.currentUserRole,
config: (
state.threadInfo.disappearingMessagesConfiguration ??
DisappearingMessagesConfiguration.defaultWith(state.threadInfo.id)
),
using: dependencies
)
)
)
}
)
),
(state.threadInfo.isBlocked ? nil :
SessionCell.Info(
id: .pinConversation,
leadingAccessory: .icon(
(state.threadInfo.pinnedPriority > 0 ?
.pinOff :
.pin
)
),
title: (state.threadInfo.pinnedPriority > 0 ?
"pinUnpinConversation".localized() :
"pinConversation".localized()
),
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).pin_conversation",
label: "Pin Conversation"
),
onTap: { [weak viewModel] in
Task {
await viewModel?.toggleConversationPinnedStatus(
threadInfo: state.threadInfo
)
}
}
)
),
(state.threadInfo.isNoteToSelf || state.threadInfo.isBlocked ? nil :
SessionCell.Info(
id: .notifications,
leadingAccessory: .icon(
{
if state.threadInfo.onlyNotifyForMentions {
return .atSign
}
if state.threadInfo.mutedUntilTimestamp != nil {
return .volumeOff
}
return .volume2
}()
),
title: "sessionNotifications".localized(),
subtitle: {
if state.threadInfo.onlyNotifyForMentions {
return "notificationsMentionsOnly".localized()
}
if state.threadInfo.mutedUntilTimestamp != nil {
return "notificationsMuted".localized()
}
return "notificationsAllMessages".localized()
}(),
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).notifications",
label: "Notifications"
),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
viewModel?.transitionToScreen(
SessionTableViewController(
viewModel: ThreadNotificationSettingsViewModel(
threadId: state.threadInfo.id,
threadVariant: state.threadInfo.variant,
threadOnlyNotifyForMentions: state.threadInfo.onlyNotifyForMentions,
threadMutedUntilTimestamp: state.threadInfo.mutedUntilTimestamp,
using: dependencies
)
)
)
}
)
),
(state.threadInfo.variant != .community ? nil :
SessionCell.Info(
id: .addToOpenGroup,
leadingAccessory: .icon(.userRoundPlus),
title: "membersInvite".localized(),
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).add_to_open_group"
),
onTap: { [weak viewModel] in viewModel?.inviteUsersToCommunity(threadInfo: state.threadInfo) }
)
),
(state.threadInfo.groupInfo?.currentUserRole == nil ? nil :
SessionCell.Info(
id: .groupMembers,
leadingAccessory: .icon(.usersRound),
title: "groupMembers".localized(),
accessibility: Accessibility(
identifier: "Group members",
label: "Group members"
),
onTap: { [weak viewModel] in viewModel?.viewMembers(state: state) }
)
),
SessionCell.Info(
id: .attachments,
leadingAccessory: .icon(.file),
title: "attachments".localized(),
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).all_media",
label: "All media"
),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
viewModel?.transitionToScreen(
MediaGalleryViewModel.createAllMediaViewController(
threadId: state.threadInfo.id,
threadVariant: state.threadInfo.variant,
threadTitle: threadDisplayName,
focusedAttachmentId: nil,
using: dependencies
)
)
}
)
].compactMap { $0 }
)
// MARK: - Admin Actions
var adminActionsSection: SectionModel?
if state.threadInfo.groupInfo?.currentUserRole == .admin {
adminActionsSection = SectionModel(
model: .adminActions,
elements: [
SessionCell.Info(
id: .editGroup,
leadingAccessory: .icon(.userRoundPen),
title: "manageMembers".localized(),
accessibility: Accessibility(
identifier: "Edit group",
label: "Edit group"
),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
viewModel?.transitionToScreen(
SessionTableViewController(
viewModel: EditGroupViewModel(
threadId: state.threadInfo.id,
using: dependencies
)
)
)
}
)
]
)
if viewModel.dependencies[feature: .updatedGroupsAllowPromotions] {
adminActionsSection?.elements.append(
SessionCell.Info(
id: .promoteAdmins,
leadingAccessory: .icon(
UIImage(named: "table_ic_group_edit")?
.withRenderingMode(.alwaysTemplate)
),
title: "adminPromote".localized(),
accessibility: Accessibility(
identifier: "Promote admins",
label: "Promote admins"
),
onTap: { [weak viewModel] in
viewModel?.promoteAdmins(state: state)
}
)
)
}
adminActionsSection?.elements.append(
SessionCell.Info(
id: .disappearingMessages,
leadingAccessory: .icon(.timer),
title: "disappearingMessages".localized(),
subtitle: {
guard
let config: DisappearingMessagesConfiguration = state.threadInfo.disappearingMessagesConfiguration,
config.isEnabled
else { return "off".localized() }
return (config.type ?? .unknown)
.localizedState(durationString: config.durationString)
}(),
accessibility: Accessibility(
identifier: "Disappearing messages",
label: "\(ThreadSettingsViewModel.self).disappearing_messages"
),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
viewModel?.transitionToScreen(
SessionTableViewController(
viewModel: ThreadDisappearingMessagesSettingsViewModel(
threadId: state.threadInfo.id,
threadVariant: state.threadInfo.variant,
currentUserRole: state.threadInfo.groupInfo?.currentUserRole,
config: (
state.threadInfo.disappearingMessagesConfiguration ??
DisappearingMessagesConfiguration.defaultWith(state.threadInfo.id)
),
using: dependencies
)
)
)
}
)
)
// FIXME: [GROUPS REBUILD] Need to build this properly in a future release
if viewModel.dependencies[feature: .updatedGroupsAllowDisplayPicture] {
adminActionsSection?.elements.append(
SessionCell.Info(
id: .debugSetCustomDisplayPicture,
leadingAccessory: .icon(
Lucide.image(icon: .image, size: 24)?
.withRenderingMode(.alwaysTemplate),
customTint: .textPrimary
),
title: "[DEBUG] Set Custom Display Picture", // stringlint:disable
onTap: { [weak viewModel] in
viewModel?.updateGroupDisplayPicture(
state: state,
currentUrl: state.threadInfo.displayPictureUrl
)
}
)
)
}
}
// MARK: - Destructive Actions
var destructiveActionsSection: SectionModel = SectionModel(
model: .destructiveActions,
elements: []
)
if !state.threadInfo.isNoteToSelf && state.threadInfo.variant == .contact {
destructiveActionsSection.elements.append(
SessionCell.Info(
id: .blockUser,
leadingAccessory: (state.threadInfo.isBlocked ?
.icon(.userRoundCheck) :
.icon(UIImage(named: "ic_user_round_ban")?.withRenderingMode(.alwaysTemplate))
),
title: (state.threadInfo.isBlocked ?
"blockUnblock".localized() :
"block".localized()
),
styling: SessionCell.StyleInfo(tintColor: .danger),
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).block",
label: "Block"
),
confirmationInfo: ConfirmationModal.Info(
title: (state.threadInfo.isBlocked ?
"blockUnblock".localized() :
"block".localized()
),
body: (state.threadInfo.isBlocked ?
.attributedText(
"blockUnblockName"
.put(key: "name", value: threadDisplayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
) :
.attributedText(
"blockDescription"
.put(key: "name", value: threadDisplayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
)
),
confirmTitle: (state.threadInfo.isBlocked ?
"blockUnblock".localized() :
"block".localized()
),
confirmStyle: .danger,
cancelStyle: .alert_text
),
onTap: { [weak viewModel] in
viewModel?.updateBlockedState(
from: state.threadInfo.isBlocked,
isBlocked: !state.threadInfo.isBlocked,
threadId: state.threadInfo.id,
displayName: threadDisplayName
)
}
)
)
}
if state.threadInfo.isNoteToSelf {
destructiveActionsSection.elements.append(
SessionCell.Info(
id: .hideNoteToSelf,
leadingAccessory: .icon(isThreadHidden ? .eye : .eyeOff),
title: isThreadHidden ? "showNoteToSelf".localized() : "noteToSelfHide".localized(),
styling: SessionCell.StyleInfo(tintColor: isThreadHidden ? .textPrimary : .danger),
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).hide_note_to_self",
label: "Hide Note to Self"
),
confirmationInfo: ConfirmationModal.Info(
title: isThreadHidden ? "showNoteToSelf".localized() : "noteToSelfHide".localized(),
body: (isThreadHidden ?
.attributedText(
"showNoteToSelfDescription"
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
) :
.attributedText(
"hideNoteToSelfDescription"
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
)
),
confirmTitle: isThreadHidden ? "show".localized() : "hide".localized(),
confirmStyle: isThreadHidden ? .alert_text : .danger,
cancelStyle: .alert_text
),
onTap: { [dependencies = viewModel.dependencies] in
dependencies[singleton: .storage].writeAsync { db in
if isThreadHidden {
try SessionThread.update(
db,
id: state.threadInfo.id,
values: SessionThread.TargetValues(
shouldBeVisible: .setTo(true)
),
using: dependencies
)
} else {
try SessionThread.deleteOrLeave(
db,
type: .hideContactConversation,
threadId: state.threadInfo.id,
threadVariant: state.threadInfo.variant,
using: dependencies
)
}
}
}
)
)
}
destructiveActionsSection.elements.append(
SessionCell.Info(
id: .clearAllMessages,
leadingAccessory: .icon(
UIImage(named: "ic_message_trash")?.withRenderingMode(.alwaysTemplate)
),
title: "clearMessages".localized(),
styling: SessionCell.StyleInfo(tintColor: .danger),
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).clear_all_messages",
label: "Clear All Messages"
),
confirmationInfo: ConfirmationModal.Info(
title: "clearMessages".localized(),
body: {
guard !state.threadInfo.isNoteToSelf else {
return .attributedText(
"clearMessagesNoteToSelfDescriptionUpdated"
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
)
}
switch state.threadInfo.variant {
case .contact:
return .attributedText(
"clearMessagesChatDescriptionUpdated"
.put(key: "name", value: threadDisplayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
)
case .legacyGroup:
return .attributedText(
"clearMessagesGroupDescriptionUpdated"
.put(key: "group_name", value: threadDisplayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
)
case .community:
return .attributedText(
"clearMessagesCommunityUpdated"
.put(key: "community_name", value: threadDisplayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
)
case .group:
if state.threadInfo.groupInfo?.currentUserRole == .admin {
return .radio(
explanation: "clearMessagesGroupAdminDescriptionUpdated"
.put(key: "group_name", value: threadDisplayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont),
warning: nil,
options: [
ConfirmationModal.Info.Body.RadioOptionInfo(
title: "clearOnThisDevice".localized(),
enabled: true,
selected: true,
accessibility: Accessibility(
identifier: "",
label: ""
)
),
ConfirmationModal.Info.Body.RadioOptionInfo(
title: "clearMessagesForEveryone".localized(),
enabled: true,
selected: false,
accessibility: Accessibility(
identifier: "",
label: ""
)
)
]
)
} else {
return .attributedText(
"clearMessagesGroupDescriptionUpdated"
.put(key: "group_name", value: threadDisplayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
)
}
}
}(),
confirmTitle: "clear".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text,
dismissOnConfirm: false,
onConfirm: { [weak viewModel, dependencies = viewModel.dependencies] modal in
if state.threadInfo.variant == .group && state.threadInfo.groupInfo?.currentUserRole == .admin {
/// Determine the selected action index
let selectedIndex: Int = {
switch modal.info.body {
case .radio(_, _, let options):
return options
.enumerated()
.first(where: { _, value in value.selected })
.map { index, _ in index }
.defaulting(to: 0)
default: return 0
}
}()
// Don't update the group if the selected option is `Clear on this device`
if selectedIndex != 0 {
viewModel?.deleteAllMessagesBeforeNow(state: state)
}
}
dependencies[singleton: .storage].writeAsync(
updates: { db in
try Interaction.markAllAsDeleted(
db,
threadId: state.threadInfo.id,
threadVariant: state.threadInfo.variant,
options: [.local, .noArtifacts],
using: dependencies
)
},
completion: { [weak viewModel] result in
switch result {
case .failure(let error):
Log.error("Failed to clear messages due to error: \(error)")
DispatchQueue.main.async {
modal.dismiss(animated: true) {
viewModel?.showToast(
text: "deleteMessageFailed"
.putNumber(0)
.localized(),
backgroundColor: .backgroundSecondary
)
}
}
case .success:
DispatchQueue.main.async {
modal.dismiss(animated: true) {
viewModel?.showToast(
text: "deleteMessageDeleted"
.putNumber(0)
.localized(),
backgroundColor: .backgroundSecondary
)
}
}
}
}
)
}
)
)
)
if state.threadInfo.variant == .community {
destructiveActionsSection.elements.append(
SessionCell.Info(
id: .leaveCommunity,
leadingAccessory: .icon(.logOut),
title: "communityLeave".localized(),
styling: SessionCell.StyleInfo(tintColor: .danger),
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).leave_community",
label: "Leave Community"
),
confirmationInfo: ConfirmationModal.Info(
title: "communityLeave".localized(),
body: .attributedText(
"groupLeaveDescription"
.put(key: "group_name", value: threadDisplayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
),
confirmTitle: "leave".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text
),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
viewModel?.dismissScreen(type: .popToRoot) {
dependencies[singleton: .storage].writeAsync { db in
try SessionThread.deleteOrLeave(
db,
type: .deleteCommunityAndContent,
threadId: state.threadInfo.id,
threadVariant: state.threadInfo.variant,
using: dependencies
)
}
}
}
)
)
}
if
state.threadInfo.groupInfo?.currentUserRole == nil || (
state.threadInfo.groupInfo?.currentUserRole == .admin &&
(state.threadInfo.groupInfo?.numAdmins ?? 0) > 1
)
{
destructiveActionsSection.elements.append(
SessionCell.Info(
id: .leaveGroup,
leadingAccessory: .icon(.logOut),
title: "groupLeave".localized(),
styling: SessionCell.StyleInfo(tintColor: .danger),
accessibility: Accessibility(
identifier: "Leave group",
label: "Leave group"
),
confirmationInfo: ConfirmationModal.Info(
title: "groupLeave".localized(),
body: .attributedText(
"groupLeaveDescription"
.put(key: "group_name", value: threadDisplayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
),
confirmTitle: "leave".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text
),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
viewModel?.dismissScreen(type: .popToRoot) {
dependencies[singleton: .storage].writeAsync { db in
try SessionThread.deleteOrLeave(
db,
type: .leaveGroupAsync,
threadId: state.threadInfo.id,
threadVariant: state.threadInfo.variant,
using: dependencies
)
}
}
}
)
)
}
if state.threadInfo.groupInfo?.currentUserRole == .admin {
destructiveActionsSection.elements.append(
SessionCell.Info(
id: .leaveGroup,
leadingAccessory: .icon(.trash2),
title: "groupDelete".localized(),
styling: SessionCell.StyleInfo(tintColor: .danger),
accessibility: Accessibility(
identifier: "Delete group",
label: "Delete group"
),
confirmationInfo: ConfirmationModal.Info(
title: "groupDelete".localized(),
body: .attributedText(
"groupDeleteDescription"
.put(key: "group_name", value: threadDisplayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
),
confirmTitle: "delete".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text
),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
viewModel?.dismissScreen(type: .popToRoot) {
dependencies[singleton: .storage].writeAsync { db in
try SessionThread.deleteOrLeave(
db,
type: .deleteGroupAndContentForEveryoneAsync,
threadId: state.threadInfo.id,
threadVariant: state.threadInfo.variant,
using: dependencies
)
}
}
}
)
)
}
if !state.threadInfo.isNoteToSelf && state.threadInfo.variant == .contact {
destructiveActionsSection.elements.append(
SessionCell.Info(
id: .deleteConversation,
leadingAccessory: .icon(.trash2),
title: "conversationsDelete".localized(),
styling: SessionCell.StyleInfo(tintColor: .danger),
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).delete_conversation",
label: "Delete Conversation"
),
confirmationInfo: ConfirmationModal.Info(
title: "conversationsDelete".localized(),
body: .attributedText(
"deleteConversationDescription"
.put(key: "name", value: threadDisplayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
),
confirmTitle: "delete".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text
),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
viewModel?.dismissScreen(type: .popToRoot) {
dependencies[singleton: .storage].writeAsync { db in
try SessionThread.deleteOrLeave(
db,
type: .deleteContactConversationAndMarkHidden,
threadId: state.threadInfo.id,
threadVariant: state.threadInfo.variant,
using: dependencies
)
}
}
}
)
)
destructiveActionsSection.elements.append(
SessionCell.Info(
id: .deleteContact,
leadingAccessory: .icon(
UIImage(named: "ic_user_round_trash")?.withRenderingMode(.alwaysTemplate)
),
title: "contactDelete".localized(),
styling: SessionCell.StyleInfo(tintColor: .danger),
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).delete_contact",
label: "Delete Contact"
),
confirmationInfo: ConfirmationModal.Info(
title: "contactDelete".localized(),
body: .attributedText(
"deleteContactDescription"
.put(key: "name", value: threadDisplayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont),
scrollMode: .never
),
confirmTitle: "delete".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text
),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
viewModel?.dismissScreen(type: .popToRoot) {
dependencies[singleton: .storage].writeAsync { db in
try SessionThread.deleteOrLeave(
db,
type: .deleteContactConversationAndContact,
threadId: state.threadInfo.id,
threadVariant: state.threadInfo.variant,
using: dependencies
)
}
}
}
)
)
}
// FIXME: [GROUPS REBUILD] Need to build this properly in a future release
if
state.threadInfo.variant == .group &&
viewModel.dependencies[feature: .updatedGroupsDeleteAttachmentsBeforeNow]
{
destructiveActionsSection.elements.append(
SessionCell.Info(
id: .debugDeleteAttachmentsBeforeNow,
leadingAccessory: .icon(
Lucide.image(icon: .trash2, size: 24)?
.withRenderingMode(.alwaysTemplate),
customTint: .danger
),
title: "[DEBUG] Delete all arrachments before now", // stringlint:disable
styling: SessionCell.StyleInfo(
tintColor: .danger
),
confirmationInfo: ConfirmationModal.Info(
title: "delete".localized(),
body: .text("Are you sure you want to delete all attachments (and their associated messages) sent before now for all group members?"), // stringlint:disable
confirmTitle: "delete".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text
),
onTap: { [weak viewModel] in viewModel?.deleteAllAttachmentsBeforeNow(state: state) }
)
)
}
return [
conversationInfoSection,
(!showThreadPubkey ? nil : sessionIdSection),
standardActionsSection,
adminActionsSection,
destructiveActionsSection
].compactMap { $0 }
}
// MARK: - Functions
private func inviteUsersToCommunity(threadInfo: ConversationInfoViewModel) {
guard
let communityInfo: ConversationInfoViewModel.CommunityInfo = threadInfo.communityInfo,
let communityUrl: String = LibSession.communityUrlFor(
server: communityInfo.server,
roomToken: communityInfo.roomToken,
publicKey: communityInfo.publicKey
)
else { return }
let contact: TypedTableAlias<Contact> = TypedTableAlias()
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
self.transitionToScreen(
SessionTableViewController(
viewModel: UserListViewModel<Contact>(
title: "membersInvite".localized(),
emptyState: "contactNone".localized(),
showProfileIcons: false,
request: SQLRequest("""
SELECT \(contact.allColumns)
FROM \(contact)
LEFT JOIN \(groupMember) ON (
\(groupMember[.groupId]) = \(threadInfo.id) AND
\(groupMember[.profileId]) = \(contact[.id])
)
WHERE (
\(groupMember[.profileId]) IS NULL AND
\(contact[.isApproved]) = TRUE AND
\(contact[.didApproveMe]) = TRUE AND
\(contact[.isBlocked]) = FALSE AND
\(contact[.id]) NOT IN \(threadInfo.currentUserSessionIds)
)
"""),
footerTitle: "membersInviteTitle".localized(),
footerAccessibility: Accessibility(
identifier: "Invite contacts button"
),
onSubmit: .callback { [dependencies] viewModel, selectedUserInfo in
viewModel?.showToast(
text: "groupInviteSending"
.putNumber(selectedUserInfo.count)
.localized(),
backgroundColor: .backgroundSecondary
)
dependencies[singleton: .storage].writeAsync { db in
try selectedUserInfo.forEach { userInfo in
let sentTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs()
let thread: SessionThread = try SessionThread.upsert(
db,
id: userInfo.profileId,
variant: .contact,
values: SessionThread.TargetValues(
creationDateTimestamp: .useExistingOrSetTo(TimeInterval(sentTimestampMs) / 1000),
shouldBeVisible: .useExisting
),
using: dependencies
)
try LinkPreview(
url: communityUrl,
variant: .openGroupInvitation,
title: communityInfo.name,
using: dependencies
)
.upsert(db)
let destinationDisappearingMessagesConfiguration: DisappearingMessagesConfiguration? = try? DisappearingMessagesConfiguration
.filter(id: userInfo.profileId)
.filter(DisappearingMessagesConfiguration.Columns.isEnabled == true)
.fetchOne(db)
let interaction: Interaction = try Interaction(
threadId: thread.id,
threadVariant: thread.variant,
authorId: threadInfo.userSessionId.hexString,
variant: .standardOutgoing,
timestampMs: sentTimestampMs,
expiresInSeconds: destinationDisappearingMessagesConfiguration?.expiresInSeconds(),
expiresStartedAtMs: destinationDisappearingMessagesConfiguration?.initialExpiresStartedAtMs(
sentTimestampMs: Double(sentTimestampMs)
),
linkPreviewUrl: communityUrl,
using: dependencies
)
.inserted(db)
try MessageSender.send(
db,
interaction: interaction,
threadId: thread.id,
threadVariant: thread.variant,
using: dependencies
)
// Trigger disappear after read
dependencies[singleton: .jobRunner].upsert(
db,
job: DisappearingMessagesJob.updateNextRunIfNeeded(
db,
interaction: interaction,
startedAtMs: Double(sentTimestampMs),
using: dependencies
),
canStartJob: true
)
}
}
},
using: dependencies
)
),
transitionType: .push
)
}
public static func createMemberListViewController(
threadId: String,
transitionToConversation: @escaping @MainActor (ConversationInfoViewModel?) -> Void,
using dependencies: Dependencies
) -> UIViewController {
return SessionTableViewController(
viewModel: UserListViewModel(
title: "groupMembers".localized(),
showProfileIcons: true,
request: GroupMember
.select(
GroupMember.Columns.groupId,
GroupMember.Columns.profileId,
max(GroupMember.Columns.role).forKey(GroupMember.Columns.role.name),
GroupMember.Columns.roleStatus,
GroupMember.Columns.isHidden
)
.filter(GroupMember.Columns.groupId == threadId)
.group(GroupMember.Columns.profileId),
onTap: .callback { _, memberInfo in
let maybeThreadInfo: ConversationInfoViewModel? = try? await dependencies[singleton: .storage].writeAsync { db in
try SessionThread.upsert(
db,
id: memberInfo.profileId,
variant: .contact,
values: SessionThread.TargetValues(
creationDateTimestamp: .useExistingOrSetTo(
dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000
),
shouldBeVisible: .useExisting,
isDraft: .useExistingOrSetTo(true)
),
using: dependencies
)
return try ConversationViewModel.fetchConversationInfo(
db,
threadId: memberInfo.profileId,
using: dependencies
)
}
await MainActor.run {
transitionToConversation(maybeThreadInfo)
}
},
using: dependencies
)
)
}
private func viewMembers(state: State) {
self.transitionToScreen(
ThreadSettingsViewModel.createMemberListViewController(
threadId: state.threadInfo.id,
transitionToConversation: { [weak self, dependencies] maybeThreadInfo in
guard let threadInfo: ConversationInfoViewModel = maybeThreadInfo else {
self?.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "theError".localized(),
body: .text("errorUnknown".localized()),
cancelTitle: "okay".localized(),
cancelStyle: .alert_text
)
),
transitionType: .present
)
return
}
self?.transitionToScreen(
ConversationVC(
threadInfo: threadInfo,
focusedInteractionInfo: nil,
using: dependencies
),
transitionType: .push
)
},
using: dependencies
)
)
}
private func promoteAdmins(state: State) {
guard dependencies[feature: .updatedGroupsAllowPromotions] else { return }
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
/// Submitting and resending using the same logic
func send(
_ viewModel: UserListViewModel<GroupMember>?,
_ memberInfo: [(id: String, profile: Profile?)],
isResend: Bool
) {
/// Show a toast immediately that we are sending invitations
viewModel?.showToast(
text: "adminSendingPromotion"
.putNumber(memberInfo.count)
.localized(),
backgroundColor: .backgroundSecondary
)
/// Actually trigger the sending process
MessageSender
.promoteGroupMembers(
groupSessionId: SessionId(.group, hex: state.threadInfo.id),
members: memberInfo,
isResend: isResend,
using: dependencies
)
.subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies)
.receive(on: DispatchQueue.main, using: dependencies)
.sinkUntilComplete(
receiveCompletion: { [dependencies] result in
switch result {
case .finished: break
case .failure:
let memberIds: [String] = memberInfo.map(\.id)
/// Flag the members as failed
dependencies[singleton: .storage].writeAsync { db in
try? GroupMember
.filter(GroupMember.Columns.groupId == state.threadInfo.id)
.filter(memberIds.contains(GroupMember.Columns.profileId))
.updateAllAndConfig(
db,
GroupMember.Columns.roleStatus.set(to: GroupMember.RoleStatus.failed),
using: dependencies
)
}
/// Show a toast that the promotions failed to send
viewModel?.showToast(
text: GroupPromoteMemberJob.failureMessage(
groupName: (state.threadInfo.groupInfo?.name ?? "groupUnknown".localized()),
memberIds: memberIds,
profileInfo: memberInfo.reduce(into: [:]) { result, next in
result[next.id] = next.profile
}
),
backgroundColor: .backgroundSecondary
)
}
}
)
}
/// Show the selection list
self.transitionToScreen(
SessionTableViewController(
viewModel: UserListViewModel<GroupMember>(
title: "promote".localized(),
// FIXME: Localise this
emptyState: "There are no group members which can be promoted.",
showProfileIcons: true,
request: SQLRequest("""
SELECT \(groupMember.allColumns)
FROM \(groupMember)
WHERE (
\(groupMember[.groupId]) == \(state.threadInfo.id) AND (
\(groupMember[.role]) == \(GroupMember.Role.admin) OR
(
\(groupMember[.role]) != \(GroupMember.Role.admin) AND
\(groupMember[.roleStatus]) == \(GroupMember.RoleStatus.accepted)
)
)
)
GROUP BY \(groupMember[.profileId])
"""),
footerTitle: "promote".localized(),
onTap: .conditionalAction(
action: { memberInfo in
guard memberInfo.profileId != memberInfo.currentUserSessionId.hexString else {
return .none
}
switch (memberInfo.value.role, memberInfo.value.roleStatus) {
case (.standard, _): return .radio
default:
return .custom(
trailingAccessory: { _ in
.highlightingBackgroundLabel(
title: "resend".localized()
)
},
onTap: { viewModel, info in
send(viewModel, [(info.profileId, info.profile)], isResend: true)
}
)
}
}
),
onSubmit: .callback { viewModel, selectedInfo in
send(viewModel, selectedInfo.map { ($0.profileId, $0.profile) }, isResend: false)
},
using: dependencies
)
),
transitionType: .push
)
}
private func updateNickname(
state: State,
current: String?,
displayName: String
) -> ConfirmationModal.Info {
/// Set `updatedName` to `current` so we can disable the "save" button when there are no changes and don't need to worry about retrieving them in the confirmation closure
self.updatedName = current
let currentUserSessionId: SessionId = dependencies[cache: .general].sessionId
return ConfirmationModal.Info(
title: "nicknameSet".localized(),
body: .input(
explanation: "nicknameDescription"
.put(key: "name", value: displayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont),
info: ConfirmationModal.Info.Body.InputInfo(
placeholder: "nicknameEnter".localized(),
initialValue: current,
accessibility: Accessibility(
identifier: "Username input"
),
inputChecker: { text in
let nickname: String = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !Profile.isTooLong(profileName: nickname) else {
return "nicknameErrorShorter".localized()
}
return nil
}
),
onChange: { [weak self] updatedName in self?.updatedName = updatedName }
),
confirmTitle: "save".localized(),
confirmEnabled: .afterChange { [weak self] _ in
self?.updatedName != current &&
self?.updatedName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
},
cancelTitle: "remove".localized(),
cancelStyle: .danger,
cancelEnabled: .bool(current?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false),
hasCloseButton: true,
dismissOnConfirm: false,
onConfirm: { [weak self, dependencies] modal in
guard
let finalNickname: String = (self?.updatedName ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.nullIfEmpty
else { return }
/// Check if the data violates the size constraints
guard !Profile.isTooLong(profileName: finalNickname) else {
modal.updateContent(withError: "nicknameErrorShorter".localized())
return
}
/// Update the nickname
dependencies[singleton: .storage].writeAsync(
updates: { db in
try Profile.updateIfNeeded(
db,
publicKey: state.threadInfo.id,
nicknameUpdate: .set(to: finalNickname),
profileUpdateTimestamp: nil, /// Not set for `nickname`
currentUserSessionIds: [currentUserSessionId.hexString], /// Contact thread
using: dependencies
)
},
completion: { _ in
DispatchQueue.main.async {
modal.dismiss(animated: true)
}
}
)
},
onCancel: { [dependencies] modal in
/// Remove the nickname
dependencies[singleton: .storage].writeAsync(
updates: { db in
try Profile.updateIfNeeded(
db,
publicKey: state.threadInfo.id,
nicknameUpdate: .set(to: nil),
profileUpdateTimestamp: nil, /// Not set for `nickname`
currentUserSessionIds: [currentUserSessionId.hexString], /// Contact thread
using: dependencies
)
},
completion: { _ in
DispatchQueue.main.async {
modal.dismiss(animated: true)
}
}
)
}
)
}
private func updateGroupNameAndDescription(
state: State,
currentName: String,
currentDescription: String?,
isUpdatedGroup: Bool
) -> ConfirmationModal.Info {
/// Set the `updatedName` and `updatedDescription` values to the current values so we can disable the "save" button when there are
/// no changes and don't need to worry about retrieving them in the confirmation closure
self.updatedName = currentName
self.updatedDescription = currentDescription
return ConfirmationModal.Info(
title: "updateGroupInformation".localized(),
body: { [weak self] in
return .dualInput(
explanation: "updateGroupInformationDescription"
.localizedFormatted(baseFont: ConfirmationModal.explanationFont),
firstInfo: ConfirmationModal.Info.Body.InputInfo(
placeholder: "groupNameEnter".localized(),
initialValue: currentName,
clearButton: true,
accessibility: Accessibility(
identifier: "Group name text field"
),
inputChecker: { text in
let groupName: String = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !LibSession.isTooLong(groupName: groupName) else {
return "groupNameEnterShorter".localized()
}
return nil
}
),
secondInfo: ConfirmationModal.Info.Body.InputInfo(
placeholder: "groupDescriptionEnter".localized(),
initialValue: currentDescription,
clearButton: true,
accessibility: Accessibility(
identifier: "Group description text field"
),
inputChecker: { text in
let groupDescription: String = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !LibSession.isTooLong(groupDescription: groupDescription) else {
return "updateGroupInformationEnterShorterDescription".localized()
}
return nil
}
),
onChange: { updatedName, updatedDescription in
self?.updatedName = updatedName
self?.updatedDescription = updatedDescription
}
)
}(),
confirmTitle: "save".localized(),
confirmEnabled: .afterChange { [weak self] _ in
self?.updatedName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false && (
self?.updatedName != currentName ||
self?.updatedDescription != currentDescription
)
},
cancelStyle: .danger,
dismissOnConfirm: false,
onConfirm: { [weak self, dependencies] modal in
guard
let finalName: String = (self?.updatedName ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.nullIfEmpty
else { return }
let finalDescription: String? = self?.updatedDescription
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
/// Check if the data violates any of the size constraints
let maybeNameError: String? = LibSession.isTooLong(groupName: finalName) ?
"groupNameEnterShorter".localized() : nil
let maybeDescriptionError: String? = LibSession.isTooLong(groupDescription: (finalDescription ?? "")) ?
"updateGroupInformationEnterShorterDescription".localized() : nil
guard maybeNameError == nil && maybeDescriptionError == nil else {
modal.updateContent(withError: maybeNameError, additionalError: maybeDescriptionError)
return
}
/// Update the group appropriately
MessageSender
.updateGroup(
groupSessionId: state.threadInfo.id,
name: finalName,
groupDescription: finalDescription,
using: dependencies
)
.subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies)
.receive(on: DispatchQueue.main, using: dependencies)
.sinkUntilComplete()
modal.dismiss(animated: true)
}
)
}
private func updateGroupDisplayPicture(state: State, currentUrl: String?) {
guard dependencies[feature: .updatedGroupsAllowDisplayPicture] else { return }
let iconName: String = "profile_placeholder" // stringlint:ignore
var hasSetNewProfilePicture: Bool = false
let currentSource: ImageDataManager.DataSource? = {
let source: ImageDataManager.DataSource? = currentUrl
.map { try? dependencies[singleton: .displayPictureManager].path(for: $0) }
.map { ImageDataManager.DataSource.url(URL(fileURLWithPath: $0)) }
return (source?.contentExists == true ? source : nil)
}()
let body: ConfirmationModal.Info.Body = .image(
source: nil,
placeholder: (
currentSource ??
Lucide.image(icon: .image, size: 40).map { image in
ImageDataManager.DataSource.image(
iconName,
image
.withTintColor(#colorLiteral(red: 0.631372549, green: 0.6352941176, blue: 0.631372549, alpha: 1), renderingMode: .alwaysTemplate)
.withCircularBackground(backgroundColor: #colorLiteral(red: 0.1764705882, green: 0.1764705882, blue: 0.1764705882, alpha: 1))
)
}
),
icon: (currentUrl != nil ? .pencil : .rightPlus),
style: .circular,
description: nil, // FIXME: Need to add Group Pro display pic description
accessibility: Accessibility(
identifier: "Upload",
label: "Upload"
),
dataManager: dependencies[singleton: .imageDataManager],
onProBageTapped: nil, // FIXME: Need to add Group Pro display pic CTA
onClick: { [weak self] onDisplayPictureSelected in
self?.onDisplayPictureSelected = { source, cropRect in
onDisplayPictureSelected(.image(
source: source,
cropRect: cropRect,
replacementIcon: .pencil,
replacementCancelTitle: "clear".localized()
))
hasSetNewProfilePicture = true
}
self?.showPhotoLibraryForAvatar()
}
)
self.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "groupSetDisplayPicture".localized(),
body: body,
confirmTitle: "save".localized(),
confirmEnabled: .afterChange { info in
switch info.body {
case .image(.some(let source), _, _, _, _, _, _, _, _): return source.contentExists
default: return false
}
},
cancelTitle: "remove".localized(),
cancelEnabled: (currentUrl != nil ? .bool(true) : .afterChange { info in
switch info.body {
case .image(.some(let source), _, _, _, _, _, _, _, _): return source.contentExists
default: return false
}
}),
hasCloseButton: true,
dismissOnConfirm: false,
onConfirm: { [weak self] modal in
switch modal.info.body {
case .image(.some(let source), _, _, let style, _, _, _, _, _):
// FIXME: Need to add Group Pro display pic CTA
self?.updateGroupDisplayPicture(
state: state,
displayPictureUpdate: .groupUploadImage(
source: source,
cropRect: style.cropRect
),
onUploadComplete: { [weak modal] in
Task { @MainActor in modal?.close() }
}
)
default: modal.close()
}
},
onCancel: { [weak self] modal in
if hasSetNewProfilePicture {
modal.updateContent(
with: modal.info.with(
body: body,
cancelTitle: "remove".localized()
)
)
hasSetNewProfilePicture = false
} else {
self?.updateGroupDisplayPicture(
state: state,
displayPictureUpdate: .groupRemove,
onUploadComplete: { [weak modal] in
Task { @MainActor in modal?.close() }
}
)
}
}
)
),
transitionType: .present
)
}
@MainActor private func showPhotoLibraryForAvatar() {
Permissions.requestLibraryPermissionIfNeeded(isSavingMedia: false, using: dependencies) { [weak self] granted in
guard granted else { return }
DispatchQueue.main.async {
var configuration: PHPickerConfiguration = PHPickerConfiguration()
configuration.selectionLimit = 1
configuration.filter = .any(of: [.images, .livePhotos])
let picker: PHPickerViewController = PHPickerViewController(configuration: configuration)
picker.delegate = self?.imagePickerHandler
self?.transitionToScreen(picker, transitionType: .present)
}
}
}
private func updateGroupDisplayPicture(
state: State,
displayPictureUpdate: DisplayPictureManager.Update,
onUploadComplete: @escaping () -> ()
) {
switch displayPictureUpdate {
case .none: onUploadComplete()
default: break
}
Task.detached(priority: .userInitiated) { [weak self, dependencies] in
var targetUpdate: DisplayPictureManager.Update = displayPictureUpdate
var indicator: ModalActivityIndicatorViewController?
do {
switch displayPictureUpdate {
case .none, .currentUserRemove, .currentUserUpdateTo, .contactRemove,
.contactUpdateTo:
throw AttachmentError.invalidStartState
case .groupRemove, .groupUpdateTo: break
case .groupUploadImage(let source, let cropRect):
/// Show a blocking loading indicator while uploading but not while updating or syncing the group configs
indicator = await MainActor.run { [weak self] in
let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController(onAppear: { _ in })
self?.transitionToScreen(indicator, transitionType: .present)
return indicator
}
let pendingAttachment: PendingAttachment = PendingAttachment(
source: .media(source),
using: dependencies
)
let preparedAttachment: PreparedAttachment = try await dependencies[singleton: .displayPictureManager]
.prepareDisplayPicture(
attachment: pendingAttachment,
fallbackIfConversionTakesTooLong: true,
cropRect: cropRect
)
let result = try await dependencies[singleton: .displayPictureManager]
.uploadDisplayPicture(preparedAttachment: preparedAttachment)
await MainActor.run { onUploadComplete() }
targetUpdate = .groupUpdateTo(
url: result.downloadUrl,
key: result.encryptionKey
)
}
}
catch {
let message: String = {
switch (displayPictureUpdate, error) {
case (.groupRemove, _): return "profileDisplayPictureRemoveError".localized()
case (_, AttachmentError.fileSizeTooLarge):
return "profileDisplayPictureSizeError".localized()
default: return "errorConnection".localized()
}
}()
await indicator?.dismiss { [weak self] in
self?.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "deleteAfterLegacyGroupsGroupUpdateErrorTitle".localized(),
body: .text(message),
cancelTitle: "okay".localized(),
cancelStyle: .alert_text,
dismissType: .single
)
),
transitionType: .present
)
}
return
}
let existingDownloadUrl: String? = try? await dependencies[singleton: .storage].readAsync { db in
try? ClosedGroup
.filter(id: state.threadInfo.id)
.select(.displayPictureUrl)
.asRequest(of: String.self)
.fetchOne(db)
}
do {
try await MessageSender.updateGroup(
groupSessionId: state.threadInfo.id,
displayPictureUpdate: targetUpdate,
using: dependencies
)
/// Remove any cached avatar image value (only want to do so if the above update succeeded)
if
let existingDownloadUrl: String = existingDownloadUrl,
let existingFilePath: String = try? dependencies[singleton: .displayPictureManager]
.path(for: existingDownloadUrl)
{
Task { [dependencies] in
await dependencies[singleton: .imageDataManager].removeImage(
identifier: existingFilePath
)
try? dependencies[singleton: .fileManager].removeItem(atPath: existingFilePath)
}
}
}
catch {}
await indicator?.dismiss()
}
}
private func updateBlockedState(
from oldBlockedState: Bool,
isBlocked: Bool,
threadId: String,
displayName: String
) {
guard oldBlockedState != isBlocked else { return }
dependencies[singleton: .storage].writeAsync { [dependencies] db in
try Contact
.filter(id: threadId)
.updateAllAndConfig(
db,
Contact.Columns.isBlocked.set(to: isBlocked),
using: dependencies
)
db.addContactEvent(id: threadId, change: .isBlocked(isBlocked))
}
}
private func toggleConversationPinnedStatus(threadInfo: ConversationInfoViewModel) async {
let isCurrentlyPinned: Bool = (threadInfo.pinnedPriority > LibSession.visiblePriority)
let sessionProState: SessionPro.State = await dependencies[singleton: .sessionProManager]
.state
.first(defaultValue: .invalid)
if sessionProState.sessionProEnabled && !isCurrentlyPinned && sessionProState.status != .active {
// TODO: [Database Relocation] Retrieve the full conversation list from lib session and check the pinnedPriority that way instead of using the database
do {
let numPinnedConversations: Int = try await dependencies[singleton: .storage].writeAsync { [dependencies] db in
let numPinnedConversations: Int = try SessionThread
.filter(SessionThread.Columns.pinnedPriority > LibSession.visiblePriority)
.fetchCount(db)
guard numPinnedConversations < SessionPro.PinnedConversationLimit else {
return numPinnedConversations
}
/// We have the space to pin the conversation, so do so
try SessionThread.update(
db,
id: threadInfo.id,
values: SessionThread.TargetValues(
shouldBeVisible: .setTo(true),
pinnedPriority: .setTo(threadInfo.pinnedPriority <= LibSession.visiblePriority ?
1 :
LibSession.visiblePriority
)
),
using: dependencies
)
return -1
}
/// If we already have too many conversations pinned then we need to show the CTA modal
guard numPinnedConversations > 0 else { return }
_ = await MainActor.run { [weak self, dependencies] in
dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded(
.morePinnedConvos(
isGrandfathered: (numPinnedConversations > SessionPro.PinnedConversationLimit),
renew: (sessionProState.status == .expired)
),
onConfirm: { [weak self] in
dependencies[singleton: .sessionProManager].showSessionProBottomSheetIfNeeded(
presenting: { [weak self] bottomSheet in
self?.transitionToScreen(bottomSheet, transitionType: .present)
}
)
},
presenting: { [weak self] modal in
self?.transitionToScreen(modal, transitionType: .present)
}
)
}
}
catch {}
return
}
// If we are unpinning then no need to check the current count, just unpin immediately
try? await dependencies[singleton: .storage].writeAsync { [dependencies] db in
try SessionThread.update(
db,
id: threadInfo.id,
values: SessionThread.TargetValues(
shouldBeVisible: .setTo(true),
pinnedPriority: .setTo(threadInfo.pinnedPriority <= LibSession.visiblePriority ?
1 :
LibSession.visiblePriority
)
),
using: dependencies
)
}
}
private func deleteAllMessagesBeforeNow(state: State) {
guard state.threadInfo.variant == .group else { return }
dependencies[singleton: .storage].writeAsync { [dependencies] db in
try LibSession.deleteMessagesBefore(
db,
groupSessionId: SessionId(.group, hex: state.threadInfo.id),
timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000),
using: dependencies
)
}
}
private func deleteAllAttachmentsBeforeNow(state: State) {
guard state.threadInfo.variant == .group else { return }
dependencies[singleton: .storage].writeAsync { [dependencies] db in
try LibSession.deleteAttachmentsBefore(
db,
groupSessionId: SessionId(.group, hex: state.threadInfo.id),
timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000),
using: dependencies
)
}
}
// MARK: - Confirmation Modals
private func updateDisplayNameModal(state: State) -> ConfirmationModal.Info? {
guard !state.threadInfo.isNoteToSelf else { return nil }
switch (state.threadInfo.variant, state.threadInfo.groupInfo?.currentUserRole) {
case (.contact, _):
return self.updateNickname(
state: state,
current: state.threadInfo.profile?.nickname,
displayName: (
/// **Note:** We want to use the `profile` directly rather than `threadViewModel.displayName`
/// as the latter would use the `nickname` here which is incorrect
state.threadInfo.profile?.displayName(ignoreNickname: true) ??
state.threadInfo.displayName.deformatted()
)
)
case (.group, .admin), (.legacyGroup, .admin):
return self.updateGroupNameAndDescription(
state: state,
currentName: state.threadInfo.displayName.deformatted(),
currentDescription: state.threadInfo.conversationDescription,
isUpdatedGroup: (state.threadInfo.variant == .group)
)
case (.community, _), (.legacyGroup, _), (.group, _): return nil
}
}
private func showQRCodeLightBox(for threadInfo: ConversationInfoViewModel) {
let qrCodeImage: UIImage = QRCode.generate(
for: threadInfo.qrCodeString,
hasBackground: false,
iconName: "SessionWhite40" // stringlint:ignore
)
.withRenderingMode(.alwaysTemplate)
let viewController = SessionHostingViewController(
rootView: LightBox(
itemsToShare: [
QRCode.qrCodeImageWithBackground(
image: qrCodeImage,
size: CGSize(width: 400, height: 400),
insets: UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
)
]
) {
VStack {
Spacer()
QRCodeView(
qrCodeImage: qrCodeImage,
themeStyle: ThemeManager.currentTheme.interfaceStyle
)
.aspectRatio(1, contentMode: .fit)
.frame(
maxWidth: .infinity,
maxHeight: .infinity
)
Spacer()
}
.backgroundColor(themeColor: .newConversation_background)
},
customizedNavigationBackground: .backgroundSecondary
)
viewController.modalPresentationStyle = .fullScreen
self.transitionToScreen(viewController, transitionType: .present)
}
}
// MARK: - Convenience
private extension ObservedEvent {
var handlingStrategy: EventHandlingStrategy {
let threadInfoStrategy: EventHandlingStrategy? = ConversationInfoViewModel.handlingStrategy(for: self)
let localStrategy: EventHandlingStrategy = {
switch (key, key.generic) {
case (.appLifecycle(.willEnterForeground), _): return .databaseQuery
case (.databaseLifecycle(.resumed), _): return .databaseQuery
default: return .directCacheUpdate
}
}()
return localStrategy.union(threadInfoStrategy ?? .none)
}
}
private extension ConversationInfoViewModel {
var qrCodeString: String {
switch self.variant {
case .contact, .legacyGroup, .group: return id
case .community:
guard
let communityInfo: CommunityInfo = self.communityInfo,
let urlString: String = LibSession.communityUrlFor(
server: communityInfo.server,
roomToken: communityInfo.roomToken,
publicKey: communityInfo.publicKey
)
else { return "" }
return urlString
}
}
}