Path: blob/main/Signal/src/ViewControllers/ThreadSettings/DisappearingMessagesTimerSettingsViewController.swift
1 views
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
import SwiftUI
class DisappearingMessagesTimerSettingsViewController: HostingController<DisappearingMessagesTimerSettingsView> {
enum SettingsMode {
case chat(thread: TSThread)
case newGroup
case universal
}
private let initialConfiguration: DisappearingMessagesConfigurationRecord
private var selectedConfiguration: DisappearingMessagesConfigurationRecord
private let settingsMode: SettingsMode
private let completion: (DisappearingMessagesConfigurationRecord) -> Void
private let viewModel: DisappearingMessagesTimerSettingsViewModel
private lazy var setButton: UIBarButtonItem = .setButton { [weak self] in
self?.completeAndDismiss()
}
init(
initialConfiguration: DisappearingMessagesConfigurationRecord,
settingsMode: SettingsMode,
completion: @escaping (DisappearingMessagesConfigurationRecord) -> Void,
) {
self.initialConfiguration = initialConfiguration
self.selectedConfiguration = initialConfiguration
self.settingsMode = settingsMode
self.completion = completion
self.viewModel = DisappearingMessagesTimerSettingsViewModel(
initialDurationSeconds: initialConfiguration.durationSeconds,
settingsMode: settingsMode,
)
super.init(wrappedView: DisappearingMessagesTimerSettingsView(viewModel: viewModel))
title = OWSLocalizedString(
"DISAPPEARING_MESSAGES",
comment: "table cell label in conversation settings",
)
OWSTableViewController2.removeBackButtonText(viewController: self)
viewModel.actionsDelegate = self
navigationItem.leftBarButtonItem = .cancelButton(
dismissingFrom: self,
hasUnsavedChanges: { [weak self] in self?.hasUnsavedChanges },
)
navigationItem.rightBarButtonItem = self.setButton
updateNavigationItem()
}
private var hasUnsavedChanges: Bool {
return initialConfiguration.asToken != selectedConfiguration.asToken
}
// Don't allow interactive dismiss when there are unsaved changes.
override var isModalInPresentation: Bool {
get { hasUnsavedChanges }
set {}
}
private func updateNavigationItem() {
setButton.isEnabled = hasUnsavedChanges
}
private func completeAndDismiss() {
let configuration = selectedConfiguration
// We use this view some places that don't have a thread like the
// new group view and the universal timer in privacy settings. We
// only need to do the extra "save" logic to apply the timer
// immediately if we have a thread.
guard
let thread = switch settingsMode
{
case .chat(let thread): thread
case .newGroup, .universal: nil
},
hasUnsavedChanges
else {
completion(configuration)
dismiss(animated: true)
return
}
GroupViewUtils.updateGroupWithActivityIndicator(
fromViewController: self,
updateBlock: {
let databaseStorage = SSKEnvironment.shared.databaseStorageRef
await databaseStorage.awaitableWrite { tx in
// We're sending a message, so we're accepting any pending message request.
_ = ThreadUtil.addThreadToProfileWhitelistIfEmptyOrPendingRequest(thread, setDefaultTimerIfNecessary: true, tx: tx)
}
try await self.localUpdateDisappearingMessagesConfiguration(
thread: thread,
newToken: configuration.asVersionedToken,
)
},
completion: { [weak self] in
self?.completion(configuration)
self?.dismiss(animated: true)
},
)
}
private func localUpdateDisappearingMessagesConfiguration(
thread: TSThread,
newToken: VersionedDisappearingMessageToken,
) async throws {
if let contactThread = thread as? TSContactThread {
await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
GroupManager.localUpdateDisappearingMessageToken(
newToken,
inContactThread: contactThread,
tx: tx,
)
}
} else if let groupThread = thread as? TSGroupThread {
if let groupV2Model = groupThread.groupModel as? TSGroupModelV2 {
try await GroupManager.updateGroupV2(
groupModel: groupV2Model,
description: "Update disappearing messages",
) { changeSet in
changeSet.setNewDisappearingMessageToken(newToken.unversioned)
}
} else {
throw OWSAssertionError("Cannot update disappearing message config for V1 groups!")
}
} else {
throw OWSAssertionError("Unexpected thread type in disappearing message update! \(type(of: thread))")
}
}
}
// MARK: - DisappearingMessagesTimerSettingsViewModel.ActionsDelegate
extension DisappearingMessagesTimerSettingsViewController: DisappearingMessagesTimerSettingsViewModel.ActionsDelegate {
fileprivate func updateForSelection(_ durationSeconds: UInt32) {
selectedConfiguration = initialConfiguration
selectedConfiguration.isEnabled = durationSeconds != 0
selectedConfiguration.durationSeconds = durationSeconds
selectedConfiguration.timerVersion = initialConfiguration.timerVersion + 1
updateNavigationItem()
}
fileprivate func showCustomTimePicker() {
guard let navigationController else {
owsFailDebug("Missing navigation controller!")
return
}
let initialDurationSeconds: UInt32? = switch viewModel.selection {
case .preset: nil
case .custom(let durationSeconds): durationSeconds
}
let customTimePickerViewController = DisappearingMessagesCustomTimePickerViewController(
initialDurationSeconds: initialDurationSeconds,
) { [self] durationSeconds in
viewModel.selection = .custom(durationSeconds: durationSeconds)
updateForSelection(durationSeconds)
updateNavigationItem()
}
navigationController.pushViewController(customTimePickerViewController, animated: true)
}
}
// MARK: -
private class DisappearingMessagesTimerSettingsViewModel: ObservableObject {
protocol ActionsDelegate: AnyObject {
func updateForSelection(_ durationSeconds: UInt32)
func showCustomTimePicker()
}
struct Preset: Identifiable, Equatable {
let localizedDescription: String
let durationSeconds: UInt32
var id: UInt32 { durationSeconds }
}
enum Selection {
case preset(Preset)
case custom(durationSeconds: UInt32)
var durationSeconds: UInt32 {
switch self {
case .preset(let preset): preset.durationSeconds
case .custom(let durationSeconds): durationSeconds
}
}
}
weak var actionsDelegate: ActionsDelegate?
let presets: [Preset]
let settingsMode: DisappearingMessagesTimerSettingsViewController.SettingsMode
@Published var selection: Selection
init(
initialDurationSeconds: UInt32,
settingsMode: DisappearingMessagesTimerSettingsViewController.SettingsMode,
) {
let disabledPreset = Preset(
localizedDescription: CommonStrings.switchOff,
durationSeconds: 0,
)
let enabledPresets = DisappearingMessagesConfigurationRecord
.presetDurationsSeconds()
.reversed()
.map { durationSeconds in
Preset(
localizedDescription: DateUtil.formatDuration(seconds: durationSeconds, useShortFormat: false),
durationSeconds: durationSeconds,
)
}
self.presets = [disabledPreset] + enabledPresets
self.settingsMode = settingsMode
self.selection = if let matchingPreset = presets.first(where: { $0.durationSeconds == initialDurationSeconds }) {
.preset(matchingPreset)
} else {
.custom(durationSeconds: initialDurationSeconds)
}
}
func setSelection(_ selection: Selection) {
self.selection = selection
actionsDelegate?.updateForSelection(selection.durationSeconds)
}
}
struct DisappearingMessagesTimerSettingsView: View {
@ObservedObject private var viewModel: DisappearingMessagesTimerSettingsViewModel
fileprivate init(viewModel: DisappearingMessagesTimerSettingsViewModel) {
self.viewModel = viewModel
}
var body: some View {
SignalList {
Section {
ForEach(viewModel.presets) { preset in
Button {
viewModel.setSelection(.preset(preset))
} label: {
Label {
Text(preset.localizedDescription)
.padding(.leading, -8)
} icon: {
switch viewModel.selection {
case .preset(let selectedPreset) where selectedPreset == preset:
Image(.check)
case .preset, .custom:
Color.clear
.frame(width: 24)
}
}
.foregroundStyle(Color.Signal.label)
}
.padding(.leading, -8)
}
Button {
viewModel.actionsDelegate?.showCustomTimePicker()
} label: {
HStack {
Label {
Text(OWSLocalizedString(
"DISAPPEARING_MESSAGES_CUSTOM_TIME",
comment: "Disappearing message option to define a custom time",
))
.padding(.leading, -8)
} icon: {
switch viewModel.selection {
case .custom:
Image(.check)
case .preset:
Color.clear
.frame(width: 24)
}
}
.foregroundStyle(Color.Signal.label)
Spacer()
switch viewModel.selection {
case .preset:
EmptyView()
case .custom(let durationSeconds):
Text(DateUtil.formatDuration(
seconds: durationSeconds,
useShortFormat: false,
))
.foregroundStyle(Color.Signal.secondaryLabel)
}
Image(systemName: "chevron.right")
.foregroundStyle(Color.Signal.secondaryLabel)
}
}
.padding(.leading, -8)
} header: {
let headerText = switch viewModel.settingsMode {
case .chat, .newGroup:
OWSLocalizedString(
"DISAPPEARING_MESSAGES_DESCRIPTION",
comment: "subheading in conversation settings",
)
case .universal:
OWSLocalizedString(
"DISAPPEARING_MESSAGES_UNIVERSAL_DESCRIPTION",
comment: "subheading in privacy settings",
)
}
Text(headerText)
.textCase(.none)
.font(.subheadline)
.foregroundStyle(Color.Signal.secondaryLabel)
.padding(.bottom, 16)
}
}
}
}
// MARK: -
#if DEBUG
private extension DisappearingMessagesTimerSettingsViewModel {
static func forPreview(
settingsMode: DisappearingMessagesTimerSettingsViewController.SettingsMode,
) -> DisappearingMessagesTimerSettingsViewModel {
return DisappearingMessagesTimerSettingsViewModel(
initialDurationSeconds: 120,
settingsMode: settingsMode,
)
}
}
#Preview {
DisappearingMessagesTimerSettingsView(viewModel: .forPreview(
settingsMode: .universal,
))
}
#endif