Path: blob/main/Signal/src/ViewControllers/ThreadSettings/MemberActionSheet.swift
1 views
//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import ContactsUI
import SignalServiceKit
import SignalUI
struct ProfileSheetSheetCoordinator {
private let address: SignalServiceAddress
private let groupViewHelper: GroupViewHelper?
private let spoilerState: SpoilerRenderState
private let memberLabel: MemberLabelForRendering?
init(
address: SignalServiceAddress,
groupViewHelper: GroupViewHelper?,
spoilerState: SpoilerRenderState,
memberLabel: MemberLabelForRendering? = nil,
) {
self.address = address
self.groupViewHelper = groupViewHelper
self.spoilerState = spoilerState
self.memberLabel = memberLabel
}
/// Present a ``MemberActionSheet`` for other users, and present a
/// ``ContactAboutSheet`` for the local user.
func presentAppropriateSheet(from viewController: UIViewController) {
let threadViewModel = MemberActionSheet.fetchThreadViewModel(address: address)
let thread = threadViewModel.threadRecord
if thread.isNoteToSelf, let contactThread = thread as? TSContactThread {
ContactAboutSheet(thread: contactThread, spoilerState: spoilerState, memberLabel: memberLabel, groupViewHelper: groupViewHelper)
.present(from: viewController)
return
}
let sheet = MemberActionSheet(
threadViewModel: threadViewModel,
address: address,
groupViewHelper: groupViewHelper,
spoilerState: spoilerState,
memberLabel: memberLabel,
)
if viewController.overrideUserInterfaceStyle == .dark {
sheet.overrideUserInterfaceStyle = .dark
sheet.tableViewController.forceDarkMode = true
}
sheet.present(from: viewController)
}
}
class MemberActionSheet: OWSTableSheetViewController {
override var sheetBackgroundColor: UIColor {
if #available(iOS 26, *) {
.clear
} else {
super.sheetBackgroundColor
}
}
private var groupViewHelper: GroupViewHelper?
var avatarView: ConversationAvatarView?
var thread: TSThread { threadViewModel.threadRecord }
var threadViewModel: ThreadViewModel
let address: SignalServiceAddress
let spoilerState: SpoilerRenderState
let memberLabel: MemberLabelForRendering?
fileprivate init(
threadViewModel: ThreadViewModel,
address: SignalServiceAddress,
groupViewHelper: GroupViewHelper?,
spoilerState: SpoilerRenderState,
memberLabel: MemberLabelForRendering?,
) {
self.threadViewModel = threadViewModel
self.groupViewHelper = groupViewHelper
self.address = address
self.spoilerState = spoilerState
self.memberLabel = memberLabel
if #available(iOS 26.0, *) {
super.init(visualEffect: UIGlassEffect())
self.topCornerRadius = 40
self.tableViewController.backgroundStyle = .clear
} else {
super.init()
}
tableViewController.defaultSeparatorInsetLeading =
OWSTableViewController2.cellHInnerMargin + 24 + OWSTableItem.iconSpacing
NotificationCenter.default.addObserver(
self,
selector: #selector(recipientUpdated(notification:)),
name: .OWSContactsManagerSignalAccountsDidChange,
object: nil,
)
}
fileprivate static func fetchThreadViewModel(address: SignalServiceAddress) -> ThreadViewModel {
// Avoid opening a write transaction if we can
guard
let threadViewModel: ThreadViewModel = SSKEnvironment.shared.databaseStorageRef.read(block: { transaction in
guard
let thread = TSContactThread.getWithContactAddress(
address,
transaction: transaction,
) else { return nil }
return ThreadViewModel(
thread: thread,
forChatList: false,
transaction: transaction,
)
})
else {
return SSKEnvironment.shared.databaseStorageRef.write { transaction in
let thread = TSContactThread.getOrCreateThread(
withContactAddress: address,
transaction: transaction,
)
return ThreadViewModel(
thread: thread,
forChatList: false,
transaction: transaction,
)
}
}
return threadViewModel
}
private weak var fromViewController: UIViewController?
fileprivate func present(from viewController: UIViewController) {
fromViewController = viewController
viewController.present(self, animated: true)
}
override func tableContents() -> OWSTableContents {
let contents = OWSTableContents()
let topSpacerSection = OWSTableSection()
topSpacerSection.customHeaderHeight = 12
contents.add(topSpacerSection)
let section = OWSTableSection()
contents.add(section)
section.customHeaderView = ConversationHeaderBuilder.buildHeader(
for: thread,
sizeClass: .eighty,
options: [.message, .videoCall, .audioCall, .noBackground],
memberLabel: memberLabel,
delegate: self,
)
// If the local user, show no options.
guard !address.isLocalAddress else {
return contents
}
// Nickname
section.add(.item(
icon: .buttonEdit,
tintColor: .Signal.label,
name: OWSLocalizedString(
"NICKNAME_BUTTON_TITLE",
comment: "Title for the table cell in conversation settings for presenting the profile nickname editor.",
),
textColor: .Signal.label,
actionBlock: { [weak self] in
guard let self else { return }
let db = DependenciesBridge.shared.db
let nicknameEditor = db.read { tx in
NicknameEditorViewController.create(
for: self.address,
context: .init(
db: db,
nicknameManager: DependenciesBridge.shared.nicknameManager,
),
tx: tx,
)
}
guard let nicknameEditor else { return }
let navigationController = OWSNavigationController(rootViewController: nicknameEditor)
self.presentFormSheet(navigationController, animated: true)
},
))
let isBlocked = threadViewModel.isBlocked
if isBlocked {
section.add(.item(
icon: .chatSettingsBlock,
tintColor: .Signal.label,
name: OWSLocalizedString(
"BLOCK_LIST_UNBLOCK_BUTTON",
comment: "Button label for the 'unblock' button",
),
textColor: .Signal.label,
actionBlock: { [weak self] in
self?.didTapUnblockThread {}
},
))
} else {
section.add(.item(
icon: .chatSettingsBlock,
tintColor: .Signal.label,
name: OWSLocalizedString(
"BLOCK_LIST_BLOCK_BUTTON",
comment: "Button label for the 'block' button",
),
textColor: .Signal.label,
actionBlock: { [weak self] in
guard let self, let fromViewController = self.fromViewController else { return }
self.dismiss(animated: true) {
BlockListUIUtils.showBlockAddressActionSheet(
self.address,
from: fromViewController,
completion: nil,
)
}
},
))
}
if let groupViewHelper = self.groupViewHelper, groupViewHelper.isFullOrInvitedMember(address) {
if groupViewHelper.canRemoveFromGroup(address: address) {
section.add(.item(
icon: .groupMemberRemoveFromGroup,
tintColor: .Signal.label,
name: OWSLocalizedString(
"CONVERSATION_SETTINGS_REMOVE_FROM_GROUP_BUTTON",
comment: "Label for 'remove from group' button in conversation settings view.",
),
textColor: .Signal.label,
actionBlock: { [weak self] in
guard let self else { return }
self.dismiss(animated: true) {
self.groupViewHelper?.presentRemoveFromGroupActionSheet(address: self.address)
}
},
))
}
if groupViewHelper.memberActionSheetCanMakeGroupAdmin(address: address), !isBlocked {
section.add(.item(
icon: .groupMemberMakeGroupAdmin,
tintColor: .Signal.label,
name: OWSLocalizedString(
"CONVERSATION_SETTINGS_MAKE_GROUP_ADMIN_BUTTON",
comment: "Label for 'make group admin' button in conversation settings view.",
),
textColor: .Signal.label,
actionBlock: { [weak self] in
guard let self else { return }
self.dismiss(animated: true) {
self.groupViewHelper?.memberActionSheetMakeGroupAdminWasSelected(address: self.address)
}
},
))
}
if groupViewHelper.memberActionSheetCanRevokeGroupAdmin(address: address) {
section.add(.item(
icon: .groupMemberRevokeGroupAdmin,
tintColor: .Signal.label,
name: OWSLocalizedString(
"CONVERSATION_SETTINGS_REVOKE_GROUP_ADMIN_BUTTON",
comment: "Label for 'revoke group admin' button in conversation settings view.",
),
textColor: .Signal.label,
actionBlock: { [weak self] in
guard let self else { return }
self.dismiss(animated: true) {
self.groupViewHelper?.memberActionSheetRevokeGroupAdminWasSelected(
address: self.address,
hasMemberLabel: self.memberLabel != nil,
)
}
},
))
}
}
if !isBlocked {
section.add(.item(
icon: .groupMemberAddToGroup,
tintColor: .Signal.label,
name: OWSLocalizedString(
"ADD_TO_GROUP",
comment: "Label for button or row which allows users to add to another group.",
),
textColor: .Signal.label,
actionBlock: { [weak self] in
guard let self, let fromViewController = self.fromViewController else { return }
self.dismiss(animated: true) {
AddToGroupViewController.presentForUser(self.address, from: fromViewController)
}
},
))
}
let isSystemContact = SSKEnvironment.shared.databaseStorageRef.read { tx in
return SSKEnvironment.shared.contactManagerRef.fetchSignalAccount(for: address, transaction: tx) != nil
}
if !isBlocked, isSystemContact {
section.add(.item(
icon: .contactInfoUserInContacts,
tintColor: .Signal.label,
name: OWSLocalizedString(
"CONVERSATION_SETTINGS_VIEW_IS_SYSTEM_CONTACT",
comment: "Indicates that user is in the system contacts list.",
),
textColor: .Signal.label,
actionBlock: { [weak self] in
guard let self else { return }
self.viewSystemContactDetails(contactAddress: self.address)
},
))
} else if !isBlocked, address.phoneNumber != nil {
section.add(.item(
icon: .contactInfoAddToContacts,
tintColor: .Signal.label,
name: OWSLocalizedString(
"CONVERSATION_SETTINGS_ADD_TO_SYSTEM_CONTACTS",
comment: "button in conversation settings view.",
),
textColor: .Signal.label,
actionBlock: { [weak self] in
guard let self else { return }
self.showAddToSystemContactsActionSheet(contactAddress: self.address)
},
))
}
if !isBlocked {
section.add(.item(
icon: .contactInfoSafetyNumber,
tintColor: .Signal.label,
name: OWSLocalizedString(
"VERIFY_PRIVACY",
comment: "Label for button or row which allows users to verify the safety number of another user.",
),
textColor: .Signal.label,
actionBlock: { [weak self] in
guard let self, let fromViewController = self.fromViewController else { return }
self.dismiss(animated: true) {
FingerprintViewController.present(for: self.address.aci, from: fromViewController)
}
},
))
}
return contents
}
private func viewSystemContactDetails(contactAddress: SignalServiceAddress) {
guard let viewController = fromViewController else { return }
let contactsViewHelper = SUIEnvironment.shared.contactsViewHelperRef
dismiss(animated: true) {
contactsViewHelper.presentSystemContactsFlow(
CreateOrEditContactFlow(address: contactAddress, editImmediately: false),
from: viewController,
)
}
}
private func showAddToSystemContactsActionSheet(contactAddress: SignalServiceAddress) {
guard let viewController = fromViewController else { return }
let contactsViewHelper = SUIEnvironment.shared.contactsViewHelperRef
dismiss(animated: true) {
let actionSheet = ActionSheetController()
let createNewTitle = OWSLocalizedString(
"CONVERSATION_SETTINGS_NEW_CONTACT",
comment: "Label for 'new contact' button in conversation settings view.",
)
actionSheet.addAction(ActionSheetAction(
title: createNewTitle,
style: .default,
handler: { _ in
contactsViewHelper.presentSystemContactsFlow(
CreateOrEditContactFlow(address: contactAddress),
from: viewController,
)
},
))
let addToExistingTitle = OWSLocalizedString(
"CONVERSATION_SETTINGS_ADD_TO_EXISTING_CONTACT",
comment: "Label for 'new contact' button in conversation settings view.",
)
actionSheet.addAction(ActionSheetAction(
title: addToExistingTitle,
style: .default,
handler: { _ in
contactsViewHelper.presentSystemContactsFlow(
AddToExistingContactFlow(address: contactAddress),
from: viewController,
)
},
))
actionSheet.addAction(OWSActionSheets.cancelAction)
viewController.presentActionSheet(actionSheet)
}
}
@objc
private func recipientUpdated(notification: NSNotification) {
guard self.isViewLoaded else { return }
AssertIsOnMainThread()
updateTableContents()
}
}
extension MemberActionSheet: ConversationHeaderDelegate {
var isGroupV1Thread: Bool { groupViewHelper?.isGroupV1Thread == true }
func presentStoryViewController() {
dismiss(animated: true) {
let vc = StoryPageViewController(context: self.thread.storyContext, spoilerState: self.spoilerState)
self.fromViewController?.present(vc, animated: true)
}
}
func presentAvatarViewController() {
guard let avatarView, avatarView.primaryImage != nil else { return }
guard
let vc = SSKEnvironment.shared.databaseStorageRef.read(block: { readTx in
AvatarViewController(address: self.address, renderLocalUserAsNoteToSelf: false, readTx: readTx)
}) else { return }
present(vc, animated: true)
}
func didTapBadge() {
guard avatarView != nil else { return }
let (profile, shortName) = SSKEnvironment.shared.databaseStorageRef.read { transaction in
return (
SSKEnvironment.shared.profileManagerRef.userProfile(for: address, tx: transaction),
SSKEnvironment.shared.contactManagerRef.displayName(for: address, tx: transaction).resolvedValue(useShortNameIfAvailable: true),
)
}
guard let primaryBadge = profile?.primaryBadge?.badge else { return }
let owner: BadgeDetailsSheet.Owner
if address.isLocalAddress {
owner = .local(shortName: shortName)
} else {
owner = .remote(shortName: shortName)
}
let badgeSheet = BadgeDetailsSheet(focusedBadge: primaryBadge, owner: owner)
present(badgeSheet, animated: true, completion: nil)
}
func tappedConversationSearch() {}
func didTapUnblockThread(completion: @escaping () -> Void) {
guard let fromViewController else { return }
dismiss(animated: true) {
BlockListUIUtils.showUnblockAddressActionSheet(
self.address,
from: fromViewController,
) { _ in
completion()
}
}
}
func tappedButton() {
dismiss(animated: true)
}
func didTapAddGroupDescription() {}
var canEditConversationAttributes: Bool { false }
var groupDescriptionDelegate: GroupDescriptionViewControllerDelegate? { nil }
var canTapThreadName: Bool { true }
func didTapThreadName() {
guard let contactThread = self.thread as? TSContactThread else {
owsFailDebug("How is member sheet not showing a contact?")
return
}
let sheet = ContactAboutSheet(thread: contactThread, spoilerState: spoilerState)
dismiss(animated: true) {
guard let fromViewController = self.fromViewController else { return }
sheet.present(from: fromViewController)
}
}
func didTapMemberLabel() {
guard
BuildFlags.MemberLabel.send,
let presenter = self.fromViewController as? MemberLabelViewControllerPresenter,
let groupViewHelper,
let groupThread = groupViewHelper.thread as? TSGroupThread,
let groupModel = groupThread.groupModel as? TSGroupModelV2,
let memberLabelCoordinator = groupViewHelper.memberLabelCoordinator
else {
return
}
let localUserHasMemberLabel = groupModel.groupMembership.localUserMemberLabel != nil
dismiss(animated: true) {
memberLabelCoordinator.presenter = presenter
memberLabelCoordinator.presentWithEducationSheet(
localUserHasMemberLabel: localUserHasMemberLabel,
canEditMemberLabel: groupViewHelper.canEditMemberLabels,
)
}
}
}
extension MemberActionSheet: AvatarViewPresentationContextProvider {
var conversationAvatarView: ConversationAvatarView? { avatarView }
}