You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-ios/Session/Closed Groups/EditGroupViewModel.swift

657 lines
30 KiB
Swift

// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Combine
import GRDB
import YYImage
import DifferenceKit
import SessionUIKit
import SessionSnodeKit
import SessionMessagingKit
import SessionUtilitiesKit
import SignalUtilitiesKit
class EditGroupViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, EditableStateHolder, ObservableTableSource {
public let dependencies: Dependencies
public let navigatableState: NavigatableState = NavigatableState()
public let editableState: EditableState<TableItem> = EditableState()
public let state: TableDataState<Section, TableItem> = TableDataState()
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
private let selectedIdsSubject: CurrentValueSubject<Set<String>, Never> = CurrentValueSubject([])
private let threadId: String
private let userSessionId: SessionId
private lazy var imagePickerHandler: ImagePickerHandler = ImagePickerHandler(
onTransition: { [weak self] in self?.transitionToScreen($0, transitionType: $1) },
onImageDataPicked: { [weak self] resultImageData in
guard let oldDisplayName: String = self?.oldDisplayName else { return }
self?.updatedDisplayPictureSelected(update: .uploadImageData(resultImageData))
}
)
fileprivate var oldDisplayName: String
fileprivate var oldDescription: String?
private var editedName: String?
private var editedDescription: String?
private var editDisplayPictureModal: ConfirmationModal?
private var editDisplayPictureModalInfo: ConfirmationModal.Info?
// MARK: - Initialization
init(threadId: String, using dependencies: Dependencies = Dependencies()) {
self.dependencies = dependencies
self.threadId = threadId
self.userSessionId = getUserSessionId(using: dependencies)
let closedGroup: ClosedGroup? = dependencies[singleton: .storage].read(using: dependencies) { db in
try ClosedGroup.fetchOne(db, id: threadId)
}
self.oldDisplayName = (closedGroup?.name ?? "")
self.oldDescription = closedGroup?.groupDescription
}
// MARK: - Config
enum NavState {
case standard
case editing
}
enum NavItem: Equatable {
case cancel
case done
}
public enum Section: SessionTableSection {
case groupInfo
case invite
case members
public var title: String? {
switch self {
case .members: return "GROUP_MEMBERS".localized()
default: return nil
}
}
var style: SessionTableSectionStyle {
switch self {
case .members: return .titleEdgeToEdgeContent
default: return .none
}
}
}
public enum TableItem: Equatable, Hashable, Differentiable {
case avatar
case groupName
case groupDescription
case invite
case member(String)
}
// MARK: - NavigationItemSource
lazy var navState: AnyPublisher<NavState, Never> = Publishers
.CombineLatest(
isEditing,
textChanged
.handleEvents(
receiveOutput: { [weak self] value, item in
switch item {
case .groupName: self?.editedName = value
case .groupDescription: self?.editedDescription = value
default: break
}
}
)
.filter { _ in false }
.prepend((nil, .groupName))
)
.map { isEditing, _ -> NavState in (isEditing ? .editing : .standard) }
.removeDuplicates()
.prepend(.standard) // Initial value
.shareReplay(1)
.eraseToAnyPublisher()
lazy var leftNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = navState
.map { navState -> [SessionNavItem<NavItem>] in
switch navState {
case .standard: return []
case .editing:
return [
SessionNavItem(
id: .cancel,
systemItem: .cancel,
accessibilityIdentifier: "Cancel button"
) { [weak self] in
self?.setIsEditing(false)
self?.editedName = self?.oldDisplayName
self?.editedDescription = self?.oldDescription
}
]
}
}
.eraseToAnyPublisher()
lazy var rightNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = navState
.map { [weak self] navState -> [SessionNavItem<NavItem>] in
switch navState {
case .standard: return []
case .editing:
return [
SessionNavItem(
id: .done,
systemItem: .done,
accessibilityIdentifier: "Done"
) { [weak self] in
self?.updateGroupNameAndDescription(
updatedName: self?.editedName,
updatedDescription: self?.editedDescription
) { didComplete, finalName, finalDescription in
guard didComplete else { return }
self?.oldDisplayName = finalName
self?.oldDescription = finalDescription
self?.setIsEditing(false)
}
}
]
}
}
.eraseToAnyPublisher()
// MARK: - Content
private struct State: Equatable {
let group: ClosedGroup
let profileFront: Profile?
let profileBack: Profile?
let members: [WithProfile<GroupMember>]
let isValid: Bool
static let invalidState: State = State(
group: ClosedGroup(threadId: "", name: "", formationTimestamp: 0, shouldPoll: false, invited: false),
profileFront: nil,
profileBack: nil,
members: [],
isValid: false
)
}
let title: String = "EDIT_GROUP_ACTION".localized()
lazy var observation: TargetObservation = ObservationBuilder
.databaseObservation(self) { [dependencies, threadId, userSessionId] db -> State in
guard let group: ClosedGroup = try ClosedGroup.fetchOne(db, id: threadId) else {
return State.invalidState
}
var profileFront: Profile?
var profileBack: Profile?
if group.displayPictureFilename == nil {
let frontProfileId: String? = try GroupMember
.filter(GroupMember.Columns.groupId == threadId)
.filter(GroupMember.Columns.role == GroupMember.Role.standard)
.filter(GroupMember.Columns.profileId != userSessionId.hexString)
.select(min(GroupMember.Columns.profileId))
.asRequest(of: String.self)
.fetchOne(db)
let backProfileId: String? = try GroupMember
.filter(GroupMember.Columns.groupId == threadId)
.filter(GroupMember.Columns.role == GroupMember.Role.standard)
.filter(GroupMember.Columns.profileId != userSessionId.hexString)
.filter(GroupMember.Columns.profileId != frontProfileId)
.select(max(GroupMember.Columns.profileId))
.asRequest(of: String.self)
.fetchOne(db)
profileFront = try frontProfileId.map { try Profile.fetchOne(db, id: $0) }
profileBack = try Profile.fetchOne(db, id: backProfileId ?? userSessionId.hexString)
}
return State(
group: group,
profileFront: profileFront,
profileBack: profileBack,
members: try GroupMember
.filter(GroupMember.Columns.groupId == threadId)
.fetchAllWithProfiles(db),
isValid: true
)
}
.map { [weak self, dependencies, threadId, userSessionId, selectedIdsSubject] (state: State) -> [SectionModel] in
guard state.isValid else {
return [
SectionModel(
model: .groupInfo,
elements: [
SessionCell.Info(
id: .groupName,
title: SessionCell.TextInfo(
"ERROR_UNABLE_TO_FIND_DATA".localized(),
font: .subtitle,
alignment: .center
),
styling: SessionCell.StyleInfo(
tintColor: .textSecondary,
alignment: .centerHugging,
customPadding: SessionCell.Padding(top: Values.smallSpacing),
backgroundStyle: .noBackground
)
)
]
)
]
}
let isUpdatedGroup: Bool = (((try? SessionId.Prefix(from: threadId)) ?? .group) == .group)
let threadVariant: SessionThread.Variant = (isUpdatedGroup ? .group : .legacyGroup)
let editIcon: UIImage? = UIImage(systemName: "pencil")
return [
SectionModel(
model: .groupInfo,
elements: [
SessionCell.Info(
id: .avatar,
accessory: .profile(
id: threadId,
size: .hero,
threadVariant: (isUpdatedGroup ? .group : .legacyGroup),
displayPictureFilename: state.group.displayPictureFilename,
profile: state.profileFront,
profileIcon: .none,
additionalProfile: state.profileBack,
additionalProfileIcon: .none,
accessibility: nil
),
styling: SessionCell.StyleInfo(
alignment: .centerHugging,
customPadding: SessionCell.Padding(bottom: Values.smallSpacing),
backgroundStyle: .noBackground
),
accessibility: Accessibility(
label: "Profile picture"
)
SessionCell.Info(
id: .groupName,
leftAccessory: .icon(
editIcon?.withRenderingMode(.alwaysTemplate),
size: .medium,
customTint: .textSecondary
),
title: SessionCell.TextInfo(
state.group.name,
font: .titleLarge,
alignment: .center,
editingPlaceholder: "EDIT_GROUP_NAME_PLACEHOLDER".localized(),
interaction: .editable
),
styling: SessionCell.StyleInfo(
alignment: .centerHugging,
customPadding: SessionCell.Padding(
top: Values.smallSpacing,
leading: -((IconSize.medium.size + (Values.smallSpacing * 2)) / 2),
bottom: Values.smallSpacing,
interItem: 0
),
backgroundStyle: .noBackground
),
accessibility: Accessibility(
identifier: "Group name",
label: state.group.name
),
onTap: { self?.setIsEditing(true) }
)
]
),
SectionModel(
model: .invite,
elements: [
SessionCell.Info(
id: .invite,
leftAccessory: .icon(UIImage(named: "icon_invite")?.withRenderingMode(.alwaysTemplate)),
title: "GROUP_ACTION_INVITE_CONTACTS".localized(),
accessibility: Accessibility(
identifier: "Invite Contacts",
label: "Invite Contacts"
),
onTap: { self?.inviteContacts() }
)
]
),
SectionModel(
model: .members,
elements: state.members
.sorted()
.map { memberInfo -> SessionCell.Info in
SessionCell.Info(
id: .member(memberInfo.profileId),
leftAccessory: .profile(
id: memberInfo.profileId,
profile: memberInfo.profile,
profileIcon: memberInfo.value.profileIcon
),
title: (
memberInfo.profile?.displayName() ??
Profile.truncated(id: memberInfo.profileId, truncating: .middle)
),
subtitle: (isUpdatedGroup ? memberInfo.value.statusDescription : nil),
rightAccessory: {
switch (memberInfo.value.role, memberInfo.value.roleStatus) {
case (.admin, _), (.moderator, _): return nil
case (.standard, .failed), (.standard, .sending):
return .highlightingBackgroundLabel(
title: "context_menu_resend".localized()
)
// Intentionally including the 'pending' state in here as we want admins to
// be able to remove pending members - to resend the admin will have to remove
// and re-add the member
case (.standard, .pending), (.standard, .accepted), (.zombie, _):
return .radio(
isSelected: selectedIdsSubject.value.contains(memberInfo.profileId)
)
}
}(),
styling: SessionCell.StyleInfo(
subtitleTintColor: (isUpdatedGroup ? memberInfo.value.statusDescriptionColor : nil),
allowedSeparators: [],
customPadding: SessionCell.Padding(
top: Values.smallSpacing,
bottom: Values.smallSpacing
),
backgroundStyle: .noBackgroundEdgeToEdge
),
onTap: {
switch (memberInfo.value.role, memberInfo.value.roleStatus) {
case (.moderator, _): return
case (.admin, _):
self?.showToast(
text: "EDIT_GROUP_MEMBERS_ERROR_REMOVE_ADMIN".localized(),
backgroundColor: .backgroundSecondary
)
case (.standard, .failed), (.standard, .sending):
self?.resendInvitation(memberId: memberInfo.profileId)
case (.standard, .pending), (.standard, .accepted), (.zombie, _):
if !selectedIdsSubject.value.contains(memberInfo.profileId) {
selectedIdsSubject.send(selectedIdsSubject.value.inserting(memberInfo.profileId))
}
else {
selectedIdsSubject.send(selectedIdsSubject.value.removing(memberInfo.profileId))
}
// Force the table data to be refreshed (the database wouldn't
// have been changed)
self?.forceRefresh(type: .postDatabaseQuery)
}
}
)
}
)
]
}
lazy var footerButtonInfo: AnyPublisher<SessionButton.Info?, Never> = selectedIdsSubject
.prepend([])
.map { selectedIds in
SessionButton.Info(
style: .destructive,
title: "GROUP_ACTION_REMOVE".localized(),
isEnabled: !selectedIds.isEmpty,
onTap: { [weak self] in self?.removeMembers(memberIds: selectedIds) }
)
}
.eraseToAnyPublisher()
// MARK: - Functions
private func updateGroupNameAndDescription(
updatedName: String?,
updatedDescription: String?,
onComplete: ((Bool, String, String?) -> ())? = nil
) {
let finalName: String = (updatedName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let finalDescription: String? = updatedDescription.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
/// Check if the data violates any of the size constraints
let maybeErrorString: String? = {
guard !finalName.isEmpty else { return "EDIT_GROUP_NAME_ERROR_MISSING".localized() }
guard !Profile.isTooLong(profileName: finalName) else { return "EDIT_GROUP_NAME_ERROR_LONG".localized() }
guard !SessionUtil.isTooLong(groupDescription: (finalDescription ?? "")) else {
return "EDIT_GROUP_DESCRIPTION_ERROR_LONG".localized()
}
return nil
}()
if let errorString: String = maybeErrorString {
self.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "ALERT_ERROR_TITLE".localized(),
body: .text(errorString),
cancelTitle: "BUTTON_OK".localized(),
cancelStyle: .alert_text
)
),
transitionType: .present
)
onComplete?(false, finalName, finalDescription)
return
}
/// Update the group appropriately
MessageSender
.updateGroup(
groupSessionId: threadId,
name: finalName,
groupDescription: finalDescription,
using: dependencies
)
.sinkUntilComplete(
receiveCompletion: { [weak self] result in
switch result {
case .finished: onComplete?(true, finalName, finalDescription)
case .failure:
self?.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "ALERT_ERROR_TITLE".localized(),
body: .text("DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE".localized()),
cancelTitle: "BUTTON_OK".localized(),
cancelStyle: .alert_text
)
),
transitionType: .present
)
onComplete?(false, finalName, finalDescription)
}
}
)
}
private func inviteContacts() {
let contact: TypedTableAlias<Contact> = TypedTableAlias()
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
let currentMemberIds: Set<String> = (tableData
.first(where: { $0.model == .members })?
.elements
.compactMap { item -> String? in
switch item.id {
case .member(let profileId): return profileId
default: return nil
}
})
.defaulting(to: [])
.asSet()
self.transitionToScreen(
SessionTableViewController(
viewModel: UserListViewModel<Contact>(
title: "GROUP_ACTION_INVITE_CONTACTS".localized(),
emptyState: "GROUP_ACTION_INVITE_EMPTY_STATE".localized(),
showProfileIcons: true,
request: SQLRequest("""
SELECT \(contact.allColumns)
FROM \(contact)
LEFT JOIN \(groupMember) ON (
\(groupMember[.groupId]) = \(threadId) AND
\(groupMember[.profileId]) = \(contact[.id])
)
WHERE \(groupMember[.profileId]) IS NULL
"""),
footerTitle: "GROUP_ACTION_INVITE".localized(),
onSubmit: {
switch try? SessionId.Prefix(from: threadId) {
case .group:
return .callback { [dependencies, threadId] viewModel, selectedMemberInfo in
let updatedMemberIds: Set<String> = currentMemberIds
.inserting(contentsOf: selectedMemberInfo.map { $0.profileId }.asSet())
guard updatedMemberIds.count <= SessionUtil.sizeMaxGroupMemberCount else {
throw UserListError.error(
"vc_create_closed_group_too_many_group_members_error".localized()
)
}
MessageSender.addGroupMembers(
groupSessionId: threadId,
members: selectedMemberInfo.map { ($0.profileId, $0.profile) },
allowAccessToHistoricMessages: false,
using: dependencies
)
viewModel?.showToast(
text: (selectedMemberInfo.count == 1 ?
"GROUP_ACTION_INVITE_SENDING".localized() :
"GROUP_ACTION_INVITE_SENDING_MULTIPLE".localized()
),
backgroundColor: .backgroundSecondary
)
}
case .standard: // Assume it's a legacy group
return .publisher { [dependencies, threadId, oldDisplayName] _, selectedMemberInfo in
let updatedMemberIds: Set<String> = currentMemberIds
.inserting(contentsOf: selectedMemberInfo.map { $0.profileId }.asSet())
guard updatedMemberIds.count <= SessionUtil.sizeMaxGroupMemberCount else {
return Fail(error: .error("vc_create_closed_group_too_many_group_members_error".localized()))
.eraseToAnyPublisher()
}
return MessageSender.update(
legacyGroupSessionId: threadId,
with: updatedMemberIds,
name: oldDisplayName,
using: dependencies
)
.mapError { _ in UserListError.error("GROUP_UPDATE_ERROR_TITLE".localized()) }
.eraseToAnyPublisher()
}
default: return .none
}
}(),
using: dependencies
)
),
transitionType: .push
)
}
private func resendInvitation(memberId: String) {
MessageSender.resendInvitation(
groupSessionId: threadId,
memberId: memberId,
using: dependencies
)
self.showToast(text: "GROUP_ACTION_INVITE_SENDING".localized())
}
private func removeMembers(memberIds: Set<String>) {
guard !memberIds.isEmpty else { return }
switch try? SessionId.Prefix(from: threadId) {
case .group:
MessageSender.removeGroupMembers(
groupSessionId: threadId,
memberIds: memberIds,
removeTheirMessages: false,
sendMemberChangedMessage: true,
using: dependencies
)
self.selectedIdsSubject.send([])
case .standard: // Assume it's a legacy group
let updatedMemberIds: Set<String> = (tableData
.first(where: { $0.model == .members })?
.elements
.compactMap { item -> String? in
switch item.id {
case .member(let profileId): return profileId
default: return nil
}
})
.defaulting(to: [])
.asSet()
.removing(contentsOf: memberIds)
let viewController = ModalActivityIndicatorViewController(canCancel: false) { [weak self, dependencies, threadId, oldDisplayName] modalActivityIndicator in
MessageSender
.update(
legacyGroupSessionId: threadId,
with: updatedMemberIds,
name: oldDisplayName,
using: dependencies
)
.eraseToAnyPublisher()
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sinkUntilComplete(
receiveCompletion: { [weak self] result in
modalActivityIndicator.dismiss(completion: {
switch result {
case .finished: self?.selectedIdsSubject.send([])
case .failure:
self?.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "ALERT_ERROR_TITLE".localized(),
body: .text("GROUP_UPDATE_ERROR_TITLE".localized()),
cancelTitle: "BUTTON_OK".localized(),
cancelStyle: .alert_text
)
),
transitionType: .present
)
}
})
}
)
}
self.transitionToScreen(viewController, transitionType: .present)
default:
self.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "ALERT_ERROR_TITLE".localized(),
body: .text("GROUP_UPDATE_ERROR_TITLE".localized()),
cancelTitle: "BUTTON_OK".localized(),
cancelStyle: .alert_text
)
),
transitionType: .present
)
}
}
}