Path: blob/main/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController.swift
1 views
//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
public import SignalUI
import StoreKit
public class ChatListViewController: OWSViewController, HomeTabViewController {
let appReadiness: AppReadinessSetter
init(
chatListMode: ChatListMode,
appReadiness: AppReadinessSetter,
) {
self.appReadiness = appReadiness
self.viewState = CLVViewState(chatListMode: chatListMode, inboxFilter: nil)
super.init()
tableDataSource.scrollViewDelegate = self
tableDataSource.viewController = self
loadCoordinator.viewController = self
viewState.reminderViews.chatListViewController = self
viewState.backupDownloadProgressView.chatListViewController = self
viewState.backupExportProgressView.chatListViewController = self
viewState.settingsButtonCreator.delegate = self
viewState.proxyButtonCreator.delegate = self
viewState.configure()
}
override public var canBecomeFirstResponder: Bool {
true
}
// MARK: View Lifecycle
override public func loadView() {
view = containerView
}
override public func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .Signal.background
tableView.backgroundColor = .Signal.background
switch viewState.chatListMode {
case .inbox:
title = NSLocalizedString("CHAT_LIST_TITLE_INBOX", comment: "Title for the chat list's default mode.")
case .archive:
title = NSLocalizedString("HOME_VIEW_TITLE_ARCHIVE", comment: "Title for the conversation list's 'archive' mode.")
}
if !viewState.multiSelectState.isActive {
applyDefaultBackButton()
}
// Table View
tableView.accessibilityIdentifier = "ChatListViewController.tableView"
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 60
tableView.allowsSelectionDuringEditing = true
tableView.allowsMultipleSelectionDuringEditing = true
tableView.selectionFollowsFocus = false
if let filterControl {
filterControl.clearAction = .disableChatListFilter(target: self)
filterControl.delegate = self
}
// Empty Inbox
view.addSubview(emptyInboxView)
emptyInboxView.autoPinWidthToSuperviewMargins()
emptyInboxView.autoAlignAxis(.horizontal, toSameAxisOf: view, withMultiplier: 0.85)
// First Conversation Cue
view.addSubview(firstConversationCueView)
firstConversationCueView.autoPin(toTopLayoutGuideOf: self, withInset: 0)
// This inset bakes in assumptions about UINavigationBar layout, but I'm not sure
// there's a better way to do it, since it isn't safe to use iOS auto layout with
// UINavigationBar contents.
firstConversationCueView.autoPinEdge(toSuperviewEdge: .trailing, withInset: 6)
firstConversationCueView.autoPinEdge(toSuperviewEdge: .leading, withInset: 10, relation: .greaterThanOrEqual)
firstConversationCueView.autoPinEdge(toSuperviewMargin: .bottom, relation: .greaterThanOrEqual)
// Search
navigationItem.searchController = viewState.searchController
viewState.searchController.searchResultsUpdater = self
searchResultsController.delegate = self
// Backups
viewState.backupDownloadProgressView.startTracking()
viewState.backupExportProgressView.startTracking()
updateBarButtonItems()
updateArchiveReminderView()
updateRegistrationReminderView()
updateOutageDetectionReminderView()
updateExpirationReminderView()
updatePaymentReminderView()
updateUsernameReminderView()
updateTableViewPaddingIfNeeded()
observeNotifications()
}
private var viewHasAppeared = false
override public func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
isViewVisible = true
// Ensure the tabBar is always hidden if we're in the archive.
let shouldHideTabBar = viewState.chatListMode == .archive
if shouldHideTabBar {
tabBarController?.tabBar.isHidden = true
extendedLayoutIncludesOpaqueBars = true
}
guard #available(iOS 26, *) else {
self._viewWillAppear(animated)
return
}
// iOS 26.1 introduced an egregious bug where view controller lifecycle
// functions are called inside the push/pop animation blocks when using
// UITabViewController. In practice, this means that changes to the chat
// list while in a conversation manifest as a growing-from-the-top-left
// animation when popping back. This dispatch alleviates that issue by
// ensuring the reloading doesn't happen with an animation.
if !viewHasAppeared {
// Prevent the chat list from flickering an empty state on launch
self._viewWillAppear(animated)
viewHasAppeared = true
} else {
DispatchQueue.main.async {
self._viewWillAppear(animated)
}
}
}
private func _viewWillAppear(_ animated: Bool) {
defer {
loadCoordinator.loadIfNecessary(suppressAnimations: true)
}
if isSearching {
scrollSearchBarToTop(animated: false)
} else if let lastViewedThreadUniqueId {
owsAssertDebug((searchBar.text ?? "").stripped.isEmpty)
// When returning to conversation list, try to ensure that the "last" thread is still
// visible. The threads often change ordering while in conversation view due
// to incoming & outgoing messages. Reload to ensure we have this latest ordering
// before we find the index path we want to scroll to.
loadCoordinator.loadIfNecessary(suppressAnimations: true, shouldForceLoad: true)
if let indexPathOfLastThread = renderState.indexPath(forUniqueId: lastViewedThreadUniqueId) {
tableView.scrollToRow(at: indexPathOfLastThread, at: .none, animated: false)
}
}
if viewState.multiSelectState.isActive {
tableView.setEditing(true, animated: false)
reloadTableData()
willEnterMultiselectMode()
} else {
applyDefaultBackButton()
}
viewState.searchResultsController.viewWillAppear(animated)
viewState.backupDownloadProgressView.willAppear()
viewState.backupExportProgressView.willAppear()
updateUnreadPaymentNotificationsCountWithSneakyTransaction()
// Populate Backups error states
updateBackupFailureAlertsWithSneakyTransaction()
updateBackupSubscriptionFailedToRedeemAlertsWithSneakyTx()
updateBackupIAPNotFoundLocallyAlertsWithSneakyTx()
updateHasConsumedMediaTierCapacityWithSneakyTransaction()
// During main app launch, the chat list becomes visible _before_
// app is foreground and active. Therefore we need to make an
// exception and update the view contents; otherwise, the home
// view will briefly appear empty after launch. But to avoid
// hurting first launch perf, we only want to make an exception
// for a single load.
if !hasEverAppeared {
loadCoordinator.ensureFirstLoad()
} else {
ensureCellAnimations()
}
let isCollapsed = splitViewController?.isCollapsed ?? true
if
let selectedIndexPath = tableView.indexPathForSelectedRow,
let selectedThreadUniqueId = renderState.threadUniqueId(forIndexPath: selectedIndexPath)
{
if viewState.lastSelectedThreadId != selectedThreadUniqueId {
owsFailDebug("viewState.lastSelectedThreadId out of sync with table view")
viewState.lastSelectedThreadId = selectedThreadUniqueId
updateShouldBeUpdatingView()
}
if isCollapsed {
if animated, let transitionCoordinator {
transitionCoordinator.animate { [self] _ in
tableView.deselectRow(at: selectedIndexPath, animated: true)
} completion: { [self] context in
if context.isCancelled {
tableView.selectRow(at: selectedIndexPath, animated: false, scrollPosition: .none)
} else {
viewState.lastSelectedThreadId = nil
loadCoordinator.scheduleLoad(updatedThreadIds: [selectedThreadUniqueId], animated: true)
}
}
} else {
// No animated transition, so just update the state immediately.
viewState.lastSelectedThreadId = nil
tableView.deselectRow(at: selectedIndexPath, animated: false)
loadCoordinator.scheduleLoad(updatedThreadIds: [selectedThreadUniqueId], animated: false)
}
}
} else if isCollapsed, let threadId = viewState.lastSelectedThreadId {
// If there is no currently selected table row, clean up the
// lastSelectedThreadId viewState and reload that item
viewState.lastSelectedThreadId = nil
loadCoordinator.scheduleLoad(updatedThreadIds: [threadId], animated: animated)
}
}
private var hasPresentedBackupErrors = false
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
appReadiness.setUIIsReady()
if getStartedBanner == nil, !hasEverPresentedExperienceUpgrade, ExperienceUpgradeManager.presentNext(fromViewController: self) {
hasEverPresentedExperienceUpgrade = true
} else if !hasEverAppeared {
presentGetStartedBannerIfNecessary()
}
if !hasPresentedBackupErrors {
hasPresentedBackupErrors = true
DependenciesBridge.shared.backupArchiveErrorPresenter.presentOverTopmostViewController(completion: {})
}
requestReviewIfAppropriate()
viewState.searchResultsController.viewDidAppear(animated)
if viewState.shouldFocusSearchOnAppear {
viewState.shouldFocusSearchOnAppear = false
DispatchQueue.main.async {
self.focusSearch()
}
}
showFYISheetIfNecessary()
Task { try await self.checkForFailedServiceExtensionLaunches() }
hasEverAppeared = true
if viewState.multiSelectState.isActive {
showToolbar()
} else {
applyDefaultBackButton()
}
tableDataSource.updateAndSetRefreshTimer()
}
override public func viewWillDisappear(_ animated: Bool) {
leaveMultiselectMode()
tableDataSource.stopRefreshTimer()
super.viewWillDisappear(animated)
isViewVisible = false
searchResultsController.viewWillDisappear(animated)
}
override public func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
searchResultsController.viewDidDisappear(animated)
viewState.backupDownloadProgressView.didDisappear()
viewState.backupExportProgressView.didDisappear()
}
override public func viewIsAppearing(_ animated: Bool) {
super.viewIsAppearing(animated)
updateFilterControl(animated: false)
}
override public func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let bottomInset = if let getStartedBanner, getStartedBanner.isViewLoaded, getStartedBanner.view.alpha > 0 {
getStartedBanner.opaqueHeight
} else {
CGFloat(0.0)
}
if tableView.contentInset.bottom != bottomInset {
UIView.animate(withDuration: CATransaction.animationDuration()) {
self.tableView.contentInset.bottom = bottomInset
self.tableView.verticalScrollIndicatorInsets.bottom = bottomInset
}
}
}
// MARK: Theme, content size, and layout changes
override public func contentSizeCategoryDidChange() {
super.contentSizeCategoryDidChange()
// This is expensive but this event is very rare.
reloadTableDataAndResetCellContentCache()
}
override public func themeDidChange() {
super.themeDidChange()
reloadTableDataAndResetCellContentCache()
applyThemeToContextMenuAndToolbar()
}
override public func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
guard isViewLoaded else { return }
containerView.willTransition(to: size, with: coordinator)
// There is a subtle difference in when the split view controller
// transitions between collapsed and expanded state on iPad vs
// when it does on iPhone. We reloadData here in order to ensure
// the background color of all of our cells is updated to reflect
// the current state, so it's important that we're only doing this
// once the state is ready, otherwise there will be a flash of the
// wrong background color. For iPad, this moment is _before_ the
// transition occurs. For iPhone, this moment is _during_ the
// transition. We reload in the right places accordingly.
if UIDevice.current.isIPad {
updateTableViewPaddingIfNeeded()
reloadTableDataAndResetCellContentCache()
}
coordinator.animate { context in
if !UIDevice.current.isIPad {
self.updateTableViewPaddingIfNeeded()
self.reloadTableDataAndResetCellContentCache()
}
// The Get Started banner will occupy most of the screen in landscape
// If we're transitioning to landscape, fade out the view (if it exists)
if let getStartedBanner = self.getStartedBanner, getStartedBanner.isViewLoaded {
if size.width > size.height {
getStartedBanner.view.alpha = 0
} else {
getStartedBanner.view.alpha = 1
}
}
}
}
// MARK: - FYI sheets
@objc
func showFYISheetIfNecessary() {
let fyiSheetCoordinator = ChatListFYISheetCoordinator(
backupExportJobRunner: DependenciesBridge.shared.backupExportJobRunner,
backupSubscriptionIssueStore: BackupSubscriptionIssueStore(),
donationReceiptCredentialResultStore: DependenciesBridge.shared.donationReceiptCredentialResultStore,
donationSubscriptionManager: DonationSubscriptionManager.self,
db: DependenciesBridge.shared.db,
keyTransparencyStore: KeyTransparencyStore(),
networkManager: SSKEnvironment.shared.networkManagerRef,
profileManager: SSKEnvironment.shared.profileManagerRef,
)
Task {
await fyiSheetCoordinator.presentIfNecessary(from: self)
}
}
// MARK: UI Components
private lazy var emptyInboxView: UIView = {
let emptyInboxLabel = UILabel()
emptyInboxLabel.text = NSLocalizedString(
"INBOX_VIEW_EMPTY_INBOX",
comment: "Message shown in the conversation list when the inbox is empty.",
)
emptyInboxLabel.font = .dynamicTypeSubheadlineClamped
emptyInboxLabel.textColor
= Theme.isDarkThemeEnabled ? Theme.darkThemeSecondaryTextAndIconColor : UIColor.ows_gray45
emptyInboxLabel.textAlignment = .center
emptyInboxLabel.numberOfLines = 0
emptyInboxLabel.lineBreakMode = .byWordWrapping
emptyInboxLabel.accessibilityIdentifier = "ChatListViewController.emptyInboxView"
return emptyInboxLabel
}()
private lazy var firstConversationLabel: UILabel = {
let label = UILabel()
label.textColor = .white
label.font = .dynamicTypeBodyClamped
label.numberOfLines = 0
label.lineBreakMode = .byWordWrapping
label.accessibilityIdentifier = "ChatListViewController.firstConversationLabel"
return label
}()
lazy var firstConversationCueView: UIView = {
let kTailWidth: CGFloat = 16
let kTailHeight: CGFloat = 8
let kTailHMargin: CGFloat = 12
let layerView = OWSLayerView()
layerView.isUserInteractionEnabled = true
layerView.accessibilityIdentifier = "ChatListViewController.firstConversationCueView"
layerView.layoutMargins = UIEdgeInsets(top: 11 + kTailHeight, leading: 16, bottom: 11, trailing: 16)
let shapeLayer = CAShapeLayer()
shapeLayer.fillColor = UIColor.ows_accentBlue.cgColor
layerView.layer.addSublayer(shapeLayer)
layerView.layoutCallback = { view in
let bezierPath = UIBezierPath()
// Bubble
var bubbleBounds = view.bounds
bubbleBounds.origin.y += kTailHeight
bubbleBounds.size.height -= kTailHeight
bezierPath.append(UIBezierPath(roundedRect: bubbleBounds, cornerRadius: 9))
// Tail
var tailTop = CGPoint(x: kTailHMargin + kTailWidth * 0.5, y: 0)
var tailLeft = CGPoint(x: kTailHMargin, y: kTailHeight)
var tailRight = CGPoint(x: kTailHMargin + kTailWidth, y: kTailHeight)
if !CurrentAppContext().isRTL {
tailTop.x = view.width - tailTop.x
tailLeft.x = view.width - tailLeft.x
tailRight.x = view.width - tailRight.x
}
bezierPath.move(to: tailTop)
bezierPath.addLine(to: tailLeft)
bezierPath.addLine(to: tailRight)
bezierPath.addLine(to: tailTop)
shapeLayer.path = bezierPath.cgPath
shapeLayer.frame = view.bounds
}
layerView.addSubview(firstConversationLabel)
firstConversationLabel.autoPinEdgesToSuperviewMargins()
layerView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(firstConversationCueWasTapped)))
return layerView
}()
private func settingsBarButtonItem() -> UIBarButtonItem {
let backupSettingsStore = BackupSettingsStore()
let backupSubscriptionIssueStore = BackupSubscriptionIssueStore()
let db = SSKEnvironment.shared.databaseStorageRef
let badgeColor: UIColor?
var onDidDismissContextMenu: () -> Void
if viewState.settingsButtonCreator.showBackupsFailedAvatarBadge {
badgeColor = .Signal.yellow
onDidDismissContextMenu = { [weak self] in
db.write { tx in
backupSettingsStore.setErrorBadgeMuted(target: .chatListAvatar, tx: tx)
}
self?.updateBackupFailureAlertsWithSneakyTransaction()
}
} else if viewState.settingsButtonCreator.showBackupsSubscriptionAlreadyRedeemedAvatarBadge {
badgeColor = .Signal.yellow
onDidDismissContextMenu = { [weak self] in
db.write { tx in
backupSubscriptionIssueStore.setDidAckIAPSubscriptionAlreadyRedeemedChatListBadge(tx: tx)
}
self?.updateBackupSubscriptionFailedToRedeemAlertsWithSneakyTx()
}
} else if viewState.settingsButtonCreator.showBackupsIAPNotFoundLocallyAvatarBadge {
badgeColor = .Signal.yellow
onDidDismissContextMenu = { [weak self] in
db.write { tx in
backupSubscriptionIssueStore.setDidAckIAPSubscriptionNotFoundLocallyChatListBadge(tx: tx)
}
self?.updateBackupIAPNotFoundLocallyAlertsWithSneakyTx()
}
} else if viewState.settingsButtonCreator.hasUnreadPaymentNotification {
badgeColor = .Signal.accent
onDidDismissContextMenu = {}
} else if viewState.settingsButtonCreator.hasConsumedMediaTierCapacity {
badgeColor = .Signal.red
onDidDismissContextMenu = {}
} else {
badgeColor = nil
onDidDismissContextMenu = {}
}
let barButtonItem = createSettingsBarButtonItem(
databaseStorage: db,
badgeColor: badgeColor,
onDidDismissContextMenu: onDidDismissContextMenu,
buildActions: { settingsAction -> [UIMenuElement] in
var contextMenuActions: [UIMenuElement] = []
if viewState.settingsButtonCreator.showBackupsFailedMenuItem {
var image = Theme.iconImage(.backup).withTintColor(.Signal.label)
if viewState.settingsButtonCreator.showBackupsFailedMenuItemBadge {
image = image.withBadge(color: .Signal.yellow)
}
contextMenuActions.append(
UIMenu(options: [.displayInline], children: [
UIAction(
title: OWSLocalizedString(
"HOME_VIEW_TITLE_FAILED_TO_BACKUP",
comment: "Title for the conversation list's failed to backup context menu action.",
),
image: image,
handler: { [weak self] _ in
SignalApp.shared.showAppSettings(mode: .backups())
db.write { tx in
backupSettingsStore.setErrorBadgeMuted(target: .chatListMenuItem, tx: tx)
}
self?.updateBackupFailureAlertsWithSneakyTransaction()
},
),
]),
)
} else if viewState.settingsButtonCreator.showBackupsSubscriptionAlreadyRedeemedMenuItem {
let image = Theme.iconImage(.backup)
.withTintColor(.Signal.label)
.withBadge(color: .Signal.yellow)
contextMenuActions.append(
UIMenu(options: .displayInline, children: [
UIAction(
title: OWSLocalizedString(
"HOME_VIEW_TITLE_BACKUP_SUBSCRIPTION_FAILED_TO_REDEEM",
comment: "Title for the conversation list's failed to redeem backup subscription context menu action.",
),
image: image,
handler: { [weak self] _ in
SignalApp.shared.showAppSettings(mode: .backups())
db.write { tx in
backupSubscriptionIssueStore.setDidAckIAPSubscriptionAlreadyRedeemedChatListMenuItem(tx: tx)
}
self?.updateBackupSubscriptionFailedToRedeemAlertsWithSneakyTx()
},
),
]),
)
} else if viewState.settingsButtonCreator.showBackupsIAPNotFoundLocallyMenuItem {
let image = Theme.iconImage(.backup)
.withTintColor(.Signal.label)
.withBadge(
color: .Signal.yellow,
badgeSize: .square(8.5),
)
contextMenuActions.append(
UIMenu(options: .displayInline, children: [
UIAction(
title: OWSLocalizedString(
"HOME_VIEW_TITLE_BACKUP_SUBSCRIPTION_NOT_FOUND_LOCALLY",
comment: "Title for the conversation list's backup subscription not found locally context menu action.",
),
image: image,
handler: { [weak self] _ in
SignalApp.shared.showAppSettings(mode: .backups())
db.write { tx in
backupSubscriptionIssueStore.setDidAckIAPSubscriptionNotFoundLocallyChatListMenuItem(tx: tx)
}
self?.updateBackupIAPNotFoundLocallyAlertsWithSneakyTx()
},
),
]),
)
} else if viewState.settingsButtonCreator.hasConsumedMediaTierCapacity {
let image = Theme.iconImage(.backup)
.withTintColor(.Signal.label)
.withBadge(color: .Signal.red)
contextMenuActions.append(
UIMenu(options: [.displayInline], children: [
UIAction(
title: OWSLocalizedString(
"HOME_VIEW_TITLE_BACKUP_OUT_OF_STORAGE_QUOTA",
comment: "Title for the conversation list's backup storage full context menu action.",
),
image: image,
handler: { _ in
SignalApp.shared.showAppSettings(mode: .backups())
},
),
]),
)
}
// FIXME: combine viewState.inboxFilter and renderState.viewInfo.inboxFilter to avoid bugs with them getting out of sync
switch viewState.inboxFilter {
case .unread:
contextMenuActions.append(.disableChatListFilter(target: self))
case .none?, nil:
contextMenuActions.append(.enableChatListFilter(target: self))
}
if viewState.settingsButtonCreator.hasInboxChats {
contextMenuActions.append(
UIAction(
title: OWSLocalizedString(
"HOME_VIEW_TITLE_SELECT_CHATS",
comment: "Title for the 'Select Chats' option in the ChatList.",
),
image: Theme.iconImage(.contextMenuSelect),
handler: { [weak self] _ in
self?.willEnterMultiselectMode()
},
),
)
}
contextMenuActions.append(settingsAction)
if viewState.settingsButtonCreator.hasArchivedChats {
contextMenuActions.append(
UIAction(
title: OWSLocalizedString(
"HOME_VIEW_TITLE_ARCHIVE",
comment: "Title for the conversation list's 'archive' mode.",
),
image: Theme.iconImage(.contextMenuArchive),
handler: { [weak self] _ in
self?.showArchivedConversations(offerMultiSelectMode: true)
},
),
)
}
return contextMenuActions
},
showAppSettings: { [weak self] in
self?.showAppSettings()
},
)
barButtonItem.accessibilityLabel = CommonStrings.openAppSettingsButton
return barButtonItem
}
// MARK: Table View
func reloadTableDataAndResetThreadViewModelCache() {
threadViewModelCache.clear()
reloadTableDataAndResetCellContentCache()
}
func reloadTableDataAndResetCellContentCache() {
AssertIsOnMainThread()
cellContentCache.clear()
conversationCellHeightCache = nil
reloadTableData()
}
func reloadTableData(previouslySelectedThreadUniqueIds: [String] = []) {
AssertIsOnMainThread()
tableView.reloadData()
let selectedThreadIds: Set<String> = Set(previouslySelectedThreadUniqueIds)
if !selectedThreadIds.isEmpty {
var threadIdsToBeSelected = selectedThreadIds
for section in 0..<tableDataSource.numberOfSections(in: tableView) {
for row in 0..<tableDataSource.tableView(tableView, numberOfRowsInSection: section) {
let indexPath = IndexPath(row: row, section: section)
if
let key = renderState.threadUniqueId(forIndexPath: indexPath),
threadIdsToBeSelected.contains(key)
{
tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
threadIdsToBeSelected.remove(key)
if threadIdsToBeSelected.isEmpty {
return
}
}
}
}
}
}
func updateCellVisibility() {
AssertIsOnMainThread()
for cell in tableView.visibleCells {
guard let cell = cell as? ChatListCell else {
continue
}
updateCellVisibility(cell: cell, isCellVisible: true)
}
}
func updateCellVisibility(cell: ChatListCell, isCellVisible: Bool) {
AssertIsOnMainThread()
cell.isCellVisible = isViewVisible && isCellVisible
}
private func ensureCellAnimations() {
AssertIsOnMainThread()
for cell in tableView.visibleCells {
guard let cell = cell as? ChatListCell else {
continue
}
cell.ensureCellAnimations()
}
}
/// iOS 26+: checks if this VC is displayed in the collapsed split view controller
/// and updates `containerView.tableViewHorizontalInset` accordingly.
/// Does nothing on prior iOS versions.
private func updateTableViewPaddingIfNeeded() {
guard #available(iOS 26, *) else { return }
let useSidebarChatListCellAppearance: Bool
if let splitViewController, !splitViewController.isCollapsed {
useSidebarChatListCellAppearance = true
} else {
useSidebarChatListCellAppearance = false
}
// We need to add/remove horizotal padding around table view...
containerView.tableViewHorizontalInset = useSidebarChatListCellAppearance ? 16 : 0
// ... and tell ChatListCell to use rounded corners if there is non-zero padding.
tableDataSource.useSideBarChatListCellAppearance = useSidebarChatListCellAppearance
}
// MARK: UI Helpers
private var getStartedBanner: GetStartedBannerViewController?
private var hasEverPresentedExperienceUpgrade = false
var lastViewedThreadUniqueId: String?
func updateBarButtonItems() {
updateLeftBarButtonItem()
updateRightBarButtonItems()
}
private func updateLeftBarButtonItem() {
guard viewState.chatListMode == .inbox, !viewState.multiSelectState.isActive else { return }
// Settings button.
navigationItem.leftBarButtonItem = settingsBarButtonItem()
}
private func updateRightBarButtonItems() {
guard viewState.chatListMode == .inbox, !viewState.multiSelectState.isActive else { return }
var rightBarButtonItems = [UIBarButtonItem]()
let compose = UIBarButtonItem(
image: Theme.iconImage(.buttonCompose),
style: .plain,
target: self,
action: #selector(showNewConversationView),
accessibilityIdentifier: "ChatListViewController.compose",
)
compose.accessibilityLabel = NSLocalizedString("COMPOSE_BUTTON_LABEL", comment: "Accessibility label from compose button.")
compose.accessibilityHint = NSLocalizedString(
"COMPOSE_BUTTON_HINT",
comment: "Accessibility hint describing what you can do with the compose button",
)
rightBarButtonItems.append(compose)
let camera = UIBarButtonItem(
image: Theme.iconImage(.buttonCamera),
style: .plain,
target: self,
action: #selector(showCameraView),
accessibilityIdentifier: "ChatListViewController.camera",
)
camera.accessibilityLabel = NSLocalizedString("CAMERA_BUTTON_LABEL", comment: "Accessibility label for camera button.")
camera.accessibilityHint = NSLocalizedString(
"CAMERA_BUTTON_HINT",
comment: "Accessibility hint describing what you can do with the camera button",
)
rightBarButtonItems.append(camera)
if let proxyButton = viewState.proxyButtonCreator.buildButton() {
rightBarButtonItems.append(proxyButton)
}
navigationItem.rightBarButtonItems = rightBarButtonItems
}
@objc
func showNewConversationView() {
AssertIsOnMainThread()
Logger.info("")
// Dismiss any message actions if they're presented
conversationSplitViewController?.selectedConversationViewController?.dismissMessageContextMenu(animated: true)
let viewController = ComposeViewController()
SSKEnvironment.shared.contactManagerImplRef.requestSystemContactsOnce { error in
if let error {
Logger.error("Error when requesting contacts: \(error)")
}
// Even if there is an error fetching contacts we proceed to the next screen.
// As the compose view will present the proper thing depending on contact access.
//
// We just want to make sure contact access is *complete* before showing the compose
// screen to avoid flicker.
let modal = OWSNavigationController(rootViewController: viewController)
self.navigationController?.presentFormSheet(modal, animated: true)
}
}
func showNewGroupView() {
AssertIsOnMainThread()
Logger.info("")
// Dismiss any message actions if they're presented
conversationSplitViewController?.selectedConversationViewController?.dismissMessageContextMenu(animated: true)
let newGroupViewController = NewGroupMembersViewController()
SSKEnvironment.shared.contactManagerImplRef.requestSystemContactsOnce { error in
if let error {
Logger.error("Error when requesting contacts: \(error)")
}
// Even if there is an error fetching contacts we proceed to the next screen.
// As the compose view will present the proper thing depending on contact access.
//
// We just want to make sure contact access is *complete* before showing the compose
// screen to avoid flicker.
let modal = OWSNavigationController(rootViewController: newGroupViewController)
self.navigationController?.presentFormSheet(modal, animated: true)
}
}
@objc
private func firstConversationCueWasTapped(_ gestureRecognizer: UITapGestureRecognizer) {
Logger.info("")
showNewConversationView()
}
private func applyDefaultBackButton() {
AssertIsOnMainThread()
if #available(iOS 26, *) { return }
// We don't show any text for the back button, so there's no need to localize it. But because we left align the
// conversation title view, we add a little tappable padding after the back button, by having a title of spaces.
// Admittedly this is kind of a hack and not super fine grained, but it's simple and results in the interactive pop
// gesture animating our title view nicely vs. creating our own back button bar item with custom padding, which does
// not properly animate with the "swipe to go back" or "swipe left for info" gestures.
let paddingLength: Int = 3
let paddingString = "".padding(toLength: paddingLength, withPad: " ", startingAt: 0)
navigationItem.backBarButtonItem = UIBarButtonItem(
title: paddingString,
style: .plain,
target: nil,
action: nil,
accessibilityIdentifier: "back",
)
}
// We want to delay asking for a review until an opportune time.
// If the user has *just* launched Signal they intend to do something, we don't want to interrupt them.
private static var requestReviewCount = 0
private static var didRequestReview = false
private func requestReviewIfAppropriate() {
Self.requestReviewCount += 1
// Despite `SKStoreReviewController` docs, some people have reported seeing
// the "request review" prompt repeatedly after first installation. Let's
// make sure it only happens at most once per launch.
if Self.didRequestReview {
return
}
guard hasEverAppeared, Self.requestReviewCount > 25 else {
return
}
guard let windowScene = self.view.window?.windowScene else {
return
}
// In Production this will pop up at most 3 times per 365 days.
SKStoreReviewController.requestReview(in: windowScene)
Self.didRequestReview = true
}
// MARK: View State
let viewState: CLVViewState
private func shouldShowFirstConversationCue() -> Bool {
return shouldShowEmptyInboxView && !SSKEnvironment.shared.databaseStorageRef.read(block: SSKPreferences.hasSavedThread(transaction:))
}
private var shouldShowEmptyInboxView: Bool {
return viewState.chatListMode == .inbox && renderState.viewInfo.inboxCount == 0 && renderState.viewInfo.archiveCount == 0 && !renderState.hasVisibleReminders
}
func updateViewState() {
if shouldShowEmptyInboxView {
tableView.isHidden = true
emptyInboxView.isHidden = false
if shouldShowFirstConversationCue() {
firstConversationCueView.isHidden = false
updateFirstConversationLabel()
} else {
firstConversationCueView.isHidden = true
}
} else {
tableView.isHidden = false
emptyInboxView.isHidden = true
firstConversationCueView.isHidden = true
}
}
// MARK: -
private var donationReceiptCredentialResultStore: DonationReceiptCredentialResultStore {
DependenciesBridge.shared.donationReceiptCredentialResultStore
}
// MARK: -
func isChatListTopmostViewController() -> Bool {
guard
UIApplication.shared.frontmostViewController == self.conversationSplitViewController,
conversationSplitViewController?.selectedThread == nil,
presentedViewController == nil
else { return false }
return true
}
// MARK: - Payments
func configureUnreadPaymentsBannerSingle(
_ paymentsReminderView: UIView,
paymentModel: TSPaymentModel,
transaction: DBReadTransaction,
) {
guard
paymentModel.isIncoming,
!paymentModel.isUnidentified,
let senderAci = paymentModel.senderOrRecipientAci,
let paymentAmount = paymentModel.paymentAmount,
paymentAmount.isValid
else {
configureUnreadPaymentsBannerMultiple(paymentsReminderView, unreadCount: 1)
return
}
let address = SignalServiceAddress(senderAci)
guard nil != TSContactThread.getWithContactAddress(address, transaction: transaction) else {
configureUnreadPaymentsBannerMultiple(paymentsReminderView, unreadCount: 1)
return
}
let shortName = SSKEnvironment.shared.contactManagerRef.displayName(for: address, tx: transaction).resolvedValue(useShortNameIfAvailable: true)
let formattedAmount = PaymentsFormat.format(
paymentAmount: paymentAmount,
isShortForm: true,
withCurrencyCode: true,
withSpace: true,
)
let format = OWSLocalizedString(
"PAYMENTS_NOTIFICATION_BANNER_1_WITH_DETAILS_FORMAT",
comment: "Format for the payments notification banner for a single payment notification with details. Embeds: {{ %1$@ the name of the user who sent you the payment, %2$@ the amount of the payment }}.",
)
let title = String.nonPluralLocalizedStringWithFormat(format, shortName, formattedAmount)
let avatarView = ConversationAvatarView(sizeClass: .customDiameter(Self.paymentsBannerAvatarSize), localUserDisplayMode: .asUser)
avatarView.update(transaction) { config in
config.dataSource = .address(address)
}
let paymentsHistoryItem = PaymentsHistoryModelItem(
paymentModel: paymentModel,
displayName: shortName,
)
configureUnreadPaymentsBanner(
paymentsReminderView,
title: title,
avatarView: avatarView,
) { [weak self] in
self?.showAppSettings(mode: .payment(paymentsHistoryItem: paymentsHistoryItem))
}
}
func configureUnreadPaymentsBannerMultiple(
_ paymentsReminderView: UIView,
unreadCount: UInt,
) {
let title: String
if unreadCount == 1 {
title = OWSLocalizedString(
"PAYMENTS_NOTIFICATION_BANNER_1",
comment: "Label for the payments notification banner for a single payment notification.",
)
} else {
let format = OWSLocalizedString(
"PAYMENTS_NOTIFICATION_BANNER_N_FORMAT",
comment: "Format for the payments notification banner for multiple payment notifications. Embeds: {{ the number of unread payment notifications }}.",
)
title = String.nonPluralLocalizedStringWithFormat(format, OWSFormat.formatUInt(unreadCount))
}
let iconView = UIImageView.withTemplateImageName(
"payment",
tintColor: Theme.isDarkThemeEnabled ? .ows_gray15 : .ows_white,
)
iconView.autoSetDimensions(to: .square(24))
let iconCircleView = OWSLayerView.circleView(size: CGFloat(Self.paymentsBannerAvatarSize))
iconCircleView.backgroundColor = (
Theme.isDarkThemeEnabled
? .ows_gray80
: .ows_gray95,
)
iconCircleView.addSubview(iconView)
iconView.autoCenterInSuperview()
configureUnreadPaymentsBanner(
paymentsReminderView,
title: title,
avatarView: iconCircleView,
) { [weak self] in
self?.showAppSettings(mode: .payments)
}
}
private static let paymentsBannerAvatarSize: UInt = 40
private class PaymentsBannerView: UIView {
let block: () -> Void
init(block: @escaping () -> Void) {
self.block = block
super.init(frame: .zero)
isUserInteractionEnabled = true
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTap)))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc
func didTap() {
block()
}
}
private func configureUnreadPaymentsBanner(
_ paymentsReminderView: UIView,
title: String,
avatarView: UIView,
block: @escaping () -> Void,
) {
paymentsReminderView.removeAllSubviews()
let paymentsBannerView = PaymentsBannerView(block: block)
paymentsReminderView.addSubview(paymentsBannerView)
paymentsBannerView.autoPinEdgesToSuperviewEdges()
if UIDevice.current.isIPad {
paymentsReminderView.backgroundColor = (
Theme.isDarkThemeEnabled
? .ows_gray75
: .ows_gray05,
)
} else {
paymentsReminderView.backgroundColor = (
Theme.isDarkThemeEnabled
? .ows_gray90
: .ows_gray02,
)
}
avatarView.setCompressionResistanceHigh()
avatarView.setContentHuggingHigh()
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.textColor = Theme.primaryTextColor
titleLabel.font = UIFont.dynamicTypeSubheadlineClamped.semibold()
let viewLabel = UILabel()
viewLabel.text = CommonStrings.viewButton
viewLabel.textColor = Theme.accentBlueColor
viewLabel.font = UIFont.dynamicTypeSubheadlineClamped
let textStack = UIStackView(arrangedSubviews: [titleLabel, viewLabel])
textStack.axis = .vertical
textStack.alignment = .leading
let dismissButton = OWSLayerView.circleView(size: 20)
dismissButton.backgroundColor = (
Theme.isDarkThemeEnabled
? .ows_gray65
: .ows_gray05,
)
dismissButton.setCompressionResistanceHigh()
dismissButton.setContentHuggingHigh()
let dismissIcon = UIImageView.withTemplateImageName(
"x-compact",
tintColor: Theme.isDarkThemeEnabled
? .ows_white
: .ows_gray60,
)
dismissIcon.autoSetDimensions(to: .square(16))
dismissButton.addSubview(dismissIcon)
dismissIcon.autoCenterInSuperview()
let stack = UIStackView(arrangedSubviews: [
avatarView,
textStack,
dismissButton,
])
stack.axis = .horizontal
stack.alignment = .center
stack.spacing = 10
stack.layoutMargins = UIEdgeInsets(
top: OWSTableViewController2.cellVInnerMargin,
left: OWSTableViewController2.cellOuterInset(in: view),
bottom: OWSTableViewController2.cellVInnerMargin,
right: OWSTableViewController2.cellOuterInset(in: view),
)
stack.isLayoutMarginsRelativeArrangement = true
paymentsBannerView.addSubview(stack)
stack.autoPinEdgesToSuperviewEdges()
}
// MARK: - Notifications
func checkForFailedServiceExtensionLaunches() async throws(CancellationError) {
guard #available(iOS 17.0, *) else {
return
}
guard RemoteConfig.current.shouldCheckForServiceExtensionFailures else {
return
}
try await SSKEnvironment.shared.messageProcessorRef.waitForFetchingAndProcessing()
let notificationSettings = await UNUserNotificationCenter.current().notificationSettings()
guard notificationSettings.authorizationStatus == .authorized else {
return
}
// Has the NSE ever launched with the current version?
let appVersion = AppVersionImpl.shared
guard
let mainAppVersion = appVersion.lastCompletedLaunchMainAppVersion,
let nseAppVersion = appVersion.lastCompletedLaunchNSEAppVersion,
let upgradeDate = appVersion.firstMainAppLaunchDateAfterUpdate
else {
return
}
guard nseAppVersion != mainAppVersion else {
return
}
// Has it been at least an hour since we upgraded?
guard -upgradeDate.timeIntervalSinceNow > .hour else {
return
}
// Has the user restarted since the most recent update was installed?
let bootTime: Date? = {
var timeVal = timeval()
var timeValSize = MemoryLayout<timeval>.size
let err = sysctlbyname("kern.boottime", &timeVal, &timeValSize, nil, 0)
guard err == 0, timeValSize == MemoryLayout<timeval>.size else {
return nil
}
return Date(timeIntervalSince1970: TimeInterval(timeVal.tv_sec))
}()
guard let bootTime else {
return
}
guard bootTime < upgradeDate else {
return
}
let keyValueStore = KeyValueStore(collection: "FailedNSELaunches")
let mostRecentDateKey = "mostRecentPromptDate"
let promptCountKey = "promptCount"
let shouldShowPrompt = SSKEnvironment.shared.databaseStorageRef.read { tx -> Bool in
// If we've shown the prompt recently, don't show it again.
let promptCount = keyValueStore.getInt(promptCountKey, defaultValue: 0, transaction: tx)
let promptBackoff: TimeInterval = {
switch promptCount {
case 0:
return 0
case 1, 2:
return 24 * .hour
case 3:
return 48 * .hour
case 4:
return 72 * .hour
default:
return 96 * .hour
}
}()
let mostRecentDate = keyValueStore.getDate(mostRecentDateKey, transaction: tx)
if let mostRecentDate, -mostRecentDate.timeIntervalSinceNow < promptBackoff {
return false
}
// If we haven't received a message since upgrading, don't show it.
guard
let mostRecentMessage = InteractionFinder.lastInsertedIncomingMessage(transaction: tx),
Date(millisecondsSince1970: mostRecentMessage.receivedAtTimestamp) > upgradeDate
else {
return false
}
guard let serverTimestampMs = mostRecentMessage.serverTimestamp else {
return false
}
let serverTimestamp = Date(millisecondsSince1970: serverTimestampMs.uint64Value)
let serverDeliveryTimestamp = Date(millisecondsSince1970: mostRecentMessage.serverDeliveryTimestamp)
// If the most recent message was delivered quickly, don't show it. (This
// handles cases where the NSE doesn't launch because the app happens to be
// open when the message is received.)
guard serverDeliveryTimestamp > (serverTimestamp + .minute) else {
return false
}
return true
}
guard shouldShowPrompt else {
return
}
guard isChatListTopmostViewController() else {
return
}
OWSActionSheets.showContactSupportActionSheet(
title: OWSLocalizedString(
"NOTIFICATIONS_ERROR_TITLE",
comment: "Shown as the title of an alert when notifications can't be shown due to an error.",
),
message: String.nonPluralLocalizedStringWithFormat(
OWSLocalizedString(
"NOTIFICATIONS_ERROR_MESSAGE",
comment: "Shown as the body of an alert when notifications can't be shown due to an error.",
),
UIDevice.current.localizedModel,
),
emailFilter: .custom("NotLaunchingNSE"),
fromViewController: self,
)
let promptDate = Date()
await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
keyValueStore.setDate(promptDate, key: mostRecentDateKey, transaction: tx)
keyValueStore.setInt(
keyValueStore.getInt(promptCountKey, defaultValue: 0, transaction: tx) + 1,
key: promptCountKey,
transaction: tx,
)
}
}
}
// MARK: - ChatListFilterActions
extension ChatListViewController {
func enableChatListFilter(_ sender: AnyObject?) {
updateChatListFilter(.unread)
updateBarButtonItems()
if filterControl?.isFiltering == true {
// No need to update the filter control if it's already in the
// filtering state.
loadCoordinator.loadIfNecessary()
} else {
tableView.performBatchUpdates {
filterControl?.startFiltering(animated: true)
loadCoordinator.loadIfNecessary()
}
}
}
func disableChatListFilter(_ sender: AnyObject?) {
updateChatListFilter(.none)
updateBarButtonItems()
tableView.performBatchUpdates {
filterControl?.stopFiltering(animated: true)
loadCoordinator.loadIfNecessary()
}
}
private func updateFilterControl(animated: Bool) {
guard let filterControl else { return }
if viewState.inboxFilter == .unread {
filterControl.startFiltering(animated: animated)
} else {
filterControl.stopFiltering(animated: animated)
}
}
private func updateChatListFilter(_ inboxFilter: InboxFilter) {
viewState.inboxFilter = inboxFilter
loadCoordinator.saveInboxFilter(inboxFilter)
updateBarButtonItems()
}
}
// MARK: - Settings Button
extension ChatListViewController: ChatListSettingsButtonDelegate {
func didUpdateButton(_ settingsButtonCreator: ChatListSettingsButtonState) {
updateLeftBarButtonItem()
}
}
extension ChatListViewController: ChatListProxyButtonDelegate {
func didUpdateButton(_ proxyButtonCreator: ChatListProxyButtonCreator) {
updateRightBarButtonItems()
}
func didTapButton(_ proxyButtonCreator: ChatListProxyButtonCreator) {
showAppSettings(mode: .proxy)
}
}
// MARK: -
extension ChatListViewController {
enum ShowAppSettingsMode {
case payments
case payment(paymentsHistoryItem: PaymentsHistoryItem)
case paymentsTransferIn
case appearance
case avatarBuilder
case backups(onAppearAction: BackupSettingsViewController.OnAppearAction? = nil)
case corruptedUsernameResolution
case corruptedUsernameLinkResolution
case donate(donateMode: DonateViewController.DonateMode)
case linkedDevices
case proxy
}
func showAppSettings(mode: ShowAppSettingsMode? = nil, completion: (() -> Void)? = nil) {
AssertIsOnMainThread()
Logger.info("")
// Dismiss any message actions if they're presented
conversationSplitViewController?.selectedConversationViewController?
.dismissMessageContextMenu(animated: true)
let navigationController = OWSNavigationController()
let appSettingsViewController = AppSettingsViewController(appReadiness: appReadiness)
var internalCompletion: (() -> Void)?
var viewControllers: [UIViewController] = [appSettingsViewController]
switch mode {
case nil:
break
case .payments:
let paymentsSettings = PaymentsSettingsViewController(mode: .inAppSettings, appReadiness: appReadiness)
viewControllers += [paymentsSettings]
case .payment(let paymentsHistoryItem):
let paymentsSettings = PaymentsSettingsViewController(mode: .inAppSettings, appReadiness: appReadiness)
let paymentsDetail = PaymentsDetailViewController(paymentItem: paymentsHistoryItem)
viewControllers += [paymentsSettings, paymentsDetail]
case .paymentsTransferIn:
let paymentsSettings = PaymentsSettingsViewController(mode: .inAppSettings, appReadiness: appReadiness)
let paymentsTransferIn = PaymentsTransferInViewController()
viewControllers += [paymentsSettings, paymentsTransferIn]
case .appearance:
let appearance = AppearanceSettingsTableViewController()
viewControllers += [appearance]
case .avatarBuilder:
let profile = ProfileSettingsViewController(
usernameChangeDelegate: appSettingsViewController,
usernameLinkScanDelegate: appSettingsViewController,
)
viewControllers += [profile]
internalCompletion = { profile.presentAvatarSettingsView() }
case .backups(let onAppearAction):
viewControllers += [
BackupOnboardingCoordinator()
.prepareForPresentation(
inNavController: navigationController,
onAppearAction: onAppearAction,
),
]
case .corruptedUsernameResolution:
let profile = ProfileSettingsViewController(
usernameChangeDelegate: appSettingsViewController,
usernameLinkScanDelegate: appSettingsViewController,
)
viewControllers += [profile]
internalCompletion = { profile.presentUsernameCorruptedResolution() }
case .corruptedUsernameLinkResolution:
let profile = ProfileSettingsViewController(
usernameChangeDelegate: appSettingsViewController,
usernameLinkScanDelegate: appSettingsViewController,
)
viewControllers += [profile]
internalCompletion = { profile.presentUsernameLinkCorruptedResolution() }
case let .donate(donateMode):
guard
DonationUtilities.canDonate(
inMode: donateMode.asDonationMode,
tsAccountManager: DependenciesBridge.shared.tsAccountManager,
)
else {
DonationViewsUtil.openDonateWebsite()
return
}
let donate = DonateViewController(preferredDonateMode: donateMode) { [weak self] finishResult in
switch finishResult {
case let .completedDonation(donateSheet, receiptCredentialSuccessMode):
donateSheet.dismiss(animated: true) { [weak self] in
guard
let self,
let badgeThanksSheetPresenter = BadgeThanksSheetPresenter.fromGlobalsWithSneakyTransaction(
successMode: receiptCredentialSuccessMode,
)
else { return }
Task {
await badgeThanksSheetPresenter.presentAndRecordBadgeThanks(
fromViewController: self,
)
}
}
case let .monthlySubscriptionCancelled(donateSheet, toastText):
donateSheet.dismiss(animated: true) { [weak self] in
guard let self else { return }
self.view.presentToast(text: toastText, fromViewController: self)
}
}
}
viewControllers += [donate]
case .linkedDevices:
viewControllers += [LinkedDevicesHostingController()]
case .proxy:
viewControllers += [PrivacySettingsViewController(), AdvancedPrivacySettingsViewController(), ProxySettingsViewController()]
}
navigationController.setViewControllers(viewControllers, animated: false)
DispatchQueue.main.async { [weak self] in
self?.presentFormSheet(navigationController, animated: true) {
completion?()
internalCompletion?()
}
}
}
}
// MARK: - ThreadContextualActionProvider
extension ChatListViewController: ThreadContextualActionProvider {
func threadContextualActionShouldCloseThreadIfActive(threadViewModel: ThreadViewModel) {
if
let conversationSplitViewController,
conversationSplitViewController.selectedThread?.uniqueId == threadViewModel.threadUniqueId
{
conversationSplitViewController.closeSelectedConversation(animated: true)
}
}
func threadContextualActionDidComplete() {
updateViewState()
}
}
// MARK: - GetStartedBannerViewControllerDelegate
extension ChatListViewController: GetStartedBannerViewControllerDelegate {
func presentGetStartedBannerIfNecessary() {
guard getStartedBanner == nil, viewState.chatListMode == .inbox else { return }
let getStartedVC = GetStartedBannerViewController(delegate: self)
if getStartedVC.hasIncompleteCards {
getStartedBanner = getStartedVC
addChild(getStartedVC)
view.addSubview(getStartedVC.view)
getStartedVC.view.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .top)
// If we're in landscape, the banner covers most of the screen
// Hide it until we transition to portrait
if view.bounds.width > view.bounds.height {
getStartedVC.view.alpha = 0
}
}
}
func getStartedBannerDidTapInviteFriends(_ banner: GetStartedBannerViewController) {
let inviteFlow = InviteFlow(presentingViewController: self)
inviteFlow.present(isAnimated: true, completion: nil)
}
func getStartedBannerDidTapCreateGroup(_ banner: GetStartedBannerViewController) {
showNewGroupView()
}
func getStartedBannerDidTapAppearance(_ banner: GetStartedBannerViewController) {
showAppSettings(mode: .appearance)
}
func getStartedBannerDidDismissAllCards(_ banner: GetStartedBannerViewController, animated: Bool) {
let dismissBlock = {
banner.view.removeFromSuperview()
banner.removeFromParent()
self.getStartedBanner = nil
}
if animated {
banner.view.setIsHidden(true, withAnimationDuration: 0.5) { _ in
dismissBlock()
}
} else {
dismissBlock()
}
}
func getStartedBannerDidTapAvatarBuilder(_ banner: GetStartedBannerViewController) {
showAppSettings(mode: .avatarBuilder)
}
}
// MARK: - First conversation label
extension ChatListViewController {
func updateFirstConversationLabel() {
let contactNames = SSKEnvironment.shared.databaseStorageRef.read { tx -> [ComparableDisplayName] in
let comparableNames = SSKEnvironment.shared.contactManagerRef.sortedComparableNames(
for: SSKEnvironment.shared.profileManagerRef.allWhitelistedRegisteredAddresses(tx: tx),
tx: tx,
)
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx) else {
return []
}
return Array(
comparableNames.lazy
.filter { !localIdentifiers.contains(address: $0.address) }
.prefix(3),
)
}
let formatString = { () -> String in
switch contactNames.count {
case 0:
return OWSLocalizedString(
"HOME_VIEW_FIRST_CONVERSATION_OFFER_NO_CONTACTS",
comment: "A label offering to start a new conversation with your contacts, if you have no Signal contacts.",
)
case 1:
return OWSLocalizedString(
"HOME_VIEW_FIRST_CONVERSATION_OFFER_1_CONTACT_FORMAT",
comment: "Format string for a label offering to start a new conversation with your contacts, if you have 1 Signal contact. Embeds {{The name of 1 of your Signal contacts}}.",
)
case 2:
return OWSLocalizedString(
"HOME_VIEW_FIRST_CONVERSATION_OFFER_2_CONTACTS_FORMAT",
comment: "Format string for a label offering to start a new conversation with your contacts, if you have 2 Signal contacts. Embeds {{The names of 2 of your Signal contacts}}.",
)
case 3:
return OWSLocalizedString(
"HOME_VIEW_FIRST_CONVERSATION_OFFER_3_CONTACTS_FORMAT",
comment: "Format string for a label offering to start a new conversation with your contacts, if you have at least 3 Signal contacts. Embeds {{The names of 3 of your Signal contacts}}.",
)
default:
owsFail("Too many contactNames.")
}
}()
let attributedString = NSAttributedString.make(
fromFormat: formatString,
attributedFormatArgs: contactNames.map { comparableName in
return .string(
comparableName.resolvedValue(),
attributes: [.font: firstConversationLabel.font.semibold()],
)
},
)
firstConversationLabel.attributedText = attributedString
}
}
extension ChatListViewController: UIScrollViewDelegate {
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
filterControl?.draggingWillBegin(in: scrollView)
cancelSearch()
}
public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
filterControl?.draggingWillEnd(in: scrollView)
}
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate: Bool) {
filterControl?.draggingDidEnd(in: scrollView)
}
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
filterControl?.scrollingDidStop(in: scrollView)
}
public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
filterControl?.scrollingDidStop(in: scrollView)
}
}
extension ChatListViewController: ChatListFilterControlDelegate {
func filterControlWillChangeState(to state: ChatListFilterControl.FilterState) {
switch state {
case .on:
updateChatListFilter(.unread)
case .off:
updateChatListFilter(.none)
}
// Because this happens in response to an interactive gesture, it feels
// better to go a little slower than the default animation duration (0.25 sec).
UIView.animate(withDuration: 0.4) { [self] in
tableView.performBatchUpdates {
loadCoordinator.loadIfNecessary()
}
}
}
}