Files
session-ios/Session/Settings/SettingsViewModel.swift

1169 lines
52 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import PhotosUI
import Combine
import Lucide
import GRDB
import DifferenceKit
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
import SignalUtilitiesKit
class SettingsViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, ObservableTableSource {
public let dependencies: Dependencies
public let navigatableState: NavigatableState = NavigatableState()
public let state: TableDataState<Section, TableItem> = TableDataState()
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
private var updatedName: String?
private var onDisplayPictureSelected: ((ImageDataManager.DataSource, CGRect?) -> Void)?
private lazy var imagePickerHandler: ImagePickerHandler = ImagePickerHandler(
onTransition: { [weak self] in self?.transitionToScreen($0, transitionType: $1) },
onImagePicked: { [weak self] source, cropRect in
self?.onDisplayPictureSelected?(source, cropRect)
},
using: dependencies
)
/// This value is the current state of the view
@MainActor @Published private(set) var internalState: State
private var observationTask: Task<Void, Never>?
// MARK: - Initialization
@MainActor init(using dependencies: Dependencies) {
self.dependencies = dependencies
self.internalState = State.initialState(
userSessionId: dependencies[cache: .general].sessionId,
sessionProPlanState: dependencies[singleton: .sessionProState].sessionProStateSubject.value
)
bindState()
}
// MARK: - Config
enum NavItem: Equatable {
case close
case edit
case qrCode
}
public enum Section: SessionTableSection {
case profileInfo
case sessionId
case sessionProAndCommunity
case donationAndNetwork
case settings
case helpAndData
case footer
var title: String? {
switch self {
case .sessionId: return "accountIdYours".localized()
default: return nil
}
}
var style: SessionTableSectionStyle {
switch self {
case .sessionId: return .titleSeparator
case .sessionProAndCommunity, .donationAndNetwork, .settings, .helpAndData: return .padding
default: return .none
}
}
}
public enum TableItem: Differentiable {
case avatar
case profileName
case sessionId
case idActions
case sessionPro
case inviteAFriend
case donate
case path
case sessionNetwork
case privacy
case notifications
case conversations
case appearance
case messageRequests
case recoveryPhrase
case help
case developerSettings
case clearData
}
// MARK: - NavigationItemSource
lazy var leftNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = [
SessionNavItem(
id: .close,
image: UIImage(named: "X")?
.withRenderingMode(.alwaysTemplate),
style: .plain,
accessibilityIdentifier: "Close button"
) { [weak self] in self?.dismissScreen() }
]
lazy var rightNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = [
SessionNavItem(
id: .qrCode,
image: Lucide.image(icon: .qrCode, size: 24)?
.withRenderingMode(.alwaysTemplate),
style: .plain,
accessibilityIdentifier: "View QR code",
action: { [weak self, dependencies] in
let viewController: SessionHostingViewController = SessionHostingViewController(
rootView: QRCodeScreen(using: dependencies)
)
viewController.setNavBarTitle("qrCode".localized())
self?.transitionToScreen(viewController)
}
),
SessionNavItem(
id: .edit,
image: Lucide.image(icon: .pencil, size: 22)?
.withRenderingMode(.alwaysTemplate),
style: .plain,
accessibilityIdentifier: "Edit Profile Name",
action: { [weak self] in
Task { @MainActor [weak self] in
guard let self = self else { return }
self.transitionToScreen(
ConfirmationModal(
info: self.updateDisplayName(current: self.internalState.profile.displayName())
),
transitionType: .present
)
}
}
)
]
// MARK: - Content
public struct State: ObservableKeyProvider {
let userSessionId: SessionId
let profile: Profile
let sessionProPlanState: SessionProPlanState
let serviceNetwork: ServiceNetwork
let forceOffline: Bool
let developerModeEnabled: Bool
let hideRecoveryPasswordPermanently: Bool
@MainActor public func sections(viewModel: SettingsViewModel) -> [SectionModel] {
SettingsViewModel.sections(state: self, viewModel: viewModel)
}
public var observedKeys: Set<ObservableKey> {
[
.profile(userSessionId.hexString),
.feature(.serviceNetwork),
.feature(.forceOffline),
.feature(.mockCurrentUserSessionProState),
.setting(.developerModeEnabled),
.setting(.hideRecoveryPasswordPermanently)
// TODO: [PRO] Need to observe changes to the users pro status
]
}
static func initialState(userSessionId: SessionId, sessionProPlanState: SessionProPlanState) -> State {
return State(
userSessionId: userSessionId,
profile: Profile.defaultFor(userSessionId.hexString),
sessionProPlanState: sessionProPlanState,
serviceNetwork: .mainnet,
forceOffline: false,
developerModeEnabled: false,
hideRecoveryPasswordPermanently: false
)
}
}
let title: String = "sessionSettings".localized()
@MainActor private func bindState() {
observationTask = ObservationBuilder
.initialValue(self.internalState)
.debounce(for: .never)
.using(dependencies: dependencies)
.query(SettingsViewModel.queryState)
.assign { [weak self] updatedState in
guard let self = self else { return }
// FIXME: To slightly reduce the size of the changes this new observation mechanism is currently wired into the old SessionTableViewController observation mechanism, we should refactor it so everything uses the new mechanism
self.internalState = updatedState
self.pendingTableDataSubject.send(updatedState.sections(viewModel: self))
}
}
@Sendable private static func queryState(
previousState: State,
events: [ObservedEvent],
isInitialFetch: Bool,
using dependencies: Dependencies
) async -> State {
/// Store mutable copies of the data to update
var profile: Profile = previousState.profile
var sessionProPlanState: SessionProPlanState = previousState.sessionProPlanState
var serviceNetwork: ServiceNetwork = previousState.serviceNetwork
var forceOffline: Bool = previousState.forceOffline
var developerModeEnabled: Bool = previousState.developerModeEnabled
var hideRecoveryPasswordPermanently: Bool = previousState.hideRecoveryPasswordPermanently
if isInitialFetch {
serviceNetwork = dependencies[feature: .serviceNetwork]
forceOffline = dependencies[feature: .forceOffline]
sessionProPlanState = dependencies[singleton: .sessionProState].sessionProStateSubject.value
dependencies.mutate(cache: .libSession) { libSession in
profile = libSession.profile
developerModeEnabled = libSession.get(.developerModeEnabled)
hideRecoveryPasswordPermanently = libSession.get(.hideRecoveryPasswordPermanently)
}
}
/// If the users profile picture doesn't exist on disk then clear out the value (that way if we get events after downloading
/// it then then there will be a diff in the `State` and the UI will update
if
let displayPictureUrl: String = profile.displayPictureUrl,
let filePath: String = try? dependencies[singleton: .displayPictureManager]
.path(for: displayPictureUrl),
!dependencies[singleton: .fileManager].fileExists(atPath: filePath)
{
profile = profile.with(displayPictureUrl: .set(to: nil))
}
/// Process any event changes
let groupedEvents: [GenericObservableKey: Set<ObservedEvent>]? = events
.reduce(into: [:]) { result, event in
result[event.key.generic, default: []].insert(event)
}
groupedEvents?[.profile]?.forEach { event in
switch (event.value as? ProfileEvent)?.change {
case .name(let name): profile = profile.with(name: name)
case .nickname(let nickname): profile = profile.with(nickname: .set(to: nickname))
case .displayPictureUrl(let url): profile = profile.with(displayPictureUrl: .set(to: url))
default: break
}
}
groupedEvents?[.setting]?.forEach { event in
guard let updatedValue: Bool = event.value as? Bool else { return }
switch event.key {
case .setting(.developerModeEnabled): developerModeEnabled = updatedValue
case .setting(.hideRecoveryPasswordPermanently): hideRecoveryPasswordPermanently = updatedValue
default: break
}
}
groupedEvents?[.feature]?.forEach { event in
if event.key == .feature(.serviceNetwork) {
guard let updatedValue: ServiceNetwork = event.value as? ServiceNetwork else { return }
serviceNetwork = updatedValue
}
else if event.key == .feature(.forceOffline) {
guard let updatedValue: Bool = event.value as? Bool else { return }
forceOffline = updatedValue
}
else if event.key == .feature(.mockCurrentUserSessionProState) {
guard let updatedValue: SessionProStateMock = event.value as? SessionProStateMock else { return }
sessionProPlanState = dependencies[singleton: .sessionProState].sessionProStateSubject.value
}
}
/// Generate the new state
return State(
userSessionId: previousState.userSessionId,
profile: profile,
sessionProPlanState: sessionProPlanState,
serviceNetwork: serviceNetwork,
forceOffline: forceOffline,
developerModeEnabled: developerModeEnabled,
hideRecoveryPasswordPermanently: hideRecoveryPasswordPermanently
)
}
private static func sections(state: State, viewModel: SettingsViewModel) -> [SectionModel] {
let profileInfo: SectionModel = SectionModel(
model: .profileInfo,
elements: [
SessionCell.Info(
id: .avatar,
accessory: .profile(
id: state.profile.id,
size: .hero,
profile: state.profile,
profileIcon: {
switch (state.serviceNetwork, state.forceOffline) {
case (.testnet, false): return .letter("T", false) // stringlint:ignore
case (.testnet, true): return .letter("T", true) // stringlint:ignore
default: return (state.profile.displayPictureUrl?.isEmpty == false) ? .pencil : .rightPlus
}
}()
),
styling: SessionCell.StyleInfo(
alignment: .centerHugging,
customPadding: SessionCell.Padding(
top: 0,
leading: 0,
bottom: Values.smallSpacing
),
backgroundStyle: .noBackground
),
accessibility: Accessibility(
identifier: "User settings",
label: "Profile picture"
),
onTap: { [weak viewModel] in
viewModel?.updateProfilePicture(currentUrl: state.profile.displayPictureUrl)
}
),
SessionCell.Info(
id: .profileName,
title: SessionCell.TextInfo(
state.profile.displayName(),
font: .titleLarge,
alignment: .center,
trailingImage: {
switch state.sessionProPlanState {
case .none: return nil
case .active, .refunding:
return SessionProBadge.trailingImage(
size: .medium,
themeBackgroundColor: .primary
)
case .expired:
return SessionProBadge.trailingImage(
size: .medium,
themeBackgroundColor: .disabled
)
}
}()
),
styling: SessionCell.StyleInfo(
alignment: .centerHugging,
customPadding: SessionCell.Padding(
top: Values.smallSpacing,
bottom: Values.mediumSpacing,
interItem: 0
),
backgroundStyle: .noBackground
),
accessibility: Accessibility(
identifier: "Username",
label: state.profile.displayName()
),
confirmationInfo: viewModel.updateDisplayName(
current: state.profile.displayName()
)
)
]
)
let sessionId: SectionModel = SectionModel(
model: .sessionId,
elements: [
SessionCell.Info(
id: .sessionId,
title: SessionCell.TextInfo(
state.profile.id,
font: .monoLarge,
alignment: .center,
interaction: .copy
),
styling: SessionCell.StyleInfo(
customPadding: SessionCell.Padding(bottom: Values.smallSpacing),
backgroundStyle: .noBackground
),
accessibility: Accessibility(
identifier: "Account ID",
label: state.profile.id
)
),
SessionCell.Info(
id: .idActions,
leadingAccessory: .button(
style: .bordered,
title: "share".localized(),
accessibility: Accessibility(
identifier: "Share button",
label: "Share button"
),
run: { [weak viewModel] _ in
viewModel?.shareSessionId(state.profile.id)
}
),
trailingAccessory: .button(
style: .bordered,
title: "copy".localized(),
accessibility: Accessibility(
identifier: "Copy button",
label: "Copy button"
),
run: { [weak viewModel] button in
viewModel?.copySessionId(state.profile.id, button: button)
}
),
styling: SessionCell.StyleInfo(
customPadding: SessionCell.Padding(
top: Values.smallSpacing,
leading: 0,
trailing: 0
),
backgroundStyle: .noBackground
)
)
]
)
let sessionProAndCommunity: SectionModel
let donationAndNetwork: SectionModel
// FIXME: [PRO] Should be able to remove this once pro is properly enabled
if viewModel.dependencies[feature: .sessionProEnabled] {
sessionProAndCommunity = SectionModel(
model: .sessionProAndCommunity,
elements: [
SessionCell.Info(
id: .sessionPro,
leadingAccessory: .proBadge(size: .small),
title: {
switch state.sessionProPlanState {
case .none:
return "upgradeSession"
.put(key: "app_name", value: Constants.app_name)
.localized()
case .active, .refunding:
return "sessionProBeta"
.put(key: "app_pro", value: Constants.app_pro)
.localized()
case .expired:
return "proRenewBeta"
.put(key: "pro", value: Constants.pro)
.localized()
}
}(),
styling: SessionCell.StyleInfo(
tintColor: .sessionButton_text
),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
let viewController: SessionListHostingViewController = SessionListHostingViewController(
viewModel: SessionProSettingsViewModel(using: dependencies),
customizedNavigationBackground: .clear
)
viewModel?.transitionToScreen(viewController)
}
),
SessionCell.Info(
id: .inviteAFriend,
leadingAccessory: .icon(.userRoundPlus),
title: "sessionInviteAFriend".localized(),
onTap: { [weak viewModel] in
let invitation: String = "accountIdShare"
.put(key: "app_name", value: Constants.app_name)
.put(key: "account_id", value: state.profile.id)
.put(key: "session_download_url", value: Constants.session_download_url)
.localized()
viewModel?.transitionToScreen(
UIActivityViewController(
activityItems: [ invitation ],
applicationActivities: nil
),
transitionType: .present
)
}
)
]
)
donationAndNetwork = SectionModel(
model: .donationAndNetwork,
elements: [
SessionCell.Info(
id: .donate,
leadingAccessory: .icon(
.heart,
customTint: .sessionButton_border
),
title: "donate".localized(),
onTap: { [weak viewModel] in viewModel?.openDonationsUrl() }
),
SessionCell.Info(
id: .path,
leadingAccessory: .custom(
info: PathStatusViewAccessory.Info()
),
title: "onionRoutingPath".localized(),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
viewModel?.transitionToScreen(PathVC(using: dependencies))
}
),
SessionCell.Info(
id: .sessionNetwork,
leadingAccessory: .icon(
UIImage(named: "icon_session_network")?
.withRenderingMode(.alwaysTemplate)
),
title: Constants.network_name,
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
let viewController: SessionHostingViewController = SessionHostingViewController(
rootView: SessionNetworkScreen(
viewModel: SessionNetworkScreenContent.ViewModel(dependencies: dependencies)
)
)
viewController.setNavBarTitle(Constants.network_name)
viewModel?.transitionToScreen(viewController)
}
)
]
)
}
else {
sessionProAndCommunity = SectionModel(
model: .sessionProAndCommunity,
elements: [
SessionCell.Info(
id: .donate,
leadingAccessory: .icon(
.heart,
customTint: .sessionButton_border
),
title: "donate".localized(),
onTap: { [weak viewModel] in viewModel?.openDonationsUrl() }
),
SessionCell.Info(
id: .inviteAFriend,
leadingAccessory: .icon(.userRoundPlus),
title: "sessionInviteAFriend".localized(),
onTap: { [weak viewModel] in
let invitation: String = "accountIdShare"
.put(key: "app_name", value: Constants.app_name)
.put(key: "account_id", value: state.profile.id)
.put(key: "session_download_url", value: Constants.session_download_url)
.localized()
viewModel?.transitionToScreen(
UIActivityViewController(
activityItems: [ invitation ],
applicationActivities: nil
),
transitionType: .present
)
}
)
]
)
donationAndNetwork = SectionModel(
model: .donationAndNetwork,
elements: [
SessionCell.Info(
id: .path,
leadingAccessory: .custom(
info: PathStatusViewAccessory.Info()
),
title: "onionRoutingPath".localized(),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
viewModel?.transitionToScreen(PathVC(using: dependencies))
}
),
SessionCell.Info(
id: .sessionNetwork,
leadingAccessory: .icon(
UIImage(named: "icon_session_network")?
.withRenderingMode(.alwaysTemplate)
),
title: Constants.network_name,
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
let viewController: SessionHostingViewController = SessionHostingViewController(
rootView: SessionNetworkScreen(
viewModel: SessionNetworkScreenContent.ViewModel(dependencies: dependencies)
)
)
viewController.setNavBarTitle(Constants.network_name)
viewModel?.transitionToScreen(viewController)
}
)
]
)
}
let settings: SectionModel = SectionModel(
model: .settings,
elements: [
SessionCell.Info(
id: .privacy,
leadingAccessory: .icon(.lockKeyhole),
title: "sessionPrivacy".localized(),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
viewModel?.transitionToScreen(
SessionTableViewController(viewModel: PrivacySettingsViewModel(using: dependencies))
)
}
),
SessionCell.Info(
id: .notifications,
leadingAccessory: .icon(.volume2),
title: "sessionNotifications".localized(),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
viewModel?.transitionToScreen(
SessionTableViewController(viewModel: NotificationSettingsViewModel(using: dependencies))
)
}
),
SessionCell.Info(
id: .conversations,
leadingAccessory: .icon(.usersRound),
title: "sessionConversations".localized(),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
viewModel?.transitionToScreen(
SessionTableViewController(viewModel: ConversationSettingsViewModel(using: dependencies))
)
}
),
SessionCell.Info(
id: .appearance,
leadingAccessory: .icon(.paintbrushVertical),
title: "sessionAppearance".localized(),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
viewModel?.transitionToScreen(
SessionTableViewController(viewModel: AppearanceViewModel(using: dependencies))
)
}
),
SessionCell.Info(
id: .messageRequests,
leadingAccessory: .icon(.messageSquareWarning),
title: "sessionMessageRequests".localized(),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
viewModel?.transitionToScreen(
SessionTableViewController(viewModel: MessageRequestsViewModel(using: dependencies))
)
}
)
]
)
var helpAndDataElements: [SessionCell.Info<TableItem>] = []
if !state.hideRecoveryPasswordPermanently {
helpAndDataElements.append(
SessionCell.Info(
id: .recoveryPhrase,
leadingAccessory: .icon(
UIImage(named: "SessionShield")?
.withRenderingMode(.alwaysTemplate)
),
title: "sessionRecoveryPassword".localized(),
accessibility: Accessibility(
identifier: "Recovery password menu item",
label: "Recovery password menu item"
),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
guard let recoveryPasswordView: RecoveryPasswordScreen = try? RecoveryPasswordScreen(using: dependencies) else {
let targetViewController: UIViewController = ConfirmationModal(
info: ConfirmationModal.Info(
title: "theError".localized(),
body: .text("recoveryPasswordErrorLoad".localized()),
cancelTitle: "okay".localized(),
cancelStyle: .alert_text
)
)
viewModel?.transitionToScreen(targetViewController, transitionType: .present)
return
}
let viewController: SessionHostingViewController = SessionHostingViewController(rootView: recoveryPasswordView)
viewController.setNavBarTitle("sessionRecoveryPassword".localized())
viewModel?.transitionToScreen(viewController)
}
)
)
}
helpAndDataElements.append(
SessionCell.Info(
id: .help,
leadingAccessory: .icon(
UIImage(named: "icon_help")?
.withRenderingMode(.alwaysTemplate)
),
title: "sessionHelp".localized(),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
viewModel?.transitionToScreen(
SessionTableViewController(viewModel: HelpViewModel(using: dependencies))
)
}
)
)
if state.developerModeEnabled {
helpAndDataElements.append(
SessionCell.Info(
id: .developerSettings,
leadingAccessory: .icon(.squareCode),
title: "Developer Settings", // stringlint:ignore
styling: SessionCell.StyleInfo(tintColor: .warning),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
viewModel?.transitionToScreen(
SessionTableViewController(viewModel: DeveloperSettingsViewModel(using: dependencies))
)
}
)
)
}
helpAndDataElements.append(
SessionCell.Info(
id: .clearData,
leadingAccessory: .icon(.trash2),
title: "sessionClearData".localized(),
styling: SessionCell.StyleInfo(tintColor: .danger),
onTap: { [weak viewModel, dependencies = viewModel.dependencies] in
viewModel?.transitionToScreen(NukeDataModal(using: dependencies), transitionType: .present)
}
)
)
let helpAndData: SectionModel = SectionModel(
model: .helpAndData,
elements: helpAndDataElements
)
return [profileInfo, sessionId, sessionProAndCommunity, donationAndNetwork, settings, helpAndData]
}
public lazy var footerView: AnyPublisher<UIView?, Never> = Just(VersionFooterView(
numVersionTapsRequired: 9,
logoTapCallback: { [weak self] in self?.openTokenUrl() },
versionTapCallback: { [dependencies] in
/// Do nothing if developer mode is already enabled
guard !dependencies.mutate(cache: .libSession, { $0.get(.developerModeEnabled) }) else { return }
dependencies.setAsync(.developerModeEnabled, true)
}
)).eraseToAnyPublisher()
// MARK: - Functions
private func updateDisplayName(current: String) -> ConfirmationModal.Info {
/// Set `updatedName` to `current` so we can disable the "save" button when there are no changes and don't need to worry
/// about retrieving them in the confirmation closure
self.updatedName = current
return ConfirmationModal.Info(
title: "displayNameSet".localized(),
body: .input(
explanation: ThemedAttributedString(string: "displayNameVisible".localized()),
info: ConfirmationModal.Info.Body.InputInfo(
placeholder: "displayNameEnter".localized(),
initialValue: current,
accessibility: Accessibility(
identifier: "Username input"
),
inputChecker: { text in
let displayName: String = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !Profile.isTooLong(profileName: displayName) else {
return "displayNameErrorDescriptionShorter".localized()
}
return nil
}
),
onChange: { [weak self] updatedName in self?.updatedName = updatedName }
),
confirmTitle: "save".localized(),
confirmEnabled: .afterChange { [weak self] _ in
self?.updatedName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false &&
self?.updatedName != current
},
cancelStyle: .alert_text,
hasCloseButton: true,
dismissOnConfirm: false,
onConfirm: { [weak self] modal in
guard
let finalDisplayName: String = (self?.updatedName ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.nullIfEmpty
else { return }
/// Check if the data violates the size constraints
guard !Profile.isTooLong(profileName: finalDisplayName) else {
modal.updateContent(withError: "displayNameErrorDescriptionShorter".localized())
return
}
/// Update the nickname
self?.updateProfile(displayNameUpdate: .currentUserUpdate(finalDisplayName)) {
modal.dismiss(animated: true)
}
}
)
}
private func updateProfilePicture(currentUrl: String?) {
let iconName: String = "profile_placeholder" // stringlint:ignore
var hasSetNewProfilePicture: Bool = false
let currentSource: ImageDataManager.DataSource? = {
let source: ImageDataManager.DataSource? = currentUrl
.map { try? dependencies[singleton: .displayPictureManager].path(for: $0) }
.map { ImageDataManager.DataSource.url(URL(fileURLWithPath: $0)) }
return (source?.contentExists == true ? source : nil)
}()
let body: ConfirmationModal.Info.Body = .image(
source: nil,
placeholder: (
currentSource ??
Lucide.image(icon: .image, size: 40).map { image in
ImageDataManager.DataSource.image(
iconName,
image
.withTintColor(#colorLiteral(red: 0.631372549, green: 0.6352941176, blue: 0.631372549, alpha: 1), renderingMode: .alwaysTemplate)
.withCircularBackground(backgroundColor: #colorLiteral(red: 0.1764705882, green: 0.1764705882, blue: 0.1764705882, alpha: 1))
)
}
),
icon: (currentUrl != nil ? .pencil : .rightPlus),
style: .circular,
description: {
guard dependencies[feature: .sessionProEnabled] else { return nil }
return dependencies[cache: .libSession].isSessionPro ?
"proAnimatedDisplayPictureModalDescription"
.localized()
.addProBadge(
at: .leading,
font: .systemFont(ofSize: Values.smallFontSize),
textColor: .textSecondary,
proBadgeSize: .small,
using: dependencies
):
"proAnimatedDisplayPicturesNonProModalDescription"
.localized()
.addProBadge(
at: .trailing,
font: .systemFont(ofSize: Values.smallFontSize),
textColor: .textSecondary,
proBadgeSize: .small,
using: dependencies
)
}(),
accessibility: Accessibility(
identifier: "Upload",
label: "Upload"
),
dataManager: dependencies[singleton: .imageDataManager],
onProBageTapped: { [weak self, dependencies] in
Task { @MainActor in
dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded(
.animatedProfileImage(
isSessionProActivated: dependencies[cache: .libSession].isSessionPro,
renew: dependencies[singleton: .sessionProState].isSessionProExpired
),
onConfirm: {
dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded(
presenting: { bottomSheet in
self?.transitionToScreen(bottomSheet, transitionType: .present)
}
)
},
presenting: { modal in
self?.transitionToScreen(modal, transitionType: .present)
}
)
}
},
onClick: { [weak self] onDisplayPictureSelected in
self?.onDisplayPictureSelected = { source, cropRect in
onDisplayPictureSelected(.image(
source: source,
cropRect: cropRect,
replacementIcon: .pencil,
replacementCancelTitle: "clear".localized()
))
hasSetNewProfilePicture = true
}
self?.showPhotoLibraryForAvatar()
}
)
self.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "profileDisplayPictureSet".localized(),
body: body,
confirmTitle: "save".localized(),
confirmEnabled: .afterChange { info in
switch info.body {
case .image(.some(let source), _, _, _, _, _, _, _, _): return source.contentExists
default: return false
}
},
cancelTitle: "remove".localized(),
cancelEnabled: (currentUrl != nil ? .bool(true) : .afterChange { info in
switch info.body {
case .image(.some(let source), _, _, _, _, _, _, _, _): return source.contentExists
default: return false
}
}),
hasCloseButton: true,
dismissOnConfirm: false,
onConfirm: { [weak self, dependencies] modal in
switch modal.info.body {
case .image(.some(let source), _, _, let style, _, _, _, _, _):
let isAnimatedImage: Bool = ImageDataManager.isAnimatedImage(source)
guard (
!isAnimatedImage ||
dependencies[cache: .libSession].isSessionPro ||
!dependencies[feature: .sessionProEnabled]
) else {
Task { @MainActor in
dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded(
.animatedProfileImage(
isSessionProActivated: dependencies[cache: .libSession].isSessionPro,
renew: dependencies[singleton: .sessionProState].isSessionProExpired
),
onConfirm: {
dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded(
presenting: { bottomSheet in
self?.transitionToScreen(bottomSheet, transitionType: .present)
}
)
},
presenting: { modal in
self?.transitionToScreen(modal, transitionType: .present)
}
)
}
return
}
self?.updateProfile(
displayPictureUpdateGenerator: { [weak self] in
guard let self = self else { throw AttachmentError.uploadFailed }
return try await uploadDisplayPicture(
source: source,
cropRect: style.cropRect
)
},
onComplete: { [weak modal] in modal?.close() }
)
default: modal.close()
}
},
onCancel: { [weak self] modal in
if hasSetNewProfilePicture {
modal.updateContent(
with: modal.info.with(
body: body,
cancelTitle: "remove".localized()
)
)
hasSetNewProfilePicture = false
} else {
self?.updateProfile(
displayPictureUpdateGenerator: { .currentUserRemove },
onComplete: { [weak modal] in modal?.close() }
)
}
}
)
),
transitionType: .present
)
}
@MainActor private func showPhotoLibraryForAvatar() {
Permissions.requestLibraryPermissionIfNeeded(isSavingMedia: false, using: dependencies) { [weak self] granted in
guard granted else { return }
DispatchQueue.main.async {
var configuration: PHPickerConfiguration = PHPickerConfiguration()
configuration.selectionLimit = 1
configuration.filter = .any(of: [.images, .livePhotos])
let picker: PHPickerViewController = PHPickerViewController(configuration: configuration)
picker.delegate = self?.imagePickerHandler
self?.transitionToScreen(picker, transitionType: .present)
}
}
}
fileprivate func uploadDisplayPicture(
source: ImageDataManager.DataSource,
cropRect: CGRect?
) async throws -> DisplayPictureManager.Update {
let pendingAttachment: PendingAttachment = PendingAttachment(
source: .media(source),
using: dependencies
)
let preparedAttachment: PreparedAttachment = try await dependencies[singleton: .displayPictureManager].prepareDisplayPicture(
attachment: pendingAttachment,
fallbackIfConversionTakesTooLong: true,
cropRect: cropRect
)
let result = try await dependencies[singleton: .displayPictureManager]
.uploadDisplayPicture(preparedAttachment: preparedAttachment)
return .currentUserUpdateTo(
url: result.downloadUrl,
key: result.encryptionKey,
sessionProProof: dependencies.mutate(cache: .libSession) { $0.getCurrentUserProProof() },
isReupload: false
)
}
@MainActor fileprivate func updateProfile(
displayNameUpdate: Profile.DisplayNameUpdate = .none,
displayPictureUpdateGenerator generator: @escaping () async throws -> DisplayPictureManager.Update = { .none },
onComplete: @escaping () -> ()
) {
let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController()
self.transitionToScreen(indicator, transitionType: .present)
Task.detached(priority: .userInitiated) { [weak self, indicator, dependencies] in
var displayPictureUpdate: DisplayPictureManager.Update = .none
do {
displayPictureUpdate = try await generator()
try await Profile.updateLocal(
displayNameUpdate: displayNameUpdate,
displayPictureUpdate: displayPictureUpdate,
using: dependencies
)
await indicator.dismiss {
onComplete()
}
}
catch {
let message: String = {
switch (displayPictureUpdate, error) {
case (.currentUserRemove, _): return "profileDisplayPictureRemoveError".localized()
case (_, AttachmentError.fileSizeTooLarge):
return "profileDisplayPictureSizeError".localized()
default: return "errorConnection".localized()
}
}()
await indicator.dismiss {
self?.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "profileErrorUpdate".localized(),
body: .text(message),
cancelTitle: "okay".localized(),
cancelStyle: .alert_text,
dismissType: .single
)
),
transitionType: .present
)
}
}
}
}
private func copySessionId(_ sessionId: String, button: SessionButton?) {
UIPasteboard.general.string = sessionId
guard let button: SessionButton = button else { return }
// Ensure we are on the main thread just in case
DispatchQueue.main.async {
button.isUserInteractionEnabled = false
UIView.transition(
with: button,
duration: 0.25,
options: .transitionCrossDissolve,
animations: {
button.setTitle("copied".localized(), for: .normal)
},
completion: { _ in
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(4)) {
button.isUserInteractionEnabled = true
UIView.transition(
with: button,
duration: 0.25,
options: .transitionCrossDissolve,
animations: {
button.setTitle("copy".localized(), for: .normal)
},
completion: nil
)
}
}
)
}
}
private func shareSessionId(_ sessionId: String) {
let shareVC = UIActivityViewController(
activityItems: [ sessionId ],
applicationActivities: nil
)
self.transitionToScreen(shareVC, transitionType: .present)
}
@MainActor private func openDonationsUrl() {
guard let modal: ConfirmationModal = dependencies[singleton: .donationsManager].openDonationsUrlModal() else {
return
}
self.transitionToScreen(modal, transitionType: .present)
// Mark app review flag that donate button was tapped
if !dependencies[defaults: .standard, key: .hasPressedDonateButton] {
dependencies[defaults: .standard, key: .hasPressedDonateButton] = true
}
}
private func openTokenUrl() {
guard let url: URL = URL(string: Constants.session_token_url) else { return }
let modal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: "urlOpen".localized(),
body: .attributedText(
"urlOpenDescription"
.put(key: "url", value: url.absoluteString)
.localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize))
),
confirmTitle: "open".localized(),
confirmStyle: .danger,
cancelTitle: "urlCopy".localized(),
cancelStyle: .alert_text,
hasCloseButton: true,
onConfirm: { modal in
UIApplication.shared.open(url, options: [:], completionHandler: nil)
modal.dismiss(animated: true)
},
onCancel: { modal in
UIPasteboard.general.string = url.absoluteString
modal.dismiss(animated: true)
}
)
)
self.transitionToScreen(modal, transitionType: .present)
}
}