mirror of https://github.com/oxen-io/session-ios
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.
880 lines
43 KiB
Swift
880 lines
43 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, 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
|
|
self?.updatedDisplayPictureSelected(update: .uploadImageData(resultImageData))
|
|
}
|
|
)
|
|
fileprivate var newDisplayName: String?
|
|
fileprivate var newGroupDescription: 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)
|
|
}
|
|
|
|
// MARK: - Config
|
|
|
|
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: - 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
|
|
)
|
|
}
|
|
.compactMap { [weak self] state -> [SectionModel]? in self?.content(state) }
|
|
|
|
private func content(_ state: State) -> [SectionModel] {
|
|
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 editIcon: UIImage? = UIImage(systemName: "pencil")
|
|
let sortedMembers: [WithProfile<GroupMember>] = {
|
|
guard !isUpdatedGroup else { return state.members }
|
|
|
|
// FIXME: Remove this once legacy groups are deprecated
|
|
/// In legacy groups there would be both `standard` and `admin` `GroupMember` entries for admins so
|
|
/// pre-process the members in order to remove the duplicates
|
|
return Array(state.members
|
|
.sorted(by: { lhs, rhs in lhs.value.role.rawValue < rhs.value.role.rawValue })
|
|
.reduce(into: [:]) { result, next in result[next.profileId] = next }
|
|
.values)
|
|
}()
|
|
.sorted()
|
|
|
|
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: {
|
|
guard isUpdatedGroup && dependencies[feature: .updatedGroupsAllowDisplayPicture] else {
|
|
return .none
|
|
}
|
|
|
|
// If we already have a display picture then the main profile gets the icon
|
|
return (state.group.displayPictureFilename != nil ? .rightPlus : .none)
|
|
}(),
|
|
additionalProfile: state.profileBack,
|
|
additionalProfileIcon: {
|
|
guard isUpdatedGroup && dependencies[feature: .updatedGroupsAllowDisplayPicture] else {
|
|
return .none
|
|
}
|
|
|
|
// No display picture means the dual-profile so the additionalProfile gets the icon
|
|
return .rightPlus
|
|
}(),
|
|
accessibility: nil
|
|
),
|
|
styling: SessionCell.StyleInfo(
|
|
alignment: .centerHugging,
|
|
customPadding: SessionCell.Padding(bottom: Values.smallSpacing),
|
|
backgroundStyle: .noBackground
|
|
),
|
|
accessibility: Accessibility(
|
|
label: "Profile picture"
|
|
),
|
|
onTap: { [weak self, dependencies] in
|
|
guard isUpdatedGroup && dependencies[feature: .updatedGroupsAllowDisplayPicture] else {
|
|
return
|
|
}
|
|
|
|
self?.updateDisplayPicture(currentFileName: state.group.displayPictureFilename)
|
|
}
|
|
),
|
|
SessionCell.Info(
|
|
id: .groupName,
|
|
leadingAccessory: .icon(
|
|
editIcon?.withRenderingMode(.alwaysTemplate),
|
|
size: .medium,
|
|
customTint: .textSecondary
|
|
),
|
|
title: SessionCell.TextInfo(
|
|
state.group.name,
|
|
font: .titleLarge,
|
|
alignment: .center,
|
|
editingPlaceholder: "EDIT_GROUP_NAME_PLACEHOLDER".localized()
|
|
),
|
|
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: { [weak self] in
|
|
self?.updateGroupNameAndDescription(
|
|
isUpdatedGroup: isUpdatedGroup,
|
|
currentName: state.group.name,
|
|
currentDescription: state.group.groupDescription
|
|
)
|
|
}
|
|
),
|
|
((state.group.groupDescription ?? "").isEmpty ? nil :
|
|
SessionCell.Info(
|
|
id: .groupDescription,
|
|
title: SessionCell.TextInfo(
|
|
(state.group.groupDescription ?? ""),
|
|
font: .subtitle,
|
|
alignment: .center,
|
|
editingPlaceholder: "EDIT_GROUP_DESCRIPTION_PLACEHOLDER".localized()
|
|
),
|
|
styling: SessionCell.StyleInfo(
|
|
tintColor: .textSecondary,
|
|
alignment: .centerHugging,
|
|
customPadding: SessionCell.Padding(
|
|
top: 0,
|
|
bottom: Values.smallSpacing
|
|
),
|
|
backgroundStyle: .noBackground
|
|
),
|
|
accessibility: Accessibility(
|
|
identifier: "Group description",
|
|
label: (state.group.groupDescription ?? "")
|
|
),
|
|
onTap: { [weak self] in
|
|
self?.updateGroupNameAndDescription(
|
|
isUpdatedGroup: isUpdatedGroup,
|
|
currentName: state.group.name,
|
|
currentDescription: state.group.groupDescription
|
|
)
|
|
}
|
|
)
|
|
)
|
|
].compactMap { $0 }
|
|
),
|
|
SectionModel(
|
|
model: .invite,
|
|
elements: [
|
|
SessionCell.Info(
|
|
id: .invite,
|
|
leadingAccessory: .icon(UIImage(named: "icon_invite")?.withRenderingMode(.alwaysTemplate)),
|
|
title: "GROUP_ACTION_INVITE_CONTACTS".localized(),
|
|
accessibility: Accessibility(
|
|
identifier: "Invite Contacts",
|
|
label: "Invite Contacts"
|
|
),
|
|
onTap: { [weak self] in self?.inviteContacts(currentGroupName: state.group.name) }
|
|
)
|
|
]
|
|
),
|
|
SectionModel(
|
|
model: .members,
|
|
elements: sortedMembers
|
|
.map { memberInfo -> SessionCell.Info in
|
|
SessionCell.Info(
|
|
id: .member(memberInfo.profileId),
|
|
leadingAccessory: .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),
|
|
trailingAccessory: {
|
|
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: { [weak self, selectedIdsSubject] in
|
|
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 updateDisplayPicture(currentFileName: String?) {
|
|
guard dependencies[feature: .updatedGroupsAllowDisplayPicture] else { return }
|
|
|
|
let existingImageData: Data? = dependencies[singleton: .storage].read(using: dependencies) { [threadId] db in
|
|
DisplayPictureManager.displayPicture(db, id: .group(threadId))
|
|
}
|
|
let editDisplayPictureModalInfo: ConfirmationModal.Info = ConfirmationModal.Info(
|
|
title: "EDIT_GROUP_DISPLAY_PICTURE".localized(),
|
|
body: .image(
|
|
placeholderData: UIImage(named: "profile_placeholder")?.pngData(),
|
|
valueData: existingImageData,
|
|
icon: .rightPlus,
|
|
style: .circular,
|
|
accessibility: Accessibility(
|
|
identifier: "Image picker",
|
|
label: "Image picker"
|
|
),
|
|
onClick: { [weak self] in self?.showPhotoLibraryForAvatar() }
|
|
),
|
|
confirmTitle: "update_profile_modal_save".localized(),
|
|
confirmEnabled: false,
|
|
cancelTitle: "update_profile_modal_remove".localized(),
|
|
cancelEnabled: (existingImageData != nil),
|
|
hasCloseButton: true,
|
|
dismissOnConfirm: false,
|
|
onConfirm: { modal in modal.close() },
|
|
onCancel: { [weak self] modal in
|
|
self?.updateDisplayPicture(
|
|
displayPictureUpdate: .remove,
|
|
onComplete: { [weak modal] in modal?.close() }
|
|
)
|
|
},
|
|
afterClosed: { [weak self] in
|
|
self?.editDisplayPictureModal = nil
|
|
self?.editDisplayPictureModalInfo = nil
|
|
}
|
|
)
|
|
let modal: ConfirmationModal = ConfirmationModal(info: editDisplayPictureModalInfo)
|
|
|
|
self.editDisplayPictureModalInfo = editDisplayPictureModalInfo
|
|
self.editDisplayPictureModal = modal
|
|
self.transitionToScreen(modal, transitionType: .present)
|
|
}
|
|
|
|
private func updatedDisplayPictureSelected(update: DisplayPictureManager.Update) {
|
|
guard let info: ConfirmationModal.Info = self.editDisplayPictureModalInfo else { return }
|
|
|
|
self.editDisplayPictureModal?.updateContent(
|
|
with: info.with(
|
|
body: .image(
|
|
placeholderData: UIImage(named: "profile_placeholder")?.pngData(),
|
|
valueData: {
|
|
switch update {
|
|
case .uploadImageData(let imageData): return imageData
|
|
default: return nil
|
|
}
|
|
}(),
|
|
icon: .rightPlus,
|
|
style: .circular,
|
|
accessibility: Accessibility(
|
|
identifier: "Image picker",
|
|
label: "Image picker"
|
|
),
|
|
onClick: { [weak self] in self?.showPhotoLibraryForAvatar() }
|
|
),
|
|
confirmEnabled: true,
|
|
onConfirm: { [weak self] modal in
|
|
self?.updateDisplayPicture(
|
|
displayPictureUpdate: update,
|
|
onComplete: { [weak modal] in modal?.close() }
|
|
)
|
|
}
|
|
)
|
|
)
|
|
}
|
|
|
|
private func showPhotoLibraryForAvatar() {
|
|
Permissions.requestLibraryPermissionIfNeeded { [weak self] in
|
|
DispatchQueue.main.async {
|
|
let picker: UIImagePickerController = UIImagePickerController()
|
|
picker.sourceType = .photoLibrary
|
|
picker.mediaTypes = [ "public.image" ] // stringlint:disable
|
|
picker.delegate = self?.imagePickerHandler
|
|
|
|
self?.transitionToScreen(picker, transitionType: .present)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func updateDisplayPicture(
|
|
displayPictureUpdate: DisplayPictureManager.Update,
|
|
onComplete: (() -> ())? = nil
|
|
) {
|
|
switch displayPictureUpdate {
|
|
case .none: onComplete?()
|
|
default: break
|
|
}
|
|
|
|
func performChanges(_ viewController: ModalActivityIndicatorViewController, _ displayPictureUpdate: DisplayPictureManager.Update) {
|
|
let existingFileName: String? = dependencies[singleton: .storage].read(using: dependencies) { [threadId] db in
|
|
try? ClosedGroup
|
|
.filter(id: threadId)
|
|
.select(.displayPictureFilename)
|
|
.asRequest(of: String.self)
|
|
.fetchOne(db)
|
|
}
|
|
|
|
MessageSender
|
|
.updateGroup(
|
|
groupSessionId: threadId,
|
|
displayPictureUpdate: displayPictureUpdate,
|
|
using: dependencies
|
|
)
|
|
.sinkUntilComplete(
|
|
receiveCompletion: { [dependencies] result in
|
|
// Remove any cached avatar image value
|
|
if let existingFileName: String = existingFileName {
|
|
dependencies.mutate(cache: .displayPicture) { $0.imageData[existingFileName] = nil }
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
viewController.dismiss(completion: {
|
|
onComplete?()
|
|
})
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
let viewController = ModalActivityIndicatorViewController(canCancel: false) { [weak self, dependencies] viewController in
|
|
switch displayPictureUpdate {
|
|
case .none: break // Shouldn't get called
|
|
case .remove, .updateTo: performChanges(viewController, displayPictureUpdate)
|
|
case .uploadImageData(let data):
|
|
DisplayPictureManager.prepareAndUploadDisplayPicture(
|
|
queue: DispatchQueue.global(qos: .background),
|
|
imageData: data,
|
|
success: { url, fileName, key in
|
|
performChanges(viewController, .updateTo(url: url, key: key, fileName: fileName))
|
|
},
|
|
failure: { error in
|
|
DispatchQueue.main.async {
|
|
viewController.dismiss {
|
|
let title: String = {
|
|
switch (displayPictureUpdate, error) {
|
|
case (_, .uploadMaxFileSizeExceeded):
|
|
return "update_profile_modal_max_size_error_title".localized()
|
|
|
|
default: return "ALERT_ERROR_TITLE".localized()
|
|
}
|
|
}()
|
|
let message: String? = {
|
|
switch (displayPictureUpdate, error) {
|
|
case (.remove, _): return "EDIT_DISPLAY_PICTURE_ERROR_REMOVE".localized()
|
|
case (_, .uploadMaxFileSizeExceeded):
|
|
return "update_profile_modal_max_size_error_message".localized()
|
|
|
|
default: return "EDIT_DISPLAY_PICTURE_ERROR".localized()
|
|
}
|
|
}()
|
|
|
|
self?.transitionToScreen(
|
|
ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: title,
|
|
body: (message.map { .text($0) } ?? .none),
|
|
cancelTitle: "BUTTON_OK".localized(),
|
|
cancelStyle: .alert_text,
|
|
dismissType: .single
|
|
)
|
|
),
|
|
transitionType: .present
|
|
)
|
|
}
|
|
}
|
|
},
|
|
using: dependencies
|
|
)
|
|
}
|
|
}
|
|
self.transitionToScreen(viewController, transitionType: .present)
|
|
}
|
|
|
|
private func updateGroupNameAndDescription(
|
|
isUpdatedGroup: Bool,
|
|
currentName: String,
|
|
currentDescription: String?
|
|
) {
|
|
self.transitionToScreen(
|
|
ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: (isUpdatedGroup && dependencies[feature: .updatedGroupsAllowDescriptionEditing] ?
|
|
"EDIT_GROUP_INFO_TITLE".localized() :
|
|
"EDIT_LEGACY_GROUP_INFO_TITLE".localized()
|
|
),
|
|
body: { [weak self, dependencies] in
|
|
guard isUpdatedGroup && dependencies[feature: .updatedGroupsAllowDescriptionEditing] else {
|
|
return .input(
|
|
explanation: NSAttributedString(string: "EDIT_LEGACY_GROUP_INFO_MESSAGE".localized()),
|
|
info: ConfirmationModal.Info.Body.InputInfo(
|
|
placeholder: "EDIT_GROUP_NAME_PLACEHOLDER".localized(),
|
|
initialValue: currentName
|
|
),
|
|
onChange: { updatedName in
|
|
self?.newDisplayName = updatedName
|
|
}
|
|
)
|
|
}
|
|
|
|
return .dualInput(
|
|
explanation: NSAttributedString(string: "EDIT_GROUP_INFO_MESSAGE".localized()),
|
|
firstInfo: ConfirmationModal.Info.Body.InputInfo(
|
|
placeholder: "EDIT_GROUP_NAME_PLACEHOLDER".localized(),
|
|
initialValue: currentName
|
|
),
|
|
secondInfo: ConfirmationModal.Info.Body.InputInfo(
|
|
placeholder: "EDIT_GROUP_DESCRIPTION_PLACEHOLDER".localized(),
|
|
initialValue: currentDescription
|
|
),
|
|
onChange: { updatedName, updatedDescription in
|
|
self?.newDisplayName = updatedName
|
|
self?.newGroupDescription = updatedDescription
|
|
}
|
|
)
|
|
}(),
|
|
confirmTitle: "update_profile_modal_save".localized(),
|
|
cancelStyle: .danger,
|
|
dismissOnConfirm: false,
|
|
onConfirm: { [weak self, dependencies, threadId] modal in
|
|
let finalName: String = (self?.newDisplayName ?? "")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let finalDescription: String? = self?.newGroupDescription
|
|
.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,
|
|
dismissType: .single
|
|
)
|
|
),
|
|
transitionType: .present
|
|
)
|
|
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: modal.dismiss(animated: true)
|
|
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
|
|
)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
)
|
|
),
|
|
transitionType: .present
|
|
)
|
|
}
|
|
|
|
private func inviteContacts(
|
|
currentGroupName: String
|
|
) {
|
|
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: dependencies[feature: .updatedGroupsAllowHistoricAccessOnInvite],
|
|
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] _, 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: currentGroupName,
|
|
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: dependencies[feature: .updatedGroupsRemoveMessagesOnKick],
|
|
sendMemberChangedMessage: true,
|
|
using: dependencies
|
|
)
|
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies)
|
|
.sinkUntilComplete()
|
|
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] modalActivityIndicator in
|
|
let currentGroupName: String = dependencies[singleton: .storage]
|
|
.read { db in
|
|
try ClosedGroup
|
|
.filter(id: threadId)
|
|
.select(.name)
|
|
.asRequest(of: String.self)
|
|
.fetchOne(db)
|
|
}
|
|
.defaulting(to: "GROUP_TITLE_FALLBACK".localized())
|
|
|
|
MessageSender
|
|
.update(
|
|
legacyGroupSessionId: threadId,
|
|
with: updatedMemberIds,
|
|
name: currentGroupName,
|
|
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
|
|
)
|
|
}
|
|
}
|
|
}
|