From 10be4b23e39dce4a28bfb78340574e02d0139fcc Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Thu, 27 Nov 2025 09:20:05 +1100 Subject: [PATCH 01/21] wip: refactor ThreadSettingsViewModel for SessionListScreen --- .../Settings/ThreadSettingsViewModel.swift | 141 +++++++++++++----- .../DisappearingMessageConfiguration.swift | 2 +- .../SessionListScreen+Section.swift | 15 +- 3 files changed, 114 insertions(+), 44 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 48380ea00e..e5032835a6 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -13,11 +13,14 @@ 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() + + /// 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 +35,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 +48,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 +75,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi case edit } - public enum Section: SessionTableSection { + public enum Section: SessionListScreenContent.ListSection { case conversationInfo case sessionId case sessionIdNoteToSelf @@ -80,7 +92,7 @@ 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 @@ -88,9 +100,13 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi default: return .none } } + + public var divider: Bool { return true } + + public var footer: String? { return nil } } - public enum TableItem: Differentiable { + public enum ListItem: Differentiable { case avatar case qrCode case displayName @@ -121,9 +137,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 +180,36 @@ 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) + ] + + return result + } + + static func initialState(threadId: String) -> ViewModelState { + return ViewModelState( + threadId: threadId, + threadViewModel: nil, + disappearingMessagesConfig: DisappearingMessagesConfiguration.defaultWith(threadId) + ) + } } var title: String { @@ -176,35 +219,53 @@ 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 { + + default: break + } } + + + 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 +294,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 diff --git a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift index 10e9faf495..a65a1194f4 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) diff --git a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Section.swift b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Section.swift index fa99d183ff..a2888f7ec6 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Section.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Section.swift @@ -15,22 +15,31 @@ public extension SessionListScreenContent { case none case titleWithTooltips(info: TooltipInfo) case titleNoBackgroundContent + case titleSeparator + case padding + case titleRoundedContent var height: CGFloat { switch self { case .none: return 0 - case .titleWithTooltips, .titleNoBackgroundContent: + case .titleWithTooltips, .titleNoBackgroundContent, .titleRoundedContent: return 44 + case .titleSeparator: + return Separator.height + case .padding: + return Values.smallSpacing } } var edgePadding: CGFloat { switch self { - case .none: + case .none, .padding: return 0 - case .titleWithTooltips, .titleNoBackgroundContent: + case .titleWithTooltips, .titleNoBackgroundContent, .titleRoundedContent: return (Values.largeSpacing + Values.mediumSpacing) + case .titleSeparator: + return Values.largeSpacing } } } From 53eeed40cb150c761ca90be709dcf7af832c0705 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 1 Dec 2025 17:07:17 +1100 Subject: [PATCH 02/21] wip: refactor convo setting screen --- Session.xcodeproj/project.pbxproj | 4 + ...isappearingMessagesSettingsViewModel.swift | 3 +- .../Settings/ThreadSettingsViewModel.swift | 279 ++++++++---------- .../SessionListHostingViewController.swift | 9 +- .../DisappearingMessageConfiguration.swift | 2 +- .../Config Handling/LibSession+Contacts.swift | 5 + .../LibSession+GroupInfo.swift | 4 + .../ObservableKey+SessionMessagingKit.swift | 7 + .../Components/ProfilePictureView.swift | 12 +- .../SessionListScreen+ListItem.swift | 1 + .../SessionListScreen+ListItemCell.swift | 11 +- ...ionListScreen+ListItemProfilePicture.swift | 165 +++++++++++ .../SessionListScreen/SessionListScreen.swift | 69 +++-- SessionUIKit/Style Guide/Themes/Theme.swift | 2 +- 14 files changed, 392 insertions(+), 181 deletions(-) create mode 100644 SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemProfilePicture.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 3e0be7fe3e..8f263a9979 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -207,6 +207,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 */; }; 9479981C2DD44ADC008F5CD5 /* ThreadNotificationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9479981B2DD44AC5008F5CD5 /* ThreadNotificationSettingsViewModel.swift */; }; @@ -1664,6 +1665,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 = ""; }; 9479981B2DD44AC5008F5CD5 /* ThreadNotificationSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadNotificationSettingsViewModel.swift; sourceTree = ""; }; @@ -3070,6 +3072,7 @@ 943B43502EC2AF3D008ABC34 /* ListItemViews */ = { isa = PBXGroup; children = ( + 946A495E2ED956EC005A6CF2 /* SessionListScreen+ListItemProfilePicture.swift */, 943B43572EC2AFCF008ABC34 /* SessionListScreen+ListItemButton.swift */, 943B43552EC2AFAB008ABC34 /* SessionListScreen+ListItemDataMatrix.swift */, 943B43532EC2AF82008ABC34 /* SessionListScreen+ListItemLogoWithPro.swift */, @@ -6591,6 +6594,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 */, FDE754BA2C9B97B8002A2623 /* UIDevice+Utilities.swift in Sources */, C331FFB92558FA8D00070591 /* UIView+Constraints.swift in Sources */, 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 e5032835a6..69d9db64bf 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -108,7 +108,6 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio public enum ListItem: Differentiable { case avatar - case qrCode case displayName case contactName case threadDescription @@ -197,7 +196,8 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio .updateScreen(ThreadSettingsViewModel.self), .profile(threadId), .contact(threadId), - .conversationUpdated(threadId) + .conversationUpdated(threadId), + .disappearingMessagesConfigUpdated(threadId) ] return result @@ -244,13 +244,20 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio /// Process any event changes events.forEach { event in - switch event.key { - - default: break + switch event.key.generic { + case .disappearingMessagesConfigUpdate: + if threadId == id, 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, @@ -302,105 +309,62 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio 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 let sessionId: String = threadViewModel.id else { return nil } + return QRCode.generate( + for: sessionId, + 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 ( - .themedKey( - SessionProBadge.Size.medium.cacheKey, - themeBackgroundColor: .primary - ), - { SessionProBadge(size: .medium) } + variant: .cell( + info: .init( + title: .init( + threadViewModel.displayName, + font: .Headings.H4, + alignment: .center, + accessory: { + guard !threadViewModel.threadIsNoteToSelf else { return nil } + guard (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }) else { return nil } + + return ( + .themedKey( + SessionProBadge.Size.medium.cacheKey, + themeBackgroundColor: .primary + ), + { SessionProBadge(size: .medium) } + ) + }() ) - }() - ), - 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 } - - return Values.largeSpacing - }(), - interItem: 0 - ), - backgroundStyle: .noBackground + ) ), accessibility: Accessibility( identifier: "Username", label: threadViewModel.displayName ), - onTapView: { [weak self, threadId, dependencies] targetView in + onTap: { [threadId, dependencies = viewModel.dependencies] target in guard targetView is SessionProBadge, !dependencies[cache: .libSession].isSessionPro else { guard let info: ConfirmationModal.Info = self?.updateDisplayNameModal( @@ -435,47 +399,41 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio proCTAModalVariant, onConfirm: {}, presenting: { modal in - self?.transitionToScreen(modal, transitionType: .present) + viewModel.transitionToScreen(modal, transitionType: .present) } ) } ), (threadViewModel.displayName == threadViewModel.contactDisplayName ? nil : - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .contactName, - subtitle: SessionCell.TextInfo( - "(\(threadViewModel.contactDisplayName))", // stringlint:ignore - font: .subtitle, - alignment: .center + variant: .cell( + info: .init( + title: .init( + "(\(threadViewModel.contactDisplayName))", // stringlint:ignore + font: .Body.baseRegular, + alignment: .center, + color: .textSecondary + ) + ) ), - styling: SessionCell.StyleInfo( - tintColor: .textSecondary, - customPadding: SessionCell.Padding( - top: 0, - bottom: Values.largeSpacing - ), - backgroundStyle: .noBackground - ) ) ), threadViewModel.threadDescription.map { threadDescription in - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .threadDescription, - description: SessionCell.TextInfo( - threadDescription, - font: .subtitle, - alignment: .center, - interaction: .expandable - ), - styling: SessionCell.StyleInfo( - tintColor: .textSecondary, - customPadding: SessionCell.Padding( - top: 0, - bottom: (threadViewModel.threadVariant != .contact ? Values.largeSpacing : nil) - ), - backgroundStyle: .noBackground + variant: .cell( + info: .init( + title: .init( + threadDescription, + font: .Body.baseRegular, + alignment: .center, + color: .textSecondary, + interaction: .expandable + ) + ) ), accessibility: Accessibility( identifier: "Description", @@ -491,17 +449,17 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio let sessionIdSection: SectionModel = SectionModel( model: (threadViewModel.threadIsNoteToSelf == true ? .sessionIdNoteToSelf : .sessionId), elements: [ - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .sessionId, - subtitle: SessionCell.TextInfo( - threadViewModel.id, - font: .monoLarge, - alignment: .center, - interaction: .copy - ), - styling: SessionCell.StyleInfo( - customPadding: SessionCell.Padding(bottom: Values.smallSpacing), - backgroundStyle: .noBackground + variant: .cell( + info: .init( + title: .init( + threadViewModel.id, + font: .Display.extraLarge, + alignment: .center, + interaction: .copy + ) + ) ), accessibility: Accessibility( identifier: "Session ID", @@ -519,11 +477,18 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio 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), + title: .init( + "groupDelete".localized(), + font: .Headings.H8, + color: .danger + ) + ) + ), accessibility: Accessibility( identifier: "Leave group", label: "Leave group" @@ -539,8 +504,8 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio 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, @@ -564,18 +529,25 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio model: .content, elements: [ (threadViewModel.threadVariant == .legacyGroup || threadViewModel.threadVariant == .group ? nil : - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .copyThreadId, - leadingAccessory: .icon(.copy), - title: (threadViewModel.threadVariant == .community ? - "communityUrlCopy".localized() : - "accountIDCopy".localized() + variant: .cell( + info: .init( + leadingAccessory: .icon(.copy), + title: .init( + (threadViewModel.threadVariant == .community ? + "communityUrlCopy".localized() : + "accountIDCopy".localized() + ), + font: .Headings.H8 + ) + ) ), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).copy_thread_id", label: "Copy Session ID" ), - onTap: { [weak self] in + onTap: { switch threadViewModel.threadVariant { case .contact, .legacyGroup, .group: UIPasteboard.general.string = threadViewModel.threadId @@ -592,7 +564,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio UIPasteboard.general.string = urlString } - self?.showToast( + viewModel.showToast( text: "copied".localized(), backgroundColor: .backgroundSecondary ) @@ -600,15 +572,22 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio ) ), - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .searchConversation, - leadingAccessory: .icon(.search), - title: "searchConversation".localized(), + variant: .cell( + info: .init( + leadingAccessory: .icon(.search), + title: .init( + "searchConversation".localized(), + font: .Headings.H8 + ) + ) + ), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).search", label: "Search" ), - onTap: { [weak self] in self?.didTriggerSearch() } + onTap: { viewModel.didTriggerSearch() } ), ( diff --git a/Session/Shared/SessionListHostingViewController.swift b/Session/Shared/SessionListHostingViewController.swift index 123fd2a0eb..dd1b7a4296 100644 --- a/Session/Shared/SessionListHostingViewController.swift +++ b/Session/Shared/SessionListHostingViewController.swift @@ -3,6 +3,7 @@ import SwiftUI import Combine import SessionUIKit +import SessionUtilitiesKit class SessionListHostingViewController: SessionHostingViewController> where ViewModel: SessionListScreenContent.ViewModelType { private let viewModel: ViewModel @@ -13,11 +14,15 @@ class SessionListHostingViewController: SessionHostingViewController< init( viewModel: ViewModel, customizedNavigationBackground: ThemeValue? = nil, - shouldHideNavigationBar: Bool = false + shouldHideNavigationBar: Bool = false, + using dependencies: Dependencies ) { self.viewModel = viewModel super.init( - rootView: SessionListScreen(viewModel: viewModel), + rootView: SessionListScreen( + viewModel: viewModel, + dataManager: dependencies[singleton: .imageDataManager] + ), customizedNavigationBackground: customizedNavigationBackground, shouldHideNavigationBar: shouldHideNavigationBar ) diff --git a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift index a65a1194f4..eac0537c48 100644 --- a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift +++ b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift @@ -37,7 +37,7 @@ public struct DisappearingMessagesConfiguration: Codable, Identifiable, Sendable } } - 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/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/SessionUIKit/Components/ProfilePictureView.swift b/SessionUIKit/Components/ProfilePictureView.swift index e444fb41ad..0908ddde4c 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/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift index 365d7c1cba..503149e82a 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift @@ -28,6 +28,7 @@ public extension SessionListScreenContent { case logoWithPro(info: ListItemLogoWithPro.Info) case dataMatrix(info: [[ListItemDataMatrix.Info]]) case button(title: String) + case profilePicture(info: ListItemProfilePicture.Info) } let id: ID diff --git a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift index 381017ac87..91d7d8381a 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift @@ -34,9 +34,11 @@ public struct ListItemCell: View { leadingAccessory.accessoryView() } - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .center, spacing: 0) { if let title = info.title { HStack(spacing: Values.verySmallSpacing) { + if case .trailing = info.title?.alignment { Spacer() } + if case .proBadgeLeading(let themeBackgroundColor) = title.accessory { SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) } @@ -60,11 +62,15 @@ public struct ListItemCell: View { if case .proBadgeTrailing(let themeBackgroundColor) = title.accessory { SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) } + + if case .leading = info.title?.alignment { Spacer() } } } if let description = info.description { HStack(spacing: Values.verySmallSpacing) { + if case .trailing = info.description?.alignment { Spacer() } + if case .proBadgeLeading(let themeBackgroundColor) = description.accessory { SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) } @@ -88,6 +94,8 @@ public struct ListItemCell: View { if case .proBadgeTrailing(let themeBackgroundColor) = description.accessory { SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) } + + if case .leading = info.description?.alignment { Spacer() } } } } @@ -98,7 +106,6 @@ public struct ListItemCell: View { ) if let trailingAccessory = info.trailingAccessory { - Spacer(minLength: 0) trailingAccessory.accessoryView() } } diff --git a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemProfilePicture.swift b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemProfilePicture.swift new file mode 100644 index 0000000000..c1b2b6bf24 --- /dev/null +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemProfilePicture.swift @@ -0,0 +1,165 @@ +// 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 + switch content { + case .profilePicture: + 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.sessionId != 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(.top, 12) + .padding(.vertical, 5) + .padding(.horizontal, 10) + case .qrCode: + 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) + .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 + } + } + } + } + .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/SessionListScreen.swift b/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift index 5aecac0e4c..11a4780884 100644 --- a/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift +++ b/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift @@ -6,6 +6,9 @@ public struct SessionListScreen + + // MARK: - Tooltips variables + @State var isShowingTooltip: Bool = false @State var tooltipContent: ThemedAttributedString = ThemedAttributedString() @State var tooltipViewId: String = "" @@ -16,11 +19,18 @@ public struct SessionListScreen= suppressUntil else { return } - suppressUntil = Date().addingTimeInterval(0.2) - guard tooltipViewId != info.id && !isShowingTooltip else { + switch section.model.style { + case .titleWithTooltips(let info): + 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 = false + isShowingTooltip = true } - return } - tooltipContent = info.content - tooltipPosition = info.position - tooltipViewId = info.id - tooltipArrowOffset = 30 - withAnimation { - isShowingTooltip = true - } - } + case .titleSeparator: + Seperator_SwiftUI(title: "accountId".localized()) + default: + EmptyView() } } .listRowBackground(Color.clear) @@ -119,6 +134,14 @@ public struct SessionListScreen Date: Tue, 2 Dec 2025 14:15:49 +1100 Subject: [PATCH 03/21] feat: refactor ThreadSettingsViewModel --- .../Settings/ThreadSettingsViewModel.swift | 492 +++++++++++------- .../SessionListScreen+ListItem.swift | 10 +- .../SessionListScreen+Models.swift | 14 +- 3 files changed, 339 insertions(+), 177 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 69d9db64bf..95a38a7bfb 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -246,7 +246,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio events.forEach { event in switch event.key.generic { case .disappearingMessagesConfigUpdate: - if threadId == id, let newValue = event.value as? DisappearingMessagesConfiguration { + if let newValue = event.value as? DisappearingMessagesConfiguration { disappearingMessagesConfig = newValue } default: @@ -315,9 +315,9 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio info: .init( sessionId: threadViewModel.id, qrCodeImage: { - guard let sessionId: String = threadViewModel.id else { return nil } + guard threadViewModel.threadVariant != .group else { return nil } return QRCode.generate( - for: sessionId, + for: threadViewModel.threadId, hasBackground: false, iconName: "SessionWhite40" // stringlint:ignore ) @@ -345,9 +345,9 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio threadViewModel.displayName, font: .Headings.H4, alignment: .center, - accessory: { + trailingImage: { guard !threadViewModel.threadIsNoteToSelf else { return nil } - guard (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }) else { return nil } + guard (viewModel.dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: viewModel.threadId) }) else { return nil } return ( .themedKey( @@ -364,16 +364,16 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio identifier: "Username", label: threadViewModel.displayName ), - onTap: { [threadId, dependencies = viewModel.dependencies] target in - guard targetView is SessionProBadge, !dependencies[cache: .libSession].isSessionPro else { + onTap: { [dependencies = viewModel.dependencies] target in + guard case .proBadge = target, !dependencies[cache: .libSession].isSessionPro else { guard - let info: ConfirmationModal.Info = self?.updateDisplayNameModal( + let info: ConfirmationModal.Info = viewModel.updateDisplayNameModal( threadViewModel: threadViewModel, currentUserIsClosedGroupAdmin: currentUserIsClosedGroupAdmin ) else { return } - self?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present) + viewModel.transitionToScreen(ConfirmationModal(info: info), transitionType: .present) return } @@ -382,7 +382,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio case .group: return .groupLimit( isAdmin: currentUserIsClosedGroupAdmin, - isSessionProActivated: (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }), + isSessionProActivated: (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadViewModel.threadId) }), proBadgeImage: UIView.image( for: .themedKey( SessionProBadge.Size.mini.cacheKey, @@ -504,7 +504,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [dependencies = viewModel.dependencies] in + onTap: { [dependencies = viewModel.dependencies] _ in viewModel.dismissScreen(type: .popToRoot) { dependencies[singleton: .storage].writeAsync { db in try SessionThread.deleteOrLeave( @@ -547,7 +547,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio identifier: "\(ThreadSettingsViewModel.self).copy_thread_id", label: "Copy Session ID" ), - onTap: { + onTap: { _ in switch threadViewModel.threadVariant { case .contact, .legacyGroup, .group: UIPasteboard.general.string = threadViewModel.threadId @@ -587,33 +587,44 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio identifier: "\(ThreadSettingsViewModel.self).search", label: "Search" ), - onTap: { viewModel.didTriggerSearch() } + onTap: { _ in viewModel.didTriggerSearch() } ), ( threadViewModel.threadVariant == .community || threadViewModel.threadIsBlocked == true || currentUserIsClosedGroupAdmin ? nil : - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .disappearingMessages, - leadingAccessory: .icon(.timer), - title: "disappearingMessages".localized(), - subtitle: { - guard current.disappearingMessagesConfig.isEnabled else { - return "off".localized() - } - - return (current.disappearingMessagesConfig.type ?? .unknown) - .localizedState( - durationString: current.disappearingMessagesConfig.durationString + variant: .cell( + info: .init( + leadingAccessory: .icon(.timer), + title: .init( + "disappearingMessages".localized(), + font: .Headings.H8 + ), + description: .init( + { + guard current.disappearingMessagesConfig.isEnabled else { + return "off".localized() + } + + return (current.disappearingMessagesConfig.type ?? .unknown) + .localizedState( + durationString: current.disappearingMessagesConfig.durationString + ) + }(), + font: .Body.smallRegular, + color: .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, @@ -630,24 +641,28 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio ), (threadViewModel.threadIsBlocked == true ? nil : - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .pinConversation, - leadingAccessory: .icon( - (threadViewModel.threadPinnedPriority > 0 ? - .pinOff : - .pin + variant: .cell( + info: .init( + leadingAccessory: .icon( + (threadViewModel.threadPinnedPriority > 0 ? + .pinOff : + .pin + ) + ), + title: .init( + (threadViewModel.threadPinnedPriority > 0 ? "pinUnpinConversation".localized() : "pinConversation".localized()), + font: .Headings.H8 + ) ) ), - title: (threadViewModel.threadPinnedPriority > 0 ? - "pinUnpinConversation".localized() : - "pinConversation".localized() - ), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).pin_conversation", label: "Pin Conversation" ), - onTap: { [weak self] in - self?.toggleConversationPinnedStatus( + onTap: { _ in + viewModel.toggleConversationPinnedStatus( currentPinnedPriority: threadViewModel.threadPinnedPriority ) } @@ -655,39 +670,50 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio ), ((threadViewModel.threadIsNoteToSelf == true || threadViewModel.threadIsBlocked == true) ? nil : - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .notifications, - leadingAccessory: .icon( - { - if threadViewModel.threadOnlyNotifyForMentions == true { - return .atSign - } - - if threadViewModel.threadMutedUntilTimestamp != nil { - return .volumeOff - } - - return .volume2 - }() + variant: .cell( + info: .init( + leadingAccessory: .icon( + { + if threadViewModel.threadOnlyNotifyForMentions == true { + return .atSign + } + + if threadViewModel.threadMutedUntilTimestamp != nil { + return .volumeOff + } + + return .volume2 + }() + ), + title: .init( + "sessionNotifications".localized(), + font: .Headings.H8 + ), + description: .init( + { + if threadViewModel.threadOnlyNotifyForMentions == true { + return "notificationsMentionsOnly".localized() + } + + if threadViewModel.threadMutedUntilTimestamp != nil { + return "notificationsMuted".localized() + } + + return "notificationsAllMessages".localized() + }(), + font: .Body.smallRegular, + color: .textSecondary + ) + ) ), - 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, @@ -703,40 +729,61 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio ), (threadViewModel.threadVariant != .community ? nil : - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .addToOpenGroup, - leadingAccessory: .icon(.userRoundPlus), - title: "membersInvite".localized(), + variant: .cell( + info: .init( + leadingAccessory: .icon(.userRoundPlus), + title: .init( + "membersInvite".localized(), + font: .Headings.H8 + ) + ) + ), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).add_to_open_group" ), - onTap: { [weak self] in self?.inviteUsersToCommunity(threadViewModel: threadViewModel) } + onTap: { _ in viewModel.inviteUsersToCommunity(threadViewModel: threadViewModel) } ) ), (!currentUserIsClosedGroupMember ? nil : - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .groupMembers, - leadingAccessory: .icon(.usersRound), - title: "groupMembers".localized(), + variant: .cell( + info: .init( + leadingAccessory: .icon(.usersRound), + title: .init( + "groupMembers".localized(), + font: .Headings.H8 + ) + ) + ), accessibility: Accessibility( identifier: "Group members", label: "Group members" ), - onTap: { [weak self] in self?.viewMembers() } + onTap: { _ in viewModel.viewMembers() } ) ), - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .attachments, - leadingAccessory: .icon(.file), - title: "attachments".localized(), + variant: .cell( + info: .init( + leadingAccessory: .icon(.file), + title: .init( + "attachments".localized(), + font: .Headings.H8 + ) + ) + ), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).all_media", label: "All media" ), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( + onTap: { [dependencies = viewModel.dependencies] _ in + viewModel.transitionToScreen( MediaGalleryViewModel.createAllMediaViewController( threadId: threadViewModel.threadId, threadVariant: threadViewModel.threadVariant, @@ -757,16 +804,23 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio SectionModel( model: .adminActions, elements: [ - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .editGroup, - leadingAccessory: .icon(.userRoundPen), - title: "manageMembers".localized(), + variant: .cell( + info: .init( + leadingAccessory: .icon(.userRoundPen), + title: .init( + "manageMembers".localized(), + font: .Headings.H8 + ) + ) + ), accessibility: Accessibility( identifier: "Edit group", label: "Edit group" ), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( + onTap: { [dependencies = viewModel.dependencies] _ in + viewModel.transitionToScreen( SessionTableViewController( viewModel: EditGroupViewModel( threadId: threadViewModel.threadId, @@ -777,44 +831,60 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio } ), - (!dependencies[feature: .updatedGroupsAllowPromotions] ? nil : - SessionCell.Info( + (!viewModel.dependencies[feature: .updatedGroupsAllowPromotions] ? nil : + SessionListScreenContent.ListItemInfo( id: .promoteAdmins, - leadingAccessory: .icon( - UIImage(named: "table_ic_group_edit")? - .withRenderingMode(.alwaysTemplate) + variant: .cell( + info: .init( + leadingAccessory: .icon( + UIImage(named: "table_ic_group_edit")? + .withRenderingMode(.alwaysTemplate) + ), + title: .init( + "adminPromote".localized(), + font: .Headings.H8 + ) + ) ), - title: "adminPromote".localized(), accessibility: Accessibility( identifier: "Promote admins", label: "Promote admins" ), - onTap: { [weak self] in - self?.promoteAdmins(currentGroupName: threadViewModel.closedGroupName) - } + onTap: { _ in viewModel.promoteAdmins(currentGroupName: threadViewModel.closedGroupName) } ) ), - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .disappearingMessages, - leadingAccessory: .icon(.timer), - title: "disappearingMessages".localized(), - subtitle: { - guard current.disappearingMessagesConfig.isEnabled else { - return "off".localized() - } - - return (current.disappearingMessagesConfig.type ?? .unknown) - .localizedState( - durationString: current.disappearingMessagesConfig.durationString + variant: .cell( + info: .init( + leadingAccessory: .icon(.timer), + title: .init( + "disappearingMessages".localized(), + font: .Headings.H8 + ), + description: .init( + { + guard current.disappearingMessagesConfig.isEnabled else { + return "off".localized() + } + + return (current.disappearingMessagesConfig.type ?? .unknown) + .localizedState( + durationString: current.disappearingMessagesConfig.durationString + ) + }(), + font: .Body.smallRegular, + color: .textSecondary ) - }(), + ) + ), accessibility: Accessibility( identifier: "Disappearing messages", label: "\(ThreadSettingsViewModel.self).disappearing_messages" ), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( + onTap: { [dependencies = viewModel.dependencies] _ in + viewModel.transitionToScreen( SessionTableViewController( viewModel: ThreadDisappearingMessagesSettingsViewModel( threadId: threadViewModel.threadId, @@ -838,19 +908,32 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio model: .destructiveActions, elements: [ (threadViewModel.threadIsNoteToSelf || threadViewModel.threadVariant != .contact ? nil : - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .blockUser, - leadingAccessory: ( - threadViewModel.threadIsBlocked == true ? - .icon(.userRoundCheck) : - .icon(UIImage(named: "ic_user_round_ban")?.withRenderingMode(.alwaysTemplate)) - ), - title: ( - threadViewModel.threadIsBlocked == true ? - "blockUnblock".localized() : - "block".localized() + variant: .cell( + info: .init( + leadingAccessory: ( + threadViewModel.threadIsBlocked == true ? + .icon( + .userRoundCheck, + customTint: .danger + ) : + .icon( + UIImage(named: "ic_user_round_ban")?.withRenderingMode(.alwaysTemplate), + customTint: .danger + ) + ), + title: .init( + ( + threadViewModel.threadIsBlocked == true ? + "blockUnblock".localized() : + "block".localized() + ), + font: .Headings.H8, + color: .danger + ) + ) ), - styling: SessionCell.StyleInfo(tintColor: .danger), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).block", label: "Block" @@ -879,10 +962,10 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self] in + onTap: { _ in let isBlocked: Bool = (threadViewModel.threadIsBlocked == true) - self?.updateBlockedState( + viewModel.updateBlockedState( from: isBlocked, isBlocked: !isBlocked, threadId: threadViewModel.threadId, @@ -893,11 +976,21 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio ), (threadViewModel.threadIsNoteToSelf != true ? nil : - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .hideNoteToSelf, - leadingAccessory: .icon(isThreadHidden ? .eye : .eyeOff), - title: isThreadHidden ? "showNoteToSelf".localized() : "noteToSelfHide".localized(), - styling: SessionCell.StyleInfo(tintColor: isThreadHidden ? .textPrimary : .danger), + variant: .cell( + info: .init( + leadingAccessory: .icon( + isThreadHidden ? .eye : .eyeOff, + customTint: isThreadHidden ? .textPrimary : .danger + ), + title: .init( + isThreadHidden ? "showNoteToSelf".localized() : "noteToSelfHide".localized(), + font: .Headings.H8, + color: isThreadHidden ? .textPrimary : .danger + ) + ) + ), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).hide_note_to_self", label: "Hide Note to Self" @@ -915,7 +1008,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio 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( @@ -938,13 +1031,21 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio ) ), - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .clearAllMessages, - leadingAccessory: .icon( - UIImage(named: "ic_message_trash")?.withRenderingMode(.alwaysTemplate) + variant: .cell( + info: .init( + leadingAccessory: .icon( + UIImage(named: "ic_message_trash")?.withRenderingMode(.alwaysTemplate), + customTint: .danger + ), + title: .init( + "clearMessages".localized(), + font: .Headings.H8, + color: .danger + ) + ) ), - title: "clearMessages".localized(), - styling: SessionCell.StyleInfo(tintColor: .danger), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).clear_all_messages", label: "Clear All Messages" @@ -958,7 +1059,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) } - switch threadVariant { + switch threadViewModel.threadVariant { case .contact: return .attributedText( "clearMessagesChatDescriptionUpdated" @@ -1018,8 +1119,8 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio 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 { @@ -1036,7 +1137,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio // Return if the selected option is `Clear on this device` guard selectedIndex != 0 else { return } - self?.deleteAllMessagesBeforeNow() + viewModel.deleteAllMessagesBeforeNow() } dependencies[singleton: .storage].writeAsync( updates: { db in @@ -1047,13 +1148,13 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio 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(), @@ -1065,7 +1166,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio case .success: DispatchQueue.main.async { modal.dismiss(animated: true) { - self?.showToast( + viewModel.showToast( text: "deleteMessageDeleted" .putNumber(0) .localized(), @@ -1082,11 +1183,21 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio ), (threadViewModel.threadVariant != .community ? nil : - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .leaveCommunity, - leadingAccessory: .icon(.logOut), - title: "communityLeave".localized(), - styling: SessionCell.StyleInfo(tintColor: .danger), + variant: .cell( + info: .init( + leadingAccessory: .icon( + .logOut, + customTint: .danger + ), + title: .init( + "communityLeave".localized(), + font: .Headings.H8, + color: .danger + ) + ) + ), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).leave_community", label: "Leave Community" @@ -1102,8 +1213,8 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio 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, @@ -1119,11 +1230,21 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio ), (!currentUserIsClosedGroupMember ? nil : - SessionCell.Info( + SessionListScreenContent.ListItemInfo( id: .leaveGroup, - leadingAccessory: .icon(currentUserIsClosedGroupAdmin ? .trash2 : .logOut), - title: currentUserIsClosedGroupAdmin ? "groupDelete".localized() : "groupLeave".localized(), - styling: SessionCell.StyleInfo(tintColor: .danger), + variant: .cell( + info: .init( + leadingAccessory: .icon( + currentUserIsClosedGroupAdmin ? .trash2 : .logOut, + customTint: .danger + ), + title: .init( + currentUserIsClosedGroupAdmin ? "groupDelete".localized() : "groupLeave".localized(), + font: .Headings.H8, + color: .danger + ) + ) + ), accessibility: Accessibility( identifier: "Leave group", label: "Leave group" @@ -1146,8 +1267,8 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio 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, @@ -1162,12 +1283,22 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio ) ), - (threadVariant != .contact || threadViewModel.threadIsNoteToSelf == true ? nil : - SessionCell.Info( + (threadViewModel.threadVariant != .contact || threadViewModel.threadIsNoteToSelf == true ? nil : + SessionListScreenContent.ListItemInfo( id: .deleteConversation, - leadingAccessory: .icon(.trash2), - title: "conversationsDelete".localized(), - styling: SessionCell.StyleInfo(tintColor: .danger), + variant: .cell( + info: .init( + leadingAccessory: .icon( + .trash2, + customTint: .danger + ), + title: .init( + "conversationsDelete".localized(), + font: .Headings.H8, + color: .danger + ) + ) + ), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).delete_conversation", label: "Delete Conversation" @@ -1183,8 +1314,8 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio 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, @@ -1199,14 +1330,22 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio ) ), - (threadVariant != .contact || threadViewModel.threadIsNoteToSelf == true ? nil : - SessionCell.Info( + (threadViewModel.threadVariant != .contact || threadViewModel.threadIsNoteToSelf == true ? nil : + SessionListScreenContent.ListItemInfo( id: .deleteContact, - leadingAccessory: .icon( - UIImage(named: "ic_user_round_trash")?.withRenderingMode(.alwaysTemplate) + variant: .cell( + info: .init( + leadingAccessory: .icon( + UIImage(named: "ic_user_round_trash")?.withRenderingMode(.alwaysTemplate), + customTint: .danger + ), + title: .init( + "contactDelete".localized(), + font: .Headings.H8, + color: .danger + ) + ) ), - title: "contactDelete".localized(), - styling: SessionCell.StyleInfo(tintColor: .danger), accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).delete_contact", label: "Delete Contact" @@ -1223,8 +1362,8 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio 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, @@ -1240,17 +1379,22 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio ), // FIXME: [GROUPS REBUILD] Need to build this properly in a future release - (!dependencies[feature: .updatedGroupsDeleteAttachmentsBeforeNow] || threadViewModel.threadVariant != .group ? nil : - SessionCell.Info( + (!viewModel.dependencies[feature: .updatedGroupsDeleteAttachmentsBeforeNow] || threadViewModel.threadVariant != .group ? nil : + SessionListScreenContent.ListItemInfo( id: .debugDeleteAttachmentsBeforeNow, - leadingAccessory: .icon( - Lucide.image(icon: .trash2, size: 24)? - .withRenderingMode(.alwaysTemplate), - customTint: .danger - ), - title: "[DEBUG] Delete all arrachments before now", // stringlint:disable - styling: SessionCell.StyleInfo( - tintColor: .danger + variant: .cell( + info: .init( + leadingAccessory: .icon( + Lucide.image(icon: .trash2, size: 24)? + .withRenderingMode(.alwaysTemplate), + customTint: .danger + ), + title: .init( + "[DEBUG] Delete all arrachments before now", // stringlint:disable + font: .Headings.H8, + color: .danger + ) + ) ), confirmationInfo: ConfirmationModal.Info( title: "delete".localized(), @@ -1259,7 +1403,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self] in self?.deleteAllAttachmentsBeforeNow() } + onTap: { _ in viewModel.deleteAllAttachmentsBeforeNow() } ) ) ].compactMap { $0 } diff --git a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift index 503149e82a..6b4a4aa9e4 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift @@ -31,12 +31,18 @@ public extension SessionListScreenContent { case profilePicture(info: ListItemProfilePicture.Info) } + public enum TapTarget: Equatable, Hashable, Differentiable { + case none + case item + case proBadge + } + let id: ID let variant: Variant let isEnabled: Bool let accessibility: Accessibility? let confirmationInfo: ConfirmationModal.Info? - let onTap: (@MainActor () -> Void)? + let onTap: (@MainActor (TapTarget) -> Void)? public init( id: ID, @@ -44,7 +50,7 @@ public extension SessionListScreenContent { isEnabled: Bool = true, accessibility: Accessibility? = nil, confirmationInfo: ConfirmationModal.Info? = nil, - onTap: (@MainActor () -> Void)? = nil + onTap: (@MainActor (TapTarget) -> Void)? = nil ) { self.id = id self.variant = variant diff --git a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift index 19a209bee7..b448c9288c 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift @@ -38,13 +38,21 @@ public extension SessionListScreenContent { case none } + public enum Interaction: Hashable, Equatable { + case none + case copy + case expandable + } + let text: String? let font: Font? let attributedString: ThemedAttributedString? let alignment: TextAlignment let color: ThemeValue let accessory: Accessory + let interaction: Interaction let accessibility: Accessibility? + let trailingImage: (cacheKey: UIView.CachedImageKey, viewGenerator: (() -> UIView))? public init( _ text: String? = nil, @@ -53,7 +61,9 @@ public extension SessionListScreenContent { alignment: TextAlignment = .leading, color: ThemeValue = .textPrimary, accessory: Accessory = .none, - accessibility: Accessibility? = nil + interaction: Interaction = .none, + accessibility: Accessibility? = nil, + trailingImage: (cacheKey: UIView.CachedImageKey, viewGenerator: (() -> UIView))? = nil ) { self.text = text self.font = font @@ -61,7 +71,9 @@ public extension SessionListScreenContent { self.alignment = alignment self.color = color self.accessory = accessory + self.interaction = interaction self.accessibility = accessibility + self.trailingImage = trailingImage } // MARK: - Conformance From 82dc388a4c76f81b697f39c2d8360381b6fc2800 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Thu, 4 Dec 2025 14:55:16 +1100 Subject: [PATCH 04/21] refactor on SessionListScreen --- .../ConversationVC+Interaction.swift | 6 +- .../Settings/ThreadSettingsViewModel.swift | 21 +++- .../SessionProSettingsViewModel.swift | 61 +++++----- Session/Settings/SettingsViewModel.swift | 3 +- .../SwiftUI/Seperator+SwiftUI.swift | 8 +- .../SessionListScreen+Section.swift | 27 ++++- .../SessionListScreen+ListItemCell.swift | 8 +- .../SessionListScreen/SessionListScreen.swift | 111 ++++++++++-------- 8 files changed, 150 insertions(+), 95 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index ac855e16f8..69a1e27689 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -92,7 +92,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 @@ -103,7 +104,8 @@ extension ConversationVC: } }, using: self.viewModel.dependencies - ) + ), + using: self.viewModel.dependencies ) navigationController?.pushViewController(viewController, animated: true) } diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 95a38a7bfb..7fbe3542c7 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -95,15 +95,21 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio 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 { return true } + 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 ListItem: Differentiable { @@ -481,7 +487,10 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio id: .leaveGroup, variant: .cell( info: .init( - leadingAccessory: .icon(.trash2), + leadingAccessory: .icon( + .trash2, + customTint: .danger + ), title: .init( "groupDelete".localized(), font: .Headings.H8, @@ -615,7 +624,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio ) }(), font: .Body.smallRegular, - color: .textSecondary + color: .textPrimary ) ) ), @@ -704,7 +713,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio return "notificationsAllMessages".localized() }(), font: .Body.smallRegular, - color: .textSecondary + color: .textPrimary ) ) ), diff --git a/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift b/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift index 26ed5e7124..61f497a43f 100644 --- a/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift +++ b/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift @@ -76,7 +76,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 } } @@ -89,6 +89,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 { @@ -295,7 +302,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType }() ) ), - onTap: { [weak viewModel] in + onTap: { [weak viewModel] _ in switch state.loadingState { case .loading: viewModel?.showLoadingModal( @@ -345,7 +352,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType SessionListScreenContent.ListItemInfo( id: .continueButton, variant: .button(title: "theContinue".localized()), - onTap: { [weak viewModel] in viewModel?.updateProPlan() } + onTap: { [weak viewModel] _ in viewModel?.updateProPlan() } ) ) ].compactMap { $0 } @@ -362,7 +369,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .init( leadingAccessory: .icon( .messageSquare, - size: .large, + size: .medium, customTint: .primary ), title: .init( @@ -377,7 +384,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .init( leadingAccessory: .icon( .pin, - size: .large, + size: .medium, customTint: .primary ), title: .init( @@ -394,7 +401,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .init( leadingAccessory: .icon( .rectangleEllipsis, - size: .large, + size: .medium, customTint: .primary ), title: .init( @@ -410,7 +417,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .init( leadingAccessory: .icon( UIImage(named: "ic_user_group"), - size: .large, + size: .medium, customTint: .disabled ), title: .init( @@ -433,7 +440,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ] ] ), - onTap: { [weak viewModel] in + onTap: { [weak viewModel] _ in guard state.loadingState == .loading else { return } viewModel?.showLoadingModal( from: .proStats, @@ -503,7 +510,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ) ), - onTap: { [weak viewModel] in viewModel?.openUrl(Constants.session_pro_roadmap) } + onTap: { [weak viewModel] _ in viewModel?.openUrl(Constants.session_pro_roadmap) } ) ) ) @@ -534,7 +541,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ), trailingAccessory: .icon( .squareArrowUpRight, - size: .large, + size: .medium, customTint: { switch state.currentProPlanState { case .expired: return .textPrimary @@ -544,7 +551,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ) ), - onTap: { [weak viewModel] in viewModel?.openUrl(Constants.session_pro_faq_url) } + onTap: { [weak viewModel] _ in viewModel?.openUrl(Constants.session_pro_faq_url) } ), SessionListScreenContent.ListItemInfo( id: .support, @@ -562,7 +569,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ), trailingAccessory: .icon( .squareArrowUpRight, - size: .large, + size: .medium, customTint: { switch state.currentProPlanState { case .expired: return .textPrimary @@ -572,7 +579,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ) ), - onTap: { [weak viewModel] in viewModel?.openUrl(Constants.session_pro_support_url) } + onTap: { [weak viewModel] _ in viewModel?.openUrl(Constants.session_pro_support_url) } ) ] ) @@ -645,10 +652,10 @@ 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 + onTap: { [weak viewModel] _ in switch state.loadingState { case .loading: viewModel?.showLoadingModal( @@ -690,10 +697,10 @@ 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 + onTap: { [weak viewModel] _ in switch state.loadingState { case .loading: viewModel?.showLoadingModal( @@ -735,7 +742,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ) ), - onTap: { [dependencies = viewModel.dependencies] in + onTap: { [dependencies = viewModel.dependencies] _ in dependencies.setAsync(.isProBadgeEnabled, !state.isProBadgeEnabled) } ) @@ -764,20 +771,20 @@ 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() } + onTap: { [weak viewModel] _ in viewModel?.cancelPlan() } ), SessionListScreenContent.ListItemInfo( id: .requestRefund, 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() } + onTap: { [weak viewModel] _ in viewModel?.requestRefund() } ) ].compactMap { $0 } case .expired: @@ -817,16 +824,16 @@ 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 ? .primary : .textPrimary ) ) ) ), - onTap: { [weak viewModel] in + onTap: { [weak viewModel] _ in switch state.loadingState { case .loading: viewModel?.showLoadingModal( @@ -867,12 +874,12 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ), trailingAccessory: .icon( .refreshCcw, - size: .large, + size: .medium, customTint: .textPrimary ) ) ), - onTap: { [weak viewModel] in viewModel?.recoverProPlan() } + onTap: { [weak viewModel] _ in viewModel?.recoverProPlan() } ), ] case .refunding: [] diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 3a2373493f..c88b3b7cc9 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -463,7 +463,8 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl onTap: { [weak viewModel, dependencies = viewModel.dependencies] in let viewController: SessionListHostingViewController = SessionListHostingViewController( viewModel: SessionProSettingsViewModel(using: dependencies), - customizedNavigationBackground: .clear + customizedNavigationBackground: .clear, + using: dependencies ) viewModel?.transitionToScreen(viewController) } 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/ListContentModels/SessionListScreen+Section.swift b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Section.swift index a2888f7ec6..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 } } @@ -21,10 +22,8 @@ public extension SessionListScreenContent { var height: CGFloat { switch self { - case .none: + case .none, .titleWithTooltips, .titleNoBackgroundContent, .titleRoundedContent: return 0 - case .titleWithTooltips, .titleNoBackgroundContent, .titleRoundedContent: - return 44 case .titleSeparator: return Separator.height case .padding: @@ -32,16 +31,32 @@ public extension SessionListScreenContent { } } + var cellMinHeight: CGFloat { + switch self { + case .titleSeparator, .none: + return 0 + default: + return 44 + } + } + var edgePadding: CGFloat { switch self { case .none, .padding: return 0 - case .titleWithTooltips, .titleNoBackgroundContent, .titleRoundedContent: - return (Values.largeSpacing + Values.mediumSpacing) - case .titleSeparator: + 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 91d7d8381a..d7298e4fe9 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift @@ -29,7 +29,7 @@ public struct ListItemCell: View { let height: CGFloat public var body: some View { - HStack(spacing: Values.mediumSpacing) { + HStack(spacing: Values.largeSpacing) { if let leadingAccessory = info.leadingAccessory { leadingAccessory.accessoryView() } @@ -38,6 +38,7 @@ public struct ListItemCell: View { if let title = info.title { HStack(spacing: Values.verySmallSpacing) { if case .trailing = info.title?.alignment { Spacer() } + if case .center = info.title?.alignment { Spacer() } if case .proBadgeLeading(let themeBackgroundColor) = title.accessory { SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) @@ -49,20 +50,21 @@ public struct ListItemCell: View { .multilineTextAlignment(title.alignment) .foregroundColor(themeColor: title.color) .accessibility(title.accessibility) - .fixedSize() + .fixedSize(horizontal: false, vertical: true) } 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) } if case .proBadgeTrailing(let themeBackgroundColor) = title.accessory { SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) } + if case .center = info.title?.alignment { Spacer() } if case .leading = info.title?.alignment { Spacer() } } } diff --git a/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift b/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift index 11a4780884..1cd2d4aba2 100644 --- a/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift +++ b/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift @@ -40,44 +40,54 @@ public struct SessionListScreen= suppressUntil else { return } - suppressUntil = Date().addingTimeInterval(0.2) - guard tooltipViewId != info.id && !isShowingTooltip else { + 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 = false + isShowingTooltip = true } - return } - tooltipContent = info.content - tooltipPosition = info.position - tooltipViewId = info.id - tooltipArrowOffset = 30 - withAnimation { - isShowingTooltip = true - } - } + } case .titleSeparator: - Seperator_SwiftUI(title: "accountId".localized()) + Seperator_SwiftUI( + title: "accountId".localized(), + font: .Body.baseRegular + ) default: - EmptyView() + 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) } @@ -89,28 +99,30 @@ public struct SessionListScreen Date: Thu, 4 Dec 2025 16:20:27 +1100 Subject: [PATCH 05/21] fix some ui padding issues --- .../ListItemAccessory+Icon.swift | 4 +++- .../SessionListScreen+AccessoryViews.swift | 3 +++ .../SessionListScreen+ListItemCell.swift | 18 +++++++++++------- 3 files changed, 17 insertions(+), 8 deletions(-) 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/ListItemViews/SessionListScreen+ListItemCell.swift b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift index d7298e4fe9..422e3a3996 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift @@ -29,16 +29,17 @@ public struct ListItemCell: View { let height: CGFloat public var body: some View { - HStack(spacing: Values.largeSpacing) { + HStack(spacing: Values.mediumSpacing) { if let leadingAccessory = info.leadingAccessory { leadingAccessory.accessoryView() + .padding(.horizontal, leadingAccessory.padding) } VStack(alignment: .center, spacing: 0) { if let title = info.title { HStack(spacing: Values.verySmallSpacing) { - if case .trailing = info.title?.alignment { Spacer() } - if case .center = info.title?.alignment { Spacer() } + if case .trailing = info.title?.alignment { Spacer(minLength: 0) } + if case .center = info.title?.alignment { Spacer(minLength: 0) } if case .proBadgeLeading(let themeBackgroundColor) = title.accessory { SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) @@ -64,14 +65,15 @@ public struct ListItemCell: View { SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) } - if case .center = info.title?.alignment { Spacer() } - if case .leading = info.title?.alignment { Spacer() } + 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 .trailing = info.description?.alignment { Spacer() } + if case .trailing = info.description?.alignment { Spacer(minLength: 0) } + if case .center = info.description?.alignment { Spacer(minLength: 0) } if case .proBadgeLeading(let themeBackgroundColor) = description.accessory { SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) @@ -97,7 +99,8 @@ public struct ListItemCell: View { SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) } - if case .leading = info.description?.alignment { Spacer() } + if case .center = info.description?.alignment { Spacer(minLength: 0) } + if case .leading = info.description?.alignment { Spacer(minLength: 0) } } } } @@ -109,6 +112,7 @@ public struct ListItemCell: View { if let trailingAccessory = info.trailingAccessory { trailingAccessory.accessoryView() + .padding(.horizontal, trailingAccessory.padding) } } .padding(.horizontal, Values.mediumSpacing) From e3b0a4cce02bdb576c6f9d00fd37f8514c4ce36b Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Thu, 4 Dec 2025 17:09:04 +1100 Subject: [PATCH 06/21] feat: inline pro badge in ListItemCell --- .../Settings/ThreadSettingsViewModel.swift | 6 +++--- .../SessionListScreen+Models.swift | 8 +++++--- .../SessionListScreen+ListItemCell.swift | 18 ++++++++++++------ 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 7fbe3542c7..f3b6035f90 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -355,12 +355,12 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio guard !threadViewModel.threadIsNoteToSelf else { return nil } guard (viewModel.dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: viewModel.threadId) }) else { return nil } - return ( - .themedKey( + return UIView.image( + for: .themedKey( SessionProBadge.Size.medium.cacheKey, themeBackgroundColor: .primary ), - { SessionProBadge(size: .medium) } + generator: { SessionProBadge(size: .medium) } ) }() ) diff --git a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift index b448c9288c..0e5125eb97 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift @@ -52,7 +52,7 @@ public extension SessionListScreenContent { let accessory: Accessory let interaction: Interaction let accessibility: Accessibility? - let trailingImage: (cacheKey: UIView.CachedImageKey, viewGenerator: (() -> UIView))? + let trailingImage: UIImage? public init( _ text: String? = nil, @@ -63,7 +63,7 @@ public extension SessionListScreenContent { accessory: Accessory = .none, interaction: Interaction = .none, accessibility: Accessibility? = nil, - trailingImage: (cacheKey: UIView.CachedImageKey, viewGenerator: (() -> UIView))? = nil + trailingImage: UIImage? = nil ) { self.text = text self.font = font @@ -86,6 +86,7 @@ public extension SessionListScreenContent { color.hash(into: &hasher) accessory.hash(into: &hasher) accessibility.hash(into: &hasher) + trailingImage?.hash(into: &hasher) } public static func == (lhs: TextInfo, rhs: TextInfo) -> Bool { @@ -96,7 +97,8 @@ public extension SessionListScreenContent { lhs.alignment == rhs.alignment && lhs.color == rhs.color && lhs.accessory == rhs.accessory && - lhs.accessibility == rhs.accessibility + lhs.accessibility == rhs.accessibility && + lhs.trailingImage == rhs.trailingImage ) } } diff --git a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift index 422e3a3996..429e655aea 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift @@ -46,12 +46,18 @@ public struct ListItemCell: View { } if let text = title.text { - Text(text) - .font(title.font) - .multilineTextAlignment(title.alignment) - .foregroundColor(themeColor: title.color) - .accessibility(title.accessibility) - .fixedSize(horizontal: false, vertical: true) + ZStack { + if let trailingImage = title.trailingImage { + (Text(text) + Text(" \(Image(uiImage: trailingImage))")) + } else { + Text(text) + } + } + .font(title.font) + .multilineTextAlignment(title.alignment) + .foregroundColor(themeColor: title.color) + .accessibility(title.accessibility) + .fixedSize(horizontal: false, vertical: true) } else if let attributedString = title.attributedString { AttributedText(attributedString) .font(title.font) From 9bbab4d8e2d9d200ddb9753ce84b8b2b5b3d9a56 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Thu, 4 Dec 2025 17:11:05 +1100 Subject: [PATCH 07/21] renaming --- .../Conversations/Settings/ThreadSettingsViewModel.swift | 2 +- .../ListContentModels/SessionListScreen+Models.swift | 6 +++--- .../ListItemViews/SessionListScreen+ListItemCell.swift | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index f3b6035f90..beb9451ac3 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -351,7 +351,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio threadViewModel.displayName, font: .Headings.H4, alignment: .center, - trailingImage: { + inlineTrailingImage: { guard !threadViewModel.threadIsNoteToSelf else { return nil } guard (viewModel.dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: viewModel.threadId) }) else { return nil } diff --git a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift index 0e5125eb97..6d64935b46 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift @@ -52,7 +52,7 @@ public extension SessionListScreenContent { let accessory: Accessory let interaction: Interaction let accessibility: Accessibility? - let trailingImage: UIImage? + let inlineTrailingImage: UIImage? public init( _ text: String? = nil, @@ -63,7 +63,7 @@ public extension SessionListScreenContent { accessory: Accessory = .none, interaction: Interaction = .none, accessibility: Accessibility? = nil, - trailingImage: UIImage? = nil + inlineTrailingImage: UIImage? = nil ) { self.text = text self.font = font @@ -73,7 +73,7 @@ public extension SessionListScreenContent { self.accessory = accessory self.interaction = interaction self.accessibility = accessibility - self.trailingImage = trailingImage + self.inlineTrailingImage = inlineTrailingImage } // MARK: - Conformance diff --git a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift index 429e655aea..e87be1ac1b 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift @@ -48,7 +48,7 @@ public struct ListItemCell: View { if let text = title.text { ZStack { if let trailingImage = title.trailingImage { - (Text(text) + Text(" \(Image(uiImage: trailingImage))")) + Text(text) + Text(" \(Image(uiImage: trailingImage))") } else { Text(text) } From 2a7527e34f517506763b43d3d87a072847c305cb Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 5 Dec 2025 12:07:15 +1100 Subject: [PATCH 08/21] clean up and fix compiling issues --- .../ListContentModels/SessionListScreen+Models.swift | 4 ++-- .../ListItemViews/SessionListScreen+ListItemCell.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift index 6d64935b46..96a4ae38e8 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift @@ -86,7 +86,7 @@ public extension SessionListScreenContent { color.hash(into: &hasher) accessory.hash(into: &hasher) accessibility.hash(into: &hasher) - trailingImage?.hash(into: &hasher) + inlineTrailingImage?.hash(into: &hasher) } public static func == (lhs: TextInfo, rhs: TextInfo) -> Bool { @@ -98,7 +98,7 @@ public extension SessionListScreenContent { lhs.color == rhs.color && lhs.accessory == rhs.accessory && lhs.accessibility == rhs.accessibility && - lhs.trailingImage == rhs.trailingImage + lhs.inlineTrailingImage == rhs.inlineTrailingImage ) } } diff --git a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift index e87be1ac1b..4f69a397fc 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift @@ -47,7 +47,7 @@ public struct ListItemCell: View { if let text = title.text { ZStack { - if let trailingImage = title.trailingImage { + if let trailingImage = title.inlineTrailingImage { Text(text) + Text(" \(Image(uiImage: trailingImage))") } else { Text(text) From 5e1b5132f8925a9d2eaddc3b1fe174e2dff304bf Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 8 Dec 2025 14:59:18 +1100 Subject: [PATCH 09/21] refactor text info accessory --- .../Settings/ThreadSettingsViewModel.swift | 15 +++--- .../SessionProSettingsViewModel.swift | 47 +++++++++++++++---- .../SessionListScreen+Models.swift | 32 +++++++------ .../SessionListScreen+ListItemCell.swift | 25 +++------- 4 files changed, 72 insertions(+), 47 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index beb9451ac3..33753df343 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -351,16 +351,19 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio threadViewModel.displayName, font: .Headings.H4, alignment: .center, - inlineTrailingImage: { + inlineImage: { guard !threadViewModel.threadIsNoteToSelf else { return nil } guard (viewModel.dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: viewModel.threadId) }) else { return nil } - return UIView.image( - for: .themedKey( - SessionProBadge.Size.medium.cacheKey, - themeBackgroundColor: .primary + return .init( + image: UIView.image( + for: .themedKey( + SessionProBadge.Size.medium.cacheKey, + themeBackgroundColor: .primary + ), + generator: { SessionProBadge(size: .medium) } ), - generator: { SessionProBadge(size: .medium) } + position: .trailing ) }() ) diff --git a/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift b/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift index 61f497a43f..11713ef484 100644 --- a/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift +++ b/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift @@ -476,7 +476,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(info.description, font: .Body.smallRegular, color: .textSecondary) ) ) @@ -1099,7 +1099,23 @@ extension SessionProSettingsViewModel { let backgroundColors: [ThemeValue] let title: String let description: String - let accessory: SessionListScreenContent.TextInfo.Accessory + let inlineImageInfo: SessionListScreenContent.TextInfo.InlineImageInfo? + + public init( + id: ListItem, + icon: UIImage?, + backgroundColors: [ThemeValue], + title: String, + description: String, + inlineImageInfo: SessionListScreenContent.TextInfo.InlineImageInfo? = nil + ) { + self.id = id + self.icon = icon + self.backgroundColors = backgroundColors + self.title = title + self.description = description + self.inlineImageInfo = inlineImageInfo + } static func allCases(_ state: SessionProPlanState) -> [ProFeaturesInfo] { return [ @@ -1113,8 +1129,7 @@ extension SessionProSettingsViewModel { } }(), title: "proLongerMessages".localized(), - description: "proLongerMessagesDescription".localized(), - accessory: .none + description: "proLongerMessagesDescription".localized() ), ProFeaturesInfo( id: .unlimitedPins, @@ -1127,7 +1142,6 @@ extension SessionProSettingsViewModel { }(), title: "proUnlimitedPins".localized(), description: "proUnlimitedPinsDescription".localized(), - accessory: .none ), ProFeaturesInfo( id: .animatedDisplayPictures, @@ -1140,7 +1154,6 @@ extension SessionProSettingsViewModel { }(), title: "proAnimatedDisplayPictures".localized(), description: "proAnimatedDisplayPicturesDescription".localized(), - accessory: .none ), ProFeaturesInfo( id: .badges, @@ -1153,14 +1166,30 @@ extension SessionProSettingsViewModel { }(), title: "proBadges".localized(), description: "proBadgesDescription".put(key: "app_name", value: Constants.app_name).localized(), - accessory: .proBadgeLeading( - themeBackgroundColor: { + inlineImageInfo: { + let themeBackgroundColor: ThemeValue = { return switch state { 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 + ) + }() ) ] } diff --git a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift index 96a4ae38e8..17729f7809 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift @@ -32,10 +32,9 @@ public extension SessionListScreenContent { } struct TextInfo: Hashable, Equatable { - public enum Accessory: Hashable, Equatable { - case proBadgeLeading(themeBackgroundColor: ThemeValue) - case proBadgeTrailing(themeBackgroundColor: ThemeValue) - case none + public enum InlineImagePosition: Hashable, Equatable { + case leading + case trailing } public enum Interaction: Hashable, Equatable { @@ -44,15 +43,24 @@ public extension SessionListScreenContent { 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? let font: Font? let attributedString: ThemedAttributedString? let alignment: TextAlignment let color: ThemeValue - let accessory: Accessory let interaction: Interaction let accessibility: Accessibility? - let inlineTrailingImage: UIImage? + let inlineImage: InlineImageInfo? public init( _ text: String? = nil, @@ -60,20 +68,18 @@ public extension SessionListScreenContent { attributedString: ThemedAttributedString? = nil, alignment: TextAlignment = .leading, color: ThemeValue = .textPrimary, - accessory: Accessory = .none, interaction: Interaction = .none, accessibility: Accessibility? = nil, - inlineTrailingImage: UIImage? = 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.inlineTrailingImage = inlineTrailingImage + self.inlineImage = inlineImage } // MARK: - Conformance @@ -84,9 +90,8 @@ public extension SessionListScreenContent { attributedString.hash(into: &hasher) alignment.hash(into: &hasher) color.hash(into: &hasher) - accessory.hash(into: &hasher) accessibility.hash(into: &hasher) - inlineTrailingImage?.hash(into: &hasher) + inlineImage?.hash(into: &hasher) } public static func == (lhs: TextInfo, rhs: TextInfo) -> Bool { @@ -96,9 +101,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.inlineTrailingImage == rhs.inlineTrailingImage + lhs.inlineImage == rhs.inlineImage ) } } diff --git a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift index 4f69a397fc..b32e53afea 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift @@ -41,14 +41,15 @@ public struct ListItemCell: View { if case .trailing = info.title?.alignment { Spacer(minLength: 0) } if case .center = info.title?.alignment { Spacer(minLength: 0) } - if case .proBadgeLeading(let themeBackgroundColor) = title.accessory { - SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) - } - if let text = title.text { ZStack { - if let trailingImage = title.inlineTrailingImage { - Text(text) + Text(" \(Image(uiImage: trailingImage))") + 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) } @@ -67,10 +68,6 @@ public struct ListItemCell: View { .fixedSize(horizontal: false, vertical: true) } - 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) } } @@ -81,10 +78,6 @@ public struct ListItemCell: View { if case .trailing = info.description?.alignment { Spacer(minLength: 0) } if case .center = info.description?.alignment { Spacer(minLength: 0) } - if case .proBadgeLeading(let themeBackgroundColor) = description.accessory { - SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) - } - if let text = description.text { Text(text) .font(description.font) @@ -101,10 +94,6 @@ 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) } } From 0d515587ff30d7b0c65cd4a603e2ef50181b5bd9 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Tue, 9 Dec 2025 15:20:23 +1100 Subject: [PATCH 10/21] feat: tappable text/image for multiline text --- Session.xcodeproj/project.pbxproj | 4 + .../Settings/ThreadSettingsViewModel.swift | 113 +++++++++--------- .../Shared/SessionTableViewController.swift | 2 +- .../Components/SwiftUI/AttributedLabel.swift | 54 ++++++++- .../SessionListScreen+ListItem.swift | 1 + ...ssionListScreen+ListItemTappableText.swift | 103 ++++++++++++++++ .../SessionListScreen/SessionListScreen.swift | 6 + .../Utilities/UILabel+Utilities.swift | 47 +++++--- 8 files changed, 255 insertions(+), 75 deletions(-) create mode 100644 SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemTappableText.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 8f263a9979..d7b7ac8684 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 */; }; @@ -1606,6 +1607,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 = ""; }; @@ -3077,6 +3079,7 @@ 943B43552EC2AFAB008ABC34 /* SessionListScreen+ListItemDataMatrix.swift */, 943B43532EC2AF82008ABC34 /* SessionListScreen+ListItemLogoWithPro.swift */, 943B43512EC2AF64008ABC34 /* SessionListScreen+ListItemCell.swift */, + 940B82CF2EE697A40000D142 /* SessionListScreen+ListItemTappableText.swift */, ); path = ListItemViews; sourceTree = ""; @@ -6666,6 +6669,7 @@ C33100282559000A00070591 /* UIView+Utilities.swift in Sources */, FDE6E99829F8E63A00F93C5D /* Accessibility.swift in Sources */, 9463794C2E71371F0017A014 /* SessionProPaymentScreen+Models.swift in Sources */, + 940B82D02EE697C70000D142 /* SessionListScreen+ListItemTappableText.swift in Sources */, FD37E9CA28A1E4BD003AE748 /* Theme+ClassicLight.swift in Sources */, 943B43622EC3FD85008ABC34 /* ListItemAccessory+Toggle.swift in Sources */, 945E89D42E95D97000D8D907 /* SessionProPaymentScreen+SharedViews.swift in Sources */, diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 33753df343..f53ff43e7e 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -345,73 +345,70 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio ), SessionListScreenContent.ListItemInfo( id: .displayName, - variant: .cell( + variant: .tappableText( info: .init( - title: .init( - threadViewModel.displayName, - font: .Headings.H4, - alignment: .center, - inlineImage: { - guard !threadViewModel.threadIsNoteToSelf else { return nil } - guard (viewModel.dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: viewModel.threadId) }) else { return nil } - - return .init( - image: UIView.image( - for: .themedKey( - SessionProBadge.Size.medium.cacheKey, - themeBackgroundColor: .primary - ), - generator: { SessionProBadge(size: .medium) } + 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 { + UIView.image( + for: .themedKey( + SessionProBadge.Size.medium.cacheKey, + themeBackgroundColor: .primary ), - position: .trailing + generator: { SessionProBadge(size: .medium) } ) + } + }(), + 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 + } }() - ) + + dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + proCTAModalVariant, + onConfirm: {}, + presenting: { modal in + viewModel.transitionToScreen(modal, transitionType: .present) + } + ) + } ) ), accessibility: Accessibility( identifier: "Username", label: threadViewModel.displayName - ), - onTap: { [dependencies = viewModel.dependencies] target in - guard case .proBadge = target, !dependencies[cache: .libSession].isSessionPro else { - guard - let info: ConfirmationModal.Info = viewModel.updateDisplayNameModal( - threadViewModel: threadViewModel, - currentUserIsClosedGroupAdmin: currentUserIsClosedGroupAdmin - ) - else { return } - - viewModel.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: threadViewModel.threadId) }), - proBadgeImage: UIView.image( - for: .themedKey( - SessionProBadge.Size.mini.cacheKey, - themeBackgroundColor: .primary - ), - generator: { SessionProBadge(size: .mini) } - ) - ) - default: return .generic - } - }() - - dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - proCTAModalVariant, - onConfirm: {}, - presenting: { modal in - viewModel.transitionToScreen(modal, transitionType: .present) - } - ) - } + ) ), (threadViewModel.displayName == threadViewModel.contactDisplayName ? nil : diff --git a/Session/Shared/SessionTableViewController.swift b/Session/Shared/SessionTableViewController.swift index d0dcd68d58..c4dbd8c042 100644 --- a/Session/Shared/SessionTableViewController.swift +++ b/Session/Shared/SessionTableViewController.swift @@ -571,7 +571,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/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/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift index 6b4a4aa9e4..d54b704ce0 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift @@ -29,6 +29,7 @@ public extension SessionListScreenContent { case dataMatrix(info: [[ListItemDataMatrix.Info]]) case button(title: String) case profilePicture(info: ListItemProfilePicture.Info) + case tappableText(info: ListItemTappableText.Info) } public enum TapTarget: Equatable, Hashable, Differentiable { diff --git a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemTappableText.swift b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemTappableText.swift new file mode 100644 index 0000000000..de2db10b50 --- /dev/null +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemTappableText.swift @@ -0,0 +1,103 @@ +// 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?)? + let onTextTap: (@MainActor() -> Void)? + let onImageTap: (@MainActor() -> Void)? + + public init( + text: String, + font: UIFont, + themeForegroundColor: ThemeValue = .textPrimary, + imageAttachmentPosition: SessionListScreenContent.TextInfo.InlineImagePosition? = nil, + imageAttachmentGenerator: (() -> UIImage?)? = 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) + .frame(maxHeight: .infinity) + } +} diff --git a/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift b/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift index 1cd2d4aba2..7c031a2380 100644 --- a/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift +++ b/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift @@ -155,6 +155,12 @@ public struct SessionListScreen 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 @@ -45,26 +41,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 } From 3a745158eabf7ad9cc345d3ed45f37e52ffd2c78 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Tue, 9 Dec 2025 16:37:29 +1100 Subject: [PATCH 11/21] feat: interaction on cell --- .../SessionListScreen+ListItemCell.swift | 21 ++++++++++++++++++- .../Utilities/SwiftUI+Utilities.swift | 9 ++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift index b32e53afea..02804893ee 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift @@ -25,9 +25,17 @@ public struct ListItemCell: View { } } + @State var isExpanded: Bool + let info: Info let height: CGFloat + public init(info: Info, height: CGFloat) { + self.info = info + self.height = height + self.isExpanded = (info.title?.interaction != .expandable) + } + public var body: some View { HStack(spacing: Values.mediumSpacing) { if let leadingAccessory = info.leadingAccessory { @@ -48,17 +56,20 @@ public struct ListItemCell: View { case .leading: Text("\(Image(uiImage: inlineImage.image)) ") + Text(text) case .trailing: - Text(text) + Text(" \(Image(uiImage: inlineImage.image))") + Text(text) + Text(" \(Image(uiImage: inlineImage.image))") } } else { Text(text) } } + .lineLimit(isExpanded ? nil : 3) .font(title.font) .multilineTextAlignment(title.alignment) .foregroundColor(themeColor: title.color) .accessibility(title.accessibility) .fixedSize(horizontal: false, vertical: true) + .textSelection(title.interaction == .copy) + } else if let attributedString = title.attributedString { AttributedText(attributedString) .font(title.font) @@ -66,6 +77,7 @@ public struct ListItemCell: View { .foregroundColor(themeColor: title.color) .accessibility(title.accessibility) .fixedSize(horizontal: false, vertical: true) + .textSelection(title.interaction == .copy) } if case .center = info.title?.alignment { Spacer(minLength: 0) } @@ -117,5 +129,12 @@ public struct ListItemCell: View { alignment: .leading ) .contentShape(Rectangle()) + .onTapGesture { + if info.title?.interaction == .expandable { + withAnimation { + isExpanded.toggle() + } + } + } } } diff --git a/SessionUIKit/Utilities/SwiftUI+Utilities.swift b/SessionUIKit/Utilities/SwiftUI+Utilities.swift index 43aa9e263a..d4f4fd37fd 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 From 4eb814a0dde676f8ed1f50b185063b9cf1f0d0de Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Tue, 9 Dec 2025 16:41:37 +1100 Subject: [PATCH 12/21] fix: list item alignment --- SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift b/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift index 7c031a2380..094d38121a 100644 --- a/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift +++ b/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift @@ -155,12 +155,14 @@ public struct SessionListScreen Date: Wed, 10 Dec 2025 11:27:29 +1100 Subject: [PATCH 13/21] fix merging --- .../Settings/ThreadSettingsViewModel.swift | 13 ++++++++----- Session/Meta/Translations/InfoPlist.xcstrings | 6 ++++++ .../Shared/Views/SessionProBadge+Utilities.swift | 2 +- .../SessionListScreen+ListItemTappableText.swift | 4 ++-- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 73b4254f94..57a66adb7f 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -355,12 +355,15 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio guard (viewModel.dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: viewModel.threadId) }) else { return nil } return { - UIView.image( - for: .themedKey( - SessionProBadge.Size.medium.cacheKey, - themeBackgroundColor: .primary + ( + UIView.image( + for: .themedKey( + SessionProBadge.Size.medium.cacheKey, + themeBackgroundColor: .primary + ), + generator: { SessionProBadge(size: .medium) } ), - generator: { SessionProBadge(size: .medium) } + SessionProBadge.accessibilityLabel ) } }(), diff --git a/Session/Meta/Translations/InfoPlist.xcstrings b/Session/Meta/Translations/InfoPlist.xcstrings index 3d97c3284c..46511db12c 100644 --- a/Session/Meta/Translations/InfoPlist.xcstrings +++ b/Session/Meta/Translations/InfoPlist.xcstrings @@ -1558,6 +1558,12 @@ "value" : "Session necesita acceso a la red local para realizar llamadas de voz y video." } }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session tarvitsee pääsyn paikalliseen verkkoon soittaakseen ääni- ja videopuheluja." + } + }, "fr" : { "stringUnit" : { "state" : "translated", diff --git a/Session/Shared/Views/SessionProBadge+Utilities.swift b/Session/Shared/Views/SessionProBadge+Utilities.swift index 4ff22dcb9f..587c6a9ab6 100644 --- a/Session/Shared/Views/SessionProBadge+Utilities.swift +++ b/Session/Shared/Views/SessionProBadge+Utilities.swift @@ -17,7 +17,7 @@ public extension SessionProBadge.Size{ } 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/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemTappableText.swift b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemTappableText.swift index de2db10b50..f8d168782f 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemTappableText.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemTappableText.swift @@ -11,7 +11,7 @@ public struct ListItemTappableText: View { let font: UIFont let themeForegroundColor: ThemeValue let imageAttachmentPosition: SessionListScreenContent.TextInfo.InlineImagePosition? - let imageAttachmentGenerator: (() -> UIImage?)? + let imageAttachmentGenerator: (() -> (UIImage, String?)?)? let onTextTap: (@MainActor() -> Void)? let onImageTap: (@MainActor() -> Void)? @@ -20,7 +20,7 @@ public struct ListItemTappableText: View { font: UIFont, themeForegroundColor: ThemeValue = .textPrimary, imageAttachmentPosition: SessionListScreenContent.TextInfo.InlineImagePosition? = nil, - imageAttachmentGenerator: (() -> UIImage?)? = nil, + imageAttachmentGenerator: (() -> (UIImage, String?)?)? = nil, onTextTap: (@MainActor() -> Void)? = nil, onImageTap: (@MainActor() -> Void)? = nil ) { From 6d8f874a28174ff21f49cc75085690537d396f80 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Wed, 10 Dec 2025 13:04:48 +1100 Subject: [PATCH 14/21] clean up --- .../ListItemViews/SessionListScreen+ListItemTappableText.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemTappableText.swift b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemTappableText.swift index f8d168782f..ecd2c75d14 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemTappableText.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemTappableText.swift @@ -94,7 +94,7 @@ public struct ListItemTappableText: View { alignment: .center, maxWidth: (UIScreen.main.bounds.width - Values.mediumSpacing * 2 - Values.largeSpacing * 2), onTextTap: info.onTextTap, - onImageTap: info.onImageTap, + onImageTap: info.onImageTap ) .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, Values.mediumSpacing) From 0bb25379cd1c50b0b5306b5f75d91f7912a27e39 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Wed, 10 Dec 2025 13:40:42 +1100 Subject: [PATCH 15/21] fix on tap action and clean up --- .../Settings/ThreadSettingsViewModel.swift | 38 +++++++++---------- .../SessionProSettingsViewModel.swift | 26 ++++++------- .../SessionListScreen+ListItem.swift | 10 +---- .../SessionListScreen+ListItemCell.swift | 5 ++- .../SessionListScreen/SessionListScreen.swift | 25 ++++++------ 5 files changed, 52 insertions(+), 52 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 57a66adb7f..1602fdd3eb 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -516,7 +516,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [dependencies = viewModel.dependencies] _ in + onTap: { [dependencies = viewModel.dependencies] in viewModel.dismissScreen(type: .popToRoot) { dependencies[singleton: .storage].writeAsync { db in try SessionThread.deleteOrLeave( @@ -559,7 +559,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio identifier: "\(ThreadSettingsViewModel.self).copy_thread_id", label: "Copy Session ID" ), - onTap: { _ in + onTap: { switch threadViewModel.threadVariant { case .contact, .legacyGroup, .group: UIPasteboard.general.string = threadViewModel.threadId @@ -599,7 +599,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio identifier: "\(ThreadSettingsViewModel.self).search", label: "Search" ), - onTap: { _ in viewModel.didTriggerSearch() } + onTap: { viewModel.didTriggerSearch() } ), ( @@ -635,7 +635,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio identifier: "Disappearing messages", label: "\(ThreadSettingsViewModel.self).disappearing_messages" ), - onTap: { [dependencies = viewModel.dependencies] _ in + onTap: { [dependencies = viewModel.dependencies] in viewModel.transitionToScreen( SessionTableViewController( viewModel: ThreadDisappearingMessagesSettingsViewModel( @@ -673,7 +673,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio identifier: "\(ThreadSettingsViewModel.self).pin_conversation", label: "Pin Conversation" ), - onTap: { _ in + onTap: { viewModel.toggleConversationPinnedStatus( currentPinnedPriority: threadViewModel.threadPinnedPriority ) @@ -724,7 +724,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio identifier: "\(ThreadSettingsViewModel.self).notifications", label: "Notifications" ), - onTap: { [dependencies = viewModel.dependencies] _ in + onTap: { [dependencies = viewModel.dependencies] in viewModel.transitionToScreen( SessionTableViewController( viewModel: ThreadNotificationSettingsViewModel( @@ -755,7 +755,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).add_to_open_group" ), - onTap: { _ in viewModel.inviteUsersToCommunity(threadViewModel: threadViewModel) } + onTap: { viewModel.inviteUsersToCommunity(threadViewModel: threadViewModel) } ) ), @@ -775,7 +775,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio identifier: "Group members", label: "Group members" ), - onTap: { _ in viewModel.viewMembers() } + onTap: { viewModel.viewMembers() } ) ), @@ -794,7 +794,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio identifier: "\(ThreadSettingsViewModel.self).all_media", label: "All media" ), - onTap: { [dependencies = viewModel.dependencies] _ in + onTap: { [dependencies = viewModel.dependencies] in viewModel.transitionToScreen( MediaGalleryViewModel.createAllMediaViewController( threadId: threadViewModel.threadId, @@ -831,7 +831,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio identifier: "Edit group", label: "Edit group" ), - onTap: { [dependencies = viewModel.dependencies] _ in + onTap: { [dependencies = viewModel.dependencies] in viewModel.transitionToScreen( SessionTableViewController( viewModel: EditGroupViewModel( @@ -862,7 +862,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio identifier: "Promote admins", label: "Promote admins" ), - onTap: { _ in viewModel.promoteAdmins(currentGroupName: threadViewModel.closedGroupName) } + onTap: { viewModel.promoteAdmins(currentGroupName: threadViewModel.closedGroupName) } ) ), @@ -895,7 +895,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio identifier: "Disappearing messages", label: "\(ThreadSettingsViewModel.self).disappearing_messages" ), - onTap: { [dependencies = viewModel.dependencies] _ in + onTap: { [dependencies = viewModel.dependencies] in viewModel.transitionToScreen( SessionTableViewController( viewModel: ThreadDisappearingMessagesSettingsViewModel( @@ -974,7 +974,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { _ in + onTap: { let isBlocked: Bool = (threadViewModel.threadIsBlocked == true) viewModel.updateBlockedState( @@ -1020,7 +1020,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio confirmStyle: isThreadHidden ? .alert_text : .danger, cancelStyle: .alert_text ), - onTap: { [dependencies = viewModel.dependencies] _ in + onTap: { [dependencies = viewModel.dependencies] in dependencies[singleton: .storage].writeAsync { db in if isThreadHidden { try SessionThread.updateVisibility( @@ -1225,7 +1225,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [dependencies = viewModel.dependencies] _ in + onTap: { [dependencies = viewModel.dependencies] in viewModel.dismissScreen(type: .popToRoot) { dependencies[singleton: .storage].writeAsync { db in try SessionThread.deleteOrLeave( @@ -1279,7 +1279,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [dependencies = viewModel.dependencies] _ in + onTap: { [dependencies = viewModel.dependencies] in viewModel.dismissScreen(type: .popToRoot) { dependencies[singleton: .storage].writeAsync { db in try SessionThread.deleteOrLeave( @@ -1326,7 +1326,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [dependencies = viewModel.dependencies] _ in + onTap: { [dependencies = viewModel.dependencies] in viewModel.dismissScreen(type: .popToRoot) { dependencies[singleton: .storage].writeAsync { db in try SessionThread.deleteOrLeave( @@ -1374,7 +1374,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [dependencies = viewModel.dependencies] _ in + onTap: { [dependencies = viewModel.dependencies] in viewModel.dismissScreen(type: .popToRoot) { dependencies[singleton: .storage].writeAsync { db in try SessionThread.deleteOrLeave( @@ -1415,7 +1415,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { _ in viewModel.deleteAllAttachmentsBeforeNow() } + onTap: { viewModel.deleteAllAttachmentsBeforeNow() } ) ) ].compactMap { $0 } diff --git a/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift b/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift index 11713ef484..a24aeb1c52 100644 --- a/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift +++ b/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift @@ -302,7 +302,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType }() ) ), - onTap: { [weak viewModel] _ in + onTap: { [weak viewModel] in switch state.loadingState { case .loading: viewModel?.showLoadingModal( @@ -352,7 +352,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType SessionListScreenContent.ListItemInfo( id: .continueButton, variant: .button(title: "theContinue".localized()), - onTap: { [weak viewModel] _ in viewModel?.updateProPlan() } + onTap: { [weak viewModel] in viewModel?.updateProPlan() } ) ) ].compactMap { $0 } @@ -440,7 +440,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ] ] ), - onTap: { [weak viewModel] _ in + onTap: { [weak viewModel] in guard state.loadingState == .loading else { return } viewModel?.showLoadingModal( from: .proStats, @@ -510,7 +510,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ) ), - onTap: { [weak viewModel] _ in viewModel?.openUrl(Constants.session_pro_roadmap) } + onTap: { [weak viewModel] in viewModel?.openUrl(Constants.session_pro_roadmap) } ) ) ) @@ -551,7 +551,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ) ), - onTap: { [weak viewModel] _ in viewModel?.openUrl(Constants.session_pro_faq_url) } + onTap: { [weak viewModel] in viewModel?.openUrl(Constants.session_pro_faq_url) } ), SessionListScreenContent.ListItemInfo( id: .support, @@ -579,7 +579,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ) ), - onTap: { [weak viewModel] _ in viewModel?.openUrl(Constants.session_pro_support_url) } + onTap: { [weak viewModel] in viewModel?.openUrl(Constants.session_pro_support_url) } ) ] ) @@ -655,7 +655,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType trailingAccessory: state.loadingState == .loading ? .loadingIndicator(size: .medium) : .icon(.chevronRight, size: .medium) ) ), - onTap: { [weak viewModel] _ in + onTap: { [weak viewModel] in switch state.loadingState { case .loading: viewModel?.showLoadingModal( @@ -700,7 +700,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType trailingAccessory: .icon(.circleAlert, size: .medium) ) ), - onTap: { [weak viewModel] _ in + onTap: { [weak viewModel] in switch state.loadingState { case .loading: viewModel?.showLoadingModal( @@ -742,7 +742,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ) ), - onTap: { [dependencies = viewModel.dependencies] _ in + onTap: { [dependencies = viewModel.dependencies] in dependencies.setAsync(.isProBadgeEnabled, !state.isProBadgeEnabled) } ) @@ -774,7 +774,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType trailingAccessory: .icon(.circleX, size: .medium, customTint: .danger) ) ), - onTap: { [weak viewModel] _ in viewModel?.cancelPlan() } + onTap: { [weak viewModel] in viewModel?.cancelPlan() } ), SessionListScreenContent.ListItemInfo( id: .requestRefund, @@ -784,7 +784,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType trailingAccessory: .icon(.circleAlert, size: .medium, customTint: .danger) ) ), - onTap: { [weak viewModel] _ in viewModel?.requestRefund() } + onTap: { [weak viewModel] in viewModel?.requestRefund() } ) ].compactMap { $0 } case .expired: @@ -833,7 +833,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ) ), - onTap: { [weak viewModel] _ in + onTap: { [weak viewModel] in switch state.loadingState { case .loading: viewModel?.showLoadingModal( @@ -879,7 +879,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ) ), - onTap: { [weak viewModel] _ in viewModel?.recoverProPlan() } + onTap: { [weak viewModel] in viewModel?.recoverProPlan() } ), ] case .refunding: [] diff --git a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift index d54b704ce0..77769257ff 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift @@ -32,18 +32,12 @@ public extension SessionListScreenContent { case tappableText(info: ListItemTappableText.Info) } - public enum TapTarget: Equatable, Hashable, Differentiable { - case none - case item - case proBadge - } - let id: ID let variant: Variant let isEnabled: Bool let accessibility: Accessibility? let confirmationInfo: ConfirmationModal.Info? - let onTap: (@MainActor (TapTarget) -> Void)? + let onTap: (@MainActor () -> Void)? public init( id: ID, @@ -51,7 +45,7 @@ public extension SessionListScreenContent { isEnabled: Bool = true, accessibility: Accessibility? = nil, confirmationInfo: ConfirmationModal.Info? = nil, - onTap: (@MainActor (TapTarget) -> Void)? = nil + onTap: (@MainActor () -> Void)? = nil ) { self.id = id self.variant = variant diff --git a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift index 02804893ee..9dc8ab3246 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift @@ -29,11 +29,13 @@ public struct ListItemCell: View { let info: Info let height: CGFloat + let onTap: (() -> Void)? - public init(info: Info, height: CGFloat) { + public init(info: Info, height: CGFloat, onTap: (() -> Void)? = nil) { self.info = info self.height = height self.isExpanded = (info.title?.interaction != .expandable) + self.onTap = onTap } public var body: some View { @@ -135,6 +137,7 @@ public struct ListItemCell: View { isExpanded.toggle() } } + onTap?() } } } diff --git a/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift b/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift index 094d38121a..f5fe57720e 100644 --- a/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift +++ b/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift @@ -25,6 +25,8 @@ public struct SessionListScreen Date: Wed, 10 Dec 2025 14:15:52 +1100 Subject: [PATCH 16/21] clean up --- Session/Conversations/Settings/ThreadSettingsViewModel.swift | 2 +- .../SessionProSettings/SessionProSettingsViewModel.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 1602fdd3eb..9e1cd459ad 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -426,7 +426,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio color: .textSecondary ) ) - ), + ) ) ), diff --git a/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift b/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift index a24aeb1c52..f477348fa3 100644 --- a/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift +++ b/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift @@ -1141,7 +1141,7 @@ extension SessionProSettingsViewModel { } }(), title: "proUnlimitedPins".localized(), - description: "proUnlimitedPinsDescription".localized(), + description: "proUnlimitedPinsDescription".localized() ), ProFeaturesInfo( id: .animatedDisplayPictures, @@ -1153,7 +1153,7 @@ extension SessionProSettingsViewModel { } }(), title: "proAnimatedDisplayPictures".localized(), - description: "proAnimatedDisplayPicturesDescription".localized(), + description: "proAnimatedDisplayPicturesDescription".localized() ), ProFeaturesInfo( id: .badges, From 86de19f178bfc70bbb008c85287f996a5e1e98aa Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 12 Dec 2025 14:46:03 +1100 Subject: [PATCH 17/21] fix unit test --- .../ThreadSettingsViewModelSpec.swift | 220 ++++++++++-------- 1 file changed, 126 insertions(+), 94 deletions(-) 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") + } } } } From e78ec4fbe22752ea2a6bcc1e2e7ade21240823f1 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 15 Dec 2025 11:59:04 +1100 Subject: [PATCH 18/21] improve profile picture animation --- ...ionListScreen+ListItemProfilePicture.swift | 20 ++++++++++++------- .../SessionListScreen/SessionListScreen.swift | 2 +- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemProfilePicture.swift b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemProfilePicture.swift index c1b2b6bf24..fb47695abe 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemProfilePicture.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemProfilePicture.swift @@ -33,8 +33,7 @@ public struct ListItemProfilePicture: View { public var body: some View { let scale: CGFloat = isProfileImageExpanding ? (190.0 / 90) : 1 - switch content { - case .profilePicture: + ZStack(alignment: .top) { ZStack(alignment: .topTrailing) { if let profileInfo = info.profileInfo { ZStack { @@ -57,7 +56,7 @@ public struct ListItemProfilePicture: View { ) } - if info.sessionId != nil { + if info.qrCodeImage != nil { let (buttonSize, iconSize): (CGFloat, CGFloat) = isProfileImageExpanding ? (33, 20) : (24, 14) ZStack { Circle() @@ -81,10 +80,10 @@ public struct ListItemProfilePicture: View { } } } - .padding(.top, 12) .padding(.vertical, 5) .padding(.horizontal, 10) - case .qrCode: + .opacity((content == .profilePicture ? 1 : 0)) + ZStack(alignment: .topTrailing) { if let qrCodeImage = info.qrCodeImage { QRCodeView( @@ -98,7 +97,7 @@ public struct ListItemProfilePicture: View { ) ) .aspectRatio(1, contentMode: .fit) - .frame(width: 190, height: 190) + .frame(width: 190, height: 190, alignment: .top) .padding(.vertical, 5) .padding(.horizontal, 10) .onTapGesture { @@ -123,8 +122,15 @@ public struct ListItemProfilePicture: View { } } } - .padding(.top, 12) + .opacity((content == .qrCode ? 1 : 0)) } + .frame( + width: 210, + height: content == .qrCode ? 200 : (ProfilePictureView.Info.Size.modal.viewSize * scale + 10), + alignment: .top + ) + .border(Color.red) + .padding(.top, 12) } private func showQRCodeLightBox() { diff --git a/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift b/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift index f5fe57720e..1555892fc3 100644 --- a/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift +++ b/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift @@ -158,7 +158,7 @@ public struct SessionListScreen Date: Mon, 15 Dec 2025 16:14:50 +1100 Subject: [PATCH 19/21] fix: expandable group description --- .../SessionListScreen+ListItemCell.swift | 52 +++++++++---------- ...ssionListScreen+ListItemTappableText.swift | 1 - .../SessionListScreen/SessionListScreen.swift | 6 ++- 3 files changed, 30 insertions(+), 29 deletions(-) diff --git a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift index 9dc8ab3246..6f2d278205 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift @@ -28,12 +28,10 @@ public struct ListItemCell: View { @State var isExpanded: Bool let info: Info - let height: CGFloat let onTap: (() -> Void)? - public init(info: Info, height: CGFloat, onTap: (() -> Void)? = nil) { + public init(info: Info, onTap: (() -> Void)? = nil) { self.info = info - self.height = height self.isExpanded = (info.title?.interaction != .expandable) self.onTap = onTap } @@ -52,26 +50,34 @@ public struct ListItemCell: View { if case .center = info.title?.alignment { Spacer(minLength: 0) } if let text = title.text { - 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))") + 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) } - } else { - Text(text) + } + .lineLimit(isExpanded ? nil : 2) + .font(title.font) + .multilineTextAlignment(title.alignment) + .foregroundColor(themeColor: title.color) + .accessibility(title.accessibility) + .fixedSize(horizontal: false, vertical: true) + .textSelection(title.interaction == .copy) + + if info.title?.interaction == .expandable { + Text(isExpanded ? "viewLess".localized() : "viewMore".localized()) + .bold() + .font(title.font) + .foregroundColor(themeColor: .textPrimary) } } - .lineLimit(isExpanded ? nil : 3) - .font(title.font) - .multilineTextAlignment(title.alignment) - .foregroundColor(themeColor: title.color) - .accessibility(title.accessibility) - .fixedSize(horizontal: false, vertical: true) - .textSelection(title.interaction == .copy) - } else if let attributedString = title.attributedString { AttributedText(attributedString) .font(title.font) @@ -115,7 +121,6 @@ public struct ListItemCell: View { } .frame( maxWidth: .infinity, - maxHeight: .infinity, alignment: .leading ) @@ -125,11 +130,6 @@ public struct ListItemCell: View { } } .padding(.horizontal, Values.mediumSpacing) - .frame( - maxWidth: .infinity, - minHeight: height, - alignment: .leading - ) .contentShape(Rectangle()) .onTapGesture { if info.title?.interaction == .expandable { diff --git a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemTappableText.swift b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemTappableText.swift index ecd2c75d14..a7aa23b600 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemTappableText.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemTappableText.swift @@ -98,6 +98,5 @@ public struct ListItemTappableText: View { ) .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, Values.mediumSpacing) - .frame(maxHeight: .infinity) } } diff --git a/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift b/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift index c3bbbb2f49..3f358de60a 100644 --- a/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift +++ b/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift @@ -172,10 +172,12 @@ public struct SessionListScreen Date: Fri, 19 Dec 2025 09:40:38 +1100 Subject: [PATCH 20/21] clean up .init to make less confusion --- .../Settings/ThreadSettingsViewModel.swift | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 2319767f14..2d27a191be 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -427,7 +427,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio id: .contactName, variant: .cell( info: .init( - title: .init( + title: SessionListScreenContent.TextInfo( "(\(threadViewModel.contactDisplayName))", // stringlint:ignore font: .Body.baseRegular, alignment: .center, @@ -443,7 +443,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio id: .threadDescription, variant: .cell( info: .init( - title: .init( + title: SessionListScreenContent.TextInfo( threadDescription, font: .Body.baseRegular, alignment: .center, @@ -470,7 +470,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio id: .sessionId, variant: .cell( info: .init( - title: .init( + title: SessionListScreenContent.TextInfo( threadViewModel.id, font: .Display.extraLarge, alignment: .center, @@ -502,7 +502,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio .trash2, customTint: .danger ), - title: .init( + title: SessionListScreenContent.TextInfo( "groupDelete".localized(), font: .Headings.H8, color: .danger @@ -554,7 +554,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio variant: .cell( info: .init( leadingAccessory: .icon(.copy), - title: .init( + title: SessionListScreenContent.TextInfo( (threadViewModel.threadVariant == .community ? "communityUrlCopy".localized() : "accountIDCopy".localized() @@ -597,7 +597,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio variant: .cell( info: .init( leadingAccessory: .icon(.search), - title: .init( + title: SessionListScreenContent.TextInfo( "searchConversation".localized(), font: .Headings.H8 ) @@ -619,11 +619,11 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio variant: .cell( info: .init( leadingAccessory: .icon(.timer), - title: .init( + title: SessionListScreenContent.TextInfo( "disappearingMessages".localized(), font: .Headings.H8 ), - description: .init( + description: SessionListScreenContent.TextInfo( { guard current.disappearingMessagesConfig.isEnabled else { return "off".localized() @@ -671,7 +671,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio .pin ) ), - title: .init( + title: SessionListScreenContent.TextInfo( (threadViewModel.threadPinnedPriority > 0 ? "pinUnpinConversation".localized() : "pinConversation".localized()), font: .Headings.H8 ) @@ -707,11 +707,11 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio return .volume2 }() ), - title: .init( + title: SessionListScreenContent.TextInfo( "sessionNotifications".localized(), font: .Headings.H8 ), - description: .init( + description: SessionListScreenContent.TextInfo( { if threadViewModel.threadOnlyNotifyForMentions == true { return "notificationsMentionsOnly".localized() @@ -754,7 +754,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio variant: .cell( info: .init( leadingAccessory: .icon(.userRoundPlus), - title: .init( + title: SessionListScreenContent.TextInfo( "membersInvite".localized(), font: .Headings.H8 ) @@ -773,7 +773,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio variant: .cell( info: .init( leadingAccessory: .icon(.usersRound), - title: .init( + title: SessionListScreenContent.TextInfo( "groupMembers".localized(), font: .Headings.H8 ) @@ -792,7 +792,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio variant: .cell( info: .init( leadingAccessory: .icon(.file), - title: .init( + title: SessionListScreenContent.TextInfo( "attachments".localized(), font: .Headings.H8 ) @@ -829,7 +829,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio variant: .cell( info: .init( leadingAccessory: .icon(.userRoundPen), - title: .init( + title: SessionListScreenContent.TextInfo( "manageMembers".localized(), font: .Headings.H8 ) @@ -860,7 +860,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio UIImage(named: "table_ic_group_edit")? .withRenderingMode(.alwaysTemplate) ), - title: .init( + title: SessionListScreenContent.TextInfo( "adminPromote".localized(), font: .Headings.H8 ) @@ -879,11 +879,11 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio variant: .cell( info: .init( leadingAccessory: .icon(.timer), - title: .init( + title: SessionListScreenContent.TextInfo( "disappearingMessages".localized(), font: .Headings.H8 ), - description: .init( + description: SessionListScreenContent.TextInfo( { guard current.disappearingMessagesConfig.isEnabled else { return "off".localized() @@ -943,7 +943,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio customTint: .danger ) ), - title: .init( + title: SessionListScreenContent.TextInfo( ( threadViewModel.threadIsBlocked == true ? "blockUnblock".localized() : @@ -1004,7 +1004,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio isThreadHidden ? .eye : .eyeOff, customTint: isThreadHidden ? .textPrimary : .danger ), - title: .init( + title: SessionListScreenContent.TextInfo( isThreadHidden ? "showNoteToSelf".localized() : "noteToSelfHide".localized(), font: .Headings.H8, color: isThreadHidden ? .textPrimary : .danger @@ -1059,7 +1059,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio UIImage(named: "ic_message_trash")?.withRenderingMode(.alwaysTemplate), customTint: .danger ), - title: .init( + title: SessionListScreenContent.TextInfo( "clearMessages".localized(), font: .Headings.H8, color: .danger @@ -1211,7 +1211,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio .logOut, customTint: .danger ), - title: .init( + title: SessionListScreenContent.TextInfo( "communityLeave".localized(), font: .Headings.H8, color: .danger @@ -1258,7 +1258,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio currentUserIsClosedGroupAdmin ? .trash2 : .logOut, customTint: .danger ), - title: .init( + title: SessionListScreenContent.TextInfo( currentUserIsClosedGroupAdmin ? "groupDelete".localized() : "groupLeave".localized(), font: .Headings.H8, color: .danger @@ -1312,7 +1312,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio .trash2, customTint: .danger ), - title: .init( + title: SessionListScreenContent.TextInfo( "conversationsDelete".localized(), font: .Headings.H8, color: .danger @@ -1359,7 +1359,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio UIImage(named: "ic_user_round_trash")?.withRenderingMode(.alwaysTemplate), customTint: .danger ), - title: .init( + title: SessionListScreenContent.TextInfo( "contactDelete".localized(), font: .Headings.H8, color: .danger @@ -1409,7 +1409,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio .withRenderingMode(.alwaysTemplate), customTint: .danger ), - title: .init( + title: SessionListScreenContent.TextInfo( "[DEBUG] Delete all arrachments before now", // stringlint:disable font: .Headings.H8, color: .danger From fc76631df2d96f5501f5b44a4cd40e589f63d416 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 19 Dec 2025 10:04:07 +1100 Subject: [PATCH 21/21] clean up --- Session/Conversations/ConversationVC+Interaction.swift | 3 +-- .../Conversations/Settings/ThreadSettingsViewModel.swift | 8 ++++---- Session/Settings/SettingsViewModel.swift | 3 +-- Session/Shared/SessionListHostingViewController.swift | 3 +-- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 7b2f6620ac..4eca4abc37 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -110,8 +110,7 @@ extension ConversationVC: } }, using: self.viewModel.dependencies - ), - using: self.viewModel.dependencies + ) ) navigationController?.pushViewController(viewController, animated: true) } diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 2d27a191be..cf09bbad23 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -1990,7 +1990,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio 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 @@ -2180,8 +2180,8 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio 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) @@ -2253,7 +2253,7 @@ class ThreadSettingsViewModel: SessionListScreenContent.ViewModelType, Navigatio isGrandfathered: (numPinnedConversations > LibSession.PinnedConversationLimit), renew: dependencies[singleton: .sessionProState].isSessionProExpired ), - dataManager: dependencies[singleton: .imageDataManager], + dataManager: self?.imageDataManager, onConfirm: { [dependencies] in Task { await dependencies[singleton: .sessionProState].upgradeToPro( diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index b4d28e0513..2fcb82a70b 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -463,8 +463,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl onTap: { [weak viewModel, dependencies = viewModel.dependencies] in let viewController: SessionListHostingViewController = SessionListHostingViewController( viewModel: SessionProSettingsViewModel(using: dependencies), - customizedNavigationBackground: .clear, - using: dependencies + customizedNavigationBackground: .clear ) viewModel?.transitionToScreen(viewController) } diff --git a/Session/Shared/SessionListHostingViewController.swift b/Session/Shared/SessionListHostingViewController.swift index b51f0257b3..cecf78a1e2 100644 --- a/Session/Shared/SessionListHostingViewController.swift +++ b/Session/Shared/SessionListHostingViewController.swift @@ -14,8 +14,7 @@ class SessionListHostingViewController: SessionHostingViewController< init( viewModel: ViewModel, customizedNavigationBackground: ThemeValue? = nil, - shouldHideNavigationBar: Bool = false, - using dependencies: Dependencies + shouldHideNavigationBar: Bool = false ) { self.viewModel = viewModel super.init(