Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/ThreadSettings/DisappearingMessagesCustomTimePickerViewController.swift
1 views
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import SignalServiceKit
import SignalUI
import SwiftUI

class DisappearingMessagesCustomTimePickerViewController: HostingController<DisappearingMessagesCustomTimePickerView> {
    private let initialDurationSeconds: UInt32?
    private let completion: (_ selectedDurationSeconds: UInt32) -> Void

    private let viewModel: DisappearingMessagesCustomTimePickerViewModel

    init(
        initialDurationSeconds: UInt32?,
        completion: @escaping (_ selectedDurationSeconds: UInt32) -> Void,
    ) {
        self.initialDurationSeconds = initialDurationSeconds
        self.completion = completion

        self.viewModel = DisappearingMessagesCustomTimePickerViewModel(
            initialDurationSeconds: initialDurationSeconds,
        )

        super.init(wrappedView: DisappearingMessagesCustomTimePickerView(viewModel: viewModel))

        title = OWSLocalizedString(
            "DISAPPEARING_MESSAGES",
            comment: "table cell label in conversation settings",
        )

        viewModel.actionsDelegate = self
    }

    private var hasUnsavedChanges: Bool {
        return initialDurationSeconds != viewModel.selectedDurationSeconds
    }

    // Don't allow interactive dismiss when there are unsaved changes.
    override var isModalInPresentation: Bool {
        get { hasUnsavedChanges }
        set {}
    }

    private func updateNavigationItem() {
        if hasUnsavedChanges {
            navigationItem.rightBarButtonItem = .button(
                title: CommonStrings.setButton,
                style: .done,
                action: { [weak self] in
                    self?.completeAndPop()
                },
            )
        } else {
            navigationItem.rightBarButtonItem = nil
        }
    }

    private func completeAndPop() {
        completion(viewModel.selectedDurationSeconds)

        guard let navigationController else {
            owsFailDebug("Missing navigation controller!")
            return
        }

        navigationController.popViewController(animated: true)
    }
}

// MARK: - DisappearingMessagesCustomTimePickerViewModel.ActionsDelegate

extension DisappearingMessagesCustomTimePickerViewController: DisappearingMessagesCustomTimePickerViewModel.ActionsDelegate {
    fileprivate func updateForSelection(selectedDurationSeconds: UInt32) {
        updateNavigationItem()
    }
}

// MARK: -

private class DisappearingMessagesCustomTimePickerViewModel: ObservableObject {
    protocol ActionsDelegate: AnyObject {
        func updateForSelection(selectedDurationSeconds: UInt32)
    }

    enum Unit: CaseIterable {
        case seconds
        case minutes
        case hours
        case days
        case weeks

        var unitDurationSeconds: UInt32 {
            switch self {
            case .seconds: UInt32(TimeInterval.second)
            case .minutes: UInt32(TimeInterval.minute)
            case .hours: UInt32(TimeInterval.hour)
            case .days: UInt32(TimeInterval.day)
            case .weeks: UInt32(TimeInterval.week)
            }
        }

        var allowedValues: ClosedRange<UInt32> {
            switch self {
            case .seconds: 1...59
            case .minutes: 1...59
            case .hours: 1...23
            case .days: 1...6
            case .weeks: 1...4
            }
        }
    }

    weak var actionsDelegate: ActionsDelegate?

    @Published var selectedUnit: Unit
    @Published var selectedValue: UInt32

    var selectedDurationSeconds: UInt32 {
        selectedUnit.unitDurationSeconds * selectedValue
    }

    convenience init(initialDurationSeconds: UInt32?) {
        guard let initialDurationSeconds else {
            self.init(minUnit: ())
            return
        }

        let maxUnit = Unit.allCases.last!
        let maxUnitAllowedValue = maxUnit.allowedValues.upperBound
        let maxAllowedDurationSeconds = maxUnit.unitDurationSeconds * maxUnitAllowedValue
        if initialDurationSeconds > maxAllowedDurationSeconds {
            // Bugs (and poorly-behaved clients) could let us set a duration
            // greater than what the picker should allow. If we find one of
            // these durations, set to the max.
            self.init(selectedUnit: maxUnit, selectedValue: maxUnitAllowedValue)
            return
        }

        for unit in Unit.allCases {
            let quotient = initialDurationSeconds / unit.unitDurationSeconds
            let remainder = initialDurationSeconds % unit.unitDurationSeconds

            // If it divides cleanly into an allowed value, pick this unit.
            if remainder == 0, unit.allowedValues.contains(quotient) {
                self.init(selectedUnit: unit, selectedValue: quotient)
                return
            }
        }

        // The duration isn't unit-aligned, so we don't know what to choose.
        // Start with the lowest.
        self.init(minUnit: ())
    }

    private convenience init(minUnit: Void) {
        let minUnit = Unit.allCases.first!
        self.init(
            selectedUnit: minUnit,
            selectedValue: minUnit.allowedValues.lowerBound,
        )
    }

    private init(selectedUnit: Unit, selectedValue: UInt32) {
        self.selectedUnit = selectedUnit
        self.selectedValue = selectedValue
    }

    func setNewSelection(
        newSelectedUnit: Unit?,
        newSelectedValue: UInt32?,
    ) {
        selectedUnit = newSelectedUnit ?? selectedUnit
        selectedValue = newSelectedValue ?? selectedValue

        // Clamp to the max value allowed by the unit. This is important because
        // the unit can change, and the value-Picker will show a clamped value
        // because its set of possible values is constrained, but the actual
        // value property will not be clamped automatically.
        let maxAllowedValue = selectedUnit.allowedValues.upperBound
        if selectedValue > maxAllowedValue {
            selectedValue = maxAllowedValue
        }

        actionsDelegate?.updateForSelection(
            selectedDurationSeconds: selectedDurationSeconds,
        )
    }
}

struct DisappearingMessagesCustomTimePickerView: View {
    private typealias Unit = DisappearingMessagesCustomTimePickerViewModel.Unit

    @ObservedObject private var viewModel: DisappearingMessagesCustomTimePickerViewModel

    fileprivate init(viewModel: DisappearingMessagesCustomTimePickerViewModel) {
        self.viewModel = viewModel
    }

    var body: some View {
        SignalList {
            HStack {
                Picker(
                    OWSLocalizedString(
                        "DISAPPEARING_MESSAGES_CUSTOM_TIME_VALUE_PICKER",
                        comment: "Title for a picker for the amount of time, in a given unit, to use for disappearing messages.",
                    ),
                    selection: Binding(
                        get: { viewModel.selectedValue },
                        set: { viewModel.setNewSelection(newSelectedUnit: nil, newSelectedValue: $0) },
                    ),
                ) {
                    ForEach(viewModel.selectedUnit.allowedValues, id: \.self) { val in
                        Text("\(val)")
                    }
                }
                .pickerStyle(.wheel)
                .frame(maxWidth: .infinity)

                Picker(
                    OWSLocalizedString(
                        "DISAPPEARING_MESSAGES_CUSTOM_TIME_UNIT_PICKER",
                        comment: "Title for a picker for the unit of time to use for disappearing messages.",
                    ),
                    selection: Binding(
                        get: { viewModel.selectedUnit },
                        set: { viewModel.setNewSelection(newSelectedUnit: $0, newSelectedValue: nil) },
                    ),
                ) {
                    ForEach(Unit.allCases, id: \.self) { unit in
                        let localizedString = switch unit {
                        case .seconds: OWSLocalizedString(
                                "DISAPPEARING_MESSAGES_SECONDS",
                                comment: "The unit for a number of seconds",
                            )
                        case .minutes: OWSLocalizedString(
                                "DISAPPEARING_MESSAGES_MINUTES",
                                comment: "The unit for a number of minutes",
                            )
                        case .hours: OWSLocalizedString(
                                "DISAPPEARING_MESSAGES_HOURS",
                                comment: "The unit for a number of hours",
                            )
                        case .days: OWSLocalizedString(
                                "DISAPPEARING_MESSAGES_DAYS",
                                comment: "The unit for a number of days",
                            )
                        case .weeks: OWSLocalizedString(
                                "DISAPPEARING_MESSAGES_WEEKS",
                                comment: "The unit for a number of weeks",
                            )
                        }

                        Text(localizedString)
                    }
                }
                .pickerStyle(.wheel)
                .frame(maxWidth: .infinity)
            }
        }
    }
}

// MARK: -

#if DEBUG

private extension DisappearingMessagesCustomTimePickerViewModel {
    static func forPreview() -> DisappearingMessagesCustomTimePickerViewModel {
        class PreviewActionsDelegate: ActionsDelegate {
            func updateForSelection(selectedDurationSeconds: UInt32) {
                print("selectedDurationSeconds: \(selectedDurationSeconds)")
            }
        }

        let viewModel = DisappearingMessagesCustomTimePickerViewModel(initialDurationSeconds: 180)
        let actionsDelegate = PreviewActionsDelegate()
        ObjectRetainer.retainObject(actionsDelegate, forLifetimeOf: viewModel)
        viewModel.actionsDelegate = actionsDelegate

        return viewModel
    }
}

#Preview {
    DisappearingMessagesCustomTimePickerView(viewModel: .forPreview())
}

#endif