Merge pull request #373 from mpretty-cyro/feature/app-disguise

Alternate App Icons
pull/1061/head
Morgan Pretty 1 month ago committed by GitHub
commit 5e4724dc72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -618,14 +618,13 @@
FD37E9C628A1D4EC003AE748 /* Theme+ClassicDark.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C528A1D4EC003AE748 /* Theme+ClassicDark.swift */; };
FD37E9C828A1D73F003AE748 /* Theme+Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C728A1D73F003AE748 /* Theme+Colors.swift */; };
FD37E9CA28A1E4BD003AE748 /* Theme+ClassicLight.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C928A1E4BD003AE748 /* Theme+ClassicLight.swift */; };
FD37E9CC28A1E578003AE748 /* AppearanceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9CB28A1E578003AE748 /* AppearanceViewController.swift */; };
FD37E9CF28A1EB1B003AE748 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9CE28A1EB1B003AE748 /* Theme.swift */; };
FD37E9D128A1F2EB003AE748 /* ThemeSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9D028A1F2EB003AE748 /* ThemeSelectionView.swift */; };
FD37E9D328A1FCDB003AE748 /* Theme+OceanDark.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9D228A1FCDB003AE748 /* Theme+OceanDark.swift */; };
FD37E9D528A1FCE8003AE748 /* Theme+OceanLight.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9D428A1FCE8003AE748 /* Theme+OceanLight.swift */; };
FD37E9D728A20B5D003AE748 /* UIColor+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9D628A20B5D003AE748 /* UIColor+Utilities.swift */; };
FD37E9D928A230F2003AE748 /* TraitObservingWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9D828A230F2003AE748 /* TraitObservingWindow.swift */; };
FD37E9DB28A244E9003AE748 /* ThemePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9DA28A244E9003AE748 /* ThemePreviewView.swift */; };
FD37E9DB28A244E9003AE748 /* ThemeMessagePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9DA28A244E9003AE748 /* ThemeMessagePreviewView.swift */; };
FD37E9DD28A384EB003AE748 /* PrimaryColorSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9DC28A384EB003AE748 /* PrimaryColorSelectionView.swift */; };
FD37E9F628A5F106003AE748 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9F528A5F106003AE748 /* Configuration.swift */; };
FD37E9FF28A5F2CD003AE748 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9FE28A5F2CD003AE748 /* Configuration.swift */; };
@ -807,6 +806,10 @@
FD860CBC2D6E7A9F00BBE29C /* _024_FixBustedInteractionVariant.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD860CBB2D6E7A9400BBE29C /* _024_FixBustedInteractionVariant.swift */; };
FD860CBE2D6E7DAA00BBE29C /* DeveloperSettingsViewModel+Testing.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD860CBD2D6E7DA000BBE29C /* DeveloperSettingsViewModel+Testing.swift */; };
FD860CC92D6ED2ED00BBE29C /* DifferenceKit in Frameworks */ = {isa = PBXBuildFile; productRef = FD860CC82D6ED2ED00BBE29C /* DifferenceKit */; };
FD860CB42D668FD300BBE29C /* AppearanceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD860CB32D668FD000BBE29C /* AppearanceViewModel.swift */; };
FD860CB62D66913F00BBE29C /* ThemePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD860CB52D66913B00BBE29C /* ThemePreviewView.swift */; };
FD860CB82D66BC9900BBE29C /* AppIconViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD860CB72D66BC9500BBE29C /* AppIconViewModel.swift */; };
FD860CBA2D66BF2A00BBE29C /* AppIconGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD860CB92D66BF2300BBE29C /* AppIconGridView.swift */; };
FD86FDA32BC5020600EC251B /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = FD86FDA22BC5020600EC251B /* PrivacyInfo.xcprivacy */; };
FD86FDA42BC51C5400EC251B /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = FD86FDA22BC5020600EC251B /* PrivacyInfo.xcprivacy */; };
FD86FDA52BC51C5500EC251B /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = FD86FDA22BC5020600EC251B /* PrivacyInfo.xcprivacy */; };
@ -1835,14 +1838,13 @@
FD37E9C528A1D4EC003AE748 /* Theme+ClassicDark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Theme+ClassicDark.swift"; sourceTree = "<group>"; };
FD37E9C728A1D73F003AE748 /* Theme+Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Theme+Colors.swift"; sourceTree = "<group>"; };
FD37E9C928A1E4BD003AE748 /* Theme+ClassicLight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Theme+ClassicLight.swift"; sourceTree = "<group>"; };
FD37E9CB28A1E578003AE748 /* AppearanceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceViewController.swift; sourceTree = "<group>"; };
FD37E9CE28A1EB1B003AE748 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
FD37E9D028A1F2EB003AE748 /* ThemeSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeSelectionView.swift; sourceTree = "<group>"; };
FD37E9D228A1FCDB003AE748 /* Theme+OceanDark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Theme+OceanDark.swift"; sourceTree = "<group>"; };
FD37E9D428A1FCE8003AE748 /* Theme+OceanLight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Theme+OceanLight.swift"; sourceTree = "<group>"; };
FD37E9D628A20B5D003AE748 /* UIColor+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Utilities.swift"; sourceTree = "<group>"; };
FD37E9D828A230F2003AE748 /* TraitObservingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraitObservingWindow.swift; sourceTree = "<group>"; };
FD37E9DA28A244E9003AE748 /* ThemePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePreviewView.swift; sourceTree = "<group>"; };
FD37E9DA28A244E9003AE748 /* ThemeMessagePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeMessagePreviewView.swift; sourceTree = "<group>"; };
FD37E9DC28A384EB003AE748 /* PrimaryColorSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryColorSelectionView.swift; sourceTree = "<group>"; };
FD37E9F528A5F106003AE748 /* Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = "<group>"; };
FD37E9F828A5F14A003AE748 /* _001_ThemePreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_ThemePreferences.swift; sourceTree = "<group>"; };
@ -1994,6 +1996,10 @@
FD859EF127BF6BA200510D0C /* Data+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Utilities.swift"; sourceTree = "<group>"; };
FD860CBB2D6E7A9400BBE29C /* _024_FixBustedInteractionVariant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _024_FixBustedInteractionVariant.swift; sourceTree = "<group>"; };
FD860CBD2D6E7DA000BBE29C /* DeveloperSettingsViewModel+Testing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeveloperSettingsViewModel+Testing.swift"; sourceTree = "<group>"; };
FD860CB32D668FD000BBE29C /* AppearanceViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceViewModel.swift; sourceTree = "<group>"; };
FD860CB52D66913B00BBE29C /* ThemePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePreviewView.swift; sourceTree = "<group>"; };
FD860CB72D66BC9500BBE29C /* AppIconViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconViewModel.swift; sourceTree = "<group>"; };
FD860CB92D66BF2300BBE29C /* AppIconGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconGridView.swift; sourceTree = "<group>"; };
FD86FDA22BC5020600EC251B /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
FD87DCF928B74DB300AF0F98 /* ConversationSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationSettingsViewModel.swift; sourceTree = "<group>"; };
FD87DCFD28B7582C00AF0F98 /* BlockedContactsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedContactsViewModel.swift; sourceTree = "<group>"; };
@ -3201,7 +3207,8 @@
FD37EA1628AC5605003AE748 /* NotificationContentViewModel.swift */,
FD87DCF928B74DB300AF0F98 /* ConversationSettingsViewModel.swift */,
FD87DCFD28B7582C00AF0F98 /* BlockedContactsViewModel.swift */,
FD37E9CB28A1E578003AE748 /* AppearanceViewController.swift */,
FD860CB32D668FD000BBE29C /* AppearanceViewModel.swift */,
FD860CB72D66BC9500BBE29C /* AppIconViewModel.swift */,
FD37EA0228A9FDCC003AE748 /* HelpViewModel.swift */,
B86BD08523399CEF000F5AE3 /* SeedModal.swift */,
FDC1BD672CFE6EEA002CDC71 /* DeveloperSettingsViewModel.swift */,
@ -3970,8 +3977,10 @@
isa = PBXGroup;
children = (
FD37E9D028A1F2EB003AE748 /* ThemeSelectionView.swift */,
FD37E9DA28A244E9003AE748 /* ThemePreviewView.swift */,
FD860CB52D66913B00BBE29C /* ThemePreviewView.swift */,
FD37E9DA28A244E9003AE748 /* ThemeMessagePreviewView.swift */,
FD37E9DC28A384EB003AE748 /* PrimaryColorSelectionView.swift */,
FD860CB92D66BF2300BBE29C /* AppIconGridView.swift */,
FD39352B28F382920084DADA /* VersionFooterView.swift */,
);
path = Views;
@ -6426,6 +6435,7 @@
4CA485BB2232339F004B9E7D /* PhotoCaptureViewController.swift in Sources */,
C328254925CA60E60062D0A7 /* ContextMenuVC+Action.swift in Sources */,
FD6D9CF92CA152B300F706A8 /* Session+SNUIKit.swift in Sources */,
FD860CB62D66913F00BBE29C /* ThemePreviewView.swift in Sources */,
FD71164628E2CC1300B47552 /* SessionHighlightingBackgroundLabel.swift in Sources */,
4542DF54208D40AC007B4E76 /* LoadingViewController.swift in Sources */,
B8B558F126C4BB0600693325 /* CameraManager.swift in Sources */,
@ -6448,8 +6458,9 @@
FD71164228E2C85A00B47552 /* TransitionType.swift in Sources */,
942256882C23F8C800C0FDBF /* PNModeScreen.swift in Sources */,
FD848B9828422F1A000E298B /* Date+Utilities.swift in Sources */,
FD37E9DB28A244E9003AE748 /* ThemePreviewView.swift in Sources */,
FD37E9DB28A244E9003AE748 /* ThemeMessagePreviewView.swift in Sources */,
FD7443422D07A27E00862443 /* SyncPushTokensJob.swift in Sources */,
FD37E9DB28A244E9003AE748 /* ThemeMessagePreviewView.swift in Sources */,
7B3A3934298882D6002FE4AC /* SessionCarouselViewDelegate.swift in Sources */,
45B5360E206DD8BB00D61655 /* UIResponder+OWS.swift in Sources */,
942256802C23F8BB00C0FDBF /* StartConversationScreen.swift in Sources */,
@ -6501,6 +6512,7 @@
942256822C23F8BB00C0FDBF /* InviteAFriendScreen.swift in Sources */,
B8BB82A5238F627000BA5194 /* HomeVC.swift in Sources */,
4521C3C01F59F3BA00B4C582 /* TextFieldHelper.swift in Sources */,
FD860CBA2D66BF2A00BBE29C /* AppIconGridView.swift in Sources */,
FD37E9D128A1F2EB003AE748 /* ThemeSelectionView.swift in Sources */,
FD39352C28F382920084DADA /* VersionFooterView.swift in Sources */,
FD12A83F2AD63BDF00EEBA0D /* Navigatable.swift in Sources */,
@ -6516,7 +6528,9 @@
B835249B25C3AB650089A44F /* VisibleMessageCell.swift in Sources */,
B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */,
7B5233C42900E90F00F8F375 /* SessionLabelCarouselView.swift in Sources */,
FD860CB42D668FD300BBE29C /* AppearanceViewModel.swift in Sources */,
7B93D07727CF1A8A00811CB6 /* MockDataGenerator.swift in Sources */,
FD860CB82D66BC9900BBE29C /* AppIconViewModel.swift in Sources */,
7B1B52D828580C6D006069F2 /* EmojiPickerSheet.swift in Sources */,
940943402C7ED62300D9D2E0 /* StartupError.swift in Sources */,
FD368A6A29DE9E30000DBF1E /* UIContextualAction+Utilities.swift in Sources */,
@ -6533,7 +6547,6 @@
FD71164A28E3EA5B00B47552 /* DismissType.swift in Sources */,
C328251F25CA3A900062D0A7 /* QuoteView.swift in Sources */,
7B3A39322980D02B002FE4AC /* SessionCarouselView.swift in Sources */,
FD37E9CC28A1E578003AE748 /* AppearanceViewController.swift in Sources */,
B8EB20F02640F7F000773E52 /* OpenGroupInvitationView.swift in Sources */,
C328254025CA55880062D0A7 /* ContextMenuVC.swift in Sources */,
9409433E2C7EB81800D9D2E0 /* WebRTCSession+Constants.swift in Sources */,
@ -7939,6 +7952,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CLANG_ADDRESS_SANITIZER_CONTAINER_OVERFLOW = YES;
CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES;
CLANG_ANALYZER_SECURITY_INSECUREAPI_RAND = YES;
@ -8004,6 +8018,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CLANG_ADDRESS_SANITIZER_CONTAINER_OVERFLOW = NO;
CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES;
CLANG_ANALYZER_SECURITY_INSECUREAPI_RAND = YES;

@ -276,6 +276,7 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
identifier: "Contact"
)
),
tableSize: tableView.bounds.size,
using: dependencies
)

@ -22,13 +22,13 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
override var contextSnapshotView: UIView? { return snContentView }
// Constraints
private lazy var authorLabelTopConstraint = authorLabel.pin(.top, to: .top, of: self)
internal lazy var authorLabelTopConstraint = authorLabel.pin(.top, to: .top, of: self)
private lazy var authorLabelHeightConstraint = authorLabel.set(.height, to: 0)
private lazy var profilePictureViewLeadingConstraint = profilePictureView.pin(.leading, to: .leading, of: self, withInset: VisibleMessageCell.groupThreadHSpacing)
private lazy var contentViewLeadingConstraint1 = snContentView.pin(.leading, to: .trailing, of: profilePictureView, withInset: VisibleMessageCell.groupThreadHSpacing)
internal lazy var contentViewLeadingConstraint1 = snContentView.pin(.leading, to: .trailing, of: profilePictureView, withInset: VisibleMessageCell.groupThreadHSpacing)
private lazy var contentViewLeadingConstraint2 = snContentView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: VisibleMessageCell.gutterSize)
private lazy var contentViewTopConstraint = snContentView.pin(.top, to: .bottom, of: authorLabel, withInset: VisibleMessageCell.authorLabelBottomSpacing)
private lazy var contentViewTrailingConstraint1 = snContentView.pin(.trailing, to: .trailing, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing)
internal lazy var contentViewTrailingConstraint1 = snContentView.pin(.trailing, to: .trailing, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing)
private lazy var contentViewTrailingConstraint2 = snContentView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -VisibleMessageCell.gutterSize)
private lazy var contentBottomConstraint = snContentView.bottomAnchor
.constraint(lessThanOrEqualTo: self.bottomAnchor, constant: -1)

@ -468,6 +468,7 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource {
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge),
isEnabled: (authorId == self.messageViewModel.currentUserSessionId)
),
tableSize: tableView.bounds.size,
using: dependencies
)

@ -1,125 +0,0 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-40.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-60.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-29.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-58.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-87.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-80.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-120.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-121.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-180.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-20.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-41.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-30.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-59.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-42.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-81.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-76.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-152.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-167.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-1024.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
},
"properties" : {
"pre-rendered" : true
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 739 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "AppIcon-Calculator-Preview.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "AppIcon-Calculator-Preview@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "AppIcon-Calculator-Preview@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "AppIcon-Meeting-Preview.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "AppIcon-Meeting-Preview@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "AppIcon-Meeting-Preview@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "AppIcon-News-Preview.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "AppIcon-News-Preview@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "AppIcon-News-Preview@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "AppIcon-Notes-Preview.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "AppIcon-Notes-Preview@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "AppIcon-Notes-Preview@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "AppIcon-Stocks-Preview.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "AppIcon-Stocks-Preview@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "AppIcon-Stocks-Preview@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "AppIcon-Weather-Preview.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "AppIcon-Weather-Preview@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "AppIcon-Weather-Preview@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "Icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "Icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 KiB

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "Icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "Icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "Icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "Icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "Icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

@ -57,6 +57,8 @@
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSExceptionDomains</key>
@ -81,8 +83,6 @@
<false/>
</dict>
</dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSAppleMusicUsageDescription</key>
<string>Session needs to use Apple Music to play media attachments.</string>

@ -105,3 +105,60 @@ public extension NetworkStatus {
}
}
}
// MARK: - Info
final class PathStatusViewAccessory: UIView, SessionCell.Accessory.CustomView {
struct Info: Equatable, SessionCell.Accessory.CustomViewInfo {
typealias View = PathStatusViewAccessory
}
/// We want the path status to have the same sizing as other list item icons so it needs to be wrapped in
/// this contains view
public static let size: SessionCell.Accessory.Size = .fixed(
width: IconSize.medium.size,
height: IconSize.medium.size
)
static func create(maxContentWidth: CGFloat, using dependencies: Dependencies) -> PathStatusViewAccessory {
return PathStatusViewAccessory(using: dependencies)
}
private let dependencies: Dependencies
// MARK: - Components
lazy var pathStatusView: PathStatusView = PathStatusView(size: .large, using: dependencies)
// MARK: Initialization
init(using dependencies: Dependencies) {
self.dependencies = dependencies
super.init(frame: .zero)
setupUI()
}
required init?(coder: NSCoder) {
fatalError("Use init(theme:) instead")
}
// MARK: - Layout
private func setupUI() {
isUserInteractionEnabled = false
addSubview(pathStatusView)
setupLayout()
}
private func setupLayout() {
pathStatusView.center(in: self)
}
// MARK: - Content
// No need to do anything (theme with auto-update)
func update(with info: Info) {}
}

@ -0,0 +1,172 @@
// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Combine
import GRDB
import DifferenceKit
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
// MARK: - AppIcon
enum AppIcon: String, CaseIterable {
case session = "AppIcon"
case weather = "AppIcon-Weather"
case stocks = "AppIcon-Stocks"
case news = "AppIcon-News"
case notes = "AppIcon-Notes"
case meetings = "AppIcon-Meeting"
case calculator = "AppIcon-Calculator"
/// Annoyingly the alternate icons don't seem to be renderable directly so we need to include
/// additional copies in order to render in the UI
var previewImageName: String { "\(rawValue)-Preview" }
// stringlint:ignore_contents
init(name: String?) {
switch name {
case "AppIcon-Weather": self = .weather
case "AppIcon-Stocks": self = .stocks
case "AppIcon-News": self = .news
case "AppIcon-Notes": self = .notes
case "AppIcon-Meeting": self = .meetings
case "AppIcon-Calculator": self = .calculator
default: self = .session
}
}
init?(rawValue: String) {
self.init(name: rawValue)
}
}
// MARK: - AppIconViewModel
class AppIconViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource {
public let dependencies: Dependencies
public let navigatableState: NavigatableState = NavigatableState()
public let state: TableDataState<Section, TableItem> = TableDataState()
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
private let selectedOptionsSubject: CurrentValueSubject<String?, Never>
// MARK: - Initialization
init(using dependencies: Dependencies) {
self.dependencies = dependencies
/// Retrieve the current icon name
var currentIconName: String?
switch Thread.isMainThread {
case true: currentIconName = UIApplication.shared.alternateIconName
case false:
DispatchQueue.main.sync {
currentIconName = UIApplication.shared.alternateIconName
}
}
selectedOptionsSubject = CurrentValueSubject(currentIconName)
}
// MARK: - Section
public enum Section: SessionTableSection {
case appIcon
case icon
var title: String? {
switch self {
case .appIcon: return "appIcon".localized()
case .icon: return "appIconSelectionTitle".localized()
}
}
var style: SessionTableSectionStyle {
switch self {
case .appIcon: return .titleRoundedContent
case .icon: return .padding
}
}
var footer: String? {
switch self {
case .icon:
return "appIconDescription"
.put(key: "app_name", value: Constants.app_name)
.localized()
default: return nil
}
}
}
public enum TableItem: Equatable, Hashable, Differentiable {
case appIconUseAlternate
case iconGrid
}
// MARK: - Content
private struct State: Equatable {
let alternateAppIconName: String?
}
let title: String = "sessionAppearance".localized()
lazy var observation: TargetObservation = ObservationBuilder
.subject(selectedOptionsSubject)
.mapWithPrevious { [weak self, dependencies] previous, current -> [SectionModel] in
return [
SectionModel(
model: .appIcon,
elements: [
SessionCell.Info(
id: .appIconUseAlternate,
title: SessionCell.TextInfo(
"appIconEnableIcon".localized(),
font: .titleRegular
),
trailingAccessory: .toggle(
(current != nil),
oldValue: (previous != nil)
),
onTap: { [weak self] in
switch current {
case .some: self?.updateAppIcon(nil)
case .none: self?.updateAppIcon(.weather)
}
}
)
]
),
SectionModel(
model: .icon,
elements: [
SessionCell.Info(
id: .iconGrid,
leadingAccessory: .custom(
info: AppIconGridView.Info(
selectedIcon: AppIcon(name: current),
onChange: { icon in self?.updateAppIcon(icon) }
)
)
)
]
)
]
}
private func updateAppIcon(_ icon: AppIcon?) {
// Ignore if there wasn't a change
guard selectedOptionsSubject.value != icon?.rawValue else { return }
UIApplication.shared.setAlternateIconName(icon?.rawValue) { error in
guard let error: Error = error else { return }
Log.error("Failed to set alternate icon: \(error)")
}
selectedOptionsSubject.send(icon?.rawValue)
}
}

@ -1,300 +0,0 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUIKit
import SignalUtilitiesKit
import SessionUtilitiesKit
final class AppearanceViewController: BaseVC {
// MARK: - Initialization
private let dependencies: Dependencies
init(using dependencies: Dependencies) {
self.dependencies = dependencies
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Components
private let scrollView: UIScrollView = {
let result: UIScrollView = UIScrollView()
result.translatesAutoresizingMaskIntoConstraints = false
result.showsVerticalScrollIndicator = false
result.showsHorizontalScrollIndicator = false
result.contentInset = UIEdgeInsets(
top: 0,
leading: 0,
bottom: Values.largeSpacing,
trailing: 0
)
return result
}()
private let contentView: UIView = UIView()
private let themesTitleLabel: UILabel = {
let result: UILabel = UILabel()
result.translatesAutoresizingMaskIntoConstraints = false
result.font = UIFont.systemFont(ofSize: Values.mediumFontSize, weight: .regular)
result.themeTextColor = .textSecondary
result.text = "appearanceThemes".localized()
return result
}()
private let themesStackView: UIStackView = {
let result: UIStackView = UIStackView()
result.translatesAutoresizingMaskIntoConstraints = true
result.axis = .vertical
result.distribution = .equalCentering
result.alignment = .fill
return result
}()
private lazy var themeSelectionViews: [ThemeSelectionView] = Theme.allCases
.map { theme in
let result: ThemeSelectionView = ThemeSelectionView(theme: theme) { [weak self] theme in
ThemeManager.updateThemeState(theme: theme)
}
result.update(isSelected: (ThemeManager.currentTheme == theme))
return result
}
private let primaryColorTitleLabel: UILabel = {
let result: UILabel = UILabel()
result.translatesAutoresizingMaskIntoConstraints = false
result.font = UIFont.systemFont(ofSize: Values.mediumFontSize, weight: .regular)
result.themeTextColor = .textSecondary
result.text = "appearancePrimaryColor".localized()
return result
}()
private let primaryColorPreviewStackView: UIStackView = {
let result: UIStackView = UIStackView()
result.translatesAutoresizingMaskIntoConstraints = false
result.axis = .vertical
result.distribution = .equalCentering
result.alignment = .fill
return result
}()
private lazy var primaryColorPreviewView: ThemePreviewView = {
let result: ThemePreviewView = ThemePreviewView(using: dependencies)
result.translatesAutoresizingMaskIntoConstraints = false
return result
}()
private let primaryColorScrollView: UIScrollView = {
let result: UIScrollView = UIScrollView()
result.translatesAutoresizingMaskIntoConstraints = false
result.showsVerticalScrollIndicator = false
result.showsHorizontalScrollIndicator = false
result.contentInset = UIEdgeInsets(
top: 0,
leading: Values.largeSpacing,
bottom: 0,
trailing: Values.largeSpacing
)
if Dependencies.isRTL {
result.transform = CGAffineTransform.identity.scaledBy(x: -1, y: 1)
}
return result
}()
private let primaryColorSelectionStackView: UIStackView = {
let result: UIStackView = UIStackView()
result.translatesAutoresizingMaskIntoConstraints = false
result.axis = .horizontal
result.distribution = .equalCentering
result.alignment = .fill
return result
}()
private lazy var primaryColorSelectionViews: [PrimaryColorSelectionView] = Theme.PrimaryColor.allCases
.map { color in
let result: PrimaryColorSelectionView = PrimaryColorSelectionView(color: color) { [weak self] color in
ThemeManager.updateThemeState(primaryColor: color)
}
result.update(isSelected: (ThemeManager.primaryColor == color))
return result
}
private let nightModeTitleLabel: UILabel = {
let result: UILabel = UILabel()
result.translatesAutoresizingMaskIntoConstraints = false
result.font = UIFont.systemFont(ofSize: Values.mediumFontSize, weight: .regular)
result.themeTextColor = .textSecondary
result.text = "appearanceAutoDarkMode".localized()
return result
}()
private let nightModeStackView: UIStackView = {
let result: UIStackView = UIStackView()
result.translatesAutoresizingMaskIntoConstraints = false
result.axis = .vertical
result.distribution = .equalCentering
result.alignment = .fill
return result
}()
private let nightModeToggleView: UIView = {
let result: UIView = UIView()
result.translatesAutoresizingMaskIntoConstraints = false
result.themeBackgroundColor = .appearance_sectionBackground
return result
}()
private let nightModeToggleLabel: UILabel = {
let result: UILabel = UILabel()
result.translatesAutoresizingMaskIntoConstraints = false
result.font = UIFont.systemFont(ofSize: Values.mediumFontSize, weight: .regular)
result.themeTextColor = .textPrimary
result.text = "followSystemSettings".localized()
return result
}()
private lazy var nightModeToggleSwitch: UISwitch = {
let result: UISwitch = UISwitch()
result.translatesAutoresizingMaskIntoConstraints = false
result.themeOnTintColor = .primary
result.isOn = ThemeManager.matchSystemNightModeSetting
result.addTarget(self, action: #selector(nightModeToggleChanged(sender:)), for: .valueChanged)
return result
}()
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
ViewControllerUtilities.setUpDefaultSessionStyle(
for: self,
title: "sessionAppearance".localized(),
hasCustomBackButton: false
)
view.themeBackgroundColor = .backgroundPrimary
view.addSubview(scrollView)
// Note: Need to add to a 'contentView' to ensure the automatic RTL behaviour
// works properly (apparently it doesn't play nicely with UIScrollView internals)
scrollView.addSubview(contentView)
contentView.addSubview(themesTitleLabel)
contentView.addSubview(themesStackView)
contentView.addSubview(primaryColorTitleLabel)
contentView.addSubview(primaryColorPreviewStackView)
contentView.addSubview(primaryColorScrollView)
contentView.addSubview(nightModeTitleLabel)
contentView.addSubview(nightModeStackView)
themesStackView.addArrangedSubview(UIView.separator())
themeSelectionViews.forEach { view in
themesStackView.addArrangedSubview(view)
themesStackView.addArrangedSubview(UIView.separator())
}
primaryColorPreviewStackView.addArrangedSubview(UIView.separator())
primaryColorPreviewStackView.addArrangedSubview(primaryColorPreviewView)
primaryColorPreviewStackView.addArrangedSubview(UIView.separator())
primaryColorScrollView.addSubview(primaryColorSelectionStackView)
primaryColorSelectionViews.forEach { view in
primaryColorSelectionStackView.addArrangedSubview(view)
}
nightModeStackView.addArrangedSubview(UIView.separator())
nightModeStackView.addArrangedSubview(nightModeToggleView)
nightModeStackView.addArrangedSubview(UIView.separator())
nightModeToggleView.addSubview(nightModeToggleLabel)
nightModeToggleView.addSubview(nightModeToggleSwitch)
// Register an observer so when the theme changes the selected theme and primary color
// are both updated to match
ThemeManager.onThemeChange(observer: self) { [weak self] theme, primaryColor in
self?.themeSelectionViews.forEach { view in
view.update(isSelected: (theme == view.theme))
}
self?.primaryColorSelectionViews.forEach { view in
view.update(isSelected: (primaryColor == view.color))
}
}
setupLayout()
}
private func setupLayout() {
scrollView.pin(to: view)
contentView.pin(to: scrollView)
contentView.set(.width, to: .width, of: scrollView)
themesTitleLabel.pin(.top, to: .top, of: contentView)
themesTitleLabel.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
themesStackView.pin(.top, to: .bottom, of: themesTitleLabel, withInset: Values.mediumSpacing)
themesStackView.pin(.leading, to: .leading, of: contentView)
themesStackView.set(.width, to: .width, of: contentView)
primaryColorTitleLabel.pin(.top, to: .bottom, of: themesStackView, withInset: Values.mediumSpacing)
primaryColorTitleLabel.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
primaryColorPreviewStackView.pin(.top, to: .bottom, of: primaryColorTitleLabel, withInset: Values.smallSpacing)
primaryColorPreviewStackView.pin(.leading, to: .leading, of: contentView)
primaryColorPreviewStackView.set(.width, to: .width, of: contentView)
primaryColorScrollView.pin(.top, to: .bottom, of: primaryColorPreviewStackView, withInset: Values.mediumSpacing)
primaryColorScrollView.pin(.leading, to: .leading, of: contentView)
primaryColorScrollView.set(.width, to: .width, of: contentView)
primaryColorSelectionStackView.pin(to: primaryColorScrollView)
primaryColorSelectionStackView.set(.height, to: .height, of: primaryColorScrollView)
nightModeTitleLabel.pin(.top, to: .bottom, of: primaryColorScrollView, withInset: Values.largeSpacing)
nightModeTitleLabel.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
nightModeTitleLabel.set(.width, to: .width, of: contentView, withOffset: -(Values.largeSpacing * 2))
nightModeStackView.pin(.top, to: .bottom, of: nightModeTitleLabel, withInset: Values.smallSpacing)
nightModeStackView.pin(.bottom, to: .bottom, of: contentView)
nightModeStackView.pin(.leading, to: .leading, of: contentView)
nightModeStackView.set(.width, to: .width, of: contentView)
nightModeToggleLabel.setContentHugging(.vertical, to: .required)
nightModeToggleLabel.setCompressionResistance(.vertical, to: .required)
nightModeToggleLabel.center(.vertical, in: nightModeToggleView)
nightModeToggleLabel.pin(.leading, to: .leading, of: nightModeToggleView, withInset: Values.largeSpacing)
nightModeToggleSwitch.pin(.top, to: .top, of: nightModeToggleView, withInset: Values.smallSpacing)
nightModeToggleSwitch.pin(.bottom, to: .bottom, of: nightModeToggleView, withInset: -Values.smallSpacing)
nightModeToggleSwitch.pin(.trailing, to: .trailing, of: nightModeToggleView, withInset: -Values.largeSpacing)
}
// MARK: - Actions
@objc private func nightModeToggleChanged(sender: UISwitch) {
ThemeManager.updateThemeState(matchSystemNightModeSetting: sender.isOn)
}
}

@ -0,0 +1,168 @@
// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import DifferenceKit
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
class AppearanceViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource {
public let dependencies: Dependencies
public let navigatableState: NavigatableState = NavigatableState()
public let state: TableDataState<Section, TableItem> = TableDataState()
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
// MARK: - Initialization
init(using dependencies: Dependencies) {
self.dependencies = dependencies
}
// MARK: - Section
public enum Section: SessionTableSection {
case themes
case primaryColor
case primaryColorSelection
case autoDarkMode
case appIcon
var title: String? {
switch self {
case .themes: return "appearanceThemes".localized()
case .primaryColor: return "appearancePrimaryColor".localized()
case .primaryColorSelection: return nil
case .autoDarkMode: return "appearanceAutoDarkMode".localized()
case .appIcon: return "appIcon".localized()
}
}
var style: SessionTableSectionStyle {
switch self {
case .primaryColorSelection: return .none
default: return .titleRoundedContent
}
}
}
public enum TableItem: Equatable, Hashable, Differentiable {
case theme(String)
case primaryColorPreview
case primaryColorSelectionView
case darkModeMatchSystemSettings
}
// MARK: - Content
private struct State: Equatable {
let theme: Theme
let primaryColor: Theme.PrimaryColor
let authDarkModeEnabled: Bool
}
let title: String = "sessionAppearance".localized()
lazy var observation: TargetObservation = ObservationBuilder
.databaseObservation(self) { db -> State in
State(
theme: db[.theme].defaulting(to: .classicDark),
primaryColor: db[.themePrimaryColor].defaulting(to: .green),
authDarkModeEnabled: db[.themeMatchSystemDayNightCycle]
)
}
.map { [weak self, dependencies] state -> [SectionModel] in
return [
SectionModel(
model: .themes,
elements: Theme.allCases.map { theme in
SessionCell.Info(
id: .theme(theme.rawValue),
leadingAccessory: .custom(
info: ThemePreviewView.Info(theme: theme)
),
title: theme.title,
trailingAccessory: .radio(
isSelected: (state.theme == theme)
),
onTap: {
ThemeManager.updateThemeState(theme: theme)
}
)
}
),
SectionModel(
model: .primaryColor,
elements: [
SessionCell.Info(
id: .primaryColorPreview,
leadingAccessory: .custom(
info: ThemeMessagePreviewView.Info()
)
)
]
),
SectionModel(
model: .primaryColorSelection,
elements: [
SessionCell.Info(
id: .primaryColorSelectionView,
leadingAccessory: .custom(
info: PrimaryColorSelectionView.Info(
primaryColor: state.primaryColor,
onChange: { color in
ThemeManager.updateThemeState(primaryColor: color)
}
)
),
styling: SessionCell.StyleInfo(
customPadding: .none,
backgroundStyle: .noBackground
)
)
]
),
SectionModel(
model: .autoDarkMode,
elements: [
SessionCell.Info(
id: .darkModeMatchSystemSettings,
title: SessionCell.TextInfo(
"followSystemSettings".localized(),
font: .titleRegular
),
trailingAccessory: .toggle(
state.authDarkModeEnabled,
oldValue: ThemeManager.matchSystemNightModeSetting
),
onTap: {
ThemeManager.updateThemeState(
matchSystemNightModeSetting: !state.authDarkModeEnabled
)
}
)
]
),
SectionModel(
model: .appIcon,
elements: [
SessionCell.Info(
id: .darkModeMatchSystemSettings,
title: SessionCell.TextInfo(
"appIconSelect".localized(),
font: .titleRegular
),
trailingAccessory: .icon(.chevronRight),
onTap: { [weak self, dependencies] in
self?.transitionToScreen(
SessionTableViewController(
viewModel: AppIconViewModel(using: dependencies)
)
)
}
)
]
)
]
}
}

@ -255,19 +255,9 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl
elements: [
SessionCell.Info(
id: .path,
leadingAccessory: .customView(uniqueId: "PathStatusView") { [dependencies] in // stringlint:ignore
// Need to ensure this view is the same size as the icons so
// wrap it in a larger view
let result: UIView = UIView()
let pathView: PathStatusView = PathStatusView(size: .large, using: dependencies)
result.addSubview(pathView)
result.set(.width, to: IconSize.medium.size)
result.set(.height, to: IconSize.medium.size)
pathView.center(in: result)
return result
},
leadingAccessory: .custom(
info: PathStatusViewAccessory.Info()
),
title: "onionRoutingPath".localized(),
onTap: { [weak self, dependencies] in self?.transitionToScreen(PathVC(using: dependencies)) }
),
@ -331,7 +321,9 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl
),
title: "sessionAppearance".localized(),
onTap: { [weak self, dependencies] in
self?.transitionToScreen(AppearanceViewController(using: dependencies))
self?.transitionToScreen(
SessionTableViewController(viewModel: AppearanceViewModel(using: dependencies))
)
}
),
SessionCell.Info(

@ -0,0 +1,280 @@
// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
final class AppIconGridView: UIView {
public static let size: SessionCell.Accessory.Size = .fillWidthWrapHeight
/// Excluding the default icon
private var icons: [AppIcon] = AppIcon.allCases.filter { $0 != .session }
private var onChange: ((AppIcon) -> ())?
private let maxContentWidth: CGFloat
// MARK: - Components
lazy var contentViewViewHeightConstraint: NSLayoutConstraint = contentView.heightAnchor
.constraint(equalToConstant: IconView.expectedMinSize)
private var iconViewTopConstraints: [NSLayoutConstraint] = []
private var iconViewLeadingConstraints: [NSLayoutConstraint] = []
private var iconViewWidthConstraints: [NSLayoutConstraint] = []
private let contentView: UIView = UIView()
private lazy var iconViews: [IconView] = icons.map { icon in
IconView(icon: icon) { [weak self] in self?.onChange?(icon) }
}
// MARK: - Initializtion
init(maxContentWidth: CGFloat) {
self.maxContentWidth = maxContentWidth
super.init(frame: .zero)
setupUI()
}
required init?(coder: NSCoder) {
fatalError("Use init(theme:) instead")
}
// MARK: - Layout
private func setupUI() {
addSubview(contentView)
iconViews.forEach { contentView.addSubview($0) }
setupLayout()
}
private func setupLayout() {
contentView.pin(to: self)
iconViews.forEach {
iconViewTopConstraints.append($0.pin(.top, to: .top, of: contentView))
iconViewLeadingConstraints.append($0.pin(.leading, to: .leading, of: contentView))
iconViewWidthConstraints.append($0.set(.width, to: IconView.minImageSize))
}
}
/// We want the icons to fill the available space in either a 6x1 grid or a 3x2 grid depending on the available width so
/// we need to calculate the `targetSize` and `targetSpacing` for the `IconView`
private func calculatedSizes(for availableWidth: CGFloat) -> (size: CGFloat, spacing: CGFloat) {
let acceptedIconsPerColumn: [CGFloat] = [CGFloat(iconViews.count), 3]
let minSpacing: CGFloat = Values.smallSpacing
for iconsPerColumn in acceptedIconsPerColumn {
let minTotalSpacing: CGFloat = ((iconsPerColumn - 1) * minSpacing)
let availableWidthLessSpacing: CGFloat = (availableWidth - minTotalSpacing)
let size: CGFloat = floor(availableWidthLessSpacing / iconsPerColumn)
let spacing: CGFloat = ((availableWidth - (size * iconsPerColumn)) / (iconsPerColumn - 1))
/// If all of the icons would fit and be larger than the expected min size then that's the size we want to use
if size >= IconView.expectedMinSize {
return (size, spacing)
}
}
/// Fallback to the min sizes to prevent a future change resulting in a `0` value
return (IconView.expectedMinSize, minSpacing)
}
private func calculateIconViewFrames() -> [CGRect] {
let (targetSize, targetSpacing): (CGFloat, CGFloat) = calculatedSizes(for: maxContentWidth)
var nextX: CGFloat = 0
var nextY: CGFloat = 0
/// We calculate the size based on the position for the next `IconView` so we will end up with an extra `Values.smallSpacing`
/// on both dimensions which needs to be removed
return iconViews.enumerated().reduce(into: []) { result, next in
/// First add the calculated position/size for this element
result.append(
CGRect(
x: nextX,
y: nextY,
width: targetSize,
height: targetSize
)
)
/// We are at the last element so no need to calculate additional frames
guard next.offset < iconViews.count - 1 else { return }
/// Calculate the position the next `IconView` should have
nextX += (targetSize + targetSpacing)
/// If the end of the next icon would go past the `maxContentWidth` then wrap to the next line
if nextX + targetSize > maxContentWidth {
nextX = 0
nextY += (targetSize + targetSpacing)
}
}
}
override var intrinsicContentSize: CGSize {
return calculateIconViewFrames().reduce(.zero) { result, next -> CGSize in
CGSize(width: max(result.width, next.maxX), height: max(result.height, next.maxY))
}
}
override func layoutSubviews() {
super.layoutSubviews()
/// Only bother laying out if we haven't already done so
guard
!iconViewTopConstraints.contains(where: { $0.constant > 0 }) ||
!iconViewLeadingConstraints.contains(where: { $0.constant > 0 })
else { return }
/// We manually layout the `IconView` instances because it's easier than trying to get a good "overflow" behaviour doing it
/// manually than using existing UI elements
let frames: [CGRect] = calculateIconViewFrames()
/// Sanity check to avoid an index out of bounds
guard
iconViews.count == frames.count &&
iconViews.count == iconViewTopConstraints.count &&
iconViews.count == iconViewLeadingConstraints.count &&
iconViews.count == iconViewWidthConstraints.count
else { return }
iconViews.enumerated().forEach { index, iconView in
iconViewTopConstraints[index].constant = frames[index].minY
iconViewLeadingConstraints[index].constant = frames[index].minX
iconViewWidthConstraints[index].constant = frames[index].width
UIView.performWithoutAnimation { iconView.layoutIfNeeded() }
}
contentViewViewHeightConstraint.constant = frames
.reduce(0) { result, next -> CGFloat in max(result, next.maxY) }
}
// MARK: - Content
fileprivate func update(with selectedIcon: AppIcon?, onChange: @escaping (AppIcon) -> ()) {
self.onChange = onChange
iconViews.enumerated().forEach { index, iconView in
iconView.update(isSelected: (icons[index] == selectedIcon))
}
}
}
// MARK: - Info
extension AppIconGridView: SessionCell.Accessory.CustomView {
struct Info: Equatable, SessionCell.Accessory.CustomViewInfo {
typealias View = AppIconGridView
let selectedIcon: AppIcon?
let onChange: (AppIcon) -> ()
static func == (lhs: Info, rhs: Info) -> Bool {
return (lhs.selectedIcon == rhs.selectedIcon)
}
func hash(into hasher: inout Hasher) {
selectedIcon.hash(into: &hasher)
}
}
static func create(maxContentWidth: CGFloat, using dependencies: Dependencies) -> AppIconGridView {
return AppIconGridView(maxContentWidth: maxContentWidth)
}
func update(with info: Info) {
update(with: info.selectedIcon, onChange: info.onChange)
}
}
// MARK: - IconView
extension AppIconGridView {
class IconView: UIView {
fileprivate static let minImageSize: CGFloat = 85
fileprivate static let selectionInset: CGFloat = 4
fileprivate static var expectedMinSize: CGFloat = (minImageSize + (selectionInset * 2))
private let onSelected: () -> ()
// MARK: - Components
private lazy var backgroundButton: UIButton = UIButton(
type: .custom,
primaryAction: UIAction(handler: { [weak self] _ in
self?.onSelected()
})
)
private let selectionBorderView: UIView = {
let result: UIView = UIView()
result.translatesAutoresizingMaskIntoConstraints = false
result.isUserInteractionEnabled = false
result.themeBorderColor = .radioButton_selectedBorder
result.layer.borderWidth = 2
result.layer.cornerRadius = 21
result.isHidden = true
return result
}()
private let imageView: UIImageView = {
let result: UIImageView = UIImageView()
result.translatesAutoresizingMaskIntoConstraints = false
result.isUserInteractionEnabled = false
result.contentMode = .scaleAspectFit
result.layer.cornerRadius = 16
result.clipsToBounds = true
return result
}()
// MARK: - Initializtion
init(icon: AppIcon, onSelected: @escaping () -> ()) {
self.onSelected = onSelected
super.init(frame: .zero)
setupUI(icon: icon)
}
required init?(coder: NSCoder) {
fatalError("Use init(color:) instead")
}
// MARK: - Layout
private func setupUI(icon: AppIcon) {
imageView.image = UIImage(named: icon.previewImageName)
addSubview(backgroundButton)
addSubview(selectionBorderView)
addSubview(imageView)
setupLayout()
}
private func setupLayout() {
translatesAutoresizingMaskIntoConstraints = false
backgroundButton.pin(to: self)
selectionBorderView.pin(to: self)
imageView.pin(to: selectionBorderView, withInset: IconView.selectionInset)
imageView.set(.height, to: .width, of: imageView)
}
// MARK: - Content
func update(isSelected: Bool) {
selectionBorderView.isHidden = !isSelected
}
}
}

@ -2,54 +2,48 @@
import UIKit
import SessionUIKit
import SessionUtilitiesKit
class PrimaryColorSelectionView: UIView {
private static let selectionBorderSize: CGFloat = 36
private static let selectionSize: CGFloat = 30
final class PrimaryColorSelectionView: UIView {
public static let size: SessionCell.Accessory.Size = .fillWidthWrapHeight
public let color: Theme.PrimaryColor
private let onSelected: (Theme.PrimaryColor) -> ()
private var onChange: ((Theme.PrimaryColor) -> ())?
// MARK: - Components
private lazy var backgroundButton: UIButton = {
let result: UIButton = UIButton()
private let scrollView: UIScrollView = {
let result: UIScrollView = UIScrollView()
result.translatesAutoresizingMaskIntoConstraints = false
result.addTarget(self, action: #selector(itemSelected), for: .touchUpInside)
return result
}()
private let selectionBorderView: UIView = {
let result: UIView = UIView()
result.translatesAutoresizingMaskIntoConstraints = false
result.isUserInteractionEnabled = false
result.themeBorderColor = .radioButton_selectedBorder
result.layer.borderWidth = 1
result.layer.cornerRadius = (PrimaryColorSelectionView.selectionBorderSize / 2)
result.isHidden = true
result.showsVerticalScrollIndicator = false
result.showsHorizontalScrollIndicator = false
if Dependencies.isRTL {
result.transform = CGAffineTransform.identity.scaledBy(x: -1, y: 1)
}
return result
}()
private let selectionView: UIView = {
let result: UIView = UIView()
private let stackView: UIStackView = {
let result: UIStackView = UIStackView()
result.translatesAutoresizingMaskIntoConstraints = false
result.isUserInteractionEnabled = false
result.layer.cornerRadius = (PrimaryColorSelectionView.selectionSize / 2)
result.axis = .horizontal
result.distribution = .equalCentering
result.alignment = .fill
result.spacing = Values.verySmallSpacing
return result
}()
private lazy var primaryColorViews: [ColourView] = Theme.PrimaryColor.allCases
.map { color in ColourView(color: color) { [weak self] in self?.onChange?(color) } }
// MARK: - Initializtion
init(color: Theme.PrimaryColor, onSelected: @escaping (Theme.PrimaryColor) -> ()) {
self.color = color
self.onSelected = onSelected
init() {
super.init(frame: .zero)
setupUI(color: color)
setupUI()
}
required init?(coder: NSCoder) {
@ -58,37 +52,161 @@ class PrimaryColorSelectionView: UIView {
// MARK: - Layout
private func setupUI(color: Theme.PrimaryColor) {
// Set the appropriate colors
selectionView.themeBackgroundColorForced = .primary(color)
// Add the UI
addSubview(backgroundButton)
addSubview(selectionBorderView)
addSubview(selectionView)
private func setupUI() {
addSubview(scrollView)
scrollView.addSubview(stackView)
primaryColorViews.forEach { stackView.addArrangedSubview($0) }
setupLayout()
// Register an observer so when the theme changes the selected theme and primary colour
// are both updated to match
ThemeManager.onThemeChange(observer: self) { [weak self] _, primaryColor in
self?.primaryColorViews.forEach { view in
view.update(isSelected: (primaryColor == view.color))
}
}
}
private func setupLayout() {
backgroundButton.pin(to: self)
selectionBorderView.pin(to: self)
selectionBorderView.set(.width, to: PrimaryColorSelectionView.selectionBorderSize)
selectionBorderView.set(.height, to: PrimaryColorSelectionView.selectionBorderSize)
scrollView.pin(.top, to: .top, of: self)
scrollView.pin(.leading, to: .leading, of: self)
scrollView.pin(.trailing, lessThanOrEqualTo: .trailing, of: self)
.setting(priority: .required)
scrollView.pin(.bottom, to: .bottom, of: self)
scrollView.set(.width, to: .width, of: stackView)
.setting(priority: .defaultLow)
selectionView.center(in: selectionBorderView)
selectionView.set(.width, to: PrimaryColorSelectionView.selectionSize)
selectionView.set(.height, to: PrimaryColorSelectionView.selectionSize)
stackView.pin(to: scrollView)
stackView.set(.height, to: .height, of: scrollView)
}
// MARK: - Content
func update(isSelected: Bool) {
selectionBorderView.isHidden = !isSelected
func update(
with primaryColor: Theme.PrimaryColor,
onChange: @escaping (Theme.PrimaryColor) -> ()
) {
self.onChange = onChange
primaryColorViews.forEach { view in
view.update(isSelected: view.color == primaryColor)
}
}
}
// MARK: - Info
extension PrimaryColorSelectionView: SessionCell.Accessory.CustomView {
struct Info: Equatable, SessionCell.Accessory.CustomViewInfo {
typealias View = PrimaryColorSelectionView
let primaryColor: Theme.PrimaryColor
let onChange: (Theme.PrimaryColor) -> ()
static func == (lhs: Info, rhs: Info) -> Bool {
return (lhs.primaryColor == rhs.primaryColor)
}
func hash(into hasher: inout Hasher) {
primaryColor.hash(into: &hasher)
}
}
static func create(maxContentWidth: CGFloat, using dependencies: Dependencies) -> PrimaryColorSelectionView {
return PrimaryColorSelectionView()
}
@objc func itemSelected() {
onSelected(color)
func update(with info: Info) {
update(with: info.primaryColor, onChange: info.onChange)
}
}
// MARK: - ColourView
extension PrimaryColorSelectionView {
class ColourView: UIView {
private static let selectionBorderSize: CGFloat = 34
private static let selectionSize: CGFloat = 26
fileprivate let color: Theme.PrimaryColor
private let onSelected: () -> ()
// MARK: - Components
private lazy var backgroundButton: UIButton = UIButton(
type: .custom,
primaryAction: UIAction(handler: { [weak self] _ in
self?.onSelected()
})
)
private let selectionBorderView: UIView = {
let result: UIView = UIView()
result.translatesAutoresizingMaskIntoConstraints = false
result.isUserInteractionEnabled = false
result.themeBorderColor = .radioButton_selectedBorder
result.layer.borderWidth = 1
result.layer.cornerRadius = (ColourView.selectionBorderSize / 2)
result.isHidden = true
return result
}()
private let selectionView: UIView = {
let result: UIView = UIView()
result.translatesAutoresizingMaskIntoConstraints = false
result.isUserInteractionEnabled = false
result.layer.cornerRadius = (ColourView.selectionSize / 2)
return result
}()
// MARK: - Initializtion
init(color: Theme.PrimaryColor, onSelected: @escaping () -> ()) {
self.color = color
self.onSelected = onSelected
super.init(frame: .zero)
setupUI(color: color)
}
required init?(coder: NSCoder) {
fatalError("Use init(color:) instead")
}
// MARK: - Layout
private func setupUI(color: Theme.PrimaryColor) {
// Set the appropriate colours
selectionView.themeBackgroundColorForced = .primary(color)
// Add the UI
addSubview(backgroundButton)
addSubview(selectionBorderView)
addSubview(selectionView)
setupLayout()
}
private func setupLayout() {
backgroundButton.pin(to: self)
selectionBorderView.pin(to: self)
selectionBorderView.set(.width, to: ColourView.selectionBorderSize)
selectionBorderView.set(.height, to: ColourView.selectionBorderSize)
selectionView.center(in: selectionBorderView)
selectionView.set(.width, to: ColourView.selectionSize)
selectionView.set(.height, to: ColourView.selectionSize)
}
// MARK: - Content
func update(isSelected: Bool) {
selectionBorderView.isHidden = !isSelected
}
}
}

@ -0,0 +1,115 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
final class ThemeMessagePreviewView: UIView {
public static let size: SessionCell.Accessory.Size = .fillWidthWrapHeight
private let dependencies: Dependencies
// MARK: - Components
private lazy var incomingMessagePreview: UIView = {
let result: VisibleMessageCell = VisibleMessageCell()
result.translatesAutoresizingMaskIntoConstraints = true
result.update(
with: MessageViewModel(
variant: .standardIncoming,
body: "appearancePreview2".localized(),
quote: Quote(
interactionId: -1,
authorId: "",
timestampMs: 0,
body: "appearancePreview1".localized(),
attachmentId: nil
),
cellType: .textOnlyMessage
),
mediaCache: NSCache(),
playbackInfo: nil,
showExpandedReactions: false,
lastSearchText: nil,
using: dependencies
)
// Remove built-in padding
result.authorLabelTopConstraint.constant = 0
result.contentViewLeadingConstraint1.constant = 0
return result
}()
private lazy var outgoingMessagePreview: UIView = {
let result: VisibleMessageCell = VisibleMessageCell()
result.translatesAutoresizingMaskIntoConstraints = true
result.update(
with: MessageViewModel(
variant: .standardOutgoing,
body: "appearancePreview3".localized(),
cellType: .textOnlyMessage,
isLast: false // To hide the status indicator
),
mediaCache: NSCache(),
playbackInfo: nil,
showExpandedReactions: false,
lastSearchText: nil,
using: dependencies
)
// Remove built-in padding
result.authorLabelTopConstraint.constant = 0
result.contentViewTrailingConstraint1.constant = 0
return result
}()
// MARK: - Initializtion
init(using dependencies: Dependencies) {
self.dependencies = dependencies
super.init(frame: .zero)
setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Layout
private func setupUI() {
addSubview(incomingMessagePreview)
addSubview(outgoingMessagePreview)
setupLayout()
}
private func setupLayout() {
incomingMessagePreview.pin(.top, to: .top, of: self)
incomingMessagePreview.pin(.leading, to: .leading, of: self)
outgoingMessagePreview.pin(.top, to: .bottom, of: incomingMessagePreview, withInset: Values.mediumSpacing)
outgoingMessagePreview.pin(.trailing, to: .trailing, of: self)
outgoingMessagePreview.pin(.bottom, to: .bottom, of: self)
}
}
// MARK: - Info
extension ThemeMessagePreviewView: SessionCell.Accessory.CustomView {
struct Info: Equatable, SessionCell.Accessory.CustomViewInfo {
typealias View = ThemeMessagePreviewView
}
static func create(maxContentWidth: CGFloat, using dependencies: Dependencies) -> ThemeMessagePreviewView {
return ThemeMessagePreviewView(using: dependencies)
}
// No need to do anything (theme with auto-update)
func update(with info: Info) {}
}

@ -1,92 +1,97 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
public class ThemePreviewView: UIView {
private let dependencies: Dependencies
final class ThemePreviewView: UIView {
public static let size: SessionCell.Accessory.Size = .fixed(width: 76, height: 70)
// MARK: - Components
private lazy var incomingMessagePreview: UIView = {
let result: VisibleMessageCell = VisibleMessageCell()
result.translatesAutoresizingMaskIntoConstraints = true
result.update(
with: MessageViewModel(
variant: .standardIncoming,
body: "appearancePreview2".localized(),
quote: Quote(
interactionId: -1,
authorId: "",
timestampMs: 0,
body: "appearancePreview1".localized(),
attachmentId: nil
),
cellType: .textOnlyMessage
),
mediaCache: NSCache(),
playbackInfo: nil,
showExpandedReactions: false,
lastSearchText: nil,
using: dependencies
)
private let previewIncomingMessageView: UIView = {
let result: UIView = UIView()
result.translatesAutoresizingMaskIntoConstraints = false
result.isUserInteractionEnabled = false
result.layer.cornerRadius = 6
return result
}()
private lazy var outgoingMessagePreview: UIView = {
let result: VisibleMessageCell = VisibleMessageCell()
result.translatesAutoresizingMaskIntoConstraints = true
result.update(
with: MessageViewModel(
variant: .standardOutgoing,
body: "appearancePreview3".localized(),
cellType: .textOnlyMessage,
isLast: false // To hide the status indicator
),
mediaCache: NSCache(),
playbackInfo: nil,
showExpandedReactions: false,
lastSearchText: nil,
using: dependencies
)
private let previewOutgoingMessageView: UIView = {
let result: UIView = UIView()
result.translatesAutoresizingMaskIntoConstraints = false
result.isUserInteractionEnabled = false
result.layer.cornerRadius = 6
return result
}()
// MARK: - Initializtion
init(using dependencies: Dependencies) {
self.dependencies = dependencies
init() {
super.init(frame: .zero)
setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
fatalError("Use init(theme:) instead")
}
// MARK: - Layout
private func setupUI() {
self.themeBackgroundColor = .appearance_sectionBackground
isUserInteractionEnabled = false
layer.cornerRadius = 6
layer.borderWidth = 1
addSubview(incomingMessagePreview)
addSubview(outgoingMessagePreview)
// Add the UI
addSubview(previewIncomingMessageView)
addSubview(previewOutgoingMessageView)
setupLayout()
}
private func setupLayout() {
incomingMessagePreview.pin(.top, to: .top, of: self)
incomingMessagePreview.pin(.leading, to: .leading, of: self, withInset: Values.veryLargeSpacing)
previewIncomingMessageView.pin(.bottom, toCenterOf: self, withInset: -1)
previewIncomingMessageView.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing)
previewIncomingMessageView.set(.width, to: 40)
previewIncomingMessageView.set(.height, to: 12)
previewOutgoingMessageView.pin(.top, toCenterOf: self, withInset: 1)
previewOutgoingMessageView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing)
previewOutgoingMessageView.set(.width, to: 40)
previewOutgoingMessageView.set(.height, to: 12)
}
// MARK: - Content
fileprivate func update(with theme: Theme) {
themeBackgroundColorForced = .theme(theme, color: .backgroundPrimary)
themeBorderColorForced = .theme(theme, color: .borderSeparator)
// Set the appropriate colours
previewIncomingMessageView.themeBackgroundColorForced = .theme(theme, color: .messageBubble_incomingBackground)
previewOutgoingMessageView.themeBackgroundColorForced = .theme(theme, color: .defaultPrimary)
}
}
// MARK: - Info
extension ThemePreviewView: SessionCell.Accessory.CustomView {
struct Info: Equatable, SessionCell.Accessory.CustomViewInfo {
typealias View = ThemePreviewView
outgoingMessagePreview.pin(.top, to: .bottom, of: incomingMessagePreview)
outgoingMessagePreview.pin(.trailing, to: .trailing, of: self, withInset: -Values.veryLargeSpacing)
outgoingMessagePreview.pin(.bottom, to: .bottom, of: self, withInset: -Values.mediumSpacing)
let theme: Theme
}
static func create(maxContentWidth: CGFloat, using dependencies: Dependencies) -> ThemePreviewView {
return ThemePreviewView()
}
func update(with info: Info) {
update(with: info.theme)
}
}

@ -454,7 +454,7 @@ class SessionTableViewController<ViewModel>: BaseVC, UITableViewDataSource, UITa
switch (cell, info) {
case (let cell as SessionCell, _):
cell.update(with: info, using: viewModel.dependencies)
cell.update(with: info, tableSize: tableView.bounds.size, using: viewModel.dependencies)
cell.update(
isEditing: (self.isEditing || (info.title?.interaction == .alwaysEditing)),
becomeFirstResponder: false,
@ -675,7 +675,12 @@ class SessionTableViewController<ViewModel>: BaseVC, UITableViewDataSource, UITa
) {
// Try update the existing cell to have a nice animation instead of reloading the cell
if let existingCell: SessionCell = tableView.cellForRow(at: indexPath) as? SessionCell {
existingCell.update(with: info, isManualReload: true, using: viewModel.dependencies)
existingCell.update(
with: info,
tableSize: tableView.bounds.size,
isManualReload: true,
using: viewModel.dependencies
)
}
else {
tableView.reloadRows(at: [indexPath], with: .none)

@ -2,6 +2,7 @@
import UIKit
import GRDB
import Lucide
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
@ -30,6 +31,23 @@ public extension SessionCell {
// MARK: - DSL
public extension SessionCell.Accessory {
static func icon(
_ icon: Lucide.Icon,
size: IconSize = .medium,
customTint: ThemeValue? = nil,
shouldFill: Bool = false,
accessibility: Accessibility? = nil
) -> SessionCell.Accessory {
return SessionCell.AccessoryConfig.Icon(
icon: icon,
image: nil,
iconSize: size,
customTint: customTint,
shouldFill: shouldFill,
accessibility: accessibility
)
}
static func icon(
_ image: UIImage?,
size: IconSize = .medium,
@ -38,6 +56,7 @@ public extension SessionCell.Accessory {
accessibility: Accessibility? = nil
) -> SessionCell.Accessory {
return SessionCell.AccessoryConfig.Icon(
icon: nil,
image: image,
iconSize: size,
customTint: customTint,
@ -180,14 +199,12 @@ public extension SessionCell.Accessory {
)
}
static func customView(
uniqueId: AnyHashable,
accessibility: Accessibility? = nil,
viewGenerator: @escaping () -> UIView
static func custom<T: SessionCell.Accessory.CustomViewInfo>(
info: T,
accessibility: Accessibility? = nil
) -> SessionCell.Accessory {
return SessionCell.AccessoryConfig.CustomView(
uniqueId: uniqueId,
viewGenerator: viewGenerator,
return SessionCell.AccessoryConfig.Custom(
info: info,
accessibility: accessibility
)
}
@ -199,18 +216,21 @@ public extension SessionCell.AccessoryConfig {
// MARK: - Icon
class Icon: SessionCell.Accessory {
public let icon: Lucide.Icon?
public let image: UIImage?
public let iconSize: IconSize
public let customTint: ThemeValue?
public let shouldFill: Bool
fileprivate init(
icon: Lucide.Icon?,
image: UIImage?,
iconSize: IconSize,
customTint: ThemeValue?,
shouldFill: Bool,
accessibility: Accessibility?
) {
self.icon = icon
self.image = image
self.iconSize = iconSize
self.customTint = customTint
@ -222,6 +242,7 @@ public extension SessionCell.AccessoryConfig {
// MARK: - Conformance
override public func hash(into hasher: inout Hasher) {
icon.hash(into: &hasher)
image.hash(into: &hasher)
iconSize.hash(into: &hasher)
customTint.hash(into: &hasher)
@ -233,6 +254,7 @@ public extension SessionCell.AccessoryConfig {
guard let rhs: Icon = other as? Icon else { return false }
return (
icon == rhs.icon &&
image == rhs.image &&
iconSize == rhs.iconSize &&
customTint == rhs.customTint &&
@ -661,34 +683,88 @@ public extension SessionCell.AccessoryConfig {
}
}
class CustomView: SessionCell.Accessory {
public let uniqueId: AnyHashable
public let viewGenerator: () -> UIView
class Custom<T: SessionCell.Accessory.CustomViewInfo>: SessionCell.Accessory, AnyCustom {
public let info: T
fileprivate init(
uniqueId: AnyHashable,
viewGenerator: @escaping () -> UIView,
info: T,
accessibility: Accessibility?
) {
self.uniqueId = uniqueId
self.viewGenerator = viewGenerator
self.info = info
super.init(accessibility: accessibility)
}
// MARK: - Conformance
public func createView(maxContentWidth: CGFloat, using dependencies: Dependencies) -> UIView {
return info.createView(maxContentWidth: maxContentWidth, using: dependencies)
}
override public func hash(into hasher: inout Hasher) {
uniqueId.hash(into: &hasher)
info.hash(into: &hasher)
accessibility.hash(into: &hasher)
}
override fileprivate func isEqual(to other: SessionCell.Accessory) -> Bool {
return (
other is CustomView &&
uniqueId == (other as? CustomView)?.uniqueId &&
accessibility == (other as? CustomView)?.accessibility
other is Custom &&
info == (other as? Custom)?.info &&
accessibility == (other as? Custom)?.accessibility
)
}
}
protocol AnyCustom {
var accessibility: Accessibility? { get }
func createView(maxContentWidth: CGFloat, using dependencies: Dependencies) -> UIView
}
}
// MARK: - SessionCell.Accessory.CustomView
public extension SessionCell.Accessory {
enum Size {
case fixed(width: CGFloat, height: CGFloat)
case fillWidth(height: CGFloat)
case fillWidthWrapHeight
}
}
public extension SessionCell.Accessory {
protocol CustomView: UIView {
associatedtype Info
static var size: Size { get }
static func create(maxContentWidth: CGFloat, using dependencies: Dependencies) -> Self
func update(with info: Info)
}
protocol CustomViewInfo: Equatable, Hashable {
associatedtype View: CustomView where View.Info == Self
}
}
public extension SessionCell.Accessory.CustomViewInfo {
func createView(maxContentWidth: CGFloat, using dependencies: Dependencies) -> UIView {
let view: View = View.create(maxContentWidth: maxContentWidth, using: dependencies)
view.update(with: self)
switch View.size {
case .fixed(let width, let height):
view.set(.width, to: width)
view.set(.height, to: height)
case .fillWidth(let height):
view.set(.height, to: height)
case .fillWidthWrapHeight:
view.setContentHugging(.vertical, to: .required)
view.setCompressionResistance(.vertical, to: .required)
}
return view
}
}

@ -97,6 +97,7 @@ public extension SessionCell {
enum FontStyle: Hashable, Equatable {
case title
case titleLarge
case titleRegular
case subtitle
case subtitleBold
@ -108,6 +109,7 @@ public extension SessionCell {
switch self {
case .title: return .boldSystemFont(ofSize: 16)
case .titleLarge: return .systemFont(ofSize: Values.veryLargeFontSize, weight: .medium)
case .titleRegular: return .systemFont(ofSize: 16)
case .subtitle: return .systemFont(ofSize: 14)
case .subtitleBold: return .boldSystemFont(ofSize: 14)
@ -166,6 +168,10 @@ public extension SessionCell {
}
}
public extension Optional where Wrapped == SessionCell.Padding {
static let none: SessionCell.Padding = SessionCell.Padding()
}
// MARK: - ExpressibleByStringLiteral
extension SessionCell.TextInfo: ExpressibleByStringLiteral, ExpressibleByExtendedGraphemeClusterLiteral, ExpressibleByUnicodeScalarLiteral {

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import Lucide
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
@ -288,6 +289,7 @@ extension SessionCell {
with accessory: Accessory?,
tintColor: ThemeValue,
isEnabled: Bool,
maxContentWidth: CGFloat,
isManualReload: Bool,
using dependencies: Dependencies
) {
@ -301,11 +303,20 @@ extension SessionCell {
case let accessory as SessionCell.AccessoryConfig.Icon:
imageView.accessibilityIdentifier = accessory.accessibility?.identifier
imageView.accessibilityLabel = accessory.accessibility?.label
imageView.image = accessory.image
imageView.themeTintColor = (accessory.customTint ?? tintColor)
imageView.contentMode = (accessory.shouldFill ? .scaleAspectFill : .scaleAspectFit)
imageView.isHidden = false
switch (accessory.icon, accessory.image) {
case (.some(let icon), _):
imageView.image = Lucide
.image(icon: icon, size: accessory.iconSize.size)?
.withRenderingMode(.alwaysTemplate)
case (.none, .some(let image)): imageView.image = image
case (.none, .none): imageView.image = nil
}
switch accessory.iconSize {
case .fit:
imageView.sizeToFit()
@ -581,9 +592,13 @@ extension SessionCell {
minWidthConstraint.isActive = true
buttonConstraints.forEach { $0.isActive = true }
// MARK: -- CustomView
case let accessory as SessionCell.AccessoryConfig.CustomView:
let generatedView: UIView = accessory.viewGenerator()
// MARK: -- Custom
case let accessory as SessionCell.AccessoryConfig.AnyCustom:
let generatedView: UIView = accessory.createView(
maxContentWidth: maxContentWidth,
using: dependencies
)
generatedView.accessibilityIdentifier = accessory.accessibility?.identifier
generatedView.accessibilityLabel = accessory.accessibility?.label
addSubview(generatedView)

@ -323,6 +323,7 @@ public class SessionCell: UITableViewCell {
public func update<ID: Hashable & Differentiable>(
with info: Info<ID>,
tableSize: CGSize,
isManualReload: Bool = false,
using dependencies: Dependencies
) {
@ -339,48 +340,7 @@ public class SessionCell: UITableViewCell {
let leadingFitToEdge: Bool = (info.leadingAccessory?.shouldFitToEdge == true)
let trailingFitToEdge: Bool = (!leadingFitToEdge && info.trailingAccessory?.shouldFitToEdge == true)
// Content
contentStackView.spacing = (info.styling.customPadding?.interItem ?? Values.mediumSpacing)
leadingAccessoryView.update(
with: info.leadingAccessory,
tintColor: info.styling.tintColor,
isEnabled: info.isEnabled,
isManualReload: isManualReload,
using: dependencies
)
titleStackView.isHidden = (info.title == nil && info.subtitle == nil)
titleLabel.isUserInteractionEnabled = (info.title?.interaction == .copy)
titleLabel.font = info.title?.font
titleLabel.text = info.title?.text
titleLabel.themeTextColor = info.styling.tintColor
titleLabel.textAlignment = (info.title?.textAlignment ?? .left)
titleLabel.accessibilityIdentifier = info.title?.accessibility?.identifier
titleLabel.accessibilityLabel = info.title?.accessibility?.label
titleLabel.isHidden = (info.title == nil)
titleTextField.text = info.title?.text
titleTextField.textAlignment = (info.title?.textAlignment ?? .left)
titleTextField.placeholder = info.title?.editingPlaceholder
titleTextField.isHidden = (info.title == nil)
titleTextField.accessibilityIdentifier = info.title?.accessibility?.identifier
titleTextField.accessibilityLabel = info.title?.accessibility?.label
subtitleLabel.isUserInteractionEnabled = (info.subtitle?.interaction == .copy)
subtitleLabel.font = info.subtitle?.font
subtitleLabel.attributedText = info.subtitle.map { subtitle -> NSAttributedString? in
NSAttributedString(stringWithHTMLTags: subtitle.text, font: subtitle.font)
}
subtitleLabel.themeTextColor = info.styling.subtitleTintColor
subtitleLabel.textAlignment = (info.subtitle?.textAlignment ?? .left)
subtitleLabel.accessibilityIdentifier = info.subtitle?.accessibility?.identifier
subtitleLabel.accessibilityLabel = info.subtitle?.accessibility?.label
subtitleLabel.isHidden = (info.subtitle == nil)
trailingAccessoryView.update(
with: info.trailingAccessory,
tintColor: info.styling.tintColor,
isEnabled: info.isEnabled,
isManualReload: isManualReload,
using: dependencies
)
// Layout (do this before setting up the content so we can calculate the expected widths if needed)
contentStackViewLeadingConstraint.isActive = (info.styling.alignment == .leading)
contentStackViewTrailingConstraint.isActive = (info.styling.alignment == .leading)
contentStackViewHorizontalCenterConstraint.constant = ((info.styling.customPadding?.leading ?? 0) + (info.styling.customPadding?.trailing ?? 0))
@ -557,6 +517,54 @@ public class SessionCell: UITableViewCell {
)
)
}
// Content
let contentStackViewHorizontalInset: CGFloat = (
(backgroundLeftConstraint.constant + (-backgroundRightConstraint.constant)) +
(contentStackViewLeadingConstraint.constant + (-contentStackViewTrailingConstraint.constant))
)
contentStackView.spacing = (info.styling.customPadding?.interItem ?? Values.mediumSpacing)
leadingAccessoryView.update(
with: info.leadingAccessory,
tintColor: info.styling.tintColor,
isEnabled: info.isEnabled,
maxContentWidth: (tableSize.width - contentStackViewHorizontalInset),
isManualReload: isManualReload,
using: dependencies
)
titleStackView.isHidden = (info.title == nil && info.subtitle == nil)
titleLabel.isUserInteractionEnabled = (info.title?.interaction == .copy)
titleLabel.font = info.title?.font
titleLabel.text = info.title?.text
titleLabel.themeTextColor = info.styling.tintColor
titleLabel.textAlignment = (info.title?.textAlignment ?? .left)
titleLabel.accessibilityIdentifier = info.title?.accessibility?.identifier
titleLabel.accessibilityLabel = info.title?.accessibility?.label
titleLabel.isHidden = (info.title == nil)
titleTextField.text = info.title?.text
titleTextField.textAlignment = (info.title?.textAlignment ?? .left)
titleTextField.placeholder = info.title?.editingPlaceholder
titleTextField.isHidden = (info.title == nil)
titleTextField.accessibilityIdentifier = info.title?.accessibility?.identifier
titleTextField.accessibilityLabel = info.title?.accessibility?.label
subtitleLabel.isUserInteractionEnabled = (info.subtitle?.interaction == .copy)
subtitleLabel.font = info.subtitle?.font
subtitleLabel.attributedText = info.subtitle.map { subtitle -> NSAttributedString? in
NSAttributedString(stringWithHTMLTags: subtitle.text, font: subtitle.font)
}
subtitleLabel.themeTextColor = info.styling.subtitleTintColor
subtitleLabel.textAlignment = (info.subtitle?.textAlignment ?? .left)
subtitleLabel.accessibilityIdentifier = info.subtitle?.accessibility?.identifier
subtitleLabel.accessibilityLabel = info.subtitle?.accessibility?.label
subtitleLabel.isHidden = (info.subtitle == nil)
trailingAccessoryView.update(
with: info.trailingAccessory,
tintColor: info.styling.tintColor,
isEnabled: info.isEnabled,
maxContentWidth: (tableSize.width - contentStackViewHorizontalInset),
isManualReload: isManualReload,
using: dependencies
)
}
public func update(isEditing: Bool, becomeFirstResponder: Bool, animated: Bool) {

@ -143,6 +143,15 @@ public extension Anchorable {
.setting(isActive: true)
}
@discardableResult
func pin(_ constraineeEdge: UIView.HorizontalEdge, toCenterOf view: UIView, withInset inset: CGFloat = 0) -> NSLayoutConstraint {
(self as? UIView)?.translatesAutoresizingMaskIntoConstraints = false
return anchor(from: constraineeEdge)
.constraint(equalTo: view.centerXAnchor, constant: inset)
.setting(isActive: true)
}
@discardableResult
func pin(_ constraineeEdge: UIView.VerticalEdge, to constrainerEdge: UIView.VerticalEdge, of anchorable: Anchorable, withInset inset: CGFloat = 0) -> NSLayoutConstraint {
(self as? UIView)?.translatesAutoresizingMaskIntoConstraints = false
@ -178,6 +187,15 @@ public extension Anchorable {
)
.setting(isActive: true)
}
@discardableResult
func pin(_ constraineeEdge: UIView.VerticalEdge, toCenterOf view: UIView, withInset inset: CGFloat = 0) -> NSLayoutConstraint {
(self as? UIView)?.translatesAutoresizingMaskIntoConstraints = false
return anchor(from: constraineeEdge)
.constraint(equalTo: view.centerYAnchor, constant: inset)
.setting(isActive: true)
}
}
// MARK: - View extensions

@ -11,6 +11,7 @@ public enum ImageFormat {
case bmp
case webp
// stringlint:ignore_contents
public var fileExtension: String {
switch self {
case .jpeg, .unknown: return "jpg"

Loading…
Cancel
Save