Files
session-ios/Session/Conversations/Settings/ThreadSettingsViewModel.swift
Morgan Pretty 6124989ddd Fixed issues found by QA
• Added a few missing conversation updated events
• Fixed an issue where deleting the group wasn't deleting for everyone
2026-01-19 10:36:19 +11:00

2269 lines
107 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
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 threadId: String
private let threadVariant: SessionThread.Variant
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
)
private var profileImageStatus: (previous: ProfileImageStatus?, current: ProfileImageStatus?)
// TODO: Refactor this with SessionThreadViewModel
private var threadViewModelSubject: CurrentValueSubject<SessionThreadViewModel?, Never>
// MARK: - Initialization
init(
threadId: String,
threadVariant: SessionThread.Variant,
didTriggerSearch: @escaping () -> (),
using dependencies: Dependencies
) {
self.dependencies = dependencies
self.threadId = threadId
self.threadVariant = threadVariant
self.didTriggerSearch = didTriggerSearch
self.threadViewModelSubject = CurrentValueSubject(nil)
self.profileImageStatus = (previous: nil, current: .normal)
}
// MARK: - Config
enum ProfileImageStatus: Equatable {
case normal
case expanded
case qrCode
}
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
}
lazy var rightNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = threadViewModelSubject
.map { [weak self] threadViewModel -> [SessionNavItem<NavItem>] in
guard let threadViewModel: SessionThreadViewModel = threadViewModel else { return [] }
let currentUserIsClosedGroupAdmin: Bool = (
[.legacyGroup, .group].contains(threadViewModel.threadVariant) &&
threadViewModel.currentUserIsClosedGroupAdmin == true
)
let canEditDisplayName: Bool = (
threadViewModel.threadIsNoteToSelf != true &&
(
threadViewModel.threadVariant == .contact ||
currentUserIsClosedGroupAdmin
)
)
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(
threadViewModel: threadViewModel,
currentUserIsClosedGroupAdmin: currentUserIsClosedGroupAdmin
)
else { return }
self?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present)
}
)
]
}
.eraseToAnyPublisher()
// MARK: - Content
private struct State: Equatable {
let threadViewModel: SessionThreadViewModel?
let disappearingMessagesConfig: DisappearingMessagesConfiguration
}
var title: String {
switch threadVariant {
case .contact: return "sessionSettings".localized()
case .legacyGroup, .group, .community: return "deleteAfterGroupPR1GroupSettings".localized()
}
}
lazy var observation: TargetObservation = ObservationBuilderOld
.databaseObservation(self) { [ weak self, dependencies, threadId = self.threadId] db -> State in
let userSessionId: SessionId = dependencies[cache: .general].sessionId
var threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
.conversationSettingsQuery(threadId: threadId, userSessionId: userSessionId)
.fetchOne(db)
let disappearingMessagesConfig: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration
.fetchOne(db, id: threadId)
.defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId))
self?.threadViewModelSubject.send(threadViewModel)
return State(
threadViewModel: threadViewModel,
disappearingMessagesConfig: disappearingMessagesConfig
)
}
.compactMap { [weak self] current -> [SectionModel]? in
self?.content(
current,
profileImageStatus: self?.profileImageStatus
)
}
private func content(_ current: State, profileImageStatus: (previous: ProfileImageStatus?, current: ProfileImageStatus?)?) -> [SectionModel] {
// If we don't get a `SessionThreadViewModel` then it means the thread was probably deleted
// so dismiss the screen
guard let threadViewModel: SessionThreadViewModel = current.threadViewModel else {
self.dismissScreen(type: .popToRoot)
return []
}
let isGroup: Bool = (
threadViewModel.threadVariant == .legacyGroup ||
threadViewModel.threadVariant == .group
)
let currentUserKickedFromGroup: Bool = (
isGroup &&
threadViewModel.currentUserIsClosedGroupMember != true
)
let currentUserIsClosedGroupMember: Bool = (
isGroup &&
threadViewModel.currentUserIsClosedGroupMember == true
)
let currentUserIsClosedGroupAdmin: Bool = (
isGroup &&
threadViewModel.currentUserIsClosedGroupAdmin == true
)
let isThreadHidden: Bool = (
threadViewModel.threadShouldBeVisible != true &&
threadViewModel.threadPinnedPriority == LibSession.hiddenPriority
)
let showThreadPubkey: Bool = (
threadViewModel.threadVariant == .contact || (
threadViewModel.threadVariant == .group &&
dependencies[feature: .groupsShowPubkeyInConversationSettings]
)
)
// MARK: - Conversation Info
let conversationInfoSection: SectionModel = SectionModel(
model: .conversationInfo,
elements: [
(profileImageStatus?.current == .qrCode ?
SessionCell.Info(
id: .qrCode,
accessory: .qrCode(
for: threadViewModel.getQRCodeString(),
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 self] targetView in
let didTapProfileIcon: Bool = !(targetView is UIImageView)
if didTapProfileIcon {
self?.profileImageStatus = (previous: profileImageStatus?.current, current: profileImageStatus?.previous)
self?.forceRefresh(type: .postDatabaseQuery)
} else {
self?.showQRCodeLightBox(for: threadViewModel)
}
}
)
:
SessionCell.Info(
id: .avatar,
accessory: .profile(
id: threadViewModel.id,
size: (profileImageStatus?.current == .expanded ? .expanded : .hero),
threadVariant: threadViewModel.threadVariant,
displayPictureUrl: threadViewModel.threadDisplayPictureUrl,
profile: threadViewModel.profile,
profileIcon: (threadViewModel.threadIsNoteToSelf || threadVariant == .group ? .none : .qrCode),
additionalProfile: threadViewModel.additionalProfile,
accessibility: nil
),
styling: SessionCell.StyleInfo(
alignment: .centerHugging,
customPadding: SessionCell.Padding(
leading: 0,
bottom: Values.smallSpacing
),
backgroundStyle: .noBackground
),
onTapView: { [weak self] targetView in
let didTapQRCodeIcon: Bool = !(targetView is ProfilePictureView)
if didTapQRCodeIcon {
self?.profileImageStatus = (previous: profileImageStatus?.current, current: .qrCode)
} else {
self?.profileImageStatus = (
previous: profileImageStatus?.current,
current: (profileImageStatus?.current == .expanded ? .normal : .expanded)
)
}
self?.forceRefresh(type: .postDatabaseQuery)
}
)
),
SessionCell.Info(
id: .displayName,
title: SessionCell.TextInfo(
threadViewModel.displayName,
font: .titleLarge,
alignment: .center,
trailingImage: {
guard !threadViewModel.threadIsNoteToSelf else { return nil }
guard (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }) else { return nil }
return SessionProBadge.trailingImage(
size: .medium,
themeBackgroundColor: .primary
)
}()
),
styling: SessionCell.StyleInfo(
alignment: .centerHugging,
customPadding: SessionCell.Padding(
top: Values.smallSpacing,
bottom: {
guard threadViewModel.threadVariant != .contact else { return Values.mediumSpacing }
guard threadViewModel.threadDescription == nil else { return Values.smallSpacing }
return Values.largeSpacing
}(),
interItem: 0
),
backgroundStyle: .noBackground
),
accessibility: Accessibility(
identifier: "Username",
label: threadViewModel.displayName
),
onTapView: { [weak self, threadId, dependencies] targetView in
guard targetView is SessionProBadge, !dependencies[cache: .libSession].isSessionPro else {
guard
let info: ConfirmationModal.Info = self?.updateDisplayNameModal(
threadViewModel: threadViewModel,
currentUserIsClosedGroupAdmin: currentUserIsClosedGroupAdmin
)
else { return }
self?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present)
return
}
let proCTAModalVariant: ProCTAModal.Variant = {
switch threadViewModel.threadVariant {
case .group:
return .groupLimit(
isAdmin: currentUserIsClosedGroupAdmin,
isSessionProActivated: (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }),
proBadgeImage: UIView.image(
for: .themedKey(
SessionProBadge.Size.mini.cacheKey,
themeBackgroundColor: .primary
),
generator: { SessionProBadge(size: .mini) }
)
)
default:
return .generic(renew: dependencies[singleton: .sessionProState].isSessionProExpired)
}
}()
dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded(
proCTAModalVariant,
onConfirm: {
dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded(
presenting: { bottomSheet in
self?.transitionToScreen(bottomSheet, transitionType: .present)
}
)
},
presenting: { modal in
self?.transitionToScreen(modal, transitionType: .present)
}
)
}
),
(threadViewModel.displayName == threadViewModel.contactDisplayName ? nil :
SessionCell.Info(
id: .contactName,
subtitle: SessionCell.TextInfo(
"(\(threadViewModel.contactDisplayName))", // stringlint:ignore
font: .subtitle,
alignment: .center
),
styling: SessionCell.StyleInfo(
tintColor: .textSecondary,
customPadding: SessionCell.Padding(
top: 0,
bottom: Values.largeSpacing
),
backgroundStyle: .noBackground
)
)
),
threadViewModel.threadDescription.map { threadDescription in
SessionCell.Info(
id: .threadDescription,
description: SessionCell.TextInfo(
threadDescription,
font: .subtitle,
alignment: .center,
interaction: .expandable
),
styling: SessionCell.StyleInfo(
tintColor: .textSecondary,
customPadding: SessionCell.Padding(
top: 0,
bottom: (threadViewModel.threadVariant != .contact ? Values.largeSpacing : nil)
),
backgroundStyle: .noBackground
),
accessibility: Accessibility(
identifier: "Description",
label: threadDescription
)
)
}
].compactMap { $0 }
)
// MARK: - Session Id
let sessionIdSection: SectionModel = SectionModel(
model: (threadViewModel.threadIsNoteToSelf == true ? .sessionIdNoteToSelf : .sessionId),
elements: [
SessionCell.Info(
id: .sessionId,
subtitle: SessionCell.TextInfo(
threadViewModel.id,
font: .monoLarge,
alignment: .center,
interaction: .copy
),
styling: SessionCell.StyleInfo(
customPadding: SessionCell.Padding(bottom: Values.smallSpacing),
backgroundStyle: .noBackground
),
accessibility: Accessibility(
identifier: "Session ID",
label: threadViewModel.id
)
)
]
)
// MARK: - Users kicked from groups
guard !currentUserKickedFromGroup 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: threadViewModel.displayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
),
confirmTitle: "delete".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text
),
onTap: { [weak self, dependencies] in
self?.dismissScreen(type: .popToRoot) {
dependencies[singleton: .storage].writeAsync { db in
try SessionThread.deleteOrLeave(
db,
type: .leaveGroupAsync,
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant,
using: dependencies
)
}
}
}
)
]
)
]
}
// MARK: - Standard Actions
let standardActionsSection: SectionModel = SectionModel(
model: .content,
elements: [
(threadViewModel.threadVariant == .legacyGroup || threadViewModel.threadVariant == .group ? nil :
SessionCell.Info(
id: .copyThreadId,
leadingAccessory: .icon(.copy),
title: (threadViewModel.threadVariant == .community ?
"communityUrlCopy".localized() :
"accountIDCopy".localized()
),
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).copy_thread_id",
label: "Copy Session ID"
),
onTap: { [weak self] in
switch threadViewModel.threadVariant {
case .contact, .legacyGroup, .group:
UIPasteboard.general.string = threadViewModel.threadId
case .community:
guard
let urlString: String = LibSession.communityUrlFor(
server: threadViewModel.openGroupServer,
roomToken: threadViewModel.openGroupRoomToken,
publicKey: threadViewModel.openGroupPublicKey
)
else { return }
UIPasteboard.general.string = urlString
}
self?.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 self] in self?.didTriggerSearch() }
),
(
threadViewModel.threadVariant == .community ||
threadViewModel.threadIsBlocked == true ||
currentUserIsClosedGroupAdmin ? nil :
SessionCell.Info(
id: .disappearingMessages,
leadingAccessory: .icon(.timer),
title: "disappearingMessages".localized(),
subtitle: {
guard current.disappearingMessagesConfig.isEnabled else {
return "off".localized()
}
return (current.disappearingMessagesConfig.type ?? .unknown)
.localizedState(
durationString: current.disappearingMessagesConfig.durationString
)
}(),
accessibility: Accessibility(
identifier: "Disappearing messages",
label: "\(ThreadSettingsViewModel.self).disappearing_messages"
),
onTap: { [weak self, dependencies] in
self?.transitionToScreen(
SessionTableViewController(
viewModel: ThreadDisappearingMessagesSettingsViewModel(
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant,
currentUserIsClosedGroupMember: threadViewModel.currentUserIsClosedGroupMember,
currentUserIsClosedGroupAdmin: threadViewModel.currentUserIsClosedGroupAdmin,
config: current.disappearingMessagesConfig,
using: dependencies
)
)
)
}
)
),
(threadViewModel.threadIsBlocked == true ? nil :
SessionCell.Info(
id: .pinConversation,
leadingAccessory: .icon(
(threadViewModel.threadPinnedPriority > 0 ?
.pinOff :
.pin
)
),
title: (threadViewModel.threadPinnedPriority > 0 ?
"pinUnpinConversation".localized() :
"pinConversation".localized()
),
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).pin_conversation",
label: "Pin Conversation"
),
onTap: { [weak self] in
self?.toggleConversationPinnedStatus(
currentPinnedPriority: threadViewModel.threadPinnedPriority
)
}
)
),
((threadViewModel.threadIsNoteToSelf == true || threadViewModel.threadIsBlocked == true) ? nil :
SessionCell.Info(
id: .notifications,
leadingAccessory: .icon(
{
if threadViewModel.threadOnlyNotifyForMentions == true {
return .atSign
}
if threadViewModel.threadMutedUntilTimestamp != nil {
return .volumeOff
}
return .volume2
}()
),
title: "sessionNotifications".localized(),
subtitle: {
if threadViewModel.threadOnlyNotifyForMentions == true {
return "notificationsMentionsOnly".localized()
}
if threadViewModel.threadMutedUntilTimestamp != nil {
return "notificationsMuted".localized()
}
return "notificationsAllMessages".localized()
}(),
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).notifications",
label: "Notifications"
),
onTap: { [weak self, dependencies] in
self?.transitionToScreen(
SessionTableViewController(
viewModel: ThreadNotificationSettingsViewModel(
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant,
threadOnlyNotifyForMentions: threadViewModel.threadOnlyNotifyForMentions,
threadMutedUntilTimestamp: threadViewModel.threadMutedUntilTimestamp,
using: dependencies
)
)
)
}
)
),
(threadViewModel.threadVariant != .community ? nil :
SessionCell.Info(
id: .addToOpenGroup,
leadingAccessory: .icon(.userRoundPlus),
title: "membersInvite".localized(),
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).add_to_open_group"
),
onTap: { [weak self] in self?.inviteUsersToCommunity(threadViewModel: threadViewModel) }
)
),
(!currentUserIsClosedGroupMember ? nil :
SessionCell.Info(
id: .groupMembers,
leadingAccessory: .icon(.usersRound),
title: "groupMembers".localized(),
accessibility: Accessibility(
identifier: "Group members",
label: "Group members"
),
onTap: { [weak self] in self?.viewMembers() }
)
),
SessionCell.Info(
id: .attachments,
leadingAccessory: .icon(.file),
title: "attachments".localized(),
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).all_media",
label: "All media"
),
onTap: { [weak self, dependencies] in
self?.transitionToScreen(
MediaGalleryViewModel.createAllMediaViewController(
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant,
threadTitle: threadViewModel.displayName,
focusedAttachmentId: nil,
using: dependencies
)
)
}
)
].compactMap { $0 }
)
// MARK: - Admin Actions
var adminActionsSection: SectionModel?
if currentUserIsClosedGroupAdmin {
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 self, dependencies] in
self?.transitionToScreen(
SessionTableViewController(
viewModel: EditGroupViewModel(
threadId: threadViewModel.threadId,
using: dependencies
)
)
)
}
)
]
)
}
if currentUserIsClosedGroupAdmin && 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 self] in
self?.promoteAdmins(currentGroupName: threadViewModel.closedGroupName)
}
)
)
}
if currentUserIsClosedGroupAdmin {
adminActionsSection?.elements.append(
SessionCell.Info(
id: .disappearingMessages,
leadingAccessory: .icon(.timer),
title: "disappearingMessages".localized(),
subtitle: {
guard current.disappearingMessagesConfig.isEnabled else {
return "off".localized()
}
return (current.disappearingMessagesConfig.type ?? .unknown)
.localizedState(
durationString: current.disappearingMessagesConfig.durationString
)
}(),
accessibility: Accessibility(
identifier: "Disappearing messages",
label: "\(ThreadSettingsViewModel.self).disappearing_messages"
),
onTap: { [weak self, dependencies] in
self?.transitionToScreen(
SessionTableViewController(
viewModel: ThreadDisappearingMessagesSettingsViewModel(
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant,
currentUserIsClosedGroupMember: threadViewModel.currentUserIsClosedGroupMember,
currentUserIsClosedGroupAdmin: threadViewModel.currentUserIsClosedGroupAdmin,
config: current.disappearingMessagesConfig,
using: dependencies
)
)
)
}
)
)
}
// FIXME: [GROUPS REBUILD] Need to build this properly in a future release
if currentUserIsClosedGroupAdmin && 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 self] in
self?.updateGroupDisplayPicture(currentUrl: threadViewModel.threadDisplayPictureUrl)
}
)
)
}
// MARK: - Destructive Actions
var destructiveActionsSection: SectionModel = SectionModel(
model: .destructiveActions,
elements: []
)
if !threadViewModel.threadIsNoteToSelf && threadViewModel.threadVariant == .contact {
destructiveActionsSection.elements.append(
SessionCell.Info(
id: .blockUser,
leadingAccessory: (
threadViewModel.threadIsBlocked == true ?
.icon(.userRoundCheck) :
.icon(UIImage(named: "ic_user_round_ban")?.withRenderingMode(.alwaysTemplate))
),
title: (
threadViewModel.threadIsBlocked == true ?
"blockUnblock".localized() :
"block".localized()
),
styling: SessionCell.StyleInfo(tintColor: .danger),
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).block",
label: "Block"
),
confirmationInfo: ConfirmationModal.Info(
title: (threadViewModel.threadIsBlocked == true ?
"blockUnblock".localized() :
"block".localized()
),
body: (threadViewModel.threadIsBlocked == true ?
.attributedText(
"blockUnblockName"
.put(key: "name", value: threadViewModel.displayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
) :
.attributedText(
"blockDescription"
.put(key: "name", value: threadViewModel.displayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
)
),
confirmTitle: (threadViewModel.threadIsBlocked == true ?
"blockUnblock".localized() :
"block".localized()
),
confirmStyle: .danger,
cancelStyle: .alert_text
),
onTap: { [weak self] in
let isBlocked: Bool = (threadViewModel.threadIsBlocked == true)
self?.updateBlockedState(
from: isBlocked,
isBlocked: !isBlocked,
threadId: threadViewModel.threadId,
displayName: threadViewModel.displayName
)
}
)
)
}
if threadViewModel.threadIsNoteToSelf {
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: .attributedText(
isThreadHidden ?
"showNoteToSelfDescription"
.localizedFormatted(baseFont: ConfirmationModal.explanationFont) :
"hideNoteToSelfDescription"
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
),
confirmTitle: isThreadHidden ? "show".localized() : "hide".localized(),
confirmStyle: isThreadHidden ? .alert_text : .danger,
cancelStyle: .alert_text
),
onTap: { [dependencies] in
dependencies[singleton: .storage].writeAsync { db in
if isThreadHidden {
try SessionThread.updateVisibility(
db,
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant,
isVisible: true,
using: dependencies
)
} else {
try SessionThread.deleteOrLeave(
db,
type: .hideContactConversation,
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant,
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 threadViewModel.threadIsNoteToSelf != true else {
return .attributedText(
"clearMessagesNoteToSelfDescriptionUpdated"
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
)
}
switch threadVariant {
case .contact:
return .attributedText(
"clearMessagesChatDescriptionUpdated"
.put(key: "name", value: threadViewModel.displayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
)
case .legacyGroup:
return .attributedText(
"clearMessagesGroupDescriptionUpdated"
.put(key: "group_name", value: threadViewModel.displayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
)
case .community:
return .attributedText(
"clearMessagesCommunityUpdated"
.put(key: "community_name", value: threadViewModel.displayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
)
case .group:
if currentUserIsClosedGroupAdmin {
return .radio(
explanation: "clearMessagesGroupAdminDescriptionUpdated"
.put(key: "group_name", value: threadViewModel.displayName)
.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: threadViewModel.displayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
)
}
}
}(),
confirmTitle: "clear".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text,
dismissOnConfirm: false,
onConfirm: { [weak self, threadVariant, dependencies] modal in
if threadVariant == .group && currentUserIsClosedGroupAdmin {
/// 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 {
self?.deleteAllMessagesBeforeNow()
}
}
dependencies[singleton: .storage].writeAsync(
updates: { db in
try Interaction.markAllAsDeleted(
db,
threadId: threadViewModel.id,
threadVariant: threadViewModel.threadVariant,
options: [.local, .noArtifacts],
using: dependencies
)
}, completion: { [weak self] 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) {
self?.showToast(
text: "deleteMessageFailed"
.putNumber(0)
.localized(),
backgroundColor: .backgroundSecondary
)
}
}
case .success:
DispatchQueue.main.async {
modal.dismiss(animated: true) {
self?.showToast(
text: "deleteMessageDeleted"
.putNumber(0)
.localized(),
backgroundColor: .backgroundSecondary
)
}
}
}
}
)
}
)
)
)
if threadViewModel.threadVariant == .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: threadViewModel.displayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
),
confirmTitle: "leave".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text
),
onTap: { [weak self, dependencies] in
self?.dismissScreen(type: .popToRoot) {
dependencies[singleton: .storage].writeAsync { db in
try SessionThread.deleteOrLeave(
db,
type: .deleteCommunityAndContent,
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant,
using: dependencies
)
}
}
}
)
)
}
if
currentUserIsClosedGroupMember && (
!currentUserIsClosedGroupAdmin ||
(threadViewModel.closedGroupAdminCount ?? 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: threadViewModel.displayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
),
confirmTitle: "leave".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text
),
onTap: { [weak self, dependencies] in
self?.dismissScreen(type: .popToRoot) {
dependencies[singleton: .storage].writeAsync { db in
try SessionThread.deleteOrLeave(
db,
type: .leaveGroupAsync,
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant,
using: dependencies
)
}
}
}
)
)
}
if currentUserIsClosedGroupAdmin {
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: threadViewModel.displayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
),
confirmTitle: "delete".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text
),
onTap: { [weak self, dependencies] in
self?.dismissScreen(type: .popToRoot) {
dependencies[singleton: .storage].writeAsync { db in
try SessionThread.deleteOrLeave(
db,
type: .deleteGroupAndContentForEveryoneAsync,
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant,
using: dependencies
)
}
}
}
)
)
}
if threadViewModel.threadIsNoteToSelf != true && threadVariant == .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: threadViewModel.displayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
),
confirmTitle: "delete".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text
),
onTap: { [weak self, dependencies] in
self?.dismissScreen(type: .popToRoot) {
dependencies[singleton: .storage].writeAsync { db in
try SessionThread.deleteOrLeave(
db,
type: .deleteContactConversationAndMarkHidden,
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant,
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: threadViewModel.displayName)
.localizedFormatted(baseFont: ConfirmationModal.explanationFont),
scrollMode: .never
),
confirmTitle: "delete".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text
),
onTap: { [weak self, dependencies] in
self?.dismissScreen(type: .popToRoot) {
dependencies[singleton: .storage].writeAsync { db in
try SessionThread.deleteOrLeave(
db,
type: .deleteContactConversationAndContact,
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant,
using: dependencies
)
}
}
}
)
)
}
// FIXME: [GROUPS REBUILD] Need to build this properly in a future release
if threadViewModel.threadVariant == .group && 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 self] in self?.deleteAllAttachmentsBeforeNow() }
)
)
}
return [
conversationInfoSection,
(!showThreadPubkey ? nil : sessionIdSection),
standardActionsSection,
adminActionsSection,
destructiveActionsSection
].compactMap { $0 }
}
// MARK: - Functions
private func inviteUsersToCommunity(threadViewModel: SessionThreadViewModel) {
guard
let name: String = threadViewModel.openGroupName,
let server: String = threadViewModel.openGroupServer,
let roomToken: String = threadViewModel.openGroupRoomToken,
let publicKey: String = threadViewModel.openGroupPublicKey,
let communityUrl: String = LibSession.communityUrlFor(
server: threadViewModel.openGroupServer,
roomToken: threadViewModel.openGroupRoomToken,
publicKey: threadViewModel.openGroupPublicKey
)
else { return }
let openGroupCapabilityInfo: LibSession.OpenGroupCapabilityInfo = LibSession.OpenGroupCapabilityInfo(
roomToken: roomToken,
server: server,
publicKey: publicKey,
capabilities: (threadViewModel.openGroupCapabilities ?? [])
)
let currentUserSessionIds: Set<String> = Set([
dependencies[cache: .general].sessionId.hexString,
SessionThread.getCurrentUserBlindedSessionId(
threadId: threadId,
threadVariant: threadVariant,
blindingPrefix: .blinded15,
openGroupCapabilityInfo: openGroupCapabilityInfo,
using: dependencies
)?.hexString,
SessionThread.getCurrentUserBlindedSessionId(
threadId: threadId,
threadVariant: threadVariant,
blindingPrefix: .blinded25,
openGroupCapabilityInfo: openGroupCapabilityInfo,
using: dependencies
)?.hexString
].compactMap { $0 })
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]) = \(threadId) 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 \(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: 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: threadViewModel.currentUserSessionId,
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 (String) -> 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
dependencies[singleton: .storage].writeAsync(
updates: { 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
)
},
completion: { _ in
Task { @MainActor in
transitionToConversation(memberInfo.profileId)
}
}
)
},
using: dependencies
)
)
}
private func viewMembers() {
self.transitionToScreen(
ThreadSettingsViewModel.createMemberListViewController(
threadId: threadId,
transitionToConversation: { [weak self, dependencies] selectedMemberId in
self?.transitionToScreen(
ConversationVC(
threadId: selectedMemberId,
threadVariant: .contact,
using: dependencies
),
transitionType: .push
)
},
using: dependencies
)
)
}
private func promoteAdmins(currentGroupName: String?) {
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: threadId),
members: memberInfo,
isResend: isResend,
using: dependencies
)
.subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies)
.receive(on: DispatchQueue.main, using: dependencies)
.sinkUntilComplete(
receiveCompletion: { [threadId, 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 == threadId)
.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: (currentGroupName ?? "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]) == \(threadId) 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(
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
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, threadId] 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: threadId,
nicknameUpdate: .set(to: finalNickname),
profileUpdateTimestamp: nil,
using: dependencies
)
},
completion: { _ in
DispatchQueue.main.async {
modal.dismiss(animated: true)
}
}
)
},
onCancel: { [dependencies, threadId] modal in
/// Remove the nickname
dependencies[singleton: .storage].writeAsync(
updates: { db in
try Profile.updateIfNeeded(
db,
publicKey: threadId,
nicknameUpdate: .set(to: nil),
profileUpdateTimestamp: nil,
using: dependencies
)
},
completion: { _ in
DispatchQueue.main.async {
modal.dismiss(animated: true)
}
}
)
}
)
}
private func updateGroupNameAndDescription(
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, threadId] 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: threadId,
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(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(
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(
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(
displayPictureUpdate: DisplayPictureManager.Update,
onUploadComplete: @escaping () -> ()
) {
switch displayPictureUpdate {
case .none: onUploadComplete()
default: break
}
Task.detached(priority: .userInitiated) { [weak self, threadId, 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: threadId)
.select(.displayPictureUrl)
.asRequest(of: String.self)
.fetchOne(db)
}
do {
try await MessageSender.updateGroup(
groupSessionId: threadId,
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(currentPinnedPriority: Int32) {
let isCurrentlyPinned: Bool = (currentPinnedPriority > LibSession.visiblePriority)
if !isCurrentlyPinned && dependencies[feature: .sessionProEnabled] && !dependencies[cache: .libSession].isSessionPro {
// TODO: [Database Relocation] Retrieve the full conversation list from lib session and check the pinnedPriority that way instead of using the database
dependencies[singleton: .storage].writeAsync (
updates: { [threadId, threadVariant, dependencies] db in
let numPinnedConversations: Int = try SessionThread
.filter(SessionThread.Columns.pinnedPriority > LibSession.visiblePriority)
.fetchCount(db)
guard numPinnedConversations < LibSession.PinnedConversationLimit else {
return numPinnedConversations
}
// We have the space to pin the conversation, so do so
try SessionThread.updateVisibility(
db,
threadId: threadId,
threadVariant: threadVariant,
isVisible: true,
customPriority: (currentPinnedPriority <= LibSession.visiblePriority ? 1 : LibSession.visiblePriority),
using: dependencies
)
return -1
},
completion: { [weak self, dependencies] result in
guard
let numPinnedConversations: Int = try? result.successOrThrow(),
numPinnedConversations > 0
else { return }
DispatchQueue.main.async {
let sessionProModal: ModalHostingViewController = ModalHostingViewController(
modal: ProCTAModal(
variant: .morePinnedConvos(
isGrandfathered: (numPinnedConversations > LibSession.PinnedConversationLimit),
renew: dependencies[singleton: .sessionProState].isSessionProExpired
),
dataManager: dependencies[singleton: .imageDataManager],
onConfirm: { [dependencies] in
Task {
await dependencies[singleton: .sessionProState].upgradeToPro(
plan: SessionProPlan(variant: .threeMonths),
originatingPlatform: .iOS,
completion: nil
)
}
}
)
)
self?.transitionToScreen(sessionProModal, transitionType: .present)
}
}
)
return
}
// If we are unpinning then no need to check the current count, just unpin immediately
dependencies[singleton: .storage].writeAsync { [threadId, threadVariant, dependencies] db in
try SessionThread.updateVisibility(
db,
threadId: threadId,
threadVariant: threadVariant,
isVisible: true,
customPriority: (currentPinnedPriority <= LibSession.visiblePriority ? 1 : LibSession.visiblePriority),
using: dependencies
)
}
}
private func deleteAllMessagesBeforeNow() {
guard threadVariant == .group else { return }
dependencies[singleton: .storage].writeAsync { [threadId, dependencies] db in
try LibSession.deleteMessagesBefore(
db,
groupSessionId: SessionId(.group, hex: threadId),
timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000),
using: dependencies
)
}
}
private func deleteAllAttachmentsBeforeNow() {
guard threadVariant == .group else { return }
dependencies[singleton: .storage].writeAsync { [threadId, dependencies] db in
try LibSession.deleteAttachmentsBefore(
db,
groupSessionId: SessionId(.group, hex: threadId),
timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000),
using: dependencies
)
}
}
// MARK: - Confirmation Modals
private func updateDisplayNameModal(
threadViewModel: SessionThreadViewModel,
currentUserIsClosedGroupAdmin: Bool
) -> ConfirmationModal.Info? {
guard !threadViewModel.threadIsNoteToSelf else { return nil }
switch (threadViewModel.threadVariant, currentUserIsClosedGroupAdmin) {
case (.contact, _):
return self.updateNickname(
current: threadViewModel.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
threadViewModel.profile?.displayName(ignoringNickname: true) ??
threadViewModel.threadId.truncated()
)
)
case (.group, true), (.legacyGroup, true):
return self.updateGroupNameAndDescription(
currentName: threadViewModel.displayName,
currentDescription: threadViewModel.threadDescription,
isUpdatedGroup: (threadViewModel.threadVariant == .group)
)
case (.community, _), (.legacyGroup, false), (.group, false): return nil
}
}
private func showQRCodeLightBox(for threadViewModel: SessionThreadViewModel) {
let qrCodeImage: UIImage = QRCode.generate(
for: threadViewModel.getQRCodeString(),
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)
}
}