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.
873 lines
43 KiB
Swift
873 lines
43 KiB
Swift
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import Foundation
|
|
import Combine
|
|
import GRDB
|
|
import DifferenceKit
|
|
import SessionUIKit
|
|
import SessionSnodeKit
|
|
import SessionMessagingKit
|
|
import SessionUtilitiesKit
|
|
import SignalUtilitiesKit
|
|
|
|
class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, EditableStateHolder, ObservableTableSource {
|
|
private static let minVersionBannerInfo: InfoBanner.Info = InfoBanner.Info(
|
|
font: .systemFont(ofSize: Values.verySmallFontSize),
|
|
message: "groupInviteVersion".localizedFormatted(baseFont: .systemFont(ofSize: Values.verySmallFontSize)),
|
|
icon: .none,
|
|
tintColor: .black,
|
|
backgroundColor: .explicitPrimary(.orange),
|
|
accessibility: Accessibility(identifier: "Version warning banner")
|
|
)
|
|
|
|
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<(name: String, ids: Set<String>), Never> = CurrentValueSubject(("", []))
|
|
|
|
private let threadId: String
|
|
private let userSessionId: SessionId
|
|
private var inviteByIdValue: String?
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(threadId: String, using dependencies: Dependencies) {
|
|
self.dependencies = dependencies
|
|
self.threadId = threadId
|
|
self.userSessionId = dependencies[cache: .general].sessionId
|
|
}
|
|
|
|
// MARK: - Config
|
|
|
|
public enum Section: SessionTableSection {
|
|
case groupInfo
|
|
case invite
|
|
case members
|
|
|
|
public var title: String? {
|
|
switch self {
|
|
case .members: return "groupMembers".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 inviteById
|
|
|
|
case member(String)
|
|
}
|
|
|
|
// MARK: - Content
|
|
|
|
private struct State: Equatable {
|
|
let group: ClosedGroup
|
|
let profile: Profile?
|
|
let additionalProfile: Profile?
|
|
let members: [WithProfile<GroupMember>]
|
|
let isValid: Bool
|
|
|
|
static let invalidState: State = State(
|
|
group: ClosedGroup(threadId: "", name: "", formationTimestamp: 0, shouldPoll: false, invited: false),
|
|
profile: nil,
|
|
additionalProfile: nil,
|
|
members: [],
|
|
isValid: false
|
|
)
|
|
}
|
|
|
|
let title: String = "groupEdit".localized()
|
|
|
|
var bannerInfo: AnyPublisher<InfoBanner.Info?, Never> {
|
|
guard (try? SessionId.Prefix(from: threadId)) == .group else {
|
|
return Just(nil).eraseToAnyPublisher()
|
|
}
|
|
|
|
return Just(EditGroupViewModel.minVersionBannerInfo).eraseToAnyPublisher()
|
|
}
|
|
|
|
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,
|
|
profile: profileBack,
|
|
additionalProfile: profileFront,
|
|
members: try GroupMember
|
|
.filter(GroupMember.Columns.groupId == threadId)
|
|
.fetchAllWithProfiles(db, using: dependencies),
|
|
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(
|
|
"errorUnknown".localized(),
|
|
font: .subtitle,
|
|
alignment: .center
|
|
),
|
|
styling: SessionCell.StyleInfo(
|
|
tintColor: .textSecondary,
|
|
alignment: .centerHugging,
|
|
customPadding: SessionCell.Padding(top: Values.smallSpacing),
|
|
backgroundStyle: .noBackground
|
|
)
|
|
)
|
|
]
|
|
)
|
|
]
|
|
}
|
|
|
|
let userSessionId: SessionId = dependencies[cache: .general].sessionId
|
|
let isUpdatedGroup: Bool = (((try? SessionId.Prefix(from: threadId)) ?? .group) == .group)
|
|
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.profile,
|
|
profileIcon: .none,
|
|
additionalProfile: state.additionalProfile,
|
|
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,
|
|
title: SessionCell.TextInfo(
|
|
state.group.name,
|
|
font: .titleLarge,
|
|
alignment: .center,
|
|
editingPlaceholder: "groupNameEnter".localized()
|
|
),
|
|
styling: SessionCell.StyleInfo(
|
|
alignment: .centerHugging,
|
|
customPadding: SessionCell.Padding(
|
|
top: Values.smallSpacing,
|
|
bottom: Values.smallSpacing
|
|
),
|
|
backgroundStyle: .noBackground
|
|
),
|
|
accessibility: Accessibility(
|
|
identifier: "Group name text field",
|
|
label: state.group.name
|
|
)
|
|
),
|
|
((state.group.groupDescription ?? "").isEmpty ? nil :
|
|
SessionCell.Info(
|
|
id: .groupDescription,
|
|
title: SessionCell.TextInfo(
|
|
(state.group.groupDescription ?? ""),
|
|
font: .subtitle,
|
|
alignment: .center,
|
|
editingPlaceholder: "groupDescriptionEnter".localized()
|
|
),
|
|
styling: SessionCell.StyleInfo(
|
|
tintColor: .textSecondary,
|
|
alignment: .centerHugging,
|
|
customPadding: SessionCell.Padding(
|
|
top: 0,
|
|
bottom: Values.smallSpacing
|
|
),
|
|
backgroundStyle: .noBackground
|
|
),
|
|
accessibility: Accessibility(
|
|
identifier: "Group description text field",
|
|
label: (state.group.groupDescription ?? "")
|
|
)
|
|
)
|
|
)
|
|
].compactMap { $0 }
|
|
),
|
|
SectionModel(
|
|
model: .invite,
|
|
elements: [
|
|
SessionCell.Info(
|
|
id: .invite,
|
|
leadingAccessory: .icon(UIImage(named: "icon_invite")?.withRenderingMode(.alwaysTemplate)),
|
|
title: "membersInvite".localized(),
|
|
accessibility: Accessibility(
|
|
identifier: "Invite button",
|
|
label: "Invite button"
|
|
),
|
|
onTap: { [weak self] in self?.inviteContacts(currentGroupName: state.group.name) }
|
|
),
|
|
(!isUpdatedGroup || !dependencies[feature: .updatedGroupsAllowInviteById] ? nil :
|
|
SessionCell.Info(
|
|
id: .inviteById,
|
|
leadingAccessory: .icon(UIImage(named: "ic_plus_24")?.withRenderingMode(.alwaysTemplate)),
|
|
title: "accountIdOrOnsInvite".localized(),
|
|
accessibility: Accessibility(
|
|
identifier: "Invite by id",
|
|
label: "Invite by id"
|
|
),
|
|
onTap: { [weak self] in self?.inviteById(currentGroupName: state.group.name) }
|
|
)
|
|
)
|
|
].compactMap { $0 }
|
|
),
|
|
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: SessionCell.TextInfo(
|
|
{
|
|
guard memberInfo.profileId != userSessionId.hexString else { return "you".localized() }
|
|
|
|
return (
|
|
memberInfo.profile?.displayName() ??
|
|
Profile.truncated(id: memberInfo.profileId, truncating: .middle)
|
|
)
|
|
}(),
|
|
font: .title,
|
|
accessibility: Accessibility(
|
|
identifier: "Contact"
|
|
)
|
|
),
|
|
subtitle: (!isUpdatedGroup ? nil : SessionCell.TextInfo(
|
|
memberInfo.value.statusDescription,
|
|
font: .subtitle,
|
|
accessibility: Accessibility(
|
|
identifier: "Contact status"
|
|
)
|
|
)),
|
|
trailingAccessory: {
|
|
switch (memberInfo.value.role, memberInfo.value.roleStatus) {
|
|
case (.admin, _), (.moderator, _), (_, .pendingRemoval): return nil
|
|
case (.standard, .accepted), (.zombie, _):
|
|
return .radio(
|
|
isSelected: selectedIdsSubject.value.ids.contains(memberInfo.profileId)
|
|
)
|
|
|
|
case (.standard, _):
|
|
return .highlightingBackgroundLabelAndRadio(
|
|
title: "resend".localized(),
|
|
isSelected: selectedIdsSubject.value.ids.contains(memberInfo.profileId),
|
|
labelAccessibility: Accessibility(
|
|
identifier: "Resend invite button",
|
|
label: "Resend invite button"
|
|
),
|
|
radioAccessibility: Accessibility(
|
|
identifier: "Select contact",
|
|
label: "Select contact"
|
|
)
|
|
)
|
|
}
|
|
}(),
|
|
styling: SessionCell.StyleInfo(
|
|
subtitleTintColor: (isUpdatedGroup ? memberInfo.value.statusDescriptionColor : nil),
|
|
allowedSeparators: [],
|
|
customPadding: SessionCell.Padding(
|
|
top: Values.smallSpacing,
|
|
bottom: Values.smallSpacing
|
|
),
|
|
backgroundStyle: .noBackgroundEdgeToEdge
|
|
),
|
|
onTapView: { [weak self, selectedIdsSubject] targetView in
|
|
let didTapResend: Bool = (targetView is SessionHighlightingBackgroundLabel)
|
|
|
|
switch (memberInfo.value.role, memberInfo.value.roleStatus, didTapResend) {
|
|
case (_, .pendingRemoval, _): return
|
|
case (.moderator, _, _), (.admin, _, _):
|
|
self?.showToast(
|
|
text: "adminCannotBeRemoved".localized(),
|
|
backgroundColor: .backgroundSecondary
|
|
)
|
|
|
|
case (.standard, _, true):
|
|
self?.resendInvitations(
|
|
currentGroupName: state.group.name,
|
|
memberInfo: [(memberInfo.profileId, memberInfo.profile)]
|
|
)
|
|
|
|
case (.standard, _, false), (.zombie, _, _):
|
|
if !selectedIdsSubject.value.ids.contains(memberInfo.profileId) {
|
|
selectedIdsSubject.send((
|
|
state.group.name,
|
|
selectedIdsSubject.value.ids.inserting(memberInfo.profileId)
|
|
))
|
|
}
|
|
else {
|
|
selectedIdsSubject.send((
|
|
state.group.name,
|
|
selectedIdsSubject.value.ids.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 { currentGroupName, selectedIds in
|
|
SessionButton.Info(
|
|
style: .destructive,
|
|
title: "remove".localized(),
|
|
isEnabled: !selectedIds.isEmpty,
|
|
accessibility: Accessibility(
|
|
identifier: "Remove contact button"
|
|
),
|
|
onTap: { [weak self] in
|
|
self?.removeMembers(currentGroupName: currentGroupName, memberIds: selectedIds)
|
|
}
|
|
)
|
|
}
|
|
.eraseToAnyPublisher()
|
|
|
|
// MARK: - Functions
|
|
|
|
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: "membersInvite".localized(),
|
|
infoBanner: ((try? SessionId.Prefix(from: threadId)) != .group ? nil :
|
|
EditGroupViewModel.minVersionBannerInfo
|
|
),
|
|
emptyState: "contactNone".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 AND
|
|
\(contact[.isApproved]) = TRUE AND
|
|
\(contact[.didApproveMe]) = TRUE
|
|
)
|
|
"""),
|
|
footerTitle: "membersInviteTitle".localized(),
|
|
footerAccessibility: Accessibility(
|
|
identifier: "Confirm invite button"
|
|
),
|
|
onSubmit: { [weak self, threadId, dependencies] in
|
|
switch try? SessionId.Prefix(from: threadId) {
|
|
case .group:
|
|
return .callback { viewModel, selectedMemberInfo in
|
|
let updatedMemberIds: Set<String> = currentMemberIds
|
|
.inserting(contentsOf: selectedMemberInfo.map { $0.profileId }.asSet())
|
|
|
|
guard updatedMemberIds.count <= LibSession.sizeMaxGroupMemberCount else {
|
|
throw UserListError.error("groupAddMemberMaximum".localized())
|
|
}
|
|
|
|
// Adding members is an async process and after adding members we
|
|
// want to return to the edit group screen so the admin can see the
|
|
// invitation statuses
|
|
self?.addMembers(
|
|
currentGroupName: currentGroupName,
|
|
memberInfo: selectedMemberInfo.map { ($0.profileId, $0.profile) }
|
|
)
|
|
self?.dismissScreen()
|
|
}
|
|
|
|
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 <= LibSession.sizeMaxGroupMemberCount else {
|
|
return Fail(error: .error("groupAddMemberMaximum".localized()))
|
|
.eraseToAnyPublisher()
|
|
}
|
|
|
|
return MessageSender.update(
|
|
legacyGroupSessionId: threadId,
|
|
with: updatedMemberIds,
|
|
name: currentGroupName,
|
|
using: dependencies
|
|
)
|
|
.mapError { _ in
|
|
UserListError.error("deleteAfterLegacyGroupsGroupUpdateErrorTitle".localized())
|
|
}
|
|
.eraseToAnyPublisher()
|
|
}
|
|
|
|
default: return .none
|
|
}
|
|
}(),
|
|
using: dependencies
|
|
)
|
|
),
|
|
transitionType: .push
|
|
)
|
|
}
|
|
|
|
private func inviteById(currentGroupName: String) {
|
|
// Convenience functions to avoid duplicate code
|
|
func showError(_ errorString: String) {
|
|
let modal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "theError".localized(),
|
|
body: .text(errorString),
|
|
cancelTitle: "okay".localized(),
|
|
cancelStyle: .alert_text,
|
|
dismissType: .single
|
|
)
|
|
)
|
|
self.transitionToScreen(modal, transitionType: .present)
|
|
}
|
|
|
|
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()
|
|
|
|
// Make sure inviting another member wouldn't hit the member limit
|
|
guard (currentMemberIds.count + 1) <= LibSession.sizeMaxGroupMemberCount else {
|
|
return showError("groupAddMemberMaximum".localized())
|
|
}
|
|
|
|
self.transitionToScreen(
|
|
ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "accountIdOrOnsInvite".localized(),
|
|
body: .input(
|
|
explanation: nil,
|
|
info: ConfirmationModal.Info.Body.InputInfo(
|
|
placeholder: "accountIdOrOnsEnter".localized()
|
|
),
|
|
onChange: { [weak self] updatedString in self?.inviteByIdValue = updatedString }
|
|
),
|
|
confirmTitle: "membersInviteTitle".localized(),
|
|
confirmStyle: .danger,
|
|
cancelStyle: .alert_text,
|
|
dismissOnConfirm: false,
|
|
onConfirm: { [weak self, dependencies] modal in
|
|
switch (self?.inviteByIdValue, try? SessionId(from: self?.inviteByIdValue)) {
|
|
case (_, .some(let sessionId)) where sessionId.prefix == .standard:
|
|
guard !currentMemberIds.contains(sessionId.hexString) else {
|
|
// FIXME: Localise this
|
|
return showError("This Account ID or ONS belongs to an existing member")
|
|
}
|
|
|
|
modal.dismiss(animated: true) {
|
|
self?.addMembers(
|
|
currentGroupName: currentGroupName,
|
|
memberInfo: [(sessionId.hexString, nil)]
|
|
)
|
|
}
|
|
|
|
case (.none, _), (_, .some): return showError("accountIdErrorInvalid".localized())
|
|
|
|
case (.some(let inviteByIdValue), _):
|
|
// This could be an ONS name
|
|
let viewController = ModalActivityIndicatorViewController() { modalActivityIndicator in
|
|
SnodeAPI
|
|
.getSessionID(for: inviteByIdValue, using: dependencies)
|
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies)
|
|
.receive(on: DispatchQueue.main, using: dependencies)
|
|
.sinkUntilComplete(
|
|
receiveCompletion: { result in
|
|
switch result {
|
|
case .finished: break
|
|
case .failure(let error):
|
|
modalActivityIndicator.dismiss {
|
|
switch error {
|
|
case SnodeAPIError.onsNotFound:
|
|
return showError("onsErrorNotRecognized".localized())
|
|
default:
|
|
return showError("onsErrorUnableToSearch".localized())
|
|
}
|
|
}
|
|
}
|
|
},
|
|
receiveValue: { sessionIdHexString in
|
|
guard !currentMemberIds.contains(sessionIdHexString) else {
|
|
// FIXME: Localise this
|
|
return showError("This Account ID or ONS belongs to an existing member")
|
|
}
|
|
|
|
modalActivityIndicator.dismiss {
|
|
modal.dismiss(animated: true) {
|
|
self?.addMembers(
|
|
currentGroupName: currentGroupName,
|
|
memberInfo: [(sessionIdHexString, nil)]
|
|
)
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
self?.transitionToScreen(viewController, transitionType: .present)
|
|
}
|
|
},
|
|
afterClosed: { [weak self] in self?.inviteByIdValue = nil }
|
|
)
|
|
),
|
|
transitionType: .present
|
|
)
|
|
}
|
|
|
|
private func addMembers(
|
|
currentGroupName: String,
|
|
memberInfo: [(id: String, profile: Profile?)]
|
|
) {
|
|
/// Show a toast immediately that we are sending invitations
|
|
showToast(
|
|
text: "groupInviteSending"
|
|
.putNumber(memberInfo.count)
|
|
.localized(),
|
|
backgroundColor: .backgroundSecondary
|
|
)
|
|
|
|
/// Actually trigger the sending process
|
|
MessageSender
|
|
.addGroupMembers(
|
|
groupSessionId: threadId,
|
|
members: memberInfo,
|
|
allowAccessToHistoricMessages: dependencies[feature: .updatedGroupsAllowHistoricAccessOnInvite],
|
|
using: dependencies
|
|
)
|
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies)
|
|
.receive(on: DispatchQueue.main, using: dependencies)
|
|
.sinkUntilComplete(
|
|
receiveCompletion: { [weak self, 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 invitations failed to send
|
|
self?.showToast(
|
|
text: GroupInviteMemberJob.failureMessage(
|
|
groupName: currentGroupName,
|
|
memberIds: memberIds,
|
|
profileInfo: memberInfo.reduce(into: [:]) { result, next in
|
|
result[next.id] = next.profile
|
|
}
|
|
),
|
|
backgroundColor: .backgroundSecondary
|
|
)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private func resendInvitations(
|
|
currentGroupName: String,
|
|
memberInfo: [(id: String, profile: Profile?)]
|
|
) {
|
|
/// Show a toast immediately that we are sending invitations
|
|
showToast(
|
|
text: "groupInviteSending"
|
|
.putNumber(memberInfo.count)
|
|
.localized(),
|
|
backgroundColor: .backgroundSecondary
|
|
)
|
|
|
|
/// Actually trigger the sending process
|
|
let memberIds: [String] = memberInfo.map { $0.id }
|
|
|
|
MessageSender
|
|
.resendInvitations(
|
|
groupSessionId: threadId,
|
|
memberIds: memberIds,
|
|
using: dependencies
|
|
)
|
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies)
|
|
.receive(on: DispatchQueue.main, using: dependencies)
|
|
.sinkUntilComplete(
|
|
receiveCompletion: { [weak self, threadId, dependencies] result in
|
|
switch result {
|
|
case .finished: break
|
|
case .failure:
|
|
/// 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 invitations failed to send
|
|
self?.showToast(
|
|
text: GroupInviteMemberJob.failureMessage(
|
|
groupName: currentGroupName,
|
|
memberIds: memberIds,
|
|
profileInfo: memberInfo.reduce(into: [:]) { result, next in
|
|
result[next.id] = next.profile
|
|
}
|
|
),
|
|
backgroundColor: .backgroundSecondary
|
|
)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private func removeMembers(currentGroupName: String, memberIds: Set<String>) {
|
|
guard !memberIds.isEmpty else { return }
|
|
|
|
let memberNames: [String] = memberIds
|
|
.compactMap { memberId in
|
|
guard
|
|
let section: SectionModel = self.tableData
|
|
.first(where: { section in section.model == .members }),
|
|
let info: SessionCell.Info<TableItem> = section.elements
|
|
.first(where: { info in
|
|
switch info.id {
|
|
case .member(let infoMemberId): return infoMemberId == memberId
|
|
default: return false
|
|
}
|
|
})
|
|
else {
|
|
return Profile.truncated(id: memberId, truncating: .middle)
|
|
}
|
|
|
|
return info.title?.text
|
|
}
|
|
let confirmationBody: NSAttributedString = {
|
|
switch memberNames.count {
|
|
case 1:
|
|
return "groupRemoveDescription"
|
|
.put(key: "name", value: memberNames[0])
|
|
.put(key: "group_name", value: currentGroupName)
|
|
.localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize))
|
|
|
|
case 2:
|
|
return "groupRemoveDescriptionTwo"
|
|
.put(key: "name", value: memberNames[0])
|
|
.put(key: "other_name", value: memberNames[1])
|
|
.put(key: "group_name", value: currentGroupName)
|
|
.localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize))
|
|
|
|
default:
|
|
return "groupRemoveDescriptionMultiple"
|
|
.put(key: "name", value: memberNames[0])
|
|
.put(key: "count", value: memberNames.count - 1)
|
|
.put(key: "group_name", value: currentGroupName)
|
|
.localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize))
|
|
}
|
|
}()
|
|
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "remove".localized(),
|
|
body: .attributedText(confirmationBody),
|
|
confirmTitle: "remove".localized(),
|
|
confirmStyle: .danger,
|
|
cancelStyle: .alert_text,
|
|
dismissOnConfirm: false,
|
|
onConfirm: { [weak self, threadId, dependencies] modal in
|
|
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((currentGroupName, []))
|
|
modal.dismiss(animated: true)
|
|
|
|
case .standard: // Assume it's a legacy group
|
|
let updatedMemberIds: Set<String> = (self?.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
|
|
MessageSender
|
|
.update(
|
|
legacyGroupSessionId: threadId,
|
|
with: updatedMemberIds,
|
|
name: currentGroupName,
|
|
using: dependencies
|
|
)
|
|
.eraseToAnyPublisher()
|
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies)
|
|
.receive(on: DispatchQueue.main, using: dependencies)
|
|
.sinkUntilComplete(
|
|
receiveCompletion: { [weak self] result in
|
|
modalActivityIndicator.dismiss(completion: {
|
|
switch result {
|
|
case .finished:
|
|
self?.selectedIdsSubject.send((currentGroupName, []))
|
|
modalActivityIndicator.dismiss {
|
|
modal.dismiss(animated: true)
|
|
}
|
|
|
|
case .failure:
|
|
self?.transitionToScreen(
|
|
ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "theError".localized(),
|
|
body: .text("deleteAfterLegacyGroupsGroupUpdateErrorTitle".localized()),
|
|
cancelTitle: "okay".localized(),
|
|
cancelStyle: .alert_text
|
|
)
|
|
),
|
|
transitionType: .present
|
|
)
|
|
}
|
|
})
|
|
}
|
|
)
|
|
}
|
|
self?.transitionToScreen(viewController, transitionType: .present)
|
|
|
|
default:
|
|
self?.transitionToScreen(
|
|
ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "theError".localized(),
|
|
body: .text("deleteAfterLegacyGroupsGroupUpdateErrorTitle".localized()),
|
|
cancelTitle: "okay".localized(),
|
|
cancelStyle: .alert_text
|
|
)
|
|
),
|
|
transitionType: .present
|
|
)
|
|
}
|
|
}
|
|
)
|
|
)
|
|
self.transitionToScreen(confirmationModal, transitionType: .present)
|
|
}
|
|
}
|