Files
Morgan Pretty 37e66498fd Merge branch 'master' into dev
# Conflicts:
#	Session/Conversations/ConversationVC+Interaction.swift
#	Session/Conversations/Settings/ThreadSettingsViewModel.swift
#	Session/Home/HomeVC.swift
#	Session/Notifications/NotificationPresenter.swift
#	Session/Onboarding/Onboarding.swift
#	Session/Utilities/UIContextualAction+Utilities.swift
#	SessionMessagingKit/Database/Models/ClosedGroup.swift
#	SessionMessagingKit/Database/Models/Interaction.swift
#	SessionMessagingKit/Database/Models/SessionThread.swift
#	SessionMessagingKit/Open Groups/OpenGroupManager.swift
#	SessionMessagingKit/Sending & Receiving/MessageReceiver.swift
#	SessionMessagingKit/Shared Models/SessionThreadViewModel.swift
#	SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift
#	SessionShareExtension/ThreadPickerVC.swift
2026-01-20 16:33:31 +11:00

871 lines
36 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import Combine
import GRDB
import DifferenceKit
import Lucide
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
import SignalUtilitiesKit
public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableViewDataSource, UITableViewDelegate, SeedReminderViewDelegate {
private static let loadingHeaderHeight: CGFloat = 40
public static let newConversationButtonSize: CGFloat = 60
private let viewModel: HomeViewModel
private var disposables: Set<AnyCancellable> = Set()
/// Currently loaded version of the data for the `tableView`, will always match the value in the `viewModel` unless it's part way
/// through updating it's state
private var sections: [HomeViewModel.SectionModel] = []
private var initialConversationLoadComplete: Bool = false
public var afterInitialConversationsLoad: (() -> Void)?
// MARK: - LibSessionRespondingViewController
public let isConversationList: Bool = true
// MARK: - Intialization
init(using dependencies: Dependencies) {
self.viewModel = HomeViewModel(using: dependencies)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
preconditionFailure("Use init() instead.")
}
// MARK: - UI
private var navBarProfileView: ProfilePictureView?
private lazy var bannersStackView: UIStackView = {
let result: UIStackView = UIStackView(arrangedSubviews: [
versionSupportBanner,
seedReminderView
])
result.axis = .vertical
result.alignment = .fill
return result
}()
private lazy var seedReminderView: SeedReminderView = {
let result = SeedReminderView()
result.accessibilityLabel = "Recovery phrase reminder"
result.title = NSAttributedString(string: "recoveryPasswordBannerTitle".localized())
result.subtitle = "recoveryPasswordBannerDescription".localized()
result.setProgress(1, animated: false)
result.delegate = self
result.isHidden = !self.viewModel.state.showViewedSeedBanner
return result
}()
lazy var versionSupportBanner: InfoBanner = {
let result: InfoBanner = InfoBanner(
info: InfoBanner.Info(
font: .systemFont(ofSize: Values.verySmallFontSize),
message: "warningIosVersionEndingSupport"
.localizedFormatted(baseFont: .systemFont(ofSize: Values.verySmallFontSize)),
icon: .none,
tintColor: .messageBubble_outgoingText,
backgroundColor: .primary,
labelAccessibility: Accessibility(identifier: "Warning supported version banner")
)
)
result.isHidden = false
return result
}()
private lazy var loadingConversationsLabel: UILabel = {
let result: UILabel = UILabel()
result.translatesAutoresizingMaskIntoConstraints = false
result.font = .systemFont(ofSize: Values.smallFontSize)
result.text = "loading".localized()
result.themeTextColor = .textSecondary
result.textAlignment = .center
result.numberOfLines = 0
return result
}()
private lazy var tableView: UITableView = {
let result = UITableView()
result.separatorStyle = .none
result.themeBackgroundColor = .clear
result.showsVerticalScrollIndicator = false
result.register(view: MessageRequestsCell.self)
result.register(view: FullConversationCell.self)
result.dataSource = self
result.delegate = self
result.sectionHeaderTopPadding = 0
return result
}()
private lazy var appReviewPrompt: AppReviewPromptDialog = {
let result = AppReviewPromptDialog()
// Layers
result.layer.borderWidth = 1
result.layer.cornerRadius = 12
result.themeBorderColor = .borderSeparator
result.themeBackgroundColor = .alert_background
result.themeShadowColor = .black
result.onPrimaryTapped = { [viewModel = self.viewModel] state in viewModel.handlePrimaryTappedForState(state) }
result.onSecondaryTapped = { [viewModel = self.viewModel] in viewModel.handleSecondayTappedForState($0) }
result.onCloseTapped = { [viewModel = self.viewModel] in viewModel.handlePromptChangeState(nil) }
return result
}()
private lazy var newConversationButton: UIView = {
let result: UIView = UIView()
result.set(.width, to: HomeVC.newConversationButtonSize)
result.set(.height, to: HomeVC.newConversationButtonSize)
let button = UIButton()
button.accessibilityLabel = "New conversation button"
button.isAccessibilityElement = true
button.clipsToBounds = true
button.setImage(
UIImage(named: "Plus")?
.withRenderingMode(.alwaysTemplate),
for: .normal
)
button.contentMode = .center
button.adjustsImageWhenHighlighted = false
button.themeTintColor = .menuButton_icon
button.setThemeBackgroundColor(.menuButton_background, for: .normal)
button.setThemeBackgroundColor(
.highlighted(.menuButton_background, alwaysDarken: true),
for: .highlighted
)
button.contentEdgeInsets = UIEdgeInsets(
top: ((HomeVC.newConversationButtonSize - 24) / 2),
leading: ((HomeVC.newConversationButtonSize - 24) / 2),
bottom: ((HomeVC.newConversationButtonSize - 24) / 2),
trailing: ((HomeVC.newConversationButtonSize - 24) / 2)
)
button.layer.cornerRadius = (HomeVC.newConversationButtonSize / 2)
button.addTarget(self, action: #selector(createNewConversation), for: .touchUpInside)
result.addSubview(button)
button.pin(to: result)
// Add the outer shadow
result.themeShadowColor = .menuButton_outerShadow
result.layer.shadowRadius = 15
result.layer.shadowOpacity = 0.3
result.layer.shadowOffset = .zero
result.layer.cornerRadius = (HomeVC.newConversationButtonSize / 2)
result.layer.shadowPath = UIBezierPath(
ovalIn: CGRect(
origin: CGPoint.zero,
size: CGSize(
width: HomeVC.newConversationButtonSize,
height: HomeVC.newConversationButtonSize
)
)
).cgPath
// Add the inner shadow
let innerShadowLayer: CALayer = CALayer()
innerShadowLayer.masksToBounds = true
innerShadowLayer.themeShadowColor = .menuButton_innerShadow
innerShadowLayer.position = CGPoint(
x: (HomeVC.newConversationButtonSize / 2),
y: (HomeVC.newConversationButtonSize / 2)
)
innerShadowLayer.bounds = CGRect(
x: 0,
y: 0,
width: HomeVC.newConversationButtonSize,
height: HomeVC.newConversationButtonSize
)
innerShadowLayer.cornerRadius = (HomeVC.newConversationButtonSize / 2)
innerShadowLayer.shadowOffset = .zero
innerShadowLayer.shadowOpacity = 0.4
innerShadowLayer.shadowRadius = 2
let cutout: UIBezierPath = UIBezierPath(
roundedRect: innerShadowLayer.bounds
.insetBy(dx: innerShadowLayer.shadowRadius, dy: innerShadowLayer.shadowRadius),
cornerRadius: (HomeVC.newConversationButtonSize / 2)
).reversing()
let path: UIBezierPath = UIBezierPath(
roundedRect: innerShadowLayer.bounds,
cornerRadius: (HomeVC.newConversationButtonSize / 2)
)
path.append(cutout)
innerShadowLayer.shadowPath = path.cgPath
result.layer.addSublayer(innerShadowLayer)
return result
}()
private lazy var emptyStateView: UIView = {
let emptyConvoLabel = UILabel()
emptyConvoLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
emptyConvoLabel.text = "conversationsNone".localized()
emptyConvoLabel.themeTextColor = .textPrimary
emptyConvoLabel.textAlignment = .center
let instructionLabel = UILabel()
instructionLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
instructionLabel.text = "onboardingHitThePlusButton".localized()
instructionLabel.themeTextColor = .textPrimary
instructionLabel.textAlignment = .center
instructionLabel.lineBreakMode = .byWordWrapping
instructionLabel.numberOfLines = 0
let result = UIStackView(arrangedSubviews: [
emptyConvoLabel,
UIView.vSpacer(Values.smallSpacing),
instructionLabel
])
result.axis = .vertical
result.spacing = Values.verySmallSpacing
result.alignment = .center
return result
}()
private lazy var emptyStateLogoView: UIView = {
let sessionLogoImage: UIImageView = UIImageView(image: UIImage(named: "SessionGreen64"))
sessionLogoImage.contentMode = .scaleAspectFit
sessionLogoImage.set(.height, to: 103)
let sessionTitleImage: UIImageView = UIImageView(
image: UIImage(named: "SessionHeading")?
.withRenderingMode(.alwaysTemplate)
)
sessionTitleImage.themeTintColor = .textPrimary
sessionTitleImage.contentMode = .scaleAspectFit
sessionTitleImage.set(.height, to: 22)
let result = UIStackView(arrangedSubviews: [
sessionLogoImage,
UIView.vSpacer(Values.smallSpacing + Values.verySmallSpacing),
sessionTitleImage,
UIView.vSpacer(Values.verySmallSpacing)
])
result.axis = .vertical
result.spacing = Values.verySmallSpacing
result.alignment = .fill
result.isHidden = true
return result
}()
private lazy var accountCreatedView: UIView = {
let image: UIImageView = UIImageView(image: UIImage(named: "Hooray"))
image.contentMode = .center
image.set(.height, to: 96)
let accountCreatedLabel = UILabel()
accountCreatedLabel.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
accountCreatedLabel.text = "onboardingAccountCreated".localized()
accountCreatedLabel.themeTextColor = .textPrimary
accountCreatedLabel.textAlignment = .center
let welcomeLabel = UILabel()
welcomeLabel.font = .systemFont(ofSize: Values.smallFontSize)
welcomeLabel.text = "onboardingBubbleWelcomeToSession"
.put(key: "app_name", value: Constants.app_name)
.put(key: "emoji", value: "👋")
.localized()
welcomeLabel.themeTextColor = .sessionButton_text
welcomeLabel.textAlignment = .center
let result = UIStackView(arrangedSubviews: [
image,
accountCreatedLabel,
welcomeLabel,
UIView.vSpacer(Values.verySmallSpacing)
])
result.axis = .vertical
result.spacing = Values.verySmallSpacing
result.alignment = .fill
result.isHidden = true
return result
}()
private lazy var emptyStateStackView: UIStackView = {
let result = UIStackView(arrangedSubviews: [
accountCreatedView,
emptyStateLogoView,
UIView.vSpacer(Values.smallSpacing),
UIView.line(),
UIView.vSpacer(Values.smallSpacing),
emptyStateView
])
result.axis = .vertical
result.spacing = Values.verySmallSpacing
result.alignment = .fill
result.isHidden = true
return result
}()
// MARK: - Lifecycle
public override func viewDidLoad() {
super.viewDidLoad()
// Preparation
updateNavBarButtons(
userProfile: self.viewModel.state.userProfile,
serviceNetwork: self.viewModel.state.serviceNetwork,
forceOffline: self.viewModel.state.forceOffline
)
setUpNavBarSessionHeading(sessionProUIManager: viewModel.dependencies[singleton: .sessionProManager])
// Banner stack view
view.addSubview(bannersStackView)
bannersStackView.pin(.leading, to: .leading, of: view)
bannersStackView.pin(.top, to: .top, of: view)
bannersStackView.pin(.trailing, to: .trailing, of: view)
// Loading conversations label
view.addSubview(loadingConversationsLabel)
loadingConversationsLabel.pin(.leading, to: .leading, of: view, withInset: 50)
loadingConversationsLabel.pin(.trailing, to: .trailing, of: view, withInset: -50)
loadingConversationsLabel.pin(.top, to: .bottom, of: bannersStackView, withInset: Values.mediumSpacing)
// Table view
view.addSubview(tableView)
tableView.pin(.leading, to: .leading, of: view)
tableView.pin(.trailing, to: .trailing, of: view)
tableView.pin(.bottom, to: .bottom, of: view)
tableView.pin(.top, to: .bottom, of: bannersStackView)
// Empty state view
view.addSubview(emptyStateStackView)
emptyStateStackView.set(.width, to: 300)
emptyStateStackView.center(.horizontal, in: view)
let verticalCenteringConstraint2 = emptyStateStackView.center(.vertical, in: view)
verticalCenteringConstraint2.constant = -Values.massiveSpacing // Makes things appear centered visually
// New conversation button
view.addSubview(newConversationButton)
newConversationButton.center(.horizontal, in: view)
newConversationButton.pin(.bottom, to: .bottom, of: view.safeAreaLayoutGuide, withInset: -Values.smallSpacing)
// Preview prompt
view.addSubview(appReviewPrompt)
appReviewPrompt.pin(.left, to: .left, of: view, withInset: 12)
appReviewPrompt.pin(.right, to: .right, of: view, withInset: -12)
appReviewPrompt.pin(.bottom, to: .top, of: newConversationButton, withInset: -10)
// Start polling if needed (i.e. if the user just created or restored their Session ID)
if
viewModel.dependencies[cache: .general].userExists,
let appDelegate: AppDelegate = UIApplication.shared.delegate as? AppDelegate,
viewModel.dependencies[singleton: .appContext].isMainAppAndActive
{
appDelegate.startPollersIfNeeded()
}
// Onion request path countries cache
Task.detached(priority: .background) { [dependencies = viewModel.dependencies] in
dependencies.warm(cache: .ip2Country)
}
// Bind the UI to the view model
bindViewModel()
viewModel.navigatableState.setupBindings(viewController: self, disposables: &disposables)
// Notification
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationDidBecomeActive(_:)),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
viewModel.dependencies[singleton: .notificationsManager].scheduleSessionNetworkPageLocalNotifcation(force: false)
viewModel.viewDidAppear()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: - Updating
@MainActor public func afterInitialConversationsLoaded(_ closure: @escaping () -> Void) {
guard viewModel.state.viewState == .loading else { return closure() }
afterInitialConversationsLoad = closure
/// Since we wouldn't have added the `HomeVC` to the view hierarchy yet it's possible it hasn't loaded it's view yet
/// so we should trigger it to do so now if needed
loadViewIfNeeded()
}
private func bindViewModel() {
viewModel.$state
.receive(on: DispatchQueue.main)
.removeDuplicates()
.sink { [weak self] state in self?.render(state: state) }
.store(in: &disposables)
}
@MainActor private func render(state: HomeViewModel.State) {
// Update nav
updateNavBarButtons(
userProfile: state.userProfile,
serviceNetwork: state.serviceNetwork,
forceOffline: state.forceOffline
)
// Update the 'view seed' UI
seedReminderView.isHidden = !state.showViewedSeedBanner
versionSupportBanner.isHidden = !state.showVersionSupportBanner
// Update the overall view state (loading, empty, or loaded)
switch state.viewState {
case .loading:
loadingConversationsLabel.isHidden = false
emptyStateStackView.isHidden = true
case .empty(let isNewUser):
loadingConversationsLabel.isHidden = true
emptyStateStackView.isHidden = false
accountCreatedView.isHidden = !isNewUser
emptyStateLogoView.isHidden = isNewUser
case .loaded:
loadingConversationsLabel.isHidden = true
emptyStateStackView.isHidden = true
}
// If we should show the `showDonationsCTAModal` then do so
if state.showDonationsCTAModal {
viewModel.dependencies[singleton: .donationsManager].presentDonationsCTAModal(in: self)
}
// If we are still loading then don't try to load the table content (it'll be empty and we
// don't want to trigger the callbacks until a successful load)
guard state.viewState != .loading else { return }
// App reivew, check if `state.appReviewPromptState` has value
// `state.appReviewPromptState` will only have value if triggered via viewDidAppear or review prompt events
appReviewPrompt.setReviewPrompt(state.appReviewPromptState)
tableView.contentInset = UIEdgeInsets(
top: 0,
left: 0,
bottom: (
Values.largeSpacing +
HomeVC.newConversationButtonSize +
Values.smallSpacing +
(UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0) +
(state.appReviewPromptState != nil ? (appReviewPrompt.frame.size.height + 24) : 0)
),
right: 0
)
// Reload the table content (update without animations on the first render)
guard initialConversationLoadComplete else {
sections = state.sections(viewModel: viewModel)
UIView.performWithoutAnimation {
tableView.reloadData()
afterInitialConversationsLoad?()
afterInitialConversationsLoad = nil
initialConversationLoadComplete = true
}
return
}
tableView.reload(
using: StagedChangeset(source: self.sections, target: state.sections(viewModel: viewModel)),
deleteSectionsAnimation: .none,
insertSectionsAnimation: .none,
reloadSectionsAnimation: .none,
deleteRowsAnimation: .bottom,
insertRowsAnimation: .none,
reloadRowsAnimation: .none,
interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues
) { [weak self] updatedData in
self?.sections = updatedData
}
}
@MainActor private func updateNavBarButtons(
userProfile: Profile,
serviceNetwork: ServiceNetwork,
forceOffline: Bool
) {
// Profile picture view
let profilePictureView = ProfilePictureView(
size: .navigation,
dataManager: viewModel.dependencies[singleton: .imageDataManager]
)
profilePictureView.accessibilityIdentifier = "User settings"
profilePictureView.accessibilityLabel = "User settings"
profilePictureView.isAccessibilityElement = true
profilePictureView.update(
publicKey: userProfile.id,
threadVariant: .contact,
displayPictureUrl: nil,
profile: userProfile,
profileIcon: {
switch (serviceNetwork, forceOffline) {
case (.testnet, false): return .letter("T", false) // stringlint:ignore
case (.testnet, true): return .letter("T", true) // stringlint:ignore
default: return .none
}
}(),
additionalProfile: nil,
using: viewModel.dependencies
)
navBarProfileView = profilePictureView
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openSettings))
profilePictureView.addGestureRecognizer(tapGestureRecognizer)
// Path status indicator
let pathStatusView = PathStatusView(using: viewModel.dependencies)
pathStatusView.accessibilityLabel = "Current onion routing path indicator"
// Container view
let profilePictureViewContainer = UIView()
profilePictureViewContainer.addSubview(profilePictureView)
profilePictureView.pin(to: profilePictureViewContainer)
profilePictureViewContainer.addSubview(pathStatusView)
pathStatusView.pin(.trailing, to: .trailing, of: profilePictureViewContainer)
pathStatusView.pin(.bottom, to: .bottom, of: profilePictureViewContainer)
// Left bar button item
let leftBarButtonItem = UIBarButtonItem(customView: profilePictureViewContainer)
leftBarButtonItem.isAccessibilityElement = true
navigationItem.leftBarButtonItem = leftBarButtonItem
// Right bar button item - search button
let rightBarButtonItem = UIBarButtonItem(image: Lucide.image(icon: .search, size: 24), style: .plain, target: self, action: #selector(showSearchUI))
rightBarButtonItem.accessibilityLabel = "Search button"
rightBarButtonItem.isAccessibilityElement = true
navigationItem.rightBarButtonItem = rightBarButtonItem
}
// MARK: - UITableViewDataSource
public func numberOfSections(in tableView: UITableView) -> Int {
return sections.count
}
public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return sections[section].elements.count
}
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let section: HomeViewModel.SectionModel = sections[indexPath.section]
switch section.model {
case .messageRequests:
let threadInfo: ConversationInfoViewModel = section.elements[indexPath.row]
let cell: MessageRequestsCell = tableView.dequeue(type: MessageRequestsCell.self, for: indexPath)
cell.accessibilityIdentifier = "Message requests banner"
cell.isAccessibilityElement = true
cell.update(with: threadInfo.unreadCount)
return cell
case .threads:
let threadInfo: ConversationInfoViewModel = section.elements[indexPath.row]
let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
cell.update(with: threadInfo, using: viewModel.dependencies)
cell.accessibilityIdentifier = "Conversation list item"
cell.accessibilityLabel = threadInfo.displayName.deformatted()
return cell
default: preconditionFailure("Other sections should have no content")
}
}
public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let section: HomeViewModel.SectionModel = sections[section]
switch section.model {
case .loadMore:
let loadingIndicator: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium)
loadingIndicator.themeColor = .textPrimary
loadingIndicator.alpha = 0.5
loadingIndicator.startAnimating()
let view: UIView = UIView()
view.addSubview(loadingIndicator)
loadingIndicator.center(in: view)
return view
default: return nil
}
}
// MARK: - UITableViewDelegate
public func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
let section: HomeViewModel.SectionModel = sections[section]
switch section.model {
case .loadMore: return HomeVC.loadingHeaderHeight
default: return 0
}
}
public func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
switch sections[section].model {
case .loadMore: self.viewModel.loadPageAfter()
default: break
}
}
public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let section: HomeViewModel.SectionModel = sections[indexPath.section]
switch section.model {
case .messageRequests:
let viewController: SessionTableViewController = SessionTableViewController(
viewModel: MessageRequestsViewModel(using: viewModel.dependencies)
)
self.navigationController?.pushViewController(viewController, animated: true)
case .threads:
let threadInfo: ConversationInfoViewModel = section.elements[indexPath.row]
let viewController: ConversationVC = ConversationVC(
threadInfo: threadInfo,
focusedInteractionInfo: nil,
using: viewModel.dependencies
)
self.navigationController?.pushViewController(viewController, animated: true)
default: break
}
}
public func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true
}
public func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView)
}
public func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView)
}
public func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let section: HomeViewModel.SectionModel = sections[indexPath.section]
let threadInfo: ConversationInfoViewModel = section.elements[indexPath.row]
switch section.model {
case .threads:
// Cannot properly sync outgoing blinded message requests so don't provide the option,
// the 'Note to Self' conversation also doesn't support 'mark as unread' so don't
// provide it there either
guard
threadInfo.variant != .legacyGroup &&
threadInfo.id != threadInfo.userSessionId.hexString && (
threadInfo.variant != .contact ||
(try? SessionId(from: section.elements[indexPath.row].id))?.prefix == .standard
)
else { return nil }
return UIContextualAction.configuration(
for: UIContextualAction.generateSwipeActions(
[.toggleReadStatus],
for: .leading,
indexPath: indexPath,
tableView: tableView,
threadInfo: threadInfo,
viewController: self,
navigatableStateHolder: viewModel,
using: viewModel.dependencies
)
)
default: return nil
}
}
public func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let section: HomeViewModel.SectionModel = sections[indexPath.section]
let threadInfo: ConversationInfoViewModel = section.elements[indexPath.row]
switch section.model {
case .messageRequests:
return UIContextualAction.configuration(
for: UIContextualAction.generateSwipeActions(
[.hide],
for: .trailing,
indexPath: indexPath,
tableView: tableView,
threadInfo: threadInfo,
viewController: self,
navigatableStateHolder: viewModel,
using: viewModel.dependencies
)
)
case .threads:
let sessionIdPrefix: SessionId.Prefix? = try? SessionId.Prefix(from: threadInfo.id)
// Cannot properly sync outgoing blinded message requests so only provide valid options
let shouldHavePinAction: Bool = {
switch threadInfo.variant {
// Only allow unpin for legacy groups
case .legacyGroup: return (threadInfo.pinnedPriority > 0)
default:
return (
sessionIdPrefix != .blinded15 &&
sessionIdPrefix != .blinded25
)
}
}()
let shouldHaveMuteAction: Bool = {
switch threadInfo.variant {
case .contact: return (
!threadInfo.isNoteToSelf &&
sessionIdPrefix != .blinded15 &&
sessionIdPrefix != .blinded25
)
case .group: return (threadInfo.groupInfo?.currentUserRole != nil)
case .legacyGroup: return false
case .community: return true
}
}()
let destructiveAction: UIContextualAction.SwipeAction = {
switch threadInfo.variant {
case .contact: return (threadInfo.isNoteToSelf ? .hide : .delete)
case .community: return .leave
case .legacyGroup: return .delete
case .group:
if
threadInfo.groupInfo?.currentUserRole == .admin &&
threadInfo.groupInfo?.numAdmins == 1
{
return .delete
}
if
threadInfo.groupInfo?.currentUserRole == .admin &&
(threadInfo.groupInfo?.numAdmins ?? 0) > 1
{
return .leave
}
if threadInfo.groupInfo?.currentUserRole == .standard {
return .leave
}
return .delete
}
}()
return UIContextualAction.configuration(
for: UIContextualAction.generateSwipeActions(
[
(!shouldHavePinAction ? nil : .pin),
(!shouldHaveMuteAction ? nil : .mute),
destructiveAction
].compactMap { $0 },
for: .trailing,
indexPath: indexPath,
tableView: tableView,
threadInfo: threadInfo,
viewController: self,
navigatableStateHolder: viewModel,
using: viewModel.dependencies
)
)
default: return nil
}
}
// MARK: - Interaction
func handleContinueButtonTapped(from seedReminderView: SeedReminderView) {
guard let recoveryPasswordView: RecoveryPasswordScreen = try? RecoveryPasswordScreen(using: viewModel.dependencies) else {
let targetViewController: UIViewController = ConfirmationModal(
info: ConfirmationModal.Info(
title: "theError".localized(),
body: .text("recoveryPasswordErrorLoad".localized()),
cancelTitle: "okay".localized(),
cancelStyle: .alert_text
)
)
present(targetViewController, animated: true, completion: nil)
return
}
let viewController: SessionHostingViewController = SessionHostingViewController(rootView: recoveryPasswordView)
viewController.setNavBarTitle("sessionRecoveryPassword".localized())
self.navigationController?.pushViewController(viewController, animated: true)
}
@objc private func openSettings() {
let settingsViewController: SessionTableViewController = SessionTableViewController(
viewModel: SettingsViewModel(using: viewModel.dependencies)
)
let navigationController = StyledNavigationController(rootViewController: settingsViewController)
navigationController.modalPresentationStyle = .fullScreen
present(navigationController, animated: true, completion: nil)
}
@objc private func showSearchUI() {
if let presentedVC = self.presentedViewController {
presentedVC.dismiss(animated: false, completion: nil)
}
let searchController = GlobalSearchViewController(using: viewModel.dependencies)
self.navigationController?.setViewControllers([ self, searchController ], animated: true)
}
@objc private func createNewConversation() {
viewModel.dependencies[singleton: .app].createNewConversation()
}
@objc func applicationDidBecomeActive(_ notification: Notification) {
DispatchQueue.main.async { [weak self] in
self?.viewModel.didReturnFromBackground()
}
}
func createNewDMFromDeepLink(sessionId: String) {
let viewController: SessionHostingViewController = SessionHostingViewController(
rootView: NewMessageScreen(accountId: sessionId, using: viewModel.dependencies)
)
viewController.setNavBarTitle(
"messageNew"
.putNumber(1)
.localized()
)
let navigationController = StyledNavigationController(rootViewController: viewController)
if UIDevice.current.isIPad {
navigationController.modalPresentationStyle = .fullScreen
}
navigationController.modalPresentationCapturesStatusBarAppearance = true
present(navigationController, animated: true, completion: nil)
}
}