diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 20e55acb11..b3afe6bbde 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -147,6 +147,7 @@ 7BFD1A972747689000FB91B9 /* Session-Turn-Server in Resources */ = {isa = PBXBuildFile; fileRef = 7BFD1A962747689000FB91B9 /* Session-Turn-Server */; }; 9409433E2C7EB81800D9D2E0 /* WebRTCSession+Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9409433D2C7EB81800D9D2E0 /* WebRTCSession+Constants.swift */; }; 940943402C7ED62300D9D2E0 /* StartupError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9409433F2C7ED62300D9D2E0 /* StartupError.swift */; }; + 940B82D02EE697C70000D142 /* SessionListScreen+ListItemTappableText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940B82CF2EE697A40000D142 /* SessionListScreen+ListItemTappableText.swift */; }; 941375BB2D5184C20058F244 /* HTTPHeader+SessionNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = 941375BA2D5184B60058F244 /* HTTPHeader+SessionNetwork.swift */; }; 941375BD2D5195F30058F244 /* KeyValueStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 941375BC2D5195F30058F244 /* KeyValueStore.swift */; }; 9420CAC62E584B5800F738F6 /* GroupAdminCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 9420CAC42E584B5800F738F6 /* GroupAdminCTA.webp */; }; @@ -196,6 +197,9 @@ 943B43622EC3FD85008ABC34 /* ListItemAccessory+Toggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943B43612EC3FD80008ABC34 /* ListItemAccessory+Toggle.swift */; }; 943B43642EC3FDC0008ABC34 /* ListItemAccessory+LoadingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943B43632EC3FDB8008ABC34 /* ListItemAccessory+LoadingIndicator.swift */; }; 943C6D822B75E061004ACE64 /* Message+DisappearingMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */; }; + 944903E82EF21C6800E10C63 /* ListItemAccessory+ProBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 944903E72EF21C5600E10C63 /* ListItemAccessory+ProBadge.swift */; }; + 944903EA2EF2613300E10C63 /* ListItemAccessory+Button.swift in Sources */ = {isa = PBXBuildFile; fileRef = 944903E92EF2612700E10C63 /* ListItemAccessory+Button.swift */; }; + 944903EC2EF27D4C00E10C63 /* SessionButton_SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 944903EB2EF27D3F00E10C63 /* SessionButton_SwiftUI.swift */; }; 94519A932E84C20700F02723 /* _045_LastProfileUpdateTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9BE2E4ABB9F007C4595 /* _045_LastProfileUpdateTimestamp.swift */; }; 94519A952E851BF500F02723 /* SessionProPaymentScreen+UpdatePlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94519A942E851BF300F02723 /* SessionProPaymentScreen+UpdatePlan.swift */; }; 94519A972E851F1400F02723 /* SessionProPaymentScreen+RequestRefund.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94519A962E851F1400F02723 /* SessionProPaymentScreen+RequestRefund.swift */; }; @@ -205,6 +209,7 @@ 945E89D62E9602AB00D8D907 /* SessionProPaymentScreen+Purchase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945E89D52E96028B00D8D907 /* SessionProPaymentScreen+Purchase.swift */; }; 9463794A2E7131070017A014 /* SessionProManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 946379492E71308B0017A014 /* SessionProManagerType.swift */; }; 9463794C2E71371F0017A014 /* SessionProPaymentScreen+Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9463794B2E7137120017A014 /* SessionProPaymentScreen+Models.swift */; }; + 946A495F2ED956FE005A6CF2 /* SessionListScreen+ListItemProfilePicture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 946A495E2ED956EC005A6CF2 /* SessionListScreen+ListItemProfilePicture.swift */; }; 946F5A732D5DA3AC00A5ADCE /* Punycode in Frameworks */ = {isa = PBXBuildFile; productRef = 946F5A722D5DA3AC00A5ADCE /* Punycode */; }; 9473386E2BDF5F3E00B9E169 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 9473386D2BDF5F3E00B9E169 /* InfoPlist.xcstrings */; }; 9478E84B2EC6DDBB00BFDED0 /* ProCTAModal+Type.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9478E84A2EC6DDB300BFDED0 /* ProCTAModal+Type.swift */; }; @@ -1615,6 +1620,7 @@ 7BFD1A962747689000FB91B9 /* Session-Turn-Server */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "Session-Turn-Server"; sourceTree = ""; }; 9409433D2C7EB81800D9D2E0 /* WebRTCSession+Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebRTCSession+Constants.swift"; sourceTree = ""; }; 9409433F2C7ED62300D9D2E0 /* StartupError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupError.swift; sourceTree = ""; }; + 940B82CF2EE697A40000D142 /* SessionListScreen+ListItemTappableText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionListScreen+ListItemTappableText.swift"; sourceTree = ""; }; 941375BA2D5184B60058F244 /* HTTPHeader+SessionNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HTTPHeader+SessionNetwork.swift"; sourceTree = ""; }; 941375BC2D5195F30058F244 /* KeyValueStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyValueStore.swift; sourceTree = ""; }; 9420CAC42E584B5800F738F6 /* GroupAdminCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = GroupAdminCTA.webp; sourceTree = ""; }; @@ -1663,6 +1669,9 @@ 943B43632EC3FDB8008ABC34 /* ListItemAccessory+LoadingIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ListItemAccessory+LoadingIndicator.swift"; sourceTree = ""; }; 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+DisappearingMessages.swift"; sourceTree = ""; }; 943C6D832B86B5F1004ACE64 /* Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Localization.swift; sourceTree = ""; }; + 944903E72EF21C5600E10C63 /* ListItemAccessory+ProBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ListItemAccessory+ProBadge.swift"; sourceTree = ""; }; + 944903E92EF2612700E10C63 /* ListItemAccessory+Button.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ListItemAccessory+Button.swift"; sourceTree = ""; }; + 944903EB2EF27D3F00E10C63 /* SessionButton_SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionButton_SwiftUI.swift; sourceTree = ""; }; 94519A942E851BF300F02723 /* SessionProPaymentScreen+UpdatePlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProPaymentScreen+UpdatePlan.swift"; sourceTree = ""; }; 94519A962E851F1400F02723 /* SessionProPaymentScreen+RequestRefund.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProPaymentScreen+RequestRefund.swift"; sourceTree = ""; }; 94519A982E8A1A3200F02723 /* SessionProPaymentScreen+CancelPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProPaymentScreen+CancelPlan.swift"; sourceTree = ""; }; @@ -1672,6 +1681,7 @@ 945E89D52E96028B00D8D907 /* SessionProPaymentScreen+Purchase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProPaymentScreen+Purchase.swift"; sourceTree = ""; }; 946379492E71308B0017A014 /* SessionProManagerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProManagerType.swift; sourceTree = ""; }; 9463794B2E7137120017A014 /* SessionProPaymentScreen+Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProPaymentScreen+Models.swift"; sourceTree = ""; }; + 946A495E2ED956EC005A6CF2 /* SessionListScreen+ListItemProfilePicture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionListScreen+ListItemProfilePicture.swift"; sourceTree = ""; }; 9471CAA72CACFB4E00090FB7 /* GenerateLicenses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerateLicenses.swift; sourceTree = ""; }; 9473386D2BDF5F3E00B9E169 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; 9478E84A2EC6DDB300BFDED0 /* ProCTAModal+Type.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProCTAModal+Type.swift"; sourceTree = ""; }; @@ -3017,6 +3027,7 @@ 942256932C23F8DD00C0FDBF /* SwiftUI */ = { isa = PBXGroup; children = ( + 944903EB2EF27D3F00E10C63 /* SessionButton_SwiftUI.swift */, 9438D5192E6951AD008C7FFE /* AnimatedToggle.swift */, FD360EC82ECD3EAE0050CAF4 /* DonationCTAModal.swift */, 94D716812E8FA19D008294EE /* AttributedLabel.swift */, @@ -3085,10 +3096,12 @@ 943B43502EC2AF3D008ABC34 /* ListItemViews */ = { isa = PBXGroup; children = ( + 946A495E2ED956EC005A6CF2 /* SessionListScreen+ListItemProfilePicture.swift */, 943B43572EC2AFCF008ABC34 /* SessionListScreen+ListItemButton.swift */, 943B43552EC2AFAB008ABC34 /* SessionListScreen+ListItemDataMatrix.swift */, 943B43532EC2AF82008ABC34 /* SessionListScreen+ListItemLogoWithPro.swift */, 943B43512EC2AF64008ABC34 /* SessionListScreen+ListItemCell.swift */, + 940B82CF2EE697A40000D142 /* SessionListScreen+ListItemTappableText.swift */, ); path = ListItemViews; sourceTree = ""; @@ -3098,7 +3111,9 @@ children = ( 94363E672E6024960004EE43 /* SessionListScreen+AccessoryViews.swift */, 943B435F2EC3FCD5008ABC34 /* ListItemAccessory+Icon.swift */, + 944903E72EF21C5600E10C63 /* ListItemAccessory+ProBadge.swift */, 943B43612EC3FD80008ABC34 /* ListItemAccessory+Toggle.swift */, + 944903E92EF2612700E10C63 /* ListItemAccessory+Button.swift */, 943B43632EC3FDB8008ABC34 /* ListItemAccessory+LoadingIndicator.swift */, ); path = AccessoryViews; @@ -6580,6 +6595,7 @@ 948615C52ED7D4D4000A5666 /* ModalActivityIndicatorViewController.swift in Sources */, 9438D51A2E6951B3008C7FFE /* AnimatedToggle.swift in Sources */, FDAA36AC2EB2C5840040603E /* VoiceMessageRecordingView.swift in Sources */, + 944903EC2EF27D4C00E10C63 /* SessionButton_SwiftUI.swift in Sources */, FD9E26CE2EA72EFF00404C7F /* QuoteView_SwiftUI.swift in Sources */, FDAA36B92EB3FBC80040603E /* LinkPreviewManagerType.swift in Sources */, FDAA36AC2EB2C5840040603E /* VoiceMessageRecordingView.swift in Sources */, @@ -6630,6 +6646,7 @@ 9438658F2EAB380700DB989A /* MutipleLinksModal.swift in Sources */, 948615C92ED7D646000A5666 /* Publisher+Utilities.swift in Sources */, 94363E5E2E6002960004EE43 /* SessionListScreen+Models.swift in Sources */, + 944903EA2EF2613300E10C63 /* ListItemAccessory+Button.swift in Sources */, 94D716802E8F6363008294EE /* HighlightMentionView.swift in Sources */, FD8A5B292DC060E2004C689B /* Double+Utilities.swift in Sources */, FD8A5B0E2DBF2DB1004C689B /* SessionHostingViewController.swift in Sources */, @@ -6644,6 +6661,7 @@ 94B6BB002E3AE83C00E718BB /* QRCodeView.swift in Sources */, FDB3DA882E24810C00148F8D /* SessionAsyncImage.swift in Sources */, 9499E6032DDD9BF900091434 /* ExpandableLabel.swift in Sources */, + 946A495F2ED956FE005A6CF2 /* SessionListScreen+ListItemProfilePicture.swift in Sources */, 94AAB14D2E1F39B500A6FA18 /* ProCTAModal.swift in Sources */, 948615CB2ED7D6E5000A5666 /* NavigatableState.swift in Sources */, FDE754BA2C9B97B8002A2623 /* UIDevice+Utilities.swift in Sources */, @@ -6691,6 +6709,7 @@ C331FFE82558FB0000070591 /* SNTextView.swift in Sources */, FD71162028D97ABC00B47552 /* UIImage+Utilities.swift in Sources */, 947D7FE72D51837200E8E413 /* ArrowCapsule.swift in Sources */, + 944903E82EF21C6800E10C63 /* ListItemAccessory+ProBadge.swift in Sources */, 947D7FE82D51837200E8E413 /* PopoverView.swift in Sources */, FDAB8A832EB2A4CB000A6C65 /* MentionSelectionView.swift in Sources */, FD8A5B202DC03337004C689B /* AdaptiveText.swift in Sources */, @@ -6723,6 +6742,7 @@ C33100282559000A00070591 /* UIView+Utilities.swift in Sources */, FDE6E99829F8E63A00F93C5D /* Accessibility.swift in Sources */, 9463794C2E71371F0017A014 /* SessionProPaymentScreen+Models.swift in Sources */, + 940B82D02EE697C70000D142 /* SessionListScreen+ListItemTappableText.swift in Sources */, FD37E9CA28A1E4BD003AE748 /* Theme+ClassicLight.swift in Sources */, 943B43622EC3FD85008ABC34 /* ListItemAccessory+Toggle.swift in Sources */, 945E89D42E95D97000D8D907 /* SessionProPaymentScreen+SharedViews.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index f389ac8a28..7b2f6620ac 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -98,7 +98,8 @@ extension ConversationVC: } @objc func openSettings() { - let viewController = SessionTableViewController(viewModel: ThreadSettingsViewModel( + let viewController = SessionListHostingViewController( + viewModel: ThreadSettingsViewModel( threadId: self.viewModel.threadData.threadId, threadVariant: self.viewModel.threadData.threadVariant, didTriggerSearch: { [weak self] in @@ -109,7 +110,8 @@ extension ConversationVC: } }, using: self.viewModel.dependencies - ) + ), + using: self.viewModel.dependencies ) navigationController?.pushViewController(viewController, animated: true) } diff --git a/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift b/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift index ff45656e18..3700cb4996 100644 --- a/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift @@ -400,4 +400,5 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel, Naviga } } -extension String: Differentiable {} +extension String: @retroactive ContentEquatable {} +extension String: @retroactive ContentIdentifiable {} diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index bb5ca2abba..2319767f14 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -13,11 +13,15 @@ import SignalUtilitiesKit import SessionUtilitiesKit import SessionNetworkingKit -class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, ObservableTableSource { +class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, NavigationItemSource, NavigatableStateHolder { public let dependencies: Dependencies public let navigatableState: NavigatableState = NavigatableState() - public let state: TableDataState = TableDataState() - public let observableState: ObservableTableSourceState = ObservableTableSourceState() + public let state: SessionListScreenContent.ListItemDataState = SessionListScreenContent.ListItemDataState() + public var imageDataManager: ImageDataManagerType { dependencies[singleton: .imageDataManager] } + + /// This value is the current state of the view + @MainActor @Published private(set) var internalState: ViewModelState + private var observationTask: Task? private let threadId: String private let threadVariant: SessionThread.Variant @@ -32,13 +36,10 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi }, using: dependencies ) - private var profileImageStatus: (previous: ProfileImageStatus?, current: ProfileImageStatus?) - // TODO: Refactor this with SessionThreadViewModel - private var threadViewModelSubject: CurrentValueSubject // MARK: - Initialization - init( + @MainActor init( threadId: String, threadVariant: SessionThread.Variant, didTriggerSearch: @escaping () -> (), @@ -48,11 +49,23 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi self.threadId = threadId self.threadVariant = threadVariant self.didTriggerSearch = didTriggerSearch - self.threadViewModelSubject = CurrentValueSubject(nil) - self.profileImageStatus = (previous: nil, current: .normal) + self.internalState = ViewModelState.initialState(threadId: threadId) + + self.observationTask = ObservationBuilder + .initialValue(self.internalState) + .debounce(for: .never) + .using(dependencies: dependencies) + .query(ThreadSettingsViewModel.queryState) + .assign { [weak self] updatedState in + guard let self = self else { return } + + self.state.updateTableData(updatedState.sections(viewModel: self, previousState: self.internalState)) + self.internalState = updatedState + } } // MARK: - Config + enum ProfileImageStatus: Equatable { case normal case expanded @@ -63,7 +76,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi case edit } - public enum Section: SessionTableSection { + public enum Section: SessionListScreenContent.ListSection { case conversationInfo case sessionId case sessionIdNoteToSelf @@ -80,19 +93,28 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } } - public var style: SessionTableSectionStyle { + public var style: SessionListScreenContent.ListSectionStyle { switch self { case .sessionId, .sessionIdNoteToSelf: return .titleSeparator - case .destructiveActions: return .padding - case .adminActions: return .titleRoundedContent + case .adminActions, .destructiveActions, .content: return .titleRoundedContent default: return .none } } + + public var divider: Bool { + switch self { + case .conversationInfo: return false + default: return true + } + } + + public var footer: String? { return nil } + + public var extraVerticalPadding: CGFloat { return 0 } } - public enum TableItem: Differentiable { + public enum ListItem: Differentiable { case avatar - case qrCode case displayName case contactName case threadDescription @@ -121,9 +143,9 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi case debugDeleteAttachmentsBeforeNow } - lazy var rightNavItems: AnyPublisher<[SessionNavItem], Never> = threadViewModelSubject - .map { [weak self] threadViewModel -> [SessionNavItem] in - guard let threadViewModel: SessionThreadViewModel = threadViewModel else { return [] } + lazy var rightNavItems: AnyPublisher<[SessionNavItem], Never> = $internalState + .map { [weak self] state -> [SessionNavItem] in + guard let threadViewModel: SessionThreadViewModel = state.threadViewModel else { return [] } let currentUserIsClosedGroupAdmin: Bool = ( [.legacyGroup, .group].contains(threadViewModel.threadVariant) && @@ -164,9 +186,37 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi // MARK: - Content - private struct State: Equatable { + public struct ViewModelState: ObservableKeyProvider { + let threadId: String let threadViewModel: SessionThreadViewModel? let disappearingMessagesConfig: DisappearingMessagesConfiguration + + @MainActor public func sections(viewModel: ThreadSettingsViewModel, previousState: ViewModelState) -> [SectionModel] { + ThreadSettingsViewModel.sections( + current: self, + viewModel: viewModel + ) + } + + public var observedKeys: Set { + var result: Set = [ + .updateScreen(ThreadSettingsViewModel.self), + .profile(threadId), + .contact(threadId), + .conversationUpdated(threadId), + .disappearingMessagesConfigUpdated(threadId) + ] + + return result + } + + static func initialState(threadId: String) -> ViewModelState { + return ViewModelState( + threadId: threadId, + threadViewModel: nil, + disappearingMessagesConfig: DisappearingMessagesConfiguration.defaultWith(threadId) + ) + } } var title: String { @@ -176,35 +226,60 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } } - lazy var observation: TargetObservation = ObservationBuilderOld - .databaseObservation(self) { [ weak self, dependencies, threadId = self.threadId] db -> State in - let userSessionId: SessionId = dependencies[cache: .general].sessionId - var threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel - .conversationSettingsQuery(threadId: threadId, userSessionId: userSessionId) - .fetchOne(db) - let disappearingMessagesConfig: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration - .fetchOne(db, id: threadId) - .defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId)) - - self?.threadViewModelSubject.send(threadViewModel) - - return State( - threadViewModel: threadViewModel, - disappearingMessagesConfig: disappearingMessagesConfig - ) + @Sendable private static func queryState( + previousState: ViewModelState, + events: [ObservedEvent], + isInitialQuery: Bool, + using dependencies: Dependencies + ) async -> ViewModelState { + let userSessionId: SessionId = dependencies[cache: .general].sessionId + let threadId: String = previousState.threadId + var threadViewModel: SessionThreadViewModel? = previousState.threadViewModel + var disappearingMessagesConfig: DisappearingMessagesConfiguration = previousState.disappearingMessagesConfig + + /// If we have no previous state then we need to fetch the initial state + if isInitialQuery { + dependencies[singleton: .storage].read { db in + threadViewModel = try SessionThreadViewModel + .conversationSettingsQuery(threadId: threadId, userSessionId: userSessionId) + .fetchOne(db) + disappearingMessagesConfig = try DisappearingMessagesConfiguration + .fetchOne(db, id: threadId) + .defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId)) + } } - .compactMap { [weak self] current -> [SectionModel]? in - self?.content( - current, - profileImageStatus: self?.profileImageStatus - ) + + /// Process any event changes + events.forEach { event in + switch event.key.generic { + case .disappearingMessagesConfigUpdate: + if let newValue = event.value as? DisappearingMessagesConfiguration { + disappearingMessagesConfig = newValue + } + default: + dependencies[singleton: .storage].read { db in + threadViewModel = try SessionThreadViewModel + .conversationSettingsQuery(threadId: threadId, userSessionId: userSessionId) + .fetchOne(db) + } + } } + + return ViewModelState( + threadId: threadId, + threadViewModel: threadViewModel, + disappearingMessagesConfig: disappearingMessagesConfig + ) + } - private func content(_ current: State, profileImageStatus: (previous: ProfileImageStatus?, current: ProfileImageStatus?)?) -> [SectionModel] { + private static func sections( + current: ViewModelState, + viewModel: ThreadSettingsViewModel + ) -> [SectionModel] { // If we don't get a `SessionThreadViewModel` then it means the thread was probably deleted // so dismiss the screen guard let threadViewModel: SessionThreadViewModel = current.threadViewModel else { - self.dismissScreen(type: .popToRoot) + viewModel.dismissScreen(type: .popToRoot) return [] } @@ -233,7 +308,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi let showThreadPubkey: Bool = ( threadViewModel.threadVariant == .contact || ( threadViewModel.threadVariant == .group && - dependencies[feature: .groupsShowPubkeyInConversationSettings] + viewModel.dependencies[feature: .groupsShowPubkeyInConversationSettings] ) ) // MARK: - Conversation Info @@ -241,184 +316,141 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi let conversationInfoSection: SectionModel = SectionModel( model: .conversationInfo, elements: [ - (profileImageStatus?.current == .qrCode ? - SessionCell.Info( - id: .qrCode, - accessory: .qrCode( - for: threadViewModel.getQRCodeString(), - hasBackground: false, - logo: "SessionWhite40", // stringlint:ignore - themeStyle: ThemeManager.currentTheme.interfaceStyle - ), - styling: SessionCell.StyleInfo( - alignment: .centerHugging, - customPadding: SessionCell.Padding(bottom: Values.smallSpacing), - backgroundStyle: .noBackground - ), - onTapView: { [weak self] targetView in - let didTapProfileIcon: Bool = !(targetView is UIImageView) - - if didTapProfileIcon { - self?.profileImageStatus = (previous: profileImageStatus?.current, current: profileImageStatus?.previous) - self?.forceRefresh(type: .postDatabaseQuery) - } else { - self?.showQRCodeLightBox(for: threadViewModel) - } - } - ) - : - SessionCell.Info( - id: .avatar, - accessory: .profile( - id: threadViewModel.id, - size: (profileImageStatus?.current == .expanded ? .expanded : .hero), - threadVariant: threadViewModel.threadVariant, - displayPictureUrl: threadViewModel.threadDisplayPictureUrl, - profile: threadViewModel.profile, - profileIcon: (threadViewModel.threadIsNoteToSelf || threadVariant == .group ? .none : .qrCode), - additionalProfile: threadViewModel.additionalProfile, - accessibility: nil - ), - styling: SessionCell.StyleInfo( - alignment: .centerHugging, - customPadding: SessionCell.Padding( - leading: 0, - bottom: Values.smallSpacing - ), - backgroundStyle: .noBackground - ), - onTapView: { [weak self] targetView in - let didTapQRCodeIcon: Bool = !(targetView is ProfilePictureView) - - if didTapQRCodeIcon { - self?.profileImageStatus = (previous: profileImageStatus?.current, current: .qrCode) - } else { - self?.profileImageStatus = ( - previous: profileImageStatus?.current, - current: (profileImageStatus?.current == .expanded ? .normal : .expanded) + SessionListScreenContent.ListItemInfo( + id: .avatar, + variant: .profilePicture( + info: .init( + sessionId: threadViewModel.id, + qrCodeImage: { + guard threadViewModel.threadVariant != .group else { return nil } + return QRCode.generate( + for: threadViewModel.threadId, + hasBackground: false, + iconName: "SessionWhite40" // stringlint:ignore ) - } - self?.forceRefresh(type: .postDatabaseQuery) - } + }(), + profileInfo: { + let (info, _) = ProfilePictureView.Info.generateInfoFrom( + size: .hero, + publicKey: threadViewModel.id, + threadVariant: threadViewModel.threadVariant, + displayPictureUrl: nil, + profile: threadViewModel.profile, + using: viewModel.dependencies + ) + + return info + }() + ) ) ), - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .displayName, - title: SessionCell.TextInfo( - threadViewModel.displayName, - font: .titleLarge, - alignment: .center, - trailingImage: { - guard !threadViewModel.threadIsNoteToSelf else { return nil } - guard (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }) else { return nil } - - return SessionProBadge.trailingImage( - size: .medium, - themeBackgroundColor: .primary - ) - }() - ), - styling: SessionCell.StyleInfo( - alignment: .centerHugging, - customPadding: SessionCell.Padding( - top: Values.smallSpacing, - bottom: { - guard threadViewModel.threadVariant != .contact else { return Values.mediumSpacing } - guard threadViewModel.threadDescription == nil else { return Values.smallSpacing } + variant: .tappableText( + info: .init( + text: threadViewModel.displayName, + font: Fonts.Headings.H4, + imageAttachmentPosition: .trailing, + imageAttachmentGenerator: { + guard !threadViewModel.threadIsNoteToSelf else { return nil } + guard (viewModel.dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: viewModel.threadId) }) else { return nil } - return Values.largeSpacing - }(), - interItem: 0 - ), - backgroundStyle: .noBackground - ), - accessibility: Accessibility( - identifier: "Username", - label: threadViewModel.displayName - ), - onTapView: { [weak self, threadId, dependencies] targetView in - guard targetView is SessionProBadge, !dependencies[cache: .libSession].isSessionPro else { - guard - let info: ConfirmationModal.Info = self?.updateDisplayNameModal( - threadViewModel: threadViewModel, - currentUserIsClosedGroupAdmin: currentUserIsClosedGroupAdmin - ) - else { return } - - self?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present) - return - } - - let proCTAModalVariant: ProCTAModal.Variant = { - switch threadViewModel.threadVariant { - case .group: - return .groupLimit( - isAdmin: currentUserIsClosedGroupAdmin, - isSessionProActivated: (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }), - proBadgeImage: UIView.image( + return { + ( + UIView.image( for: .themedKey( - SessionProBadge.Size.mini.cacheKey, + SessionProBadge.Size.medium.cacheKey, themeBackgroundColor: .primary ), - generator: { SessionProBadge(size: .mini) } - ) + generator: { SessionProBadge(size: .medium) } + ), + SessionProBadge.accessibilityLabel ) - default: - return .generic(renew: dependencies[singleton: .sessionProState].isSessionProExpired) - } - }() - - dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - proCTAModalVariant, - onConfirm: { - dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( - presenting: { bottomSheet in - self?.transitionToScreen(bottomSheet, transitionType: .present) + } + }(), + onTextTap: { + guard + let info: ConfirmationModal.Info = viewModel.updateDisplayNameModal( + threadViewModel: threadViewModel, + currentUserIsClosedGroupAdmin: currentUserIsClosedGroupAdmin + ) + else { return } + + viewModel.transitionToScreen(ConfirmationModal(info: info), transitionType: .present) + }, + onImageTap: { [dependencies = viewModel.dependencies] in + guard !dependencies[cache: .libSession].isSessionPro else { return } + + let proCTAModalVariant: ProCTAModal.Variant = { + switch threadViewModel.threadVariant { + case .group: + return .groupLimit( + isAdmin: currentUserIsClosedGroupAdmin, + isSessionProActivated: (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadViewModel.threadId) }), + proBadgeImage: UIView.image( + for: .themedKey( + SessionProBadge.Size.mini.cacheKey, + themeBackgroundColor: .primary + ), + generator: { SessionProBadge(size: .mini) } + ) + ) + default: + return .generic(renew: dependencies[singleton: .sessionProState].isSessionProExpired) + } + }() + + dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + proCTAModalVariant, + onConfirm: { + dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( + presenting: { bottomSheet in + viewModel.transitionToScreen(bottomSheet, transitionType: .present) + } + ) + }, + presenting: { modal in + viewModel.transitionToScreen(modal, transitionType: .present) } ) - }, - presenting: { modal in - self?.transitionToScreen(modal, transitionType: .present) } ) - } + ), + accessibility: Accessibility( + identifier: "Username", + label: threadViewModel.displayName + ) ), (threadViewModel.displayName == threadViewModel.contactDisplayName ? nil : - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .contactName, - subtitle: SessionCell.TextInfo( - "(\(threadViewModel.contactDisplayName))", // stringlint:ignore - font: .subtitle, - alignment: .center - ), - styling: SessionCell.StyleInfo( - tintColor: .textSecondary, - customPadding: SessionCell.Padding( - top: 0, - bottom: Values.largeSpacing - ), - backgroundStyle: .noBackground + variant: .cell( + info: .init( + title: .init( + "(\(threadViewModel.contactDisplayName))", // stringlint:ignore + font: .Body.baseRegular, + alignment: .center, + color: .textSecondary + ) + ) ) ) ), threadViewModel.threadDescription.map { threadDescription in - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .threadDescription, - description: SessionCell.TextInfo( - threadDescription, - font: .subtitle, - alignment: .center, - interaction: .expandable - ), - styling: SessionCell.StyleInfo( - tintColor: .textSecondary, - customPadding: SessionCell.Padding( - top: 0, - bottom: (threadViewModel.threadVariant != .contact ? Values.largeSpacing : nil) - ), - backgroundStyle: .noBackground + variant: .cell( + info: .init( + title: .init( + threadDescription, + font: .Body.baseRegular, + alignment: .center, + color: .textSecondary, + interaction: .expandable + ) + ) ), accessibility: Accessibility( identifier: "Description", @@ -434,17 +466,17 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi let sessionIdSection: SectionModel = SectionModel( model: (threadViewModel.threadIsNoteToSelf == true ? .sessionIdNoteToSelf : .sessionId), elements: [ - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .sessionId, - subtitle: SessionCell.TextInfo( - threadViewModel.id, - font: .monoLarge, - alignment: .center, - interaction: .copy - ), - styling: SessionCell.StyleInfo( - customPadding: SessionCell.Padding(bottom: Values.smallSpacing), - backgroundStyle: .noBackground + variant: .cell( + info: .init( + title: .init( + threadViewModel.id, + font: .Display.extraLarge, + alignment: .center, + interaction: .copy + ) + ) ), accessibility: Accessibility( identifier: "Session ID", @@ -462,11 +494,21 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi SectionModel( model: .destructiveActions, elements: [ - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .leaveGroup, - leadingAccessory: .icon(.trash2), - title: "groupDelete".localized(), - styling: SessionCell.StyleInfo(tintColor: .danger), + variant: .cell( + info: .init( + leadingAccessory: .icon( + .trash2, + customTint: .danger + ), + title: .init( + "groupDelete".localized(), + font: .Headings.H8, + color: .danger + ) + ) + ), accessibility: Accessibility( identifier: "Leave group", label: "Leave group" @@ -482,8 +524,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self, dependencies] in - self?.dismissScreen(type: .popToRoot) { + onTap: { [dependencies = viewModel.dependencies] in + viewModel.dismissScreen(type: .popToRoot) { dependencies[singleton: .storage].writeAsync { db in try SessionThread.deleteOrLeave( db, @@ -507,18 +549,25 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi model: .content, elements: [ (threadViewModel.threadVariant == .legacyGroup || threadViewModel.threadVariant == .group ? nil : - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .copyThreadId, - leadingAccessory: .icon(.copy), - title: (threadViewModel.threadVariant == .community ? - "communityUrlCopy".localized() : - "accountIDCopy".localized() + variant: .cell( + info: .init( + leadingAccessory: .icon(.copy), + title: .init( + (threadViewModel.threadVariant == .community ? + "communityUrlCopy".localized() : + "accountIDCopy".localized() + ), + font: .Headings.H8 + ) + ) ), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).copy_thread_id", label: "Copy Session ID" ), - onTap: { [weak self] in + onTap: { switch threadViewModel.threadVariant { case .contact, .legacyGroup, .group: UIPasteboard.general.string = threadViewModel.threadId @@ -535,7 +584,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi UIPasteboard.general.string = urlString } - self?.showToast( + viewModel.showToast( text: "copied".localized(), backgroundColor: .backgroundSecondary ) @@ -543,41 +592,59 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) ), - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .searchConversation, - leadingAccessory: .icon(.search), - title: "searchConversation".localized(), + variant: .cell( + info: .init( + leadingAccessory: .icon(.search), + title: .init( + "searchConversation".localized(), + font: .Headings.H8 + ) + ) + ), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).search", label: "Search" ), - onTap: { [weak self] in self?.didTriggerSearch() } + onTap: { viewModel.didTriggerSearch() } ), ( threadViewModel.threadVariant == .community || threadViewModel.threadIsBlocked == true || currentUserIsClosedGroupAdmin ? nil : - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .disappearingMessages, - leadingAccessory: .icon(.timer), - title: "disappearingMessages".localized(), - subtitle: { - guard current.disappearingMessagesConfig.isEnabled else { - return "off".localized() - } - - return (current.disappearingMessagesConfig.type ?? .unknown) - .localizedState( - durationString: current.disappearingMessagesConfig.durationString + variant: .cell( + info: .init( + leadingAccessory: .icon(.timer), + title: .init( + "disappearingMessages".localized(), + font: .Headings.H8 + ), + description: .init( + { + guard current.disappearingMessagesConfig.isEnabled else { + return "off".localized() + } + + return (current.disappearingMessagesConfig.type ?? .unknown) + .localizedState( + durationString: current.disappearingMessagesConfig.durationString + ) + }(), + font: .Body.smallRegular, + color: .textPrimary ) - }(), + ) + ), accessibility: Accessibility( identifier: "Disappearing messages", label: "\(ThreadSettingsViewModel.self).disappearing_messages" ), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( + onTap: { [dependencies = viewModel.dependencies] in + viewModel.transitionToScreen( SessionTableViewController( viewModel: ThreadDisappearingMessagesSettingsViewModel( threadId: threadViewModel.threadId, @@ -594,24 +661,28 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ), (threadViewModel.threadIsBlocked == true ? nil : - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .pinConversation, - leadingAccessory: .icon( - (threadViewModel.threadPinnedPriority > 0 ? - .pinOff : - .pin + variant: .cell( + info: .init( + leadingAccessory: .icon( + (threadViewModel.threadPinnedPriority > 0 ? + .pinOff : + .pin + ) + ), + title: .init( + (threadViewModel.threadPinnedPriority > 0 ? "pinUnpinConversation".localized() : "pinConversation".localized()), + font: .Headings.H8 + ) ) ), - title: (threadViewModel.threadPinnedPriority > 0 ? - "pinUnpinConversation".localized() : - "pinConversation".localized() - ), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).pin_conversation", label: "Pin Conversation" ), - onTap: { [weak self] in - self?.toggleConversationPinnedStatus( + onTap: { + viewModel.toggleConversationPinnedStatus( currentPinnedPriority: threadViewModel.threadPinnedPriority ) } @@ -619,39 +690,50 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ), ((threadViewModel.threadIsNoteToSelf == true || threadViewModel.threadIsBlocked == true) ? nil : - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .notifications, - leadingAccessory: .icon( - { - if threadViewModel.threadOnlyNotifyForMentions == true { - return .atSign - } - - if threadViewModel.threadMutedUntilTimestamp != nil { - return .volumeOff - } - - return .volume2 - }() + variant: .cell( + info: .init( + leadingAccessory: .icon( + { + if threadViewModel.threadOnlyNotifyForMentions == true { + return .atSign + } + + if threadViewModel.threadMutedUntilTimestamp != nil { + return .volumeOff + } + + return .volume2 + }() + ), + title: .init( + "sessionNotifications".localized(), + font: .Headings.H8 + ), + description: .init( + { + if threadViewModel.threadOnlyNotifyForMentions == true { + return "notificationsMentionsOnly".localized() + } + + if threadViewModel.threadMutedUntilTimestamp != nil { + return "notificationsMuted".localized() + } + + return "notificationsAllMessages".localized() + }(), + font: .Body.smallRegular, + color: .textPrimary + ) + ) ), - title: "sessionNotifications".localized(), - subtitle: { - if threadViewModel.threadOnlyNotifyForMentions == true { - return "notificationsMentionsOnly".localized() - } - - if threadViewModel.threadMutedUntilTimestamp != nil { - return "notificationsMuted".localized() - } - - return "notificationsAllMessages".localized() - }(), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).notifications", label: "Notifications" ), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( + onTap: { [dependencies = viewModel.dependencies] in + viewModel.transitionToScreen( SessionTableViewController( viewModel: ThreadNotificationSettingsViewModel( threadId: threadViewModel.threadId, @@ -667,40 +749,61 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ), (threadViewModel.threadVariant != .community ? nil : - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .addToOpenGroup, - leadingAccessory: .icon(.userRoundPlus), - title: "membersInvite".localized(), + variant: .cell( + info: .init( + leadingAccessory: .icon(.userRoundPlus), + title: .init( + "membersInvite".localized(), + font: .Headings.H8 + ) + ) + ), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).add_to_open_group" ), - onTap: { [weak self] in self?.inviteUsersToCommunity(threadViewModel: threadViewModel) } + onTap: { viewModel.inviteUsersToCommunity(threadViewModel: threadViewModel) } ) ), (!currentUserIsClosedGroupMember ? nil : - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .groupMembers, - leadingAccessory: .icon(.usersRound), - title: "groupMembers".localized(), + variant: .cell( + info: .init( + leadingAccessory: .icon(.usersRound), + title: .init( + "groupMembers".localized(), + font: .Headings.H8 + ) + ) + ), accessibility: Accessibility( identifier: "Group members", label: "Group members" ), - onTap: { [weak self] in self?.viewMembers() } + onTap: { viewModel.viewMembers() } ) ), - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .attachments, - leadingAccessory: .icon(.file), - title: "attachments".localized(), + variant: .cell( + info: .init( + leadingAccessory: .icon(.file), + title: .init( + "attachments".localized(), + font: .Headings.H8 + ) + ) + ), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).all_media", label: "All media" ), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( + onTap: { [dependencies = viewModel.dependencies] in + viewModel.transitionToScreen( MediaGalleryViewModel.createAllMediaViewController( threadId: threadViewModel.threadId, threadVariant: threadViewModel.threadVariant, @@ -721,16 +824,23 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi SectionModel( model: .adminActions, elements: [ - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .editGroup, - leadingAccessory: .icon(.userRoundPen), - title: "manageMembers".localized(), + variant: .cell( + info: .init( + leadingAccessory: .icon(.userRoundPen), + title: .init( + "manageMembers".localized(), + font: .Headings.H8 + ) + ) + ), accessibility: Accessibility( identifier: "Edit group", label: "Edit group" ), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( + onTap: { [dependencies = viewModel.dependencies] in + viewModel.transitionToScreen( SessionTableViewController( viewModel: EditGroupViewModel( threadId: threadViewModel.threadId, @@ -741,44 +851,60 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } ), - (!dependencies[feature: .updatedGroupsAllowPromotions] ? nil : - SessionCell.Info( + (!viewModel.dependencies[feature: .updatedGroupsAllowPromotions] ? nil : + SessionListScreenContent.ListItemInfo( id: .promoteAdmins, - leadingAccessory: .icon( - UIImage(named: "table_ic_group_edit")? - .withRenderingMode(.alwaysTemplate) + variant: .cell( + info: .init( + leadingAccessory: .icon( + UIImage(named: "table_ic_group_edit")? + .withRenderingMode(.alwaysTemplate) + ), + title: .init( + "adminPromote".localized(), + font: .Headings.H8 + ) + ) ), - title: "adminPromote".localized(), accessibility: Accessibility( identifier: "Promote admins", label: "Promote admins" ), - onTap: { [weak self] in - self?.promoteAdmins(currentGroupName: threadViewModel.closedGroupName) - } + onTap: { viewModel.promoteAdmins(currentGroupName: threadViewModel.closedGroupName) } ) ), - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .disappearingMessages, - leadingAccessory: .icon(.timer), - title: "disappearingMessages".localized(), - subtitle: { - guard current.disappearingMessagesConfig.isEnabled else { - return "off".localized() - } - - return (current.disappearingMessagesConfig.type ?? .unknown) - .localizedState( - durationString: current.disappearingMessagesConfig.durationString + variant: .cell( + info: .init( + leadingAccessory: .icon(.timer), + title: .init( + "disappearingMessages".localized(), + font: .Headings.H8 + ), + description: .init( + { + guard current.disappearingMessagesConfig.isEnabled else { + return "off".localized() + } + + return (current.disappearingMessagesConfig.type ?? .unknown) + .localizedState( + durationString: current.disappearingMessagesConfig.durationString + ) + }(), + font: .Body.smallRegular, + color: .textSecondary ) - }(), + ) + ), accessibility: Accessibility( identifier: "Disappearing messages", label: "\(ThreadSettingsViewModel.self).disappearing_messages" ), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( + onTap: { [dependencies = viewModel.dependencies] in + viewModel.transitionToScreen( SessionTableViewController( viewModel: ThreadDisappearingMessagesSettingsViewModel( threadId: threadViewModel.threadId, @@ -802,19 +928,32 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi model: .destructiveActions, elements: [ (threadViewModel.threadIsNoteToSelf || threadViewModel.threadVariant != .contact ? nil : - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .blockUser, - leadingAccessory: ( - threadViewModel.threadIsBlocked == true ? - .icon(.userRoundCheck) : - .icon(UIImage(named: "ic_user_round_ban")?.withRenderingMode(.alwaysTemplate)) - ), - title: ( - threadViewModel.threadIsBlocked == true ? - "blockUnblock".localized() : - "block".localized() + variant: .cell( + info: .init( + leadingAccessory: ( + threadViewModel.threadIsBlocked == true ? + .icon( + .userRoundCheck, + customTint: .danger + ) : + .icon( + UIImage(named: "ic_user_round_ban")?.withRenderingMode(.alwaysTemplate), + customTint: .danger + ) + ), + title: .init( + ( + threadViewModel.threadIsBlocked == true ? + "blockUnblock".localized() : + "block".localized() + ), + font: .Headings.H8, + color: .danger + ) + ) ), - styling: SessionCell.StyleInfo(tintColor: .danger), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).block", label: "Block" @@ -843,10 +982,10 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self] in + onTap: { let isBlocked: Bool = (threadViewModel.threadIsBlocked == true) - self?.updateBlockedState( + viewModel.updateBlockedState( from: isBlocked, isBlocked: !isBlocked, threadId: threadViewModel.threadId, @@ -857,11 +996,21 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ), (threadViewModel.threadIsNoteToSelf != true ? nil : - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .hideNoteToSelf, - leadingAccessory: .icon(isThreadHidden ? .eye : .eyeOff), - title: isThreadHidden ? "showNoteToSelf".localized() : "noteToSelfHide".localized(), - styling: SessionCell.StyleInfo(tintColor: isThreadHidden ? .textPrimary : .danger), + variant: .cell( + info: .init( + leadingAccessory: .icon( + isThreadHidden ? .eye : .eyeOff, + customTint: isThreadHidden ? .textPrimary : .danger + ), + title: .init( + isThreadHidden ? "showNoteToSelf".localized() : "noteToSelfHide".localized(), + font: .Headings.H8, + color: isThreadHidden ? .textPrimary : .danger + ) + ) + ), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).hide_note_to_self", label: "Hide Note to Self" @@ -879,7 +1028,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi confirmStyle: isThreadHidden ? .alert_text : .danger, cancelStyle: .alert_text ), - onTap: { [dependencies] in + onTap: { [dependencies = viewModel.dependencies] in dependencies[singleton: .storage].writeAsync { db in if isThreadHidden { try SessionThread.updateVisibility( @@ -902,13 +1051,21 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) ), - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .clearAllMessages, - leadingAccessory: .icon( - UIImage(named: "ic_message_trash")?.withRenderingMode(.alwaysTemplate) + variant: .cell( + info: .init( + leadingAccessory: .icon( + UIImage(named: "ic_message_trash")?.withRenderingMode(.alwaysTemplate), + customTint: .danger + ), + title: .init( + "clearMessages".localized(), + font: .Headings.H8, + color: .danger + ) + ) ), - title: "clearMessages".localized(), - styling: SessionCell.StyleInfo(tintColor: .danger), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).clear_all_messages", label: "Clear All Messages" @@ -922,7 +1079,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) } - switch threadVariant { + switch threadViewModel.threadVariant { case .contact: return .attributedText( "clearMessagesChatDescriptionUpdated" @@ -982,8 +1139,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi confirmStyle: .danger, cancelStyle: .alert_text, dismissOnConfirm: false, - onConfirm: { [weak self, threadVariant, dependencies] modal in - if threadVariant == .group && currentUserIsClosedGroupAdmin { + onConfirm: { [dependencies = viewModel.dependencies] modal in + if threadViewModel.threadVariant == .group && currentUserIsClosedGroupAdmin { /// Determine the selected action index let selectedIndex: Int = { switch modal.info.body { @@ -1000,7 +1157,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi // Return if the selected option is `Clear on this device` guard selectedIndex != 0 else { return } - self?.deleteAllMessagesBeforeNow() + viewModel.deleteAllMessagesBeforeNow() } dependencies[singleton: .storage].writeAsync( updates: { db in @@ -1011,13 +1168,13 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi options: [.local, .noArtifacts], using: dependencies ) - }, completion: { [weak self] result in + }, completion: { result in switch result { case .failure(let error): Log.error("Failed to clear messages due to error: \(error)") DispatchQueue.main.async { modal.dismiss(animated: true) { - self?.showToast( + viewModel.showToast( text: "deleteMessageFailed" .putNumber(0) .localized(), @@ -1029,7 +1186,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi case .success: DispatchQueue.main.async { modal.dismiss(animated: true) { - self?.showToast( + viewModel.showToast( text: "deleteMessageDeleted" .putNumber(0) .localized(), @@ -1046,11 +1203,21 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ), (threadViewModel.threadVariant != .community ? nil : - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .leaveCommunity, - leadingAccessory: .icon(.logOut), - title: "communityLeave".localized(), - styling: SessionCell.StyleInfo(tintColor: .danger), + variant: .cell( + info: .init( + leadingAccessory: .icon( + .logOut, + customTint: .danger + ), + title: .init( + "communityLeave".localized(), + font: .Headings.H8, + color: .danger + ) + ) + ), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).leave_community", label: "Leave Community" @@ -1066,8 +1233,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self, dependencies] in - self?.dismissScreen(type: .popToRoot) { + onTap: { [dependencies = viewModel.dependencies] in + viewModel.dismissScreen(type: .popToRoot) { dependencies[singleton: .storage].writeAsync { db in try SessionThread.deleteOrLeave( db, @@ -1083,11 +1250,21 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ), (!currentUserIsClosedGroupMember ? nil : - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .leaveGroup, - leadingAccessory: .icon(currentUserIsClosedGroupAdmin ? .trash2 : .logOut), - title: currentUserIsClosedGroupAdmin ? "groupDelete".localized() : "groupLeave".localized(), - styling: SessionCell.StyleInfo(tintColor: .danger), + variant: .cell( + info: .init( + leadingAccessory: .icon( + currentUserIsClosedGroupAdmin ? .trash2 : .logOut, + customTint: .danger + ), + title: .init( + currentUserIsClosedGroupAdmin ? "groupDelete".localized() : "groupLeave".localized(), + font: .Headings.H8, + color: .danger + ) + ) + ), accessibility: Accessibility( identifier: "Leave group", label: "Leave group" @@ -1110,8 +1287,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self, dependencies] in - self?.dismissScreen(type: .popToRoot) { + onTap: { [dependencies = viewModel.dependencies] in + viewModel.dismissScreen(type: .popToRoot) { dependencies[singleton: .storage].writeAsync { db in try SessionThread.deleteOrLeave( db, @@ -1126,12 +1303,22 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) ), - (threadVariant != .contact || threadViewModel.threadIsNoteToSelf == true ? nil : - SessionCell.Info( + (threadViewModel.threadVariant != .contact || threadViewModel.threadIsNoteToSelf == true ? nil : + SessionListScreenContent.ListItemInfo( id: .deleteConversation, - leadingAccessory: .icon(.trash2), - title: "conversationsDelete".localized(), - styling: SessionCell.StyleInfo(tintColor: .danger), + variant: .cell( + info: .init( + leadingAccessory: .icon( + .trash2, + customTint: .danger + ), + title: .init( + "conversationsDelete".localized(), + font: .Headings.H8, + color: .danger + ) + ) + ), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).delete_conversation", label: "Delete Conversation" @@ -1147,8 +1334,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self, dependencies] in - self?.dismissScreen(type: .popToRoot) { + onTap: { [dependencies = viewModel.dependencies] in + viewModel.dismissScreen(type: .popToRoot) { dependencies[singleton: .storage].writeAsync { db in try SessionThread.deleteOrLeave( db, @@ -1163,14 +1350,22 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) ), - (threadVariant != .contact || threadViewModel.threadIsNoteToSelf == true ? nil : - SessionCell.Info( + (threadViewModel.threadVariant != .contact || threadViewModel.threadIsNoteToSelf == true ? nil : + SessionListScreenContent.ListItemInfo( id: .deleteContact, - leadingAccessory: .icon( - UIImage(named: "ic_user_round_trash")?.withRenderingMode(.alwaysTemplate) + variant: .cell( + info: .init( + leadingAccessory: .icon( + UIImage(named: "ic_user_round_trash")?.withRenderingMode(.alwaysTemplate), + customTint: .danger + ), + title: .init( + "contactDelete".localized(), + font: .Headings.H8, + color: .danger + ) + ) ), - title: "contactDelete".localized(), - styling: SessionCell.StyleInfo(tintColor: .danger), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).delete_contact", label: "Delete Contact" @@ -1187,8 +1382,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self, dependencies] in - self?.dismissScreen(type: .popToRoot) { + onTap: { [dependencies = viewModel.dependencies] in + viewModel.dismissScreen(type: .popToRoot) { dependencies[singleton: .storage].writeAsync { db in try SessionThread.deleteOrLeave( db, @@ -1204,17 +1399,22 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ), // FIXME: [GROUPS REBUILD] Need to build this properly in a future release - (!dependencies[feature: .updatedGroupsDeleteAttachmentsBeforeNow] || threadViewModel.threadVariant != .group ? nil : - SessionCell.Info( + (!viewModel.dependencies[feature: .updatedGroupsDeleteAttachmentsBeforeNow] || threadViewModel.threadVariant != .group ? nil : + SessionListScreenContent.ListItemInfo( id: .debugDeleteAttachmentsBeforeNow, - leadingAccessory: .icon( - Lucide.image(icon: .trash2, size: 24)? - .withRenderingMode(.alwaysTemplate), - customTint: .danger - ), - title: "[DEBUG] Delete all arrachments before now", // stringlint:disable - styling: SessionCell.StyleInfo( - tintColor: .danger + variant: .cell( + info: .init( + leadingAccessory: .icon( + Lucide.image(icon: .trash2, size: 24)? + .withRenderingMode(.alwaysTemplate), + customTint: .danger + ), + title: .init( + "[DEBUG] Delete all arrachments before now", // stringlint:disable + font: .Headings.H8, + color: .danger + ) + ) ), confirmationInfo: ConfirmationModal.Info( title: "delete".localized(), @@ -1223,7 +1423,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self] in self?.deleteAllAttachmentsBeforeNow() } + onTap: { viewModel.deleteAllAttachmentsBeforeNow() } ) ) ].compactMap { $0 } diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 4385dfd86d..923fb90574 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -807,8 +807,9 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi } @objc private func openSettings() { - let settingsViewController: SessionTableViewController = SessionTableViewController( - viewModel: SettingsViewModel(using: viewModel.dependencies) + let settingsViewController: SessionListHostingViewController = SessionListHostingViewController( + viewModel: SettingsViewModel(using: viewModel.dependencies), + using: viewModel.dependencies ) let navigationController = StyledNavigationController(rootViewController: settingsViewController) navigationController.modalPresentationStyle = .fullScreen diff --git a/Session/Meta/Translations/InfoPlist.xcstrings b/Session/Meta/Translations/InfoPlist.xcstrings index 46511db12c..dc9e363720 100644 --- a/Session/Meta/Translations/InfoPlist.xcstrings +++ b/Session/Meta/Translations/InfoPlist.xcstrings @@ -1504,6 +1504,12 @@ "NSLocalNetworkUsageDescription" : { "extractionState" : "manual", "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يتطلب Session الوصول إلى الشبكة المحلية لإجراء المكالمات الصوتية ومكالمات الفيديو." + } + }, "az" : { "stringUnit" : { "state" : "translated", @@ -1600,18 +1606,42 @@ "value" : "Session이 음성 및 영상 통화를 하기 위해 로컬 네트워크에 접근해야 합니다." } }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session må ha tilgang til det lokale nettverket for å kunne foreta tale- og videosamtaler." + } + }, + "nb-NO" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session må ha tilgang til det lokale nettverket for å kunne foreta tale- og videosamtaler." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Session heeft toegang nodig tot het lokale netwerk om spraak- en videogesprekken uit te voeren." } }, + "nn-NO" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session må ha tilgang til det lokale nettverket for å kunne foreta tale- og videosamtaler." + } + }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "Session potrzebuje dostępu do sieci lokalnej, aby wykonywać połączenia głosowe i wideo." } }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session precisa de acesso à rede local para realizar chamadas de voz e vídeo." + } + }, "pt-PT" : { "stringUnit" : { "state" : "translated", diff --git a/Session/Path/PathStatusView.swift b/Session/Path/PathStatusView.swift index bd3eefe57b..2349bc0e50 100644 --- a/Session/Path/PathStatusView.swift +++ b/Session/Path/PathStatusView.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import SwiftUI import Combine import SessionUIKit import SessionNetworkingKit @@ -95,67 +96,66 @@ final class PathStatusView: UIView { } } -public extension NetworkStatus { - var themeColor: ThemeValue { - switch self { - case .unknown: return .path_unknown - case .connecting: return .path_connecting - case .connected: return .path_connected - case .disconnected: return .path_error +struct PathStatusView_SwiftUI: View { + enum Size { + case small + case large + + var pointSize: CGFloat { + switch self { + case .small: return 8 + case .large: return 16 + } + } + + func offset(for colorScheme: ColorScheme) -> CGFloat { + switch self { + case .small: return (colorScheme == .light ? 6 : 8) + case .large: return (colorScheme == .light ? 6 : 8) + } } } -} - -// MARK: - Info - -final class PathStatusViewAccessory: UIView, SessionCell.Accessory.CustomView { - struct Info: Equatable, SessionCell.Accessory.CustomViewInfo { - typealias View = PathStatusViewAccessory - } - - /// We want the path status to have the same sizing as other list item icons so it needs to be wrapped in - /// this contains view - public static let size: SessionCell.Accessory.Size = .minWidth(height: IconSize.medium.size) - static func create(maxContentWidth: CGFloat, using dependencies: Dependencies) -> PathStatusViewAccessory { - return PathStatusViewAccessory(using: dependencies) - } + // MARK: - Properties private let dependencies: Dependencies + private let size: Size - // MARK: - Components - - lazy var pathStatusView: PathStatusView = PathStatusView(size: .large, using: dependencies) + @State private var networkStatus: NetworkStatus = .unknown + @Environment(\.colorScheme) private var colorScheme - // MARK: Initialization + // MARK: - Initialization - init(using dependencies: Dependencies) { + init(size: Size = .small, using dependencies: Dependencies) { self.dependencies = dependencies - - super.init(frame: .zero) - - setupUI() - } - - required init?(coder: NSCoder) { - fatalError("Use init(theme:) instead") + self.size = size } - // MARK: - Layout - - private func setupUI() { - isUserInteractionEnabled = false - addSubview(pathStatusView) - - setupLayout() + // MARK: - Body + + var body: some View { + Circle() + .fill(themeColor: networkStatus.themeColor) + .frame(width: size.pointSize, height: size.pointSize) + .shadow( + color: .black.opacity(colorScheme == .light ? 0.4 : 1.0), + radius: size.offset(for: colorScheme), + x: 0, + y: 0.8 + ) + .onReceive(dependencies[cache: .libSessionNetwork].networkStatus) { status in + networkStatus = status + } } - - private func setupLayout() { - pathStatusView.center(in: self) +} + +public extension NetworkStatus { + var themeColor: ThemeValue { + switch self { + case .unknown: return .path_unknown + case .connecting: return .path_connecting + case .connected: return .path_connected + case .disconnected: return .path_error + } } - - // MARK: - Content - - // No need to do anything (theme with auto-update) - func update(with info: Info) {} } diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 2fcb82a70b..0b432d5c7d 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SwiftUI import PhotosUI import Combine import Lucide @@ -11,11 +12,11 @@ import SessionMessagingKit import SessionUtilitiesKit import SignalUtilitiesKit -class SettingsViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, ObservableTableSource { +class SettingsViewModel: SessionListScreenContent.ViewModelType, NavigationItemSource, NavigatableStateHolder { public let dependencies: Dependencies public let navigatableState: NavigatableState = NavigatableState() - public let state: TableDataState = TableDataState() - public let observableState: ObservableTableSourceState = ObservableTableSourceState() + public let state: SessionListScreenContent.ListItemDataState = SessionListScreenContent.ListItemDataState() + public var imageDataManager: ImageDataManagerType { dependencies[singleton: .imageDataManager] } private var updatedName: String? private var onDisplayPictureSelected: ((ImageDataManager.DataSource, CGRect?) -> Void)? @@ -28,14 +29,14 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ) /// This value is the current state of the view - @MainActor @Published private(set) var internalState: State + @MainActor @Published private(set) var internalState: ViewModelState private var observationTask: Task? // MARK: - Initialization @MainActor init(using dependencies: Dependencies) { self.dependencies = dependencies - self.internalState = State.initialState( + self.internalState = ViewModelState.initialState( userSessionId: dependencies[cache: .general].sessionId, sessionProPlanState: dependencies[singleton: .sessionProState].sessionProStateSubject.value ) @@ -51,7 +52,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl case qrCode } - public enum Section: SessionTableSection { + public enum Section: SessionListScreenContent.ListSection { case profileInfo case sessionId @@ -69,16 +70,27 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } } - var style: SessionTableSectionStyle { + var style: SessionListScreenContent.ListSectionStyle { switch self { case .sessionId: return .titleSeparator case .sessionProAndCommunity, .donationAndNetwork, .settings, .helpAndData: return .padding default: return .none } } + + public var divider: Bool { + switch self { + case .profileInfo, .sessionId, .footer: return false + default: return true + } + } + + public var footer: String? { return nil } + + public var extraVerticalPadding: CGFloat { return 0 } } - public enum TableItem: Differentiable { + public enum ListItem: Differentiable { case avatar case profileName @@ -153,7 +165,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl // MARK: - Content - public struct State: ObservableKeyProvider { + public struct ViewModelState: ObservableKeyProvider { let userSessionId: SessionId let profile: Profile let sessionProPlanState: SessionProPlanState @@ -178,8 +190,8 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ] } - static func initialState(userSessionId: SessionId, sessionProPlanState: SessionProPlanState) -> State { - return State( + static func initialState(userSessionId: SessionId, sessionProPlanState: SessionProPlanState) -> ViewModelState { + return ViewModelState( userSessionId: userSessionId, profile: Profile.defaultFor(userSessionId.hexString), sessionProPlanState: sessionProPlanState, @@ -202,18 +214,17 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl .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.state.updateTableData(updatedState.sections(viewModel: self)) self.internalState = updatedState - self.pendingTableDataSubject.send(updatedState.sections(viewModel: self)) } } @Sendable private static func queryState( - previousState: State, + previousState: ViewModelState, events: [ObservedEvent], isInitialFetch: Bool, using dependencies: Dependencies - ) async -> State { + ) async -> ViewModelState { /// Store mutable copies of the data to update var profile: Profile = previousState.profile var sessionProPlanState: SessionProPlanState = previousState.sessionProPlanState @@ -286,7 +297,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } /// Generate the new state - return State( + return ViewModelState( userSessionId: previousState.userSessionId, profile: profile, sessionProPlanState: sessionProPlanState, @@ -297,32 +308,37 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ) } - private static func sections(state: State, viewModel: SettingsViewModel) -> [SectionModel] { + private static func sections(state: ViewModelState, viewModel: SettingsViewModel) -> [SectionModel] { let profileInfo: SectionModel = SectionModel( model: .profileInfo, elements: [ - SessionCell.Info( + SessionListScreenContent.ListItemInfo( 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 + variant: .profilePicture( + info: .init( + sessionId: state.profile.id, + qrCodeImage: nil, + profileInfo: { + let (info, _) = ProfilePictureView.Info.generateInfoFrom( + size: .hero, + publicKey: state.profile.id, + threadVariant: .contact, + displayPictureUrl: nil, + 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 + } + }(), + using: viewModel.dependencies + ) + + return info + }(), + isExpandable: false + ) ), accessibility: Accessibility( identifier: "User settings", @@ -332,37 +348,47 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl viewModel?.updateProfilePicture(currentUrl: state.profile.displayPictureUrl) } ), - SessionCell.Info( + SessionListScreenContent.ListItemInfo( 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 + variant: .tappableText( + info: .init( + text: state.profile.displayName(), + font: Fonts.Headings.H4, + themeForegroundColor: .textPrimary, + imageAttachmentPosition: .trailing, + imageAttachmentGenerator: { + switch state.sessionProPlanState { + case .none: return nil + case .active, .refunding: + return { + ( + UIView.image( + for: .themedKey( + SessionProBadge.Size.medium.cacheKey, + themeBackgroundColor: .primary + ), + generator: { SessionProBadge(size: .medium) } + ), + SessionProBadge.accessibilityLabel + ) + } + + case .expired: + return { + ( + UIView.image( + for: .themedKey( + SessionProBadge.Size.medium.cacheKey, + themeBackgroundColor: .disabled + ), + generator: { SessionProBadge(size: .medium) } + ), + SessionProBadge.accessibilityLabel + ) + } + } + }() + ) ), accessibility: Accessibility( identifier: "Username", @@ -377,54 +403,54 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl let sessionId: SectionModel = SectionModel( model: .sessionId, elements: [ - SessionCell.Info( + SessionListScreenContent.ListItemInfo( 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 + variant: .cell( + info: .init( + title: .init( + state.profile.id, + font: .Display.extraLarge, + alignment: .center, + interaction: .copy + ) + ) ), accessibility: Accessibility( identifier: "Account ID", label: state.profile.id ) ), - SessionCell.Info( + SessionListScreenContent.ListItemInfo( 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 + variant: .cell( + info: .init( + leadingAccessory: .button( + .init( + title: "share".localized(), + style: .bordered, + accessibility: Accessibility( + identifier: "Share button", + label: "Share button" + ), + action: { [weak viewModel] _ in + viewModel?.shareSessionId(state.profile.id) + } + ) + ), + trailingAccessory: .button( + .init( + title: "copy".localized(), + style: .bordered, + accessibility: Accessibility( + identifier: "Copy button", + label: "Copy button" + ), + action: { [weak viewModel] buttonViewModel in + viewModel?.copySessionId(state.profile.id, buttonViewModel: buttonViewModel) + } + ) + ) + ) ) ) ] @@ -438,40 +464,56 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl sessionProAndCommunity = SectionModel( model: .sessionProAndCommunity, elements: [ - SessionCell.Info( + SessionListScreenContent.ListItemInfo( 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 + variant: .cell( + info: .init( + leadingAccessory: .proBadge( + size: .small, + themeBackgroundColor: .primary + ), + title: .init( + { + 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() + } + }(), + font: .Headings.H8, + color: .sessionButton_text + ) + ) ), onTap: { [weak viewModel, dependencies = viewModel.dependencies] in let viewController: SessionListHostingViewController = SessionListHostingViewController( viewModel: SessionProSettingsViewModel(using: dependencies), - customizedNavigationBackground: .clear + customizedNavigationBackground: .clear, + using: dependencies ) viewModel?.transitionToScreen(viewController) } ), - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .inviteAFriend, - leadingAccessory: .icon(.userRoundPlus), - title: "sessionInviteAFriend".localized(), + variant: .cell( + info: .init( + leadingAccessory: .icon(.userRoundPlus), + title: .init( + "sessionInviteAFriend".localized(), + font: .Headings.H8 + ) + ) + ), onTap: { [weak viewModel] in let invitation: String = "accountIdShare" .put(key: "app_name", value: Constants.app_name) @@ -493,32 +535,53 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl donationAndNetwork = SectionModel( model: .donationAndNetwork, elements: [ - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .donate, - leadingAccessory: .icon( - .heart, - customTint: .sessionButton_border + variant: .cell( + info: .init( + leadingAccessory: .icon( + .heart, + customTint: .sessionButton_border + ), + title: .init( + "donate".localized(), + font: .Headings.H8 + ), + ) ), - title: "donate".localized(), onTap: { [weak viewModel] in viewModel?.openDonationsUrl() } ), - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .path, - leadingAccessory: .custom( - info: PathStatusViewAccessory.Info() + variant: .cell( + info: .init( +// leadingAccessory: .init(accessoryView: { +// PathStatusViewAccessory +// }, + title: .init( + "onionRoutingPath".localized(), + font: .Headings.H8 + ) + ) ), - title: "onionRoutingPath".localized(), onTap: { [weak viewModel, dependencies = viewModel.dependencies] in viewModel?.transitionToScreen(PathVC(using: dependencies)) } ), - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .sessionNetwork, - leadingAccessory: .icon( - UIImage(named: "icon_session_network")? - .withRenderingMode(.alwaysTemplate) + variant: .cell( + info: .init( + leadingAccessory: .icon( + UIImage(named: "icon_session_network")? + .withRenderingMode(.alwaysTemplate) + ), + title: .init( + Constants.network_name, + font: .Headings.H8 + ) + ) ), - title: Constants.network_name, onTap: { [weak viewModel, dependencies = viewModel.dependencies] in let viewController: SessionHostingViewController = SessionHostingViewController( rootView: SessionNetworkScreen( @@ -536,19 +599,33 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl sessionProAndCommunity = SectionModel( model: .sessionProAndCommunity, elements: [ - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .donate, - leadingAccessory: .icon( - .heart, - customTint: .sessionButton_border + variant: .cell( + info: .init( + leadingAccessory: .icon( + .heart, + customTint: .sessionButton_border + ), + title: .init( + "donate".localized(), + font: .Headings.H8 + ) + ) ), - title: "donate".localized(), onTap: { [weak viewModel] in viewModel?.openDonationsUrl() } ), - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .inviteAFriend, - leadingAccessory: .icon(.userRoundPlus), - title: "sessionInviteAFriend".localized(), + variant: .cell( + info: .init( + leadingAccessory: .icon(.userRoundPlus), + title: .init( + "sessionInviteAFriend".localized(), + font: .Headings.H8 + ) + ) + ), onTap: { [weak viewModel] in let invitation: String = "accountIdShare" .put(key: "app_name", value: Constants.app_name) @@ -570,23 +647,37 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl donationAndNetwork = SectionModel( model: .donationAndNetwork, elements: [ - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .path, - leadingAccessory: .custom( - info: PathStatusViewAccessory.Info() + variant: .cell( + info: .init( +// leadingAccessory: .init(accessoryView: { +// PathStatusViewAccessory +// }, + title: .init( + "onionRoutingPath".localized(), + font: .Headings.H8 + ) + ) ), - title: "onionRoutingPath".localized(), onTap: { [weak viewModel, dependencies = viewModel.dependencies] in viewModel?.transitionToScreen(PathVC(using: dependencies)) } ), - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .sessionNetwork, - leadingAccessory: .icon( - UIImage(named: "icon_session_network")? - .withRenderingMode(.alwaysTemplate) + variant: .cell( + info: .init( + leadingAccessory: .icon( + UIImage(named: "icon_session_network")? + .withRenderingMode(.alwaysTemplate) + ), + title: .init( + Constants.network_name, + font: .Headings.H8 + ) + ) ), - title: Constants.network_name, onTap: { [weak viewModel, dependencies = viewModel.dependencies] in let viewController: SessionHostingViewController = SessionHostingViewController( rootView: SessionNetworkScreen( @@ -604,50 +695,85 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl let settings: SectionModel = SectionModel( model: .settings, elements: [ - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .privacy, - leadingAccessory: .icon(.lockKeyhole), - title: "sessionPrivacy".localized(), + variant: .cell( + info: .init( + leadingAccessory: .icon(.lockKeyhole), + title: .init( + "sessionPrivacy".localized(), + font: .Headings.H8 + ) + ) + ), onTap: { [weak viewModel, dependencies = viewModel.dependencies] in viewModel?.transitionToScreen( SessionTableViewController(viewModel: PrivacySettingsViewModel(using: dependencies)) ) } ), - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .notifications, - leadingAccessory: .icon(.volume2), - title: "sessionNotifications".localized(), + variant: .cell( + info: .init( + leadingAccessory: .icon(.volume2), + title: .init( + "sessionNotifications".localized(), + font: .Headings.H8 + ) + ) + ), onTap: { [weak viewModel, dependencies = viewModel.dependencies] in viewModel?.transitionToScreen( SessionTableViewController(viewModel: NotificationSettingsViewModel(using: dependencies)) ) } ), - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .conversations, - leadingAccessory: .icon(.usersRound), - title: "sessionConversations".localized(), + variant: .cell( + info: .init( + leadingAccessory: .icon(.usersRound), + title: .init( + "sessionConversations".localized(), + font: .Headings.H8 + ) + ) + ), onTap: { [weak viewModel, dependencies = viewModel.dependencies] in viewModel?.transitionToScreen( SessionTableViewController(viewModel: ConversationSettingsViewModel(using: dependencies)) ) } ), - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .appearance, - leadingAccessory: .icon(.paintbrushVertical), - title: "sessionAppearance".localized(), + variant: .cell( + info: .init( + leadingAccessory: .icon(.paintbrushVertical), + title: .init( + "sessionAppearance".localized(), + font: .Headings.H8 + ) + ) + ), onTap: { [weak viewModel, dependencies = viewModel.dependencies] in viewModel?.transitionToScreen( SessionTableViewController(viewModel: AppearanceViewModel(using: dependencies)) ) } ), - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .messageRequests, - leadingAccessory: .icon(.messageSquareWarning), - title: "sessionMessageRequests".localized(), + variant: .cell( + info: .init( + leadingAccessory: .icon(.messageSquareWarning), + title: .init( + "sessionMessageRequests".localized(), + font: .Headings.H8 + ) + ) + ), onTap: { [weak viewModel, dependencies = viewModel.dependencies] in viewModel?.transitionToScreen( SessionTableViewController(viewModel: MessageRequestsViewModel(using: dependencies)) @@ -657,16 +783,23 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ] ) - var helpAndDataElements: [SessionCell.Info] = [] + var helpAndDataElements: [SessionListScreenContent.ListItemInfo] = [] if !state.hideRecoveryPasswordPermanently { helpAndDataElements.append( - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .recoveryPhrase, - leadingAccessory: .icon( - UIImage(named: "SessionShield")? - .withRenderingMode(.alwaysTemplate) + variant: .cell( + info: .init( + leadingAccessory: .icon( + UIImage(named: "SessionShield")? + .withRenderingMode(.alwaysTemplate) + ), + title: .init( + "sessionRecoveryPassword".localized(), + font: .Headings.H8 + ) + ) ), - title: "sessionRecoveryPassword".localized(), accessibility: Accessibility( identifier: "Recovery password menu item", label: "Recovery password menu item" @@ -694,13 +827,20 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } helpAndDataElements.append( - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .help, - leadingAccessory: .icon( - UIImage(named: "icon_help")? - .withRenderingMode(.alwaysTemplate) + variant: .cell( + info: .init( + leadingAccessory: .icon( + UIImage(named: "icon_help")? + .withRenderingMode(.alwaysTemplate) + ), + title: .init( + "sessionHelp".localized(), + font: .Headings.H8 + ) + ) ), - title: "sessionHelp".localized(), onTap: { [weak viewModel, dependencies = viewModel.dependencies] in viewModel?.transitionToScreen( SessionTableViewController(viewModel: HelpViewModel(using: dependencies)) @@ -711,11 +851,21 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl if state.developerModeEnabled { helpAndDataElements.append( - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .developerSettings, - leadingAccessory: .icon(.squareCode), - title: "Developer Settings", // stringlint:ignore - styling: SessionCell.StyleInfo(tintColor: .warning), + variant: .cell( + info: .init( + leadingAccessory: .icon( + .squareCode, + customTint: .warning + ), + title: .init( + "Developer Settings", // stringlint:ignore + font: .Headings.H8, + color: .warning + ) + ) + ), onTap: { [weak viewModel, dependencies = viewModel.dependencies] in viewModel?.transitionToScreen( SessionTableViewController(viewModel: DeveloperSettingsViewModel(using: dependencies)) @@ -726,11 +876,21 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } helpAndDataElements.append( - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .clearData, - leadingAccessory: .icon(.trash2), - title: "sessionClearData".localized(), - styling: SessionCell.StyleInfo(tintColor: .danger), + variant: .cell( + info: .init( + leadingAccessory: .icon( + .trash2, + customTint: .danger + ), + title: .init( + "sessionClearData".localized(), + font: .Headings.H8, + color: .danger + ) + ) + ), onTap: { [weak viewModel, dependencies = viewModel.dependencies] in viewModel?.transitionToScreen(NukeDataModal(using: dependencies), transitionType: .present) } @@ -744,16 +904,19 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl return [profileInfo, sessionId, sessionProAndCommunity, donationAndNetwork, settings, helpAndData] } - public lazy var footerView: AnyPublisher = 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() + public var footerView: VersionFooterView { + 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) + } + ) + } + // MARK: - Functions @@ -1077,38 +1240,18 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } } - private func copySessionId(_ sessionId: String, button: SessionButton?) { + private func copySessionId(_ sessionId: String, buttonViewModel: SessionButtonViewModel) { 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 + buttonViewModel.isEnabled = false + withAnimation { buttonViewModel.title = "copied".localized() } - 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 - ) - } - } - ) + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(4)) { + buttonViewModel.isEnabled = true + withAnimation { buttonViewModel.title = "copy".localized() } + } } } @@ -1164,3 +1307,4 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl self.transitionToScreen(modal, transitionType: .present) } } + diff --git a/Session/Settings/Views/VersionFooterView.swift b/Session/Settings/Views/VersionFooterView.swift index f46c93670f..cb7ee9b571 100644 --- a/Session/Settings/Views/VersionFooterView.swift +++ b/Session/Settings/Views/VersionFooterView.swift @@ -1,131 +1,91 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import UIKit +import SwiftUI import SessionUIKit -class VersionFooterView: UIView { +struct VersionFooterView: View { private static let footerHeight: CGFloat = 75 private static let logoHeight: CGFloat = 24 - private let logoTapCallback: () -> Void - private let versionTapCallback: () -> Void + let numVersionTapsRequired: Int + let logoTapCallback: () -> Void + let versionTapCallback: () -> Void - // MARK: - UI + @State private var versionTapCount = 0 + @State private var lastTapTime = Date() - private lazy var logoImageView: UIImageView = { - let result: UIImageView = UIImageView( - image: UIImage(named: "token_logo")? - .withRenderingMode(.alwaysTemplate) - ) - result.setContentHuggingPriority(.required, for: .vertical) - result.setContentCompressionResistancePriority(.required, for: .vertical) - result.themeTintColor = .textSecondary - result.contentMode = .scaleAspectFit - result.set(.height, to: VersionFooterView.logoHeight) - result.isUserInteractionEnabled = true - - return result - }() - - private lazy var versionLabelContainer: UIView = UIView() - - private lazy var versionLabel: UILabel = { - let result: UILabel = UILabel() - result.setContentHuggingPriority(.required, for: .vertical) - result.setContentCompressionResistancePriority(.required, for: .vertical) - result.font = .systemFont(ofSize: Values.verySmallFontSize) - result.themeTextColor = .textSecondary - result.textAlignment = .center - result.lineBreakMode = .byCharWrapping - result.numberOfLines = 0 - - // stringlint:ignore_start + private var versionText: String { let infoDict = Bundle.main.infoDictionary - let version: String = ((infoDict?["CFBundleShortVersionString"] as? String) ?? "0.0.0") - let buildNumber: String? = (infoDict?["CFBundleVersion"] as? String) - let commitInfo: String? = (infoDict?["GitCommitHash"] as? String) - let buildInfo: String? = [buildNumber, commitInfo] + let version = (infoDict?["CFBundleShortVersionString"] as? String) ?? "0.0.0" + let buildNumber = infoDict?["CFBundleVersion"] as? String + let commitInfo = infoDict?["GitCommitHash"] as? String + + let buildInfo = [buildNumber, commitInfo] .compactMap { $0 } .joined(separator: " - ") - .nullIfEmpty - .map { "(\($0))" } - // stringlint:ignore_stop - result.text = [ - "Version \(version)", - buildInfo - ].compactMap { $0 }.joined(separator: " ") - return result - }() - - // MARK: - Initialization + var components = ["Version \(version)"] + if !buildInfo.isEmpty { + components.append("(\(buildInfo))") + } + + return components.joined(separator: " ") + } init( numVersionTapsRequired: Int = 0, - logoTapCallback: @escaping () -> Void, - versionTapCallback: @escaping () -> Void + logoTapCallback: @escaping () -> Void = {}, + versionTapCallback: @escaping () -> Void = {} ) { + self.numVersionTapsRequired = numVersionTapsRequired self.logoTapCallback = logoTapCallback self.versionTapCallback = versionTapCallback - - // Note: Need to explicitly set the height for a table footer view - // or it will have no height - super.init( - frame: CGRect( - x: 0, - y: 0, - width: 0, - height: VersionFooterView.footerHeight - ) - ) - - setupViewHierarchy(numVersionTapsRequired: numVersionTapsRequired) } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + var body: some View { + VStack(spacing: Values.mediumSpacing) { + Image("token_logo") + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: Self.logoHeight) + .foregroundColor(themeColor: .textSecondary) + .offset(x: -2) + .onTapGesture { + logoTapCallback() + } + + Text(versionText) + .font(.Body.extraSmallRegular) + .foregroundColor(themeColor: .textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .contentShape(Rectangle()) + .onTapGesture { + handleVersionTap() + } + } + .frame(height: Self.footerHeight) } - // MARK: - Content - - private func setupViewHierarchy(numVersionTapsRequired: Int) { - addSubview(logoImageView) - addSubview(versionLabelContainer) - versionLabelContainer.addSubview(versionLabel) + private func handleVersionTap() { + guard numVersionTapsRequired > 0 else { return } - logoImageView.pin(.top, to: .top, of: self, withInset: Values.mediumSpacing) - logoImageView.center(.horizontal, in: self, withInset: -2) - versionLabelContainer.pin(.top, to: .bottom, of: logoImageView) - versionLabelContainer.pin(.leading, to: .leading, of: self) - versionLabelContainer.pin(.trailing, to: .trailing, of: self) - versionLabelContainer.pin(.bottom, to: .bottom, of: self) + let now = Date() + let timeSinceLastTap = now.timeIntervalSince(lastTapTime) - versionLabel.pin(.top, to: .top, of: versionLabelContainer, withInset: Values.mediumSpacing) - versionLabel.pin(.leading, to: .leading, of: versionLabelContainer) - versionLabel.pin(.trailing, to: .trailing, of: versionLabelContainer) - versionLabel.pin(.bottom, to: .bottom, of: versionLabelContainer) + // Reset count if more than 0.5 seconds between taps + if timeSinceLastTap > 0.5 { + versionTapCount = 1 + } else { + versionTapCount += 1 + } - let tapGestureRecognizer: UITapGestureRecognizer = UITapGestureRecognizer( - target: self, - action: #selector(onLogoTap) - ) - logoImageView.addGestureRecognizer(tapGestureRecognizer) + lastTapTime = now - if numVersionTapsRequired > 0 { - let tapGestureRecognizer: UITapGestureRecognizer = UITapGestureRecognizer( - target: self, - action: #selector(onVersionMultiTap) - ) - tapGestureRecognizer.numberOfTapsRequired = numVersionTapsRequired - versionLabelContainer.addGestureRecognizer(tapGestureRecognizer) + if versionTapCount >= numVersionTapsRequired { + versionTapCallback() + versionTapCount = 0 } } - - @objc private func onLogoTap() { - self.logoTapCallback() - } - - @objc private func onVersionMultiTap() { - self.versionTapCallback() - } } diff --git a/Session/Shared/SessionListHostingViewController.swift b/Session/Shared/SessionListHostingViewController.swift index 123fd2a0eb..b51f0257b3 100644 --- a/Session/Shared/SessionListHostingViewController.swift +++ b/Session/Shared/SessionListHostingViewController.swift @@ -3,6 +3,7 @@ import SwiftUI import Combine import SessionUIKit +import SessionUtilitiesKit class SessionListHostingViewController: SessionHostingViewController> where ViewModel: SessionListScreenContent.ViewModelType { private let viewModel: ViewModel @@ -13,11 +14,14 @@ class SessionListHostingViewController: SessionHostingViewController< init( viewModel: ViewModel, customizedNavigationBackground: ThemeValue? = nil, - shouldHideNavigationBar: Bool = false + shouldHideNavigationBar: Bool = false, + using dependencies: Dependencies ) { self.viewModel = viewModel super.init( - rootView: SessionListScreen(viewModel: viewModel), + rootView: SessionListScreen( + viewModel: viewModel + ), customizedNavigationBackground: customizedNavigationBackground, shouldHideNavigationBar: shouldHideNavigationBar ) diff --git a/Session/Shared/SessionTableViewController.swift b/Session/Shared/SessionTableViewController.swift index 6f67b42863..6c25363896 100644 --- a/Session/Shared/SessionTableViewController.swift +++ b/Session/Shared/SessionTableViewController.swift @@ -591,7 +591,7 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa info.title?.trailingImage != nil, let localPoint: CGPoint = touchLocation?.location(in: cell.titleLabel), cell.titleLabel.bounds.contains(localPoint), - cell.titleLabel.isPointOnTrailingAttachment(localPoint) == true + cell.titleLabel.isPointOnAttachment(localPoint) == true { return SessionProBadge(size: .large) } diff --git a/Session/Shared/Views/SessionProBadge+Utilities.swift b/Session/Shared/Views/SessionProBadge+Utilities.swift index 3b1aae707a..5e5f9da144 100644 --- a/Session/Shared/Views/SessionProBadge+Utilities.swift +++ b/Session/Shared/Views/SessionProBadge+Utilities.swift @@ -4,20 +4,8 @@ import UIKit import SessionUIKit import SessionUtilitiesKit -public extension SessionProBadge.Size{ - // stringlint:ignore_contents - var cacheKey: String { - switch self { - case .mini: return "SessionProBadge.Mini" - case .small: return "SessionProBadge.Small" - case .medium: return "SessionProBadge.Medium" - case .large: return "SessionProBadge.Large" - } - } -} - public extension SessionProBadge { - fileprivate static let accessibilityLabel: String = Constants.app_pro + static let accessibilityLabel: String = Constants.app_pro static func trailingImage( size: SessionProBadge.Size, diff --git a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift index 10e9faf495..eac0537c48 100644 --- a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift +++ b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift @@ -7,7 +7,7 @@ import SessionUtil import SessionUtilitiesKit import SessionNetworkingKit -public struct DisappearingMessagesConfiguration: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct DisappearingMessagesConfiguration: Codable, Identifiable, Sendable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "disappearingMessagesConfiguration" } internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) @@ -37,7 +37,7 @@ public struct DisappearingMessagesConfiguration: Codable, Identifiable, Equatabl } } - public enum DisappearingMessageType: Int, Codable, Hashable, DatabaseValueConvertible { + public enum DisappearingMessageType: Int, Codable, Hashable, Sendable, DatabaseValueConvertible { case unknown case disappearAfterRead case disappearAfterSend diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift index 73f287b5a2..361ad71593 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift @@ -639,6 +639,11 @@ public extension LibSession { } } } + + db.addEvent( + disappearingMessagesConfig, + forKey: .disappearingMessagesConfigUpdated(sessionId) + ) } } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift index 82cdc5060c..5e2b964e71 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift @@ -450,6 +450,10 @@ public extension LibSession { if let config: DisappearingMessagesConfiguration = disappearingConfig { groups_info_set_expiry_timer(conf, Int32(config.durationSeconds)) + db.addEvent( + config, + forKey: .disappearingMessagesConfigUpdated(groupSessionId.hexString) + ) } } } diff --git a/SessionMessagingKit/SessionPro/SessionProSettingsViewModel.swift b/SessionMessagingKit/SessionPro/SessionProSettingsViewModel.swift index 6f272e3700..2632b70c8d 100644 --- a/SessionMessagingKit/SessionPro/SessionProSettingsViewModel.swift +++ b/SessionMessagingKit/SessionPro/SessionProSettingsViewModel.swift @@ -14,6 +14,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType public var navigatableStateSwiftUI: NavigatableState_SwiftUI = NavigatableState_SwiftUI() public let title: String = "" public let state: SessionListScreenContent.ListItemDataState = SessionListScreenContent.ListItemDataState() + public var imageDataManager: ImageDataManagerType { dependencies[singleton: .imageDataManager] } public let isInBottomSheet: Bool /// This value is the current state of the view @@ -77,7 +78,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType position: .topRight ) ) - case .proSettings, .proFeatures, .proManagement, .help: return .titleNoBackgroundContent + case .proSettings, .proFeatures, .proManagement, .help: return .titleRoundedContent default: return .none } } @@ -90,6 +91,13 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType } public var footer: String? { return nil } + + public var extraVerticalPadding: CGFloat { + switch self { + case .proFeatures: return Values.smallSpacing + default : return 0 + } + } } public enum ListItem: Differentiable { @@ -445,7 +453,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ), trailingAccessory: .icon( .squareArrowUpRight, - size: .large, + size: .medium, customTint: { switch state.currentProPlanState { case .expired: return .textPrimary @@ -473,7 +481,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ), trailingAccessory: .icon( .squareArrowUpRight, - size: .large, + size: .medium, customTint: { switch state.currentProPlanState { case .expired: return .textPrimary @@ -633,7 +641,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType backgroundSize: .veryLarge, backgroundCornerRadius: 8 ), - title: .init(info.title, font: .Headings.H9, accessory: info.accessory), + title: .init(info.title, font: .Headings.H9, inlineImage: info.inlineImageInfo), description: .init(font: .Body.smallRegular, attributedString: info.description, color: .textSecondary) ) ) @@ -725,7 +733,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) } }(), - trailingAccessory: state.loadingState == .loading ? .loadingIndicator(size: .large) : .icon(.chevronRight, size: .large) + trailingAccessory: state.loadingState == .loading ? .loadingIndicator(size: .medium) : .icon(.chevronRight, size: .medium) ) ), onTap: { [weak viewModel] in @@ -770,7 +778,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .put(key: "platform", value: originatingPlatform.name) .localizedFormatted(Fonts.Body.smallRegular) ), - trailingAccessory: .icon(.circleAlert, size: .large) + trailingAccessory: .icon(.circleAlert, size: .medium) ) ), onTap: { [weak viewModel] in @@ -876,7 +884,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType font: .Headings.H8, color: .danger ), - trailingAccessory: .icon(.circleX, size: .large, customTint: .danger) + trailingAccessory: .icon(.circleX, size: .medium, customTint: .danger) ) ), onTap: { [weak viewModel] in viewModel?.cancelPlan() } @@ -886,7 +894,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType variant: .cell( info: .init( title: .init("requestRefund".localized(), font: .Headings.H8, color: .danger), - trailingAccessory: .icon(.circleAlert, size: .large, customTint: .danger) + trailingAccessory: .icon(.circleAlert, size: .medium, customTint: .danger) ) ), onTap: { [weak viewModel] in viewModel?.requestRefund() } @@ -929,10 +937,10 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType }(), trailingAccessory: ( state.loadingState == .loading ? - .loadingIndicator(size: .large) : + .loadingIndicator(size: .medium) : .icon( .circlePlus, - size: .large, + size: .medium, customTint: state.loadingState == .success ? .sessionButton_text : .textPrimary ) ) @@ -979,7 +987,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ), trailingAccessory: .icon( .refreshCcw, - size: .large, + size: .medium, customTint: .textPrimary ) ) diff --git a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift index 614db65dbb..00ca43abb8 100644 --- a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift @@ -85,6 +85,12 @@ public extension ObservableKey { static let messageRequestDeleted: ObservableKey = "messageRequestDeleted" static let messageRequestMessageRead: ObservableKey = "messageRequestMessageRead" static let messageRequestUnreadMessageReceived: ObservableKey = "messageRequestUnreadMessageReceived" + + // MARK: - Disappearing Messages + + static func disappearingMessagesConfigUpdated(_ id: String) -> ObservableKey { + ObservableKey("disappearingMessagesConfigUpdated-\(id)", .disappearingMessagesConfigUpdate) + } } public extension GenericObservableKey { @@ -105,6 +111,7 @@ public extension GenericObservableKey { static let attachmentCreated: GenericObservableKey = "attachmentCreated" static let attachmentUpdated: GenericObservableKey = "attachmentUpdated" static let attachmentDeleted: GenericObservableKey = "attachmentDeleted" + static let disappearingMessagesConfigUpdate: GenericObservableKey = "disappearingMessagesConfigUpdate" } // MARK: - Event Payloads - General diff --git a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift index b1c9946e0b..7eba5a7c76 100644 --- a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift @@ -13,7 +13,7 @@ import SessionUtilitiesKit @testable import Session class ThreadSettingsViewModelSpec: AsyncSpec { - private typealias Item = SessionCell.Info + private typealias Item = SessionListScreenContent.ListItemInfo override class func spec() { // MARK: Configuration @@ -90,25 +90,18 @@ class ThreadSettingsViewModelSpec: AsyncSpec { @TestState var disposables: [AnyCancellable]! = [] @TestState var screenTransitions: [(destination: UIViewController, transition: TransitionType)]! = [] - func item(section: ThreadSettingsViewModel.Section, id: ThreadSettingsViewModel.TableItem) -> Item? { - return viewModel.tableData + func item(section: ThreadSettingsViewModel.Section, id: ThreadSettingsViewModel.ListItem) -> Item? { + return viewModel.state.listItemData .first(where: { (sectionModel: ThreadSettingsViewModel.SectionModel) -> Bool in sectionModel.model == section })? .elements - .first(where: { (item: SessionCell.Info) -> Bool in + .first(where: { (item: SessionListScreenContent.ListItemInfo) -> Bool in item.id == id }) } func setupTestSubscriptions() { - viewModel.tableDataPublisher - .receive(on: ImmediateScheduler.shared) - .sink( - receiveCompletion: { _ in }, - receiveValue: { viewModel.updateTableData($0) } - ) - .store(in: &disposables) viewModel.navigatableState.transitionToScreen .receive(on: ImmediateScheduler.shared) .sink( @@ -129,7 +122,7 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ).insert(db) } - viewModel = ThreadSettingsViewModel( + viewModel = await ThreadSettingsViewModel( threadId: user2Pubkey, threadVariant: .contact, didTriggerSearch: { @@ -164,7 +157,7 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ).insert(db) } - viewModel = ThreadSettingsViewModel( + viewModel = await ThreadSettingsViewModel( threadId: userPubkey, threadVariant: .contact, didTriggerSearch: { @@ -185,15 +178,13 @@ class ThreadSettingsViewModelSpec: AsyncSpec { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - expect(item?.title?.text).to(equal("noteToSelf".localized())) - } - - // MARK: ---- has no edit icon - it("has no edit icon") { - let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) - .toEventuallyNot(beNil()) - .retrieveValue() - expect(item?.leadingAccessory).to(beNil()) + + switch item?.variant { + case .tappableText(let info): + expect(info.text).to(equal("noteToSelf".localized())) + default: + fail("Expected .tappableText variant for displayName") + } } // MARK: ---- does nothing when tapped @@ -217,7 +208,7 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ).insert(db) } - viewModel = ThreadSettingsViewModel( + viewModel = await ThreadSettingsViewModel( threadId: user2Pubkey, threadVariant: .contact, didTriggerSearch: { @@ -238,7 +229,13 @@ class ThreadSettingsViewModelSpec: AsyncSpec { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - expect(item?.title?.text).to(equal("TestUser")) + + switch item?.variant { + case .tappableText(let info): + expect(info.text).to(equal("TestUser")) + default: + fail("Expected .tappableText variant for displayName") + } } // MARK: ---- presents a confirmation modal when tapped @@ -246,10 +243,15 @@ class ThreadSettingsViewModelSpec: AsyncSpec { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - await item?.onTapView?(UIView()) - await expect(screenTransitions.first?.destination) - .toEventually(beAKindOf(ConfirmationModal.self)) - expect(screenTransitions.first?.transition).to(equal(TransitionType.present)) + switch item?.variant { + case .tappableText(let info): + await info.onTextTap?() + await expect(screenTransitions.first?.destination) + .toEventually(beAKindOf(ConfirmationModal.self)) + expect(screenTransitions.first?.transition).to(equal(TransitionType.present)) + default: + fail("Expected .tappableText variant for displayName") + } } // MARK: ---- when updating the nickname @@ -262,15 +264,21 @@ class ThreadSettingsViewModelSpec: AsyncSpec { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - await item?.onTapView?(UIView()) - await expect(screenTransitions.first?.destination) - .toEventually(beAKindOf(ConfirmationModal.self)) - - modal = (screenTransitions.first?.destination as? ConfirmationModal) - modalInfo = await modal?.info - switch await modal?.info.body { - case .input(_, _, let onChange_): onChange = onChange_ - default: break + switch item?.variant { + case .tappableText(let info): + await info.onTextTap?() + await expect(screenTransitions.first?.destination) + .toEventually(beAKindOf(ConfirmationModal.self)) + expect(screenTransitions.first?.transition).to(equal(TransitionType.present)) + + modal = (screenTransitions.first?.destination as? ConfirmationModal) + modalInfo = await modal?.info + switch await modal?.info.body { + case .input(_, _, let onChange_): onChange = onChange_ + default: break + } + default: + fail("Expected .tappableText variant for displayName") } } @@ -377,7 +385,7 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ).insert(db) } - viewModel = ThreadSettingsViewModel( + viewModel = await ThreadSettingsViewModel( threadId: legacyGroupPubkey, threadVariant: .legacyGroup, didTriggerSearch: { @@ -398,26 +406,28 @@ class ThreadSettingsViewModelSpec: AsyncSpec { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - expect(item?.title?.text).to(equal("TestGroup")) + switch item?.variant { + case .tappableText(let info): + expect(info.text).to(equal("TestGroup")) + default: + fail("Expected .tappableText variant for displayName") + } } // MARK: ---- when the user is a standard member context("when the user is a standard member") { - // MARK: ------ has no edit icon - it("has no edit icon") { - let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) - .toEventuallyNot(beNil()) - .retrieveValue() - expect(item?.leadingAccessory).to(beNil()) - } - // MARK: ------ does nothing when tapped it("does nothing when tapped") { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - await item?.onTap?() - await expect(screenTransitions).toEventually(beEmpty()) + switch item?.variant { + case .tappableText(let info): + await info.onTextTap?() + await expect(screenTransitions).toEventually(beEmpty()) + default: + fail("Expected .tappableText variant for displayName") + } } } @@ -436,7 +446,7 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ).insert(db) } - viewModel = ThreadSettingsViewModel( + viewModel = await ThreadSettingsViewModel( threadId: legacyGroupPubkey, threadVariant: .legacyGroup, didTriggerSearch: { @@ -452,10 +462,15 @@ class ThreadSettingsViewModelSpec: AsyncSpec { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - await item?.onTapView?(UIView()) - await expect(screenTransitions.first?.destination) - .toEventually(beAKindOf(ConfirmationModal.self)) - expect(screenTransitions.first?.transition).to(equal(TransitionType.present)) + switch item?.variant { + case .tappableText(let info): + await info.onTextTap?() + await expect(screenTransitions.first?.destination) + .toEventually(beAKindOf(ConfirmationModal.self)) + expect(screenTransitions.first?.transition).to(equal(TransitionType.present)) + default: + fail("Expected .tappableText variant for displayName") + } } } } @@ -492,7 +507,7 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ).insert(db) } - viewModel = ThreadSettingsViewModel( + viewModel = await ThreadSettingsViewModel( threadId: groupPubkey, threadVariant: .group, didTriggerSearch: { @@ -513,26 +528,29 @@ class ThreadSettingsViewModelSpec: AsyncSpec { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - expect(item?.title?.text).to(equal("TestGroup")) + + switch item?.variant { + case .tappableText(let info): + expect(info.text).to(equal("TestGroup")) + default: + fail("Expected .tappableText variant for displayName") + } } // MARK: ---- when the user is a standard member context("when the user is a standard member") { - // MARK: ------ has no edit icon - it("has no edit icon") { - let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) - .toEventuallyNot(beNil()) - .retrieveValue() - expect(item?.leadingAccessory).to(beNil()) - } - // MARK: ------ does nothing when tapped it("does nothing when tapped") { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - await item?.onTap?() - await expect(screenTransitions).toEventually(beEmpty()) + switch item?.variant { + case .tappableText(let info): + await info.onTextTap?() + await expect(screenTransitions).toEventually(beEmpty()) + default: + fail("Expected .tappableText variant for displayName") + } } } @@ -556,7 +574,7 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ).insert(db) } - viewModel = ThreadSettingsViewModel( + viewModel = await ThreadSettingsViewModel( threadId: groupPubkey, threadVariant: .group, didTriggerSearch: { @@ -572,10 +590,16 @@ class ThreadSettingsViewModelSpec: AsyncSpec { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - await item?.onTapView?(UIView()) - await expect(screenTransitions.first?.destination) - .toEventually(beAKindOf(ConfirmationModal.self)) - expect(screenTransitions.first?.transition).to(equal(TransitionType.present)) + + switch item?.variant { + case .tappableText(let info): + await info.onTextTap?() + await expect(screenTransitions.first?.destination) + .toEventually(beAKindOf(ConfirmationModal.self)) + expect(screenTransitions.first?.transition).to(equal(TransitionType.present)) + default: + fail("Expected .tappableText variant for displayName") + } } // MARK: ------ when updating the group info @@ -587,7 +611,7 @@ class ThreadSettingsViewModelSpec: AsyncSpec { beforeEach { dependencies[feature: .updatedGroupsAllowDescriptionEditing] = true - viewModel = ThreadSettingsViewModel( + viewModel = await ThreadSettingsViewModel( threadId: groupPubkey, threadVariant: .group, didTriggerSearch: { @@ -600,16 +624,21 @@ class ThreadSettingsViewModelSpec: AsyncSpec { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - await item?.onTapView?(UIView()) - await expect(screenTransitions.first?.destination) - .toEventually(beAKindOf(ConfirmationModal.self)) - - modal = (screenTransitions.first?.destination as? ConfirmationModal) - modalInfo = await modal?.info - switch modalInfo?.body { - case .input(_, _, let onChange_): onChange = onChange_ - case .dualInput(_, _, _, let onChange2_): onChange2 = onChange2_ - default: break + switch item?.variant { + case .tappableText(let info): + await info.onTextTap?() + await expect(screenTransitions.first?.destination) + .toEventually(beAKindOf(ConfirmationModal.self)) + + modal = (screenTransitions.first?.destination as? ConfirmationModal) + modalInfo = await modal?.info + switch modalInfo?.body { + case .input(_, _, let onChange_): onChange = onChange_ + case .dualInput(_, _, _, let onChange2_): onChange2 = onChange2_ + default: break + } + default: + fail("Expected .tappableText variant for displayName") } } @@ -800,7 +829,7 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ).insert(db) } - viewModel = ThreadSettingsViewModel( + viewModel = await ThreadSettingsViewModel( threadId: communityId, threadVariant: .community, didTriggerSearch: { @@ -821,15 +850,13 @@ class ThreadSettingsViewModelSpec: AsyncSpec { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - expect(item?.title?.text).to(equal("TestCommunity")) - } - - // MARK: ---- has no edit icon - it("has no edit icon") { - let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) - .toEventuallyNot(beNil()) - .retrieveValue() - expect(item?.leadingAccessory).to(beNil()) + + switch item?.variant { + case .tappableText(let info): + expect(info.text).to(equal("TestCommunity")) + default: + fail("Expected .tappableText variant for displayName") + } } // MARK: ---- does nothing when tapped @@ -837,8 +864,13 @@ class ThreadSettingsViewModelSpec: AsyncSpec { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - await item?.onTap?() - await expect(screenTransitions).toEventually(beEmpty()) + switch item?.variant { + case .tappableText(let info): + await info.onTextTap?() + await expect(screenTransitions).toEventually(beEmpty()) + default: + fail("Expected .tappableText variant for displayName") + } } } } diff --git a/SessionUIKit/Components/ProfilePictureView.swift b/SessionUIKit/Components/ProfilePictureView.swift index e42e8dcb98..b213998257 100644 --- a/SessionUIKit/Components/ProfilePictureView.swift +++ b/SessionUIKit/Components/ProfilePictureView.swift @@ -10,7 +10,7 @@ public protocol ProfilePictureAnimationManagerType: AnyObject { } public final class ProfilePictureView: UIView { - public struct Info { + public struct Info: Equatable, Hashable { public enum Size { case navigation case message @@ -119,6 +119,16 @@ public final class ProfilePictureView: UIView { self.backgroundColor = backgroundColor self.forcedBackgroundColor = forcedBackgroundColor } + + public func hash(into hasher: inout Hasher) { + source.hash(into: &hasher) + canAnimate.hash(into: &hasher) + renderingMode.hash(into: &hasher) + themeTintColor.hash(into: &hasher) + icon.hash(into: &hasher) + backgroundColor.hash(into: &hasher) + forcedBackgroundColor.hash(into: &hasher) + } } private var dataManager: ImageDataManagerType? diff --git a/SessionUIKit/Components/SessionProBadge.swift b/SessionUIKit/Components/SessionProBadge.swift index b6396e3f0b..22fbc88012 100644 --- a/SessionUIKit/Components/SessionProBadge.swift +++ b/SessionUIKit/Components/SessionProBadge.swift @@ -46,6 +46,16 @@ public class SessionProBadge: UIView { case .large: return 40 } } + + // stringlint:ignore_contents + public var cacheKey: String { + switch self { + case .mini: return "SessionProBadge.Mini" + case .small: return "SessionProBadge.Small" + case .medium: return "SessionProBadge.Medium" + case .large: return "SessionProBadge.Large" + } + } } public var size: Size { diff --git a/SessionUIKit/Components/SwiftUI/AttributedLabel.swift b/SessionUIKit/Components/SwiftUI/AttributedLabel.swift index 968af2033b..c0758b10bc 100644 --- a/SessionUIKit/Components/SwiftUI/AttributedLabel.swift +++ b/SessionUIKit/Components/SwiftUI/AttributedLabel.swift @@ -6,20 +6,38 @@ public struct AttributedLabel: UIViewRepresentable { public typealias UIViewType = UILabel let themedAttributedString: ThemedAttributedString? + let alignment: NSTextAlignment let maxWidth: CGFloat? + let onTextTap: (@MainActor () -> Void)? + let onImageTap: (@MainActor () -> Void)? - public init(_ themedAttributedString: ThemedAttributedString?, maxWidth: CGFloat? = nil) { + public init( + _ themedAttributedString: ThemedAttributedString?, + alignment: NSTextAlignment = .natural, + maxWidth: CGFloat? = nil, + onTextTap: (@MainActor () -> Void)? = nil, + onImageTap: (@MainActor () -> Void)? = nil + ) { self.themedAttributedString = themedAttributedString + self.alignment = alignment self.maxWidth = maxWidth + self.onTextTap = onTextTap + self.onImageTap = onImageTap } public func makeUIView(context: Context) -> UILabel { let label = UILabel() label.numberOfLines = 0 label.themeAttributedText = themedAttributedString + label.textAlignment = alignment label.setContentHuggingPriority(.required, for: .horizontal) label.setContentHuggingPriority(.required, for: .vertical) label.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + label.isUserInteractionEnabled = true + + let tapGesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap)) + label.addGestureRecognizer(tapGesture) + return label } @@ -29,4 +47,38 @@ public struct AttributedLabel: UIViewRepresentable { label.preferredMaxLayoutWidth = maxWidth } } + + public func makeCoordinator() -> Coordinator { + Coordinator( + onTextTap: onTextTap, + onImageTap: onImageTap + ) + } + + public class Coordinator: NSObject { + let onTextTap: (@MainActor () -> Void)? + let onImageTap: (@MainActor () -> Void)? + + init( + onTextTap: (@MainActor () -> Void)?, + onImageTap: (@MainActor () -> Void)? + ) { + self.onTextTap = onTextTap + self.onImageTap = onImageTap + } + + @objc func handleTap(_ gesture: UITapGestureRecognizer) { + guard let label = gesture.view as? UILabel else { return } + let localPoint = gesture.location(in: label) + if label.isPointOnAttachment(localPoint) == true { + DispatchQueue.main.async { + self.onImageTap?() + } + } else { + DispatchQueue.main.async { + self.onTextTap?() + } + } + } + } } diff --git a/SessionUIKit/Components/SwiftUI/Seperator+SwiftUI.swift b/SessionUIKit/Components/SwiftUI/Seperator+SwiftUI.swift index 2700be3d17..df58b06257 100644 --- a/SessionUIKit/Components/SwiftUI/Seperator+SwiftUI.swift +++ b/SessionUIKit/Components/SwiftUI/Seperator+SwiftUI.swift @@ -5,13 +5,19 @@ import SwiftUI public struct Seperator_SwiftUI: View { public let title: String + public let font: Font + + public init(title: String, font: Font = .Body.smallRegular) { + self.title = title + self.font = font + } public var body: some View { HStack(spacing: 0) { Line(color: .textSecondary, lineWidth: Values.separatorThickness) Text(title) - .font(.Body.smallRegular) + .font(font) .foregroundColor(themeColor: .textSecondary) .fixedSize() .padding(.horizontal, 30) diff --git a/SessionUIKit/Components/SwiftUI/SessionButton_SwiftUI.swift b/SessionUIKit/Components/SwiftUI/SessionButton_SwiftUI.swift new file mode 100644 index 0000000000..e096013fcc --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/SessionButton_SwiftUI.swift @@ -0,0 +1,152 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI + +public class SessionButtonViewModel: ObservableObject { + public enum Style { + case bordered + case borderless + case destructive + case destructiveBorderless + case filled + + var themeTitleColor: ThemeValue { + switch self { + case .bordered, .borderless: return .sessionButton_text + case .destructive, .destructiveBorderless: return .sessionButton_destructiveText + case .filled: return .sessionButton_filledText + } + } + + var themeTitleHightlightColor: ThemeValue { + switch self { + case .borderless: return .highlighted(.sessionButton_text) + case .destructiveBorderless: return .highlighted(.sessionButton_destructiveText) + case .bordered, .destructive, .filled: return themeTitleColor + } + } + + var themeBackgroundColor: ThemeValue { + switch self { + case .bordered: return .sessionButton_background + case .destructive: return .sessionButton_destructiveBackground + case .borderless, .destructiveBorderless: return .clear + case .filled: return .sessionButton_filledBackground + } + } + + var themeBackgroundHightlightColor: ThemeValue { + switch self { + case .bordered: return .sessionButton_highlight + case .destructive: return .sessionButton_destructiveHighlight + case .borderless, .destructiveBorderless: return themeBackgroundColor + case .filled: return .sessionButton_filledHighlight + } + } + + var themeBorderColor: ThemeValue { + switch self { + case .bordered: return .sessionButton_border + case .destructive: return .sessionButton_destructiveBorder + case .filled, .borderless, .destructiveBorderless: return .clear + } + } + + var borderWidth: CGFloat { + switch self { + case .borderless, .destructiveBorderless: return 0 + default: return 1 + } + } + } + + public enum Size { + case small + case medium + case large + + var height: CGFloat { + switch self { + case .small: return Values.smallButtonHeight + case .medium: return Values.mediumButtonHeight + case .large: return Values.largeButtonHeight + } + } + + var font: Font { + switch self { + case .small: return .Headings.H9 + case .medium, .large: return .Headings.H8 + } + } + } + + @Published public var title: String + public var isEnabled: Bool = true + let style: Style + let size: Size + let accessibility: Accessibility? + let action: (SessionButtonViewModel) -> Void + + public init(title: String, style: Style, size: Size = .medium, accessibility: Accessibility? = nil, action: @escaping (SessionButtonViewModel) -> Void) { + self.title = title + self.style = style + self.size = size + self.accessibility = accessibility + self.action = action + } +} + +struct SessionButtonStyle: ButtonStyle { + let style: SessionButtonViewModel.Style + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundColor( + themeColor: ( + configuration.isPressed ? + style.themeTitleHightlightColor : + style.themeTitleColor + ) + ) + .backgroundColor( + themeColor: ( + configuration.isPressed ? + style.themeBackgroundHightlightColor : + style.themeBackgroundColor + ) + ) + .overlay( + Capsule() + .stroke( + themeColor: style.themeBorderColor, + lineWidth: style.borderWidth + ) + ) + } +} + +public struct SessionButton_SwiftUI: View { + @StateObject private var viewModel: SessionButtonViewModel + + public init(_ viewModel: SessionButtonViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + public var body: some View { + Button { + if viewModel.isEnabled { + viewModel.action(viewModel) + } + } label: { + Text(viewModel.title) + .font(viewModel.size.font) + .framing( + maxWidth: .infinity, + height: viewModel.size.height + ) + } + .accessibility(viewModel.accessibility) + .buttonStyle(SessionButtonStyle(style: viewModel.style)) + } +} diff --git a/SessionUIKit/Screens/SessionListScreen/AccessoryViews/ListItemAccessory+Button.swift b/SessionUIKit/Screens/SessionListScreen/AccessoryViews/ListItemAccessory+Button.swift new file mode 100644 index 0000000000..0c60b28d72 --- /dev/null +++ b/SessionUIKit/Screens/SessionListScreen/AccessoryViews/ListItemAccessory+Button.swift @@ -0,0 +1,17 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI + +public extension SessionListScreenContent.ListItemAccessory { + static func button( + _ buttonViewModel: SessionButtonViewModel + ) -> SessionListScreenContent.ListItemAccessory { + return SessionListScreenContent.ListItemAccessory( + padding: -Values.mediumSmallSpacing + ) { + SessionButton_SwiftUI(buttonViewModel) + .frame(maxWidth: .infinity) + } + } +} + diff --git a/SessionUIKit/Screens/SessionListScreen/AccessoryViews/ListItemAccessory+Icon.swift b/SessionUIKit/Screens/SessionListScreen/AccessoryViews/ListItemAccessory+Icon.swift index 50e7615b79..e1baba51dc 100644 --- a/SessionUIKit/Screens/SessionListScreen/AccessoryViews/ListItemAccessory+Icon.swift +++ b/SessionUIKit/Screens/SessionListScreen/AccessoryViews/ListItemAccessory+Icon.swift @@ -27,7 +27,9 @@ public extension SessionListScreenContent.ListItemAccessory { shouldFill: Bool = false, accessibility: Accessibility? = nil ) -> SessionListScreenContent.ListItemAccessory { - return SessionListScreenContent.ListItemAccessory { + return SessionListScreenContent.ListItemAccessory( + padding: Values.smallSpacing + ) { Image(uiImage: image ?? UIImage()) .renderingMode(.template) .resizable() diff --git a/SessionUIKit/Screens/SessionListScreen/AccessoryViews/ListItemAccessory+ProBadge.swift b/SessionUIKit/Screens/SessionListScreen/AccessoryViews/ListItemAccessory+ProBadge.swift new file mode 100644 index 0000000000..334c105540 --- /dev/null +++ b/SessionUIKit/Screens/SessionListScreen/AccessoryViews/ListItemAccessory+ProBadge.swift @@ -0,0 +1,26 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI + +public extension SessionListScreenContent.ListItemAccessory { + static func proBadge( + size: SessionProBadge.Size, + themeBackgroundColor: ThemeValue, + backgroundSize: IconSize = .medium, + ) -> SessionListScreenContent.ListItemAccessory { + return SessionListScreenContent.ListItemAccessory( + padding: Values.smallSpacing + ) { + ZStack { + SessionProBadge_SwiftUI( + size: size, + themeBackgroundColor: themeBackgroundColor + ) + } + .frame( + width: backgroundSize.size, + height: backgroundSize.size + ) + } + } +} diff --git a/SessionUIKit/Screens/SessionListScreen/AccessoryViews/SessionListScreen+AccessoryViews.swift b/SessionUIKit/Screens/SessionListScreen/AccessoryViews/SessionListScreen+AccessoryViews.swift index 94e85844a1..da4754c545 100644 --- a/SessionUIKit/Screens/SessionListScreen/AccessoryViews/SessionListScreen+AccessoryViews.swift +++ b/SessionUIKit/Screens/SessionListScreen/AccessoryViews/SessionListScreen+AccessoryViews.swift @@ -6,10 +6,13 @@ import Lucide public extension SessionListScreenContent { struct ListItemAccessory: Hashable, Equatable { @ViewBuilder public let accessoryView: () -> AnyView + let padding: CGFloat public init( + padding: CGFloat = 0, @ViewBuilder accessoryView: @escaping () -> Accessory ) { + self.padding = padding self.accessoryView = { accessoryView().eraseToAnyView() } } diff --git a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift index f60f897fea..9fb0418f7f 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift @@ -28,6 +28,8 @@ public extension SessionListScreenContent { case logoWithPro(info: ListItemLogoWithPro.Info) case dataMatrix(info: [[ListItemDataMatrix.Info]]) case button(title: String, enabled: Bool) + case profilePicture(info: ListItemProfilePicture.Info) + case tappableText(info: ListItemTappableText.Info) } let id: ID diff --git a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift index 7d4e90b1d2..d403b4a350 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift @@ -13,6 +13,9 @@ public extension SessionListScreenContent { protocol ViewModelType: ObservableObject, SectionedListItemData { var title: String { get } var state: ListItemDataState { get } + var imageDataManager: ImageDataManagerType { get } + associatedtype FooterView: View + @ViewBuilder var footerView: FooterView { get } } struct TooltipInfo: Hashable, Equatable { @@ -36,10 +39,25 @@ public extension SessionListScreenContent { } struct TextInfo: Hashable, Equatable { - public enum Accessory: Hashable, Equatable { - case proBadgeLeading(themeBackgroundColor: ThemeValue) - case proBadgeTrailing(themeBackgroundColor: ThemeValue) + public enum InlineImagePosition: Hashable, Equatable { + case leading + case trailing + } + + public enum Interaction: Hashable, Equatable { case none + case copy + case expandable + } + + public struct InlineImageInfo: Hashable, Equatable { + let image: UIImage + let position: InlineImagePosition + + public init(image: UIImage, position: InlineImagePosition) { + self.image = image + self.position = position + } } let text: String? @@ -47,8 +65,9 @@ public extension SessionListScreenContent { let attributedString: ThemedAttributedString? let alignment: TextAlignment let color: ThemeValue - let accessory: Accessory + let interaction: Interaction let accessibility: Accessibility? + let inlineImage: InlineImageInfo? public init( _ text: String? = nil, @@ -56,16 +75,18 @@ public extension SessionListScreenContent { attributedString: ThemedAttributedString? = nil, alignment: TextAlignment = .leading, color: ThemeValue = .textPrimary, - accessory: Accessory = .none, - accessibility: Accessibility? = nil + interaction: Interaction = .none, + accessibility: Accessibility? = nil, + inlineImage: InlineImageInfo? = nil ) { self.text = text self.font = font self.attributedString = attributedString self.alignment = alignment self.color = color - self.accessory = accessory + self.interaction = interaction self.accessibility = accessibility + self.inlineImage = inlineImage } // MARK: - Conformance @@ -76,8 +97,8 @@ public extension SessionListScreenContent { attributedString.hash(into: &hasher) alignment.hash(into: &hasher) color.hash(into: &hasher) - accessory.hash(into: &hasher) accessibility.hash(into: &hasher) + inlineImage?.hash(into: &hasher) } public static func == (lhs: TextInfo, rhs: TextInfo) -> Bool { @@ -87,9 +108,13 @@ public extension SessionListScreenContent { lhs.attributedString == rhs.attributedString && lhs.alignment == rhs.alignment && lhs.color == rhs.color && - lhs.accessory == rhs.accessory && - lhs.accessibility == rhs.accessibility + lhs.accessibility == rhs.accessibility && + lhs.inlineImage == rhs.inlineImage ) } } } + +public extension SessionListScreenContent.ViewModelType { + var footerView: some View { EmptyView() } +} diff --git a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Section.swift b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Section.swift index fa99d183ff..5c7c90c180 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Section.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Section.swift @@ -8,6 +8,7 @@ public extension SessionListScreenContent { var title: String? { get } var style: ListSectionStyle { get } var divider: Bool { get } + var extraVerticalPadding: CGFloat { get } var footer: String? { get } } @@ -15,22 +16,45 @@ public extension SessionListScreenContent { case none case titleWithTooltips(info: TooltipInfo) case titleNoBackgroundContent + case titleSeparator + case padding + case titleRoundedContent var height: CGFloat { switch self { - case .none: + case .none, .titleWithTooltips, .titleNoBackgroundContent, .titleRoundedContent: return 0 - case .titleWithTooltips, .titleNoBackgroundContent: + case .titleSeparator: + return Separator.height + case .padding: + return Values.smallSpacing + } + } + + var cellMinHeight: CGFloat { + switch self { + case .titleSeparator, .none: + return 0 + default: return 44 } } var edgePadding: CGFloat { switch self { - case .none: + case .none, .padding: return 0 - case .titleWithTooltips, .titleNoBackgroundContent: - return (Values.largeSpacing + Values.mediumSpacing) + case .titleWithTooltips, .titleNoBackgroundContent, .titleRoundedContent, .titleSeparator: + return Values.largeSpacing + } + } + + var backgroundColor: ThemeValue { + switch self { + case .titleNoBackgroundContent, .titleSeparator, .none: + return .clear + case .titleRoundedContent, .titleWithTooltips, .padding: + return .backgroundSecondary } } } diff --git a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift index 381017ac87..0d3db49fdd 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift @@ -25,89 +25,123 @@ public struct ListItemCell: View { } } + @State var isExpanded: Bool + let info: Info - let height: CGFloat + let onTap: (() -> Void)? + + public init(info: Info, onTap: (() -> Void)? = nil) { + self.info = info + self.isExpanded = (info.title?.interaction != .expandable) + self.onTap = onTap + } public var body: some View { HStack(spacing: Values.mediumSpacing) { if let leadingAccessory = info.leadingAccessory { leadingAccessory.accessoryView() + .padding(.horizontal, leadingAccessory.padding) } - VStack(alignment: .leading, spacing: 0) { - if let title = info.title { - HStack(spacing: Values.verySmallSpacing) { - if case .proBadgeLeading(let themeBackgroundColor) = title.accessory { - SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) - } - - if let text = title.text { - Text(text) - .font(title.font) - .multilineTextAlignment(title.alignment) - .foregroundColor(themeColor: title.color) - .accessibility(title.accessibility) - .fixedSize() - } else if let attributedString = title.attributedString { - AttributedText(attributedString) - .font(title.font) - .multilineTextAlignment(title.alignment) - .foregroundColor(themeColor: title.color) - .accessibility(title.accessibility) - .fixedSize() - } - - if case .proBadgeTrailing(let themeBackgroundColor) = title.accessory { - SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) + if info.title != nil || info.description != nil { + VStack(alignment: .center, spacing: 0) { + if let title = info.title { + HStack(spacing: Values.verySmallSpacing) { + if case .trailing = info.title?.alignment { Spacer(minLength: 0) } + if case .center = info.title?.alignment { Spacer(minLength: 0) } + + if let text = title.text { + VStack(spacing: Values.smallSpacing) { + ZStack { + if let inlineImage = title.inlineImage { + switch inlineImage.position { + case .leading: + Text("\(Image(uiImage: inlineImage.image)) ") + Text(text) + case .trailing: + Text(text) + Text(" \(Image(uiImage: inlineImage.image))") + } + } else { + Text(text) + } + } + .lineLimit(isExpanded ? nil : 2) + .font(title.font) + .multilineTextAlignment(title.alignment) + .foregroundColor(themeColor: title.color) + .accessibility(title.accessibility) + .fixedSize(horizontal: false, vertical: true) + .textSelection(title.interaction == .copy) + + if info.title?.interaction == .expandable { + Text(isExpanded ? "viewLess".localized() : "viewMore".localized()) + .bold() + .font(title.font) + .foregroundColor(themeColor: .textPrimary) + } + } + } else if let attributedString = title.attributedString { + AttributedText(attributedString) + .font(title.font) + .multilineTextAlignment(title.alignment) + .foregroundColor(themeColor: title.color) + .accessibility(title.accessibility) + .fixedSize(horizontal: false, vertical: true) + .textSelection(title.interaction == .copy) + } + + if case .center = info.title?.alignment { Spacer(minLength: 0) } + if case .leading = info.title?.alignment { Spacer(minLength: 0) } } } - } - - if let description = info.description { - HStack(spacing: Values.verySmallSpacing) { - if case .proBadgeLeading(let themeBackgroundColor) = description.accessory { - SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) - } - - if let text = description.text { - Text(text) - .font(description.font) - .multilineTextAlignment(description.alignment) - .foregroundColor(themeColor: description.color) - .accessibility(description.accessibility) - .fixedSize(horizontal: false, vertical: true) - } else if let attributedString = description.attributedString { - AttributedText(attributedString) - .font(description.font) - .multilineTextAlignment(description.alignment) - .foregroundColor(themeColor: description.color) - .accessibility(description.accessibility) - .fixedSize(horizontal: false, vertical: true) - } - - if case .proBadgeTrailing(let themeBackgroundColor) = description.accessory { - SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) + + if let description = info.description { + HStack(spacing: Values.verySmallSpacing) { + if case .trailing = info.description?.alignment { Spacer(minLength: 0) } + if case .center = info.description?.alignment { Spacer(minLength: 0) } + + if let text = description.text { + Text(text) + .font(description.font) + .multilineTextAlignment(description.alignment) + .foregroundColor(themeColor: description.color) + .accessibility(description.accessibility) + .fixedSize(horizontal: false, vertical: true) + } else if let attributedString = description.attributedString { + AttributedText(attributedString) + .font(description.font) + .multilineTextAlignment(description.alignment) + .foregroundColor(themeColor: description.color) + .accessibility(description.accessibility) + .fixedSize(horizontal: false, vertical: true) + } + + if case .center = info.description?.alignment { Spacer(minLength: 0) } + if case .leading = info.description?.alignment { Spacer(minLength: 0) } } } } + .frame( + maxWidth: .infinity, + alignment: .leading + ) + } else { + Spacer(minLength: Values.smallSpacing) } - .frame( - maxWidth: .infinity, - maxHeight: .infinity, - alignment: .leading - ) if let trailingAccessory = info.trailingAccessory { - Spacer(minLength: 0) trailingAccessory.accessoryView() + .padding(.horizontal, trailingAccessory.padding) } } .padding(.horizontal, Values.mediumSpacing) - .frame( - maxWidth: .infinity, - minHeight: height, - alignment: .leading - ) .contentShape(Rectangle()) + .onTapGesture { + if info.title?.interaction == .expandable { + withAnimation { + isExpanded.toggle() + } + } + onTap?() + } } } diff --git a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemProfilePicture.swift b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemProfilePicture.swift new file mode 100644 index 0000000000..50767968da --- /dev/null +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemProfilePicture.swift @@ -0,0 +1,177 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI +import Lucide +import DifferenceKit + +// MARK: - ListItemProfilePicture + +public struct ListItemProfilePicture: View { + public struct Info: Equatable, Hashable, Differentiable { + let sessionId: String? + let qrCodeImage: UIImage? + let profileInfo: ProfilePictureView.Info? + let isExpandable: Bool + + public init(sessionId: String?, qrCodeImage: UIImage?, profileInfo: ProfilePictureView.Info?, isExpandable: Bool = true) { + self.sessionId = sessionId + self.qrCodeImage = qrCodeImage + self.profileInfo = profileInfo + self.isExpandable = isExpandable + } + } + + public enum Content: Equatable, Hashable, Differentiable { + case profilePicture + case qrCode + } + + @Binding var content: Content + @Binding var isProfileImageExpanding: Bool + + var info: Info + var dataManager: ImageDataManagerType + let host: HostWrapper + let onProfilePictureTap: (@MainActor () -> Void) + + public var body: some View { + let scale: CGFloat = isProfileImageExpanding ? (190.0 / 90) : 1 + ZStack(alignment: .top) { + ZStack(alignment: .topTrailing) { + if let profileInfo = info.profileInfo { + ZStack { + ProfilePictureSwiftUI( + size: .modal, + info: profileInfo, + dataManager: self.dataManager + ) + .scaleEffect(scale, anchor: .topLeading) + .onTapGesture { + if info.isExpandable { + withAnimation(.easeInOut(duration: 0.1)) { + self.isProfileImageExpanding.toggle() + } + } else { + onProfilePictureTap() + } + } + } + .frame( + width: ProfilePictureView.Info.Size.modal.viewSize * scale, + height: ProfilePictureView.Info.Size.modal.viewSize * scale, + alignment: .center + ) + } + + if info.qrCodeImage != nil { + let (buttonSize, iconSize): (CGFloat, CGFloat) = isProfileImageExpanding ? (33, 20) : (24, 14) + ZStack { + Circle() + .foregroundColor(themeColor: .primary) + .frame(width: buttonSize, height: buttonSize) + + if let icon: UIImage = Lucide.image(icon: .qrCode, size: iconSize) { + Image(uiImage: icon) + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor(themeColor: .black) + .frame(width: iconSize, height: iconSize) + } + } + .padding(.trailing, isProfileImageExpanding ? 28 : 4) + .onTapGesture { + withAnimation { + self.content = .qrCode + } + } + } + } + .padding(.vertical, 5) + .padding(.horizontal, 10) + .opacity((content == .profilePicture ? 1 : 0)) + + ZStack(alignment: .topTrailing) { + if let qrCodeImage = info.qrCodeImage { + QRCodeView( + qrCodeImage: qrCodeImage, + themeStyle: ThemeManager.currentTheme.interfaceStyle + ) + .accessibility( + Accessibility( + identifier: "QR code", + label: "QR code" + ) + ) + .aspectRatio(1, contentMode: .fit) + .frame(width: 190, height: 190, alignment: .top) + .padding(.vertical, 5) + .padding(.horizontal, 10) + .onTapGesture { + showQRCodeLightBox() + } + + Image("ic_user_round_fill") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor(themeColor: .black) + .frame(width: 18, height: 18) + .background( + Circle() + .foregroundColor(themeColor: .primary) + .frame(width: 33, height: 33) + ) + .onTapGesture { + withAnimation { + self.content = .profilePicture + } + } + } + } + .opacity((content == .qrCode ? 1 : 0)) + } + .frame( + width: 210, + height: content == .qrCode ? 200 : (ProfilePictureView.Info.Size.modal.viewSize * scale + 10), + alignment: .top + ) + .padding(.top, 12) + } + + private func showQRCodeLightBox() { + guard let qrCodeImage: UIImage = info.qrCodeImage else { return } + + let viewController = SessionHostingViewController( + rootView: LightBox( + itemsToShare: [ + QRCode.qrCodeImageWithBackground( + image: qrCodeImage, + size: CGSize(width: 400, height: 400), + insets: UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) + ) + ] + ) { + VStack { + Spacer() + + QRCodeView( + qrCodeImage: qrCodeImage, + themeStyle: ThemeManager.currentTheme.interfaceStyle + ) + .aspectRatio(1, contentMode: .fit) + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) + + Spacer() + } + .backgroundColor(themeColor: .newConversation_background) + }, + customizedNavigationBackground: .backgroundSecondary + ) + viewController.modalPresentationStyle = .fullScreen + self.host.controller?.present(viewController, animated: true) + } +} diff --git a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemTappableText.swift b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemTappableText.swift new file mode 100644 index 0000000000..cdbb551a28 --- /dev/null +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemTappableText.swift @@ -0,0 +1,115 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI +import DifferenceKit + +// MARK: - ListItemTappableText + +public struct ListItemTappableText: View { + public struct Info: Equatable, Hashable, Differentiable { + let text: String + let font: UIFont + let themeForegroundColor: ThemeValue + let imageAttachmentPosition: SessionListScreenContent.TextInfo.InlineImagePosition? + let imageAttachmentGenerator: (() -> (UIImage, String?)?)? + let onTextTap: (@MainActor() -> Void)? + let onImageTap: (@MainActor() -> Void)? + + public init( + text: String, + font: UIFont, + themeForegroundColor: ThemeValue = .textPrimary, + imageAttachmentPosition: SessionListScreenContent.TextInfo.InlineImagePosition? = nil, + imageAttachmentGenerator: (() -> (UIImage, String?)?)? = nil, + onTextTap: (@MainActor() -> Void)? = nil, + onImageTap: (@MainActor() -> Void)? = nil + ) { + self.text = text + self.font = font + self.themeForegroundColor = themeForegroundColor + self.imageAttachmentPosition = imageAttachmentPosition + self.imageAttachmentGenerator = imageAttachmentGenerator + self.onTextTap = onTextTap + self.onImageTap = onImageTap + } + + public static func == (lhs: Info, rhs: Info) -> Bool { + return + lhs.text == rhs.text && + lhs.font == rhs.font && + lhs.themeForegroundColor == rhs.themeForegroundColor && + lhs.imageAttachmentPosition == rhs.imageAttachmentPosition + } + + public func hash(into hasher: inout Hasher) { + text.hash(into: &hasher) + font.hash(into: &hasher) + themeForegroundColor.hash(into: &hasher) + imageAttachmentPosition.hash(into: &hasher) + } + + public func makeAttributedString(spacing: String = " ") -> ThemedAttributedString { + let base = ThemedAttributedString() + + if let imageAttachmentPosition, imageAttachmentPosition == .leading, let imageAttachmentGenerator { + base.append( + ThemedAttributedString( + imageAttachmentGenerator: imageAttachmentGenerator, + referenceFont: font + ) + ) + base.append(NSAttributedString(string: spacing)) + } + + base.append( + ThemedAttributedString( + string: text, + attributes: [ + .font: font as Any, + .themeForegroundColor: themeForegroundColor + ] + ) + ) + + if let imageAttachmentPosition, imageAttachmentPosition == .trailing, let imageAttachmentGenerator { + base.append(NSAttributedString(string: spacing)) + base.append( + ThemedAttributedString( + imageAttachmentGenerator: imageAttachmentGenerator, + referenceFont: font + ) + ) + } + + return base + } + } + + let info: Info + let height: CGFloat + let onAnyTap: (@MainActor() -> Void)? + + public init(info: Info, height: CGFloat, onAnyTap: (() -> Void)? = nil) { + self.info = info + self.height = height + self.onAnyTap = onAnyTap + } + + public var body: some View { + AttributedLabel( + info.makeAttributedString(), + alignment: .center, + maxWidth: (UIScreen.main.bounds.width - Values.mediumSpacing * 2 - Values.largeSpacing * 2), + onTextTap: { + info.onTextTap?() + onAnyTap?() + }, + onImageTap: { + info.onImageTap?() + onAnyTap?() + } + ) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, Values.mediumSpacing) + } +} diff --git a/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift b/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift index 53d040a6de..557d5d4858 100644 --- a/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift +++ b/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift @@ -8,6 +8,9 @@ public struct SessionListScreen + + // MARK: - Tooltips variables + @State var isShowingTooltip: Bool = false @State var tooltipContent: ThemedAttributedString = ThemedAttributedString() @State var tooltipViewId: String = "" @@ -18,6 +21,13 @@ public struct SessionListScreen= suppressUntil else { return } - suppressUntil = Date().addingTimeInterval(0.2) - guard tooltipViewId != info.id && !isShowingTooltip else { - withAnimation { - isShowingTooltip = false + ZStack(alignment: .leading) { + switch section.model.style { + case .titleWithTooltips(let info): + HStack(spacing: 0) { + Text(title) + .font(.Body.baseRegular) + .foregroundColor(themeColor: .textSecondary) + .padding(.horizontal, Values.smallSpacing) + + Image(systemName: "questionmark.circle") + .font(.Body.baseRegular) + .foregroundColor(themeColor: .textSecondary) + .anchorView(viewId: info.id) + .accessibility( + Accessibility(identifier: "Section Header Tooltip") + ) + .onTapGesture { + guard Date() >= suppressUntil else { return } + suppressUntil = Date().addingTimeInterval(0.2) + guard tooltipViewId != info.id && !isShowingTooltip else { + withAnimation { + isShowingTooltip = false + } + return + } + tooltipContent = info.content + tooltipPosition = info.position + tooltipViewId = info.id + tooltipArrowOffset = 30 + withAnimation { + isShowingTooltip = true + } } - return - } - tooltipContent = info.content - tooltipPosition = info.position - tooltipViewId = info.id - tooltipArrowOffset = 30 - withAnimation { - isShowingTooltip = true - } } + case .titleSeparator: + Seperator_SwiftUI( + title: "accountId".localized(), + font: .Body.baseRegular + ) + default: + Text(title) + .font(.Body.baseRegular) + .foregroundColor(themeColor: .textSecondary) + .padding(.horizontal, Values.smallSpacing) } } + .frame(minHeight: section.model.style.height) + .listRowInsets(.init(top: 0, leading: section.model.style.edgePadding, bottom: 0, trailing: section.model.style.edgePadding)) .listRowBackground(Color.clear) } @@ -142,30 +167,45 @@ public struct SessionListScreen Void) = { + if let elementOnTap = element.onTap { + elementOnTap() + } + + if let confirmationInfo = element.confirmationInfo { + let modal: ConfirmationModal = ConfirmationModal(info: confirmationInfo) + host.controller?.present(modal, animated: true) + } + } switch element.variant { case .cell(let info): - ListItemCell(info: info, height: section.model.style.height) - .contentShape(Rectangle()) - .onTapGesture { - element.onTap?() - } - .padding(.vertical, Values.smallSpacing) - .padding(.top, (index == 0) ? Values.smallSpacing : 0) - .padding(.bottom, isLastElement ? Values.smallSpacing : 0) - .background( - Rectangle() - .foregroundColor(themeColor: .backgroundSecondary) + VStack(spacing: 0) { + ListItemCell( + info: info, + onTap: onTapAction + ) + .frame( + maxWidth: .infinity, + minHeight: section.model.style.cellMinHeight ) - - if (section.model.divider && !isLastElement) { - Divider() - .foregroundColor(themeColor: .borderSeparator) - .padding(.horizontal, Values.mediumSpacing) + .padding(.vertical, Values.smallSpacing) + .padding(.top, (index == 0) ? section.model.extraVerticalPadding : 0) + .padding(.bottom, isLastElement ? section.model.extraVerticalPadding : 0) + + if (section.model.divider && !isLastElement) { + Divider() + .foregroundColor(themeColor: .borderSeparator) + .padding(.horizontal, Values.mediumSpacing) + } } + .background( + Rectangle() + .foregroundColor(themeColor: section.model.style.backgroundColor) + ) case .logoWithPro(let info): ListItemLogoWithPro(info: info) .onTapGesture { - element.onTap?() + onTapAction() } case .dataMatrix(let info): ListItemDataMatrix( @@ -178,7 +218,7 @@ public struct SessionListScreen [ProFeaturesInfo] { return [ @@ -29,29 +43,49 @@ public struct ProFeaturesInfo { proState == .none ? "nonProLongerMessagesDescription".localizedFormatted(baseFont: Fonts.Body.smallRegular) : "proLongerMessagesDescription".localizedFormatted(baseFont: Fonts.Body.smallRegular) - ), - accessory: .none + ) ), ProFeaturesInfo( icon: Lucide.image(icon: .pin, size: IconSize.medium.size), backgroundColors: (proState == .expired) ? [ThemeValue.disabled] : [.explicitPrimary(.purple), .explicitPrimary(.pink)], title: "proUnlimitedPins".localized(), - description: "proUnlimitedPinsDescription".localizedFormatted(baseFont: Fonts.Body.smallRegular), - accessory: .none + description: "proUnlimitedPinsDescription".localizedFormatted(baseFont: Fonts.Body.smallRegular) ), ProFeaturesInfo( icon: Lucide.image(icon: .squarePlay, size: IconSize.medium.size), backgroundColors: (proState == .expired) ? [ThemeValue.disabled] : [.explicitPrimary(.pink), .explicitPrimary(.red)], title: "proAnimatedDisplayPictures".localized(), - description: "proAnimatedDisplayPicturesDescription".localizedFormatted(baseFont: Fonts.Body.smallRegular), - accessory: .none + description: "proAnimatedDisplayPicturesDescription".localizedFormatted(baseFont: Fonts.Body.smallRegular) ), ProFeaturesInfo( icon: Lucide.image(icon: .rectangleEllipsis, size: IconSize.medium.size), backgroundColors: (proState == .expired) ? [ThemeValue.disabled] : [.explicitPrimary(.red), .explicitPrimary(.orange)], title: "proBadges".localized(), description: "proBadgesDescription".put(key: "app_name", value: Constants.app_name).localizedFormatted(Fonts.Body.smallRegular), - accessory: .proBadgeLeading(themeBackgroundColor: (proState == .expired) ? .disabled : .primary) + inlineImageInfo: { + let themeBackgroundColor: ThemeValue = { + return switch proState { + case .expired: .disabled + default: .primary + } + }() + + return .init( + image: UIView.image( + for: .themedKey( + SessionProBadge.Size.mini.cacheKey, + themeBackgroundColor: themeBackgroundColor + ), + generator: { + SessionProBadge( + size: .mini, + themeBackgroundColor: themeBackgroundColor + ) + } + ), + position: .leading + ) + }() ) ] } @@ -64,8 +98,7 @@ public struct ProFeaturesInfo { description: "plusLoadsMoreDescription" .put(key: "pro", value: Constants.pro) .put(key: "icon", value: Lucide.Icon.squareArrowUpRight) - .localizedFormatted(Fonts.Body.smallRegular), - accessory: .proBadgeLeading(themeBackgroundColor: (proState == .expired) ? .disabled : .primary) + .localizedFormatted(Fonts.Body.smallRegular) ) } } diff --git a/SessionUIKit/Style Guide/Themes/Theme.swift b/SessionUIKit/Style Guide/Themes/Theme.swift index bd5739234d..889ecd5cdd 100644 --- a/SessionUIKit/Style Guide/Themes/Theme.swift +++ b/SessionUIKit/Style Guide/Themes/Theme.swift @@ -250,7 +250,7 @@ public indirect enum ThemeValue: Hashable, Equatable { // MARK: - ForcedThemeValue -public enum ForcedThemeValue { +public enum ForcedThemeValue: Equatable, Hashable { case color(UIColor) case theme(Theme, color: ThemeValue, alpha: CGFloat?) diff --git a/SessionUIKit/Utilities/SwiftUI+Utilities.swift b/SessionUIKit/Utilities/SwiftUI+Utilities.swift index 2b7c930981..e1f676e033 100644 --- a/SessionUIKit/Utilities/SwiftUI+Utilities.swift +++ b/SessionUIKit/Utilities/SwiftUI+Utilities.swift @@ -261,6 +261,15 @@ public extension View { ) } + @ViewBuilder + func textSelection(_ condition: Bool) -> some View { + if condition { + self.textSelection(.enabled) + } else { + self.textSelection(.disabled) + } + } + /// This function triggers a callback when any interaction is performed on a UI element /// /// **Note:** It looks like there were some bugs in the Gesture Recognizer systens prior to iOS 18.0 (specifically breaking scrolling diff --git a/SessionUIKit/Utilities/UILabel+Utilities.swift b/SessionUIKit/Utilities/UILabel+Utilities.swift index 6ef30de1d8..828d6365c6 100644 --- a/SessionUIKit/Utilities/UILabel+Utilities.swift +++ b/SessionUIKit/Utilities/UILabel+Utilities.swift @@ -35,13 +35,9 @@ public extension UILabel { lineBreakMode = .byWordWrapping } - /// Returns true if `point` (in this label's coordinate space) hits a drawn NSTextAttachment at the end of the string. - /// Works with multi-line labels, alignment, and truncation. - func isPointOnTrailingAttachment(_ point: CGPoint, hitPadding: CGFloat = 0) -> Bool { + func isPointOnAttachment(_ point: CGPoint, hitPadding: CGFloat = 0) -> Bool { guard let attributed = attributedText, attributed.length > 0 else { return false } - // Reuse the general function but also ensure the attachment range ends at string end. - // We re-run the minimal parts to get the effectiveRange. let layoutManager = NSLayoutManager() let textContainer = NSTextContainer(size: CGSize(width: bounds.width, height: .greatestFiniteMagnitude)) textContainer.lineFragmentPadding = 0 @@ -55,26 +51,47 @@ public extension UILabel { let glyphRange = layoutManager.glyphRange(for: textContainer) if glyphRange.length == 0 { return false } - let textBounds = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) - var textOrigin = CGPoint.zero + // Find which line fragment contains the point + var lineOrigin = CGPoint.zero + var lineRect = CGRect.zero + var foundLine = false + + layoutManager.enumerateLineFragments(forGlyphRange: glyphRange) { rect, usedRect, _, _, stop in + if rect.contains(CGPoint(x: 0, y: point.y)) { + lineRect = usedRect + lineOrigin = rect.origin + foundLine = true + stop.pointee = true + } + } + + guard foundLine else { return false } + + // Calculate horizontal offset for this specific line + var xOffset: CGFloat = 0 switch textAlignment { - case .center: textOrigin.x = (bounds.width - textBounds.width) / 2.0 - case .right: textOrigin.x = bounds.width - textBounds.width - case .natural where effectiveUserInterfaceLayoutDirection == .rightToLeft: - textOrigin.x = bounds.width - textBounds.width - default: break + case .center: + xOffset = lineOrigin.x + (lineRect.width < bounds.width ? (bounds.width - lineRect.width) / 2.0 : 0) + case .right: + xOffset = lineOrigin.x + (lineRect.width < bounds.width ? bounds.width - lineRect.width : 0) + case .natural where effectiveUserInterfaceLayoutDirection == .rightToLeft: + xOffset = lineOrigin.x + (lineRect.width < bounds.width ? bounds.width - lineRect.width : 0) + default: + xOffset = lineOrigin.x } - let pt = CGPoint(x: point.x - textOrigin.x, y: point.y - textOrigin.y) + let pt = CGPoint(x: point.x - xOffset, y: point.y) + + // Check if point is within text bounds with padding + let textBounds = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) if !textBounds.insetBy(dx: -hitPadding, dy: -hitPadding).contains(pt) { return false } let idx = layoutManager.characterIndex(for: pt, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil) guard idx < attributed.length else { return false } var range = NSRange(location: 0, length: 0) - guard attributed.attribute(.attachment, at: idx, effectiveRange: &range) is NSTextAttachment, - NSMaxRange(range) == attributed.length else { + guard attributed.attribute(.attachment, at: idx, effectiveRange: &range) is NSTextAttachment else { return false }