diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 33ce06d0c7..57fad52fa4 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 */; }; @@ -205,6 +206,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 +1617,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 = ""; }; @@ -1672,6 +1675,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 = ""; }; @@ -3085,10 +3089,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 = ""; @@ -6645,6 +6651,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 */, @@ -6724,6 +6731,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.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 200c4861d2..27c6f88763 100644 --- a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/mattgallagher/CwlCatchException.git", "state" : { - "revision" : "07b2ba21d361c223e25e3c1e924288742923f08c", - "version" : "2.2.1" + "revision" : "3ef6999c73b6938cc0da422f2c912d0158abb0a0", + "version" : "2.2.0" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git", "state" : { - "revision" : "0139c665ebb45e6a9fbdb68aabfd7c39f3fe0071", - "version" : "2.2.2" + "revision" : "2ef56b2caf25f55fa7eef8784c30d5a767550f54", + "version" : "2.2.1" } }, { @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/SDWebImage.git", "state" : { - "revision" : "36e79ba485e9bb4d3cd4e3318908866dac5e7b51", - "version" : "5.21.5" + "revision" : "2053b120767c42a70bcba21095f34e4cfb54a75d", + "version" : "5.21.3" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/SDWebImageWebPCoder.git", "state" : { - "revision" : "12d83edbcc795fb7b5c0c3cb74d739108d3357d2", - "version" : "0.15.0" + "revision" : "f534cfe830a7807ecc3d0332127a502426cfa067", + "version" : "0.14.6" } }, { @@ -150,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log", "state" : { - "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", - "version" : "1.8.0" + "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537", + "version" : "1.6.1" } }, { diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index e29a99e6da..940f862cfd 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 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 e0666d1e93..d280b6381d 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: SessionListScreenContent.TextInfo( + "(\(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: SessionListScreenContent.TextInfo( + 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: SessionListScreenContent.TextInfo( + 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: SessionListScreenContent.TextInfo( + "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: SessionListScreenContent.TextInfo( + (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: SessionListScreenContent.TextInfo( + "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: SessionListScreenContent.TextInfo( + "disappearingMessages".localized(), + font: .Headings.H8 + ), + description: SessionListScreenContent.TextInfo( + { + 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: SessionListScreenContent.TextInfo( + (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: SessionListScreenContent.TextInfo( + "sessionNotifications".localized(), + font: .Headings.H8 + ), + description: SessionListScreenContent.TextInfo( + { + 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: SessionListScreenContent.TextInfo( + "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: SessionListScreenContent.TextInfo( + "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: SessionListScreenContent.TextInfo( + "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: SessionListScreenContent.TextInfo( + "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: SessionListScreenContent.TextInfo( + "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: SessionListScreenContent.TextInfo( + "disappearingMessages".localized(), + font: .Headings.H8 + ), + description: SessionListScreenContent.TextInfo( + { + 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: SessionListScreenContent.TextInfo( + ( + 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: SessionListScreenContent.TextInfo( + 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: SessionListScreenContent.TextInfo( + "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 // Don't update the group if the selected option is `Clear on this device` if selectedIndex != 0 { - self?.deleteAllMessagesBeforeNow() + viewModel.deleteAllMessagesBeforeNow() } } @@ -1013,13 +1170,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(), @@ -1031,7 +1188,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi case .success: DispatchQueue.main.async { modal.dismiss(animated: true) { - self?.showToast( + viewModel.showToast( text: "deleteMessageDeleted" .putNumber(0) .localized(), @@ -1048,11 +1205,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: SessionListScreenContent.TextInfo( + "communityLeave".localized(), + font: .Headings.H8, + color: .danger + ) + ) + ), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).leave_community", label: "Leave Community" @@ -1068,8 +1235,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, @@ -1085,11 +1252,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: SessionListScreenContent.TextInfo( + currentUserIsClosedGroupAdmin ? "groupDelete".localized() : "groupLeave".localized(), + font: .Headings.H8, + color: .danger + ) + ) + ), accessibility: Accessibility( identifier: "Leave group", label: "Leave group" @@ -1112,8 +1289,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, @@ -1128,12 +1305,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: SessionListScreenContent.TextInfo( + "conversationsDelete".localized(), + font: .Headings.H8, + color: .danger + ) + ) + ), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).delete_conversation", label: "Delete Conversation" @@ -1149,8 +1336,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, @@ -1165,14 +1352,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: SessionListScreenContent.TextInfo( + "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" @@ -1189,8 +1384,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, @@ -1206,17 +1401,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: SessionListScreenContent.TextInfo( + "[DEBUG] Delete all arrachments before now", // stringlint:disable + font: .Headings.H8, + color: .danger + ) + ) ), confirmationInfo: ConfirmationModal.Info( title: "delete".localized(), @@ -1225,7 +1425,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self] in self?.deleteAllAttachmentsBeforeNow() } + onTap: { viewModel.deleteAllAttachmentsBeforeNow() } ) ) ].compactMap { $0 } @@ -1792,7 +1992,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi identifier: "Upload", label: "Upload" ), - dataManager: dependencies[singleton: .imageDataManager], + dataManager: self.imageDataManager, onProBageTapped: nil, // FIXME: Need to add Group Pro display pic CTA onClick: { [weak self] onDisplayPictureSelected in self?.onDisplayPictureSelected = { source, cropRect in @@ -1984,8 +2184,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi let existingFilePath: String = try? dependencies[singleton: .displayPictureManager] .path(for: existingDownloadUrl) { - Task { [dependencies] in - await dependencies[singleton: .imageDataManager].removeImage( + Task { [weak self, dependencies] in + await self?.imageDataManager.removeImage( identifier: existingFilePath ) try? dependencies[singleton: .fileManager].removeItem(atPath: existingFilePath) @@ -2019,11 +2219,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } private func toggleConversationPinnedStatus(currentPinnedPriority: Int32) { - let isCurrentlyPinned: Bool = (currentPinnedPriority > LibSession.visiblePriority) + let isCurrentlyPinned: Bool = (currentPinnedPriority > Int32(LibSession.visiblePriority)) if !isCurrentlyPinned && dependencies[feature: .sessionProEnabled] && !dependencies[cache: .libSession].isSessionPro { // TODO: [Database Relocation] Retrieve the full conversation list from lib session and check the pinnedPriority that way instead of using the database - dependencies[singleton: .storage].writeAsync ( + dependencies[singleton: .storage].writeAsync( updates: { [threadId, dependencies] db in let numPinnedConversations: Int = try SessionThread .filter(SessionThread.Columns.pinnedPriority > LibSession.visiblePriority) @@ -2044,7 +2244,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi return -1 }, - completion: { [weak self, dependencies] result in + completion: { [weak self, imageDataManager, dependencies] result in guard let numPinnedConversations: Int = try? result.successOrThrow(), numPinnedConversations > 0 @@ -2057,7 +2257,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi isGrandfathered: (numPinnedConversations > LibSession.PinnedConversationLimit), renew: dependencies[singleton: .sessionProState].isSessionProExpired ), - dataManager: dependencies[singleton: .imageDataManager], + dataManager: imageDataManager, onConfirm: { [dependencies] in Task { await dependencies[singleton: .sessionProState].upgradeToPro( @@ -2082,7 +2282,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi db, threadId: threadId, isVisible: true, - customPriority: (currentPinnedPriority <= LibSession.visiblePriority ? 1 : LibSession.visiblePriority), + customPriority: (currentPinnedPriority <= Int32(LibSession.visiblePriority) ? 1 : Int32(LibSession.visiblePriority)), using: dependencies ) } @@ -2186,3 +2386,4 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi self.transitionToScreen(viewController, transitionType: .present) } } + diff --git a/Session/Shared/SessionListHostingViewController.swift b/Session/Shared/SessionListHostingViewController.swift index 123fd2a0eb..cecf78a1e2 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 @@ -17,7 +18,9 @@ class SessionListHostingViewController: SessionHostingViewController< ) { 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 65d8b0e931..3f2db2214c 100644 --- a/Session/Shared/SessionTableViewController.swift +++ b/Session/Shared/SessionTableViewController.swift @@ -585,7 +585,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 e1181e0365..8c4b6d34e1 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/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/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..36f85a192a 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift @@ -13,6 +13,7 @@ public extension SessionListScreenContent { protocol ViewModelType: ObservableObject, SectionedListItemData { var title: String { get } var state: ListItemDataState { get } + var imageDataManager: ImageDataManagerType { get } } struct TooltipInfo: Hashable, Equatable { @@ -36,10 +37,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 +63,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 +73,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 +95,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,8 +106,8 @@ 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 ) } } 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..6f2d278205 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift @@ -25,49 +25,78 @@ 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) { + VStack(alignment: .center, 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 case .trailing = info.title?.alignment { Spacer(minLength: 0) } + if case .center = info.title?.alignment { Spacer(minLength: 0) } if let text = title.text { - Text(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() + .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() + .fixedSize(horizontal: false, vertical: true) + .textSelection(title.interaction == .copy) } - if case .proBadgeTrailing(let themeBackgroundColor) = title.accessory { - SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) - } + 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 case .trailing = info.description?.alignment { Spacer(minLength: 0) } + if case .center = info.description?.alignment { Spacer(minLength: 0) } if let text = description.text { Text(text) @@ -85,29 +114,30 @@ public struct ListItemCell: View { .fixedSize(horizontal: false, vertical: true) } - if case .proBadgeTrailing(let themeBackgroundColor) = description.accessory { - SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) - } + if case .center = info.description?.alignment { Spacer(minLength: 0) } + if case .leading = info.description?.alignment { Spacer(minLength: 0) } } } } .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..0a15918dd1 --- /dev/null +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemProfilePicture.swift @@ -0,0 +1,170 @@ +// 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? + + public init(sessionId: String?, qrCodeImage: UIImage?, profileInfo: ProfilePictureView.Info?) { + self.sessionId = sessionId + self.qrCodeImage = qrCodeImage + self.profileInfo = profileInfo + } + } + + 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 + + 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 { + withAnimation(.easeInOut(duration: 0.1)) { + self.isProfileImageExpanding.toggle() + } + } + } + .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..a7aa23b600 --- /dev/null +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemTappableText.swift @@ -0,0 +1,102 @@ +// 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 + + public var body: some View { + AttributedLabel( + info.makeAttributedString(), + alignment: .center, + maxWidth: (UIScreen.main.bounds.width - Values.mediumSpacing * 2 - Values.largeSpacing * 2), + onTextTap: info.onTextTap, + onImageTap: info.onImageTap + ) + .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..3f358de60a 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) } @@ -144,24 +169,29 @@ 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 }