Add storage usage stats settings view

This commit implements a new Storage settings view that displays storage
usage statistics for NostrDB, snapshot database, and Kingfisher image cache.

Key features:
- Interactive pie chart visualization (iOS 17+) with tap-to-select functionality
- Pull-to-refresh gesture to recalculate storage
- Categorized list showing each storage type with size and percentage
- Total storage sum displayed at bottom
- Conditional compilation for iOS 16/17+ compatibility
- All calculations run on background thread to avoid blocking main thread
- NostrDB storage breakdown

Changelog-Added: Storage usage statistics view in Settings
Changelog-Changed: Moved clear cache button to storage settings
Closes: https://github.com/damus-io/damus/issues/3649
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2026-02-20 18:17:09 -08:00
parent 65e767b774
commit 795fce1b65
11 changed files with 1860 additions and 70 deletions

View File

@@ -41,7 +41,6 @@
3A92C0FF2DE16E9800CEEBAC /* FaviconCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A92C0FD2DE16E9800CEEBAC /* FaviconCache.swift */; }; 3A92C0FF2DE16E9800CEEBAC /* FaviconCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A92C0FD2DE16E9800CEEBAC /* FaviconCache.swift */; };
3A92C1002DE16E9800CEEBAC /* FaviconCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A92C0FD2DE16E9800CEEBAC /* FaviconCache.swift */; }; 3A92C1002DE16E9800CEEBAC /* FaviconCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A92C0FD2DE16E9800CEEBAC /* FaviconCache.swift */; };
3A92C1022DE17ACA00CEEBAC /* NIP05DomainTimelineHeaderViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A92C1012DE17ACA00CEEBAC /* NIP05DomainTimelineHeaderViewTests.swift */; }; 3A92C1022DE17ACA00CEEBAC /* NIP05DomainTimelineHeaderViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A92C1012DE17ACA00CEEBAC /* NIP05DomainTimelineHeaderViewTests.swift */; };
EBCC3486DE53D8DB2532B98E /* LoadableNostrEventViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AE7EE3216D7983A50BE2D9 /* LoadableNostrEventViewModelTests.swift */; };
3A96E3FE2D6BCE3800AE1630 /* RepostedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A96E3FD2D6BCE3800AE1630 /* RepostedTests.swift */; }; 3A96E3FE2D6BCE3800AE1630 /* RepostedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A96E3FD2D6BCE3800AE1630 /* RepostedTests.swift */; };
3AA247FF297E3D900090C62D /* RepostsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA247FE297E3D900090C62D /* RepostsView.swift */; }; 3AA247FF297E3D900090C62D /* RepostsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA247FE297E3D900090C62D /* RepostsView.swift */; };
3AA24802297E3DC20090C62D /* RepostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA24801297E3DC20090C62D /* RepostView.swift */; }; 3AA24802297E3DC20090C62D /* RepostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA24801297E3DC20090C62D /* RepostView.swift */; };
@@ -1713,6 +1712,14 @@
D77135D62E7B78D700E7639F /* DataExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77135D22E7B766300E7639F /* DataExtensions.swift */; }; D77135D62E7B78D700E7639F /* DataExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77135D22E7B766300E7639F /* DataExtensions.swift */; };
D773BC5F2C6D538500349F0A /* CommentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D773BC5E2C6D538500349F0A /* CommentItem.swift */; }; D773BC5F2C6D538500349F0A /* CommentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D773BC5E2C6D538500349F0A /* CommentItem.swift */; };
D773BC602C6D538500349F0A /* CommentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D773BC5E2C6D538500349F0A /* CommentItem.swift */; }; D773BC602C6D538500349F0A /* CommentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D773BC5E2C6D538500349F0A /* CommentItem.swift */; };
D774A5C82F4F93F5006A4D64 /* StorageStatsViewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D774A5C72F4F93F5006A4D64 /* StorageStatsViewHelper.swift */; };
D774A5C92F4F93F5006A4D64 /* StorageStatsViewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D774A5C72F4F93F5006A4D64 /* StorageStatsViewHelper.swift */; };
D774A5CA2F4F93F5006A4D64 /* StorageStatsViewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D774A5C72F4F93F5006A4D64 /* StorageStatsViewHelper.swift */; };
D774A5CC2F4F9679006A4D64 /* StorageStatsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D774A5CB2F4F9679006A4D64 /* StorageStatsManagerTests.swift */; };
D774A5CE2F4FBDEA006A4D64 /* NdbDatabase+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = D774A5CD2F4FBDEA006A4D64 /* NdbDatabase+UI.swift */; };
D774A5CF2F4FBDEA006A4D64 /* NdbDatabase+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = D774A5CD2F4FBDEA006A4D64 /* NdbDatabase+UI.swift */; };
D774A5D02F4FBDEA006A4D64 /* NdbDatabase+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = D774A5CD2F4FBDEA006A4D64 /* NdbDatabase+UI.swift */; };
D774A5D12F4FBDEA006A4D64 /* NdbDatabase+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = D774A5CD2F4FBDEA006A4D64 /* NdbDatabase+UI.swift */; };
D776BE402F232B17002DA1C9 /* EntityPreloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D776BE3F2F232B17002DA1C9 /* EntityPreloader.swift */; }; D776BE402F232B17002DA1C9 /* EntityPreloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D776BE3F2F232B17002DA1C9 /* EntityPreloader.swift */; };
D776BE412F232B17002DA1C9 /* EntityPreloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D776BE3F2F232B17002DA1C9 /* EntityPreloader.swift */; }; D776BE412F232B17002DA1C9 /* EntityPreloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D776BE3F2F232B17002DA1C9 /* EntityPreloader.swift */; };
D776BE422F232B17002DA1C9 /* EntityPreloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D776BE3F2F232B17002DA1C9 /* EntityPreloader.swift */; }; D776BE422F232B17002DA1C9 /* EntityPreloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D776BE3F2F232B17002DA1C9 /* EntityPreloader.swift */; };
@@ -1731,6 +1738,12 @@
D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */; }; D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */; };
D7870BC12AC4750B0080BA88 /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC02AC4750B0080BA88 /* MentionView.swift */; }; D7870BC12AC4750B0080BA88 /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC02AC4750B0080BA88 /* MentionView.swift */; };
D7870BC32AC47EBC0080BA88 /* EventLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */; }; D7870BC32AC47EBC0080BA88 /* EventLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */; };
D78778222F49476700DA73E4 /* StorageStatsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78778212F49476700DA73E4 /* StorageStatsManager.swift */; };
D78778232F49476700DA73E4 /* StorageStatsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78778212F49476700DA73E4 /* StorageStatsManager.swift */; };
D78778242F49476700DA73E4 /* StorageStatsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78778212F49476700DA73E4 /* StorageStatsManager.swift */; };
D78778262F49478200DA73E4 /* StorageSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78778252F49478200DA73E4 /* StorageSettingsView.swift */; };
D78778272F49478200DA73E4 /* StorageSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78778252F49478200DA73E4 /* StorageSettingsView.swift */; };
D78778282F49478200DA73E4 /* StorageSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78778252F49478200DA73E4 /* StorageSettingsView.swift */; };
D789D1202AFEFBF20083A7AB /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = D789D11F2AFEFBF20083A7AB /* secp256k1 */; }; D789D1202AFEFBF20083A7AB /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = D789D11F2AFEFBF20083A7AB /* secp256k1 */; };
D78BA6652DD7DFB9000AE62C /* InterestSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78BA6642DD7DFB9000AE62C /* InterestSelectionView.swift */; }; D78BA6652DD7DFB9000AE62C /* InterestSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78BA6642DD7DFB9000AE62C /* InterestSelectionView.swift */; };
D78BA6662DD7DFB9000AE62C /* InterestSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78BA6642DD7DFB9000AE62C /* InterestSelectionView.swift */; }; D78BA6662DD7DFB9000AE62C /* InterestSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78BA6642DD7DFB9000AE62C /* InterestSelectionView.swift */; };
@@ -1780,6 +1793,9 @@
D7ADD3DE2B53854300F104C4 /* DamusPurpleURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */; }; D7ADD3DE2B53854300F104C4 /* DamusPurpleURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */; };
D7ADD3E02B538D4200F104C4 /* DamusPurpleURLSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3DF2B538D4200F104C4 /* DamusPurpleURLSheetView.swift */; }; D7ADD3E02B538D4200F104C4 /* DamusPurpleURLSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3DF2B538D4200F104C4 /* DamusPurpleURLSheetView.swift */; };
D7ADD3E22B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */; }; D7ADD3E22B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */; };
D7B62B9A2F4CE59B001EE26F /* NostrDBDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B62B992F4CE59B001EE26F /* NostrDBDetailView.swift */; };
D7B62B9B2F4CE59B001EE26F /* NostrDBDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B62B992F4CE59B001EE26F /* NostrDBDetailView.swift */; };
D7B62B9C2F4CE59B001EE26F /* NostrDBDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B62B992F4CE59B001EE26F /* NostrDBDetailView.swift */; };
D7B76C902C825042003A16CB /* PushNotificationClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D2A3802BF815D000E4B42B /* PushNotificationClient.swift */; }; D7B76C902C825042003A16CB /* PushNotificationClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D2A3802BF815D000E4B42B /* PushNotificationClient.swift */; };
D7B76C912C82507F003A16CB /* NIP98AuthenticatedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift */; }; D7B76C912C82507F003A16CB /* NIP98AuthenticatedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift */; };
D7BEE6F92D37B37400CF659F /* DraftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7BEE6F82D37B37400CF659F /* DraftTests.swift */; }; D7BEE6F92D37B37400CF659F /* DraftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7BEE6F82D37B37400CF659F /* DraftTests.swift */; };
@@ -1956,6 +1972,7 @@
E0EE9DD42B8E5FEA00F3002D /* ImageProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0EE9DD32B8E5FEA00F3002D /* ImageProcessing.swift */; }; E0EE9DD42B8E5FEA00F3002D /* ImageProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0EE9DD32B8E5FEA00F3002D /* ImageProcessing.swift */; };
E4FA1C032A24BB7F00482697 /* SearchSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */; }; E4FA1C032A24BB7F00482697 /* SearchSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */; };
E990020F2955F837003BBC5A /* EditMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E990020E2955F837003BBC5A /* EditMetadataView.swift */; }; E990020F2955F837003BBC5A /* EditMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E990020E2955F837003BBC5A /* EditMetadataView.swift */; };
EBCC3486DE53D8DB2532B98E /* LoadableNostrEventViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AE7EE3216D7983A50BE2D9 /* LoadableNostrEventViewModelTests.swift */; };
F71694EA2A662232001F4053 /* OnboardingSuggestionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694E92A662232001F4053 /* OnboardingSuggestionsView.swift */; }; F71694EA2A662232001F4053 /* OnboardingSuggestionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694E92A662232001F4053 /* OnboardingSuggestionsView.swift */; };
F71694EC2A662292001F4053 /* SuggestedUsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694EB2A662292001F4053 /* SuggestedUsersViewModel.swift */; }; F71694EC2A662292001F4053 /* SuggestedUsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694EB2A662292001F4053 /* SuggestedUsersViewModel.swift */; };
F71694F22A67314D001F4053 /* SuggestedUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694F12A67314D001F4053 /* SuggestedUserView.swift */; }; F71694F22A67314D001F4053 /* SuggestedUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694F12A67314D001F4053 /* SuggestedUserView.swift */; };
@@ -2094,7 +2111,6 @@
3A929C22297F2CF80090925E /* it-IT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "it-IT"; path = "it-IT.lproj/Localizable.stringsdict"; sourceTree = "<group>"; }; 3A929C22297F2CF80090925E /* it-IT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "it-IT"; path = "it-IT.lproj/Localizable.stringsdict"; sourceTree = "<group>"; };
3A92C0FD2DE16E9800CEEBAC /* FaviconCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconCache.swift; sourceTree = "<group>"; }; 3A92C0FD2DE16E9800CEEBAC /* FaviconCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconCache.swift; sourceTree = "<group>"; };
3A92C1012DE17ACA00CEEBAC /* NIP05DomainTimelineHeaderViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP05DomainTimelineHeaderViewTests.swift; sourceTree = "<group>"; }; 3A92C1012DE17ACA00CEEBAC /* NIP05DomainTimelineHeaderViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP05DomainTimelineHeaderViewTests.swift; sourceTree = "<group>"; };
C0AE7EE3216D7983A50BE2D9 /* LoadableNostrEventViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableNostrEventViewModelTests.swift; sourceTree = "<group>"; };
3A93342929884CA600D6A8F3 /* pl-PL */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pl-PL"; path = "pl-PL.lproj/InfoPlist.strings"; sourceTree = "<group>"; }; 3A93342929884CA600D6A8F3 /* pl-PL */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pl-PL"; path = "pl-PL.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
3A93342A29884CA600D6A8F3 /* pl-PL */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pl-PL"; path = "pl-PL.lproj/Localizable.strings"; sourceTree = "<group>"; }; 3A93342A29884CA600D6A8F3 /* pl-PL */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pl-PL"; path = "pl-PL.lproj/Localizable.strings"; sourceTree = "<group>"; };
3A93342B29884CA600D6A8F3 /* pl-PL */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "pl-PL"; path = "pl-PL.lproj/Localizable.stringsdict"; sourceTree = "<group>"; }; 3A93342B29884CA600D6A8F3 /* pl-PL */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "pl-PL"; path = "pl-PL.lproj/Localizable.stringsdict"; sourceTree = "<group>"; };
@@ -2763,6 +2779,7 @@
BA693073295D649800ADDB87 /* UserSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsStore.swift; sourceTree = "<group>"; }; BA693073295D649800ADDB87 /* UserSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsStore.swift; sourceTree = "<group>"; };
BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectWalletView.swift; sourceTree = "<group>"; }; BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectWalletView.swift; sourceTree = "<group>"; };
BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KingfisherImageProvider.swift; sourceTree = "<group>"; }; BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KingfisherImageProvider.swift; sourceTree = "<group>"; };
C0AE7EE3216D7983A50BE2D9 /* LoadableNostrEventViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableNostrEventViewModelTests.swift; sourceTree = "<group>"; };
D2277EE92A089BD5006C3807 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = "<group>"; }; D2277EE92A089BD5006C3807 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = "<group>"; };
D5C1AFBE2E5DF7E60092F72F /* ContactCardManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCardManager.swift; sourceTree = "<group>"; }; D5C1AFBE2E5DF7E60092F72F /* ContactCardManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCardManager.swift; sourceTree = "<group>"; };
D5C1AFC32E5DFF700092F72F /* ContactCardManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCardManagerMock.swift; sourceTree = "<group>"; }; D5C1AFC32E5DFF700092F72F /* ContactCardManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCardManagerMock.swift; sourceTree = "<group>"; };
@@ -2843,6 +2860,9 @@
D76BE18B2E0CF3D5004AD0C6 /* Interests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Interests.swift; sourceTree = "<group>"; }; D76BE18B2E0CF3D5004AD0C6 /* Interests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Interests.swift; sourceTree = "<group>"; };
D77135D22E7B766300E7639F /* DataExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtensions.swift; sourceTree = "<group>"; }; D77135D22E7B766300E7639F /* DataExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtensions.swift; sourceTree = "<group>"; };
D773BC5E2C6D538500349F0A /* CommentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentItem.swift; sourceTree = "<group>"; }; D773BC5E2C6D538500349F0A /* CommentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentItem.swift; sourceTree = "<group>"; };
D774A5C72F4F93F5006A4D64 /* StorageStatsViewHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageStatsViewHelper.swift; sourceTree = "<group>"; };
D774A5CB2F4F9679006A4D64 /* StorageStatsManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageStatsManagerTests.swift; sourceTree = "<group>"; };
D774A5CD2F4FBDEA006A4D64 /* NdbDatabase+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NdbDatabase+UI.swift"; sourceTree = "<group>"; };
D776BE3F2F232B17002DA1C9 /* EntityPreloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityPreloader.swift; sourceTree = "<group>"; }; D776BE3F2F232B17002DA1C9 /* EntityPreloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityPreloader.swift; sourceTree = "<group>"; };
D776BE432F233012002DA1C9 /* EntityPreloaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityPreloaderTests.swift; sourceTree = "<group>"; }; D776BE432F233012002DA1C9 /* EntityPreloaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityPreloaderTests.swift; sourceTree = "<group>"; };
D77A96BE2F3131BE00CC3246 /* RelayHintsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayHintsTests.swift; sourceTree = "<group>"; }; D77A96BE2F3131BE00CC3246 /* RelayHintsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayHintsTests.swift; sourceTree = "<group>"; };
@@ -2854,6 +2874,8 @@
D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContentViewTests.swift; sourceTree = "<group>"; }; D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContentViewTests.swift; sourceTree = "<group>"; };
D7870BC02AC4750B0080BA88 /* MentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionView.swift; sourceTree = "<group>"; }; D7870BC02AC4750B0080BA88 /* MentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionView.swift; sourceTree = "<group>"; };
D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventLoaderView.swift; sourceTree = "<group>"; }; D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventLoaderView.swift; sourceTree = "<group>"; };
D78778212F49476700DA73E4 /* StorageStatsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageStatsManager.swift; sourceTree = "<group>"; };
D78778252F49478200DA73E4 /* StorageSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageSettingsView.swift; sourceTree = "<group>"; };
D78BA6642DD7DFB9000AE62C /* InterestSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterestSelectionView.swift; sourceTree = "<group>"; }; D78BA6642DD7DFB9000AE62C /* InterestSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterestSelectionView.swift; sourceTree = "<group>"; };
D78CD5972B8990300014D539 /* DamusAppNotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusAppNotificationView.swift; sourceTree = "<group>"; }; D78CD5972B8990300014D539 /* DamusAppNotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusAppNotificationView.swift; sourceTree = "<group>"; };
D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorMath.swift; sourceTree = "<group>"; }; D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorMath.swift; sourceTree = "<group>"; };
@@ -2874,6 +2896,7 @@
D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURL.swift; sourceTree = "<group>"; }; D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURL.swift; sourceTree = "<group>"; };
D7ADD3DF2B538D4200F104C4 /* DamusPurpleURLSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURLSheetView.swift; sourceTree = "<group>"; }; D7ADD3DF2B538D4200F104C4 /* DamusPurpleURLSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURLSheetView.swift; sourceTree = "<group>"; };
D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleVerifyNpubView.swift; sourceTree = "<group>"; }; D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleVerifyNpubView.swift; sourceTree = "<group>"; };
D7B62B992F4CE59B001EE26F /* NostrDBDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrDBDetailView.swift; sourceTree = "<group>"; };
D7BEE6F82D37B37400CF659F /* DraftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftTests.swift; sourceTree = "<group>"; }; D7BEE6F82D37B37400CF659F /* DraftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftTests.swift; sourceTree = "<group>"; };
D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP98AuthenticatedRequest.swift; sourceTree = "<group>"; }; D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP98AuthenticatedRequest.swift; sourceTree = "<group>"; };
D7CB5D3D2B116DAD00AD4105 /* NotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsManager.swift; sourceTree = "<group>"; }; D7CB5D3D2B116DAD00AD4105 /* NotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsManager.swift; sourceTree = "<group>"; };
@@ -3406,6 +3429,7 @@
4C78EFD92A707C4D007E8197 /* secp256k1.h */, 4C78EFD92A707C4D007E8197 /* secp256k1.h */,
D798D2272B085CDA00234419 /* NdbNote+.swift */, D798D2272B085CDA00234419 /* NdbNote+.swift */,
4CF480582B633F3800F2B2C0 /* NdbBlock.swift */, 4CF480582B633F3800F2B2C0 /* NdbBlock.swift */,
D774A5CD2F4FBDEA006A4D64 /* NdbDatabase+UI.swift */,
); );
path = nostrdb; path = nostrdb;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -3894,6 +3918,7 @@
4CE6DEF627F7A08200C66700 /* damusTests */ = { 4CE6DEF627F7A08200C66700 /* damusTests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D774A5CB2F4F9679006A4D64 /* StorageStatsManagerTests.swift */,
D77A96BE2F3131BE00CC3246 /* RelayHintsTests.swift */, D77A96BE2F3131BE00CC3246 /* RelayHintsTests.swift */,
D776BE432F233012002DA1C9 /* EntityPreloaderTests.swift */, D776BE432F233012002DA1C9 /* EntityPreloaderTests.swift */,
D77DA2CD2F1C2596000B7093 /* SubscriptionManagerNegentropyTests.swift */, D77DA2CD2F1C2596000B7093 /* SubscriptionManagerNegentropyTests.swift */,
@@ -4516,6 +4541,8 @@
5C78A7912E3036DA00CF177D /* Views */ = { 5C78A7912E3036DA00CF177D /* Views */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D7B62B992F4CE59B001EE26F /* NostrDBDetailView.swift */,
D78778252F49478200DA73E4 /* StorageSettingsView.swift */,
4C15C7142A55DE7A00D0A0DB /* ReactionsSettingsView.swift */, 4C15C7142A55DE7A00D0A0DB /* ReactionsSettingsView.swift */,
4C1A9A1C29DDCF9B00516EAC /* NotificationSettingsView.swift */, 4C1A9A1C29DDCF9B00516EAC /* NotificationSettingsView.swift */,
4C1A9A1E29DDD24B00516EAC /* AppearanceSettingsView.swift */, 4C1A9A1E29DDD24B00516EAC /* AppearanceSettingsView.swift */,
@@ -5001,6 +5028,8 @@
5C78A7BD2E306D6000CF177D /* Storage */ = { 5C78A7BD2E306D6000CF177D /* Storage */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D774A5C72F4F93F5006A4D64 /* StorageStatsViewHelper.swift */,
D78778212F49476700DA73E4 /* StorageStatsManager.swift */,
D7CCDB882F034FBB00218972 /* DatabaseSnapshotManager.swift */, D7CCDB882F034FBB00218972 /* DatabaseSnapshotManager.swift */,
D7EDED322B12ACAE0018B19C /* DamusUserDefaults.swift */, D7EDED322B12ACAE0018B19C /* DamusUserDefaults.swift */,
D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */, D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */,
@@ -5892,6 +5921,7 @@
4CC6193A29DC777C006A86D1 /* RelayBootstrap.swift in Sources */, 4CC6193A29DC777C006A86D1 /* RelayBootstrap.swift in Sources */,
4C285C8A2838B985008A31F1 /* ProfilePictureSelector.swift in Sources */, 4C285C8A2838B985008A31F1 /* ProfilePictureSelector.swift in Sources */,
4CFD502F2A2DA45800A229DB /* MediaView.swift in Sources */, 4CFD502F2A2DA45800A229DB /* MediaView.swift in Sources */,
D7B62B9B2F4CE59B001EE26F /* NostrDBDetailView.swift in Sources */,
D7373BA62B688EA300F7783D /* DamusPurpleTranslationSetupView.swift in Sources */, D7373BA62B688EA300F7783D /* DamusPurpleTranslationSetupView.swift in Sources */,
D776BE402F232B17002DA1C9 /* EntityPreloader.swift in Sources */, D776BE402F232B17002DA1C9 /* EntityPreloader.swift in Sources */,
4C9F18E429ABDE6D008C55EC /* MaybeAnonPfpView.swift in Sources */, 4C9F18E429ABDE6D008C55EC /* MaybeAnonPfpView.swift in Sources */,
@@ -5974,6 +6004,7 @@
4C363A9A28283854006E126D /* Reply.swift in Sources */, 4C363A9A28283854006E126D /* Reply.swift in Sources */,
BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */, BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */,
D7ADD3E02B538D4200F104C4 /* DamusPurpleURLSheetView.swift in Sources */, D7ADD3E02B538D4200F104C4 /* DamusPurpleURLSheetView.swift in Sources */,
D774A5D02F4FBDEA006A4D64 /* NdbDatabase+UI.swift in Sources */,
4CFF8F6729CC9E3A008DB934 /* FullScreenCarouselView.swift in Sources */, 4CFF8F6729CC9E3A008DB934 /* FullScreenCarouselView.swift in Sources */,
4CA927632A290EB10098A105 /* EventTop.swift in Sources */, 4CA927632A290EB10098A105 /* EventTop.swift in Sources */,
4C90BD18283A9EE5008EE7EF /* LoginView.swift in Sources */, 4C90BD18283A9EE5008EE7EF /* LoginView.swift in Sources */,
@@ -6094,6 +6125,7 @@
D73B74E12D8365BA0067BDBC /* ExtraFonts.swift in Sources */, D73B74E12D8365BA0067BDBC /* ExtraFonts.swift in Sources */,
4C7D09622A098D0E00943473 /* WalletConnect.swift in Sources */, 4C7D09622A098D0E00943473 /* WalletConnect.swift in Sources */,
4C64987E286D082C00EAE2B3 /* DirectMessagesModel.swift in Sources */, 4C64987E286D082C00EAE2B3 /* DirectMessagesModel.swift in Sources */,
D774A5CA2F4F93F5006A4D64 /* StorageStatsViewHelper.swift in Sources */,
4C12535E2A76CA870004F4B8 /* SwitchedTimelineNotify.swift in Sources */, 4C12535E2A76CA870004F4B8 /* SwitchedTimelineNotify.swift in Sources */,
D74F430A2B23F0BE00425B75 /* DamusPurple.swift in Sources */, D74F430A2B23F0BE00425B75 /* DamusPurple.swift in Sources */,
5C4FA7ED2DC29AE900CE658C /* FollowPackEvent.swift in Sources */, 5C4FA7ED2DC29AE900CE658C /* FollowPackEvent.swift in Sources */,
@@ -6119,6 +6151,7 @@
4C011B5F2BD0A56A002F2F9B /* ChatroomThreadView.swift in Sources */, 4C011B5F2BD0A56A002F2F9B /* ChatroomThreadView.swift in Sources */,
4C9F18E229AA9B6C008C55EC /* CustomizeZapView.swift in Sources */, 4C9F18E229AA9B6C008C55EC /* CustomizeZapView.swift in Sources */,
4C2859602A12A2BE004746F7 /* SupporterBadge.swift in Sources */, 4C2859602A12A2BE004746F7 /* SupporterBadge.swift in Sources */,
D78778232F49476700DA73E4 /* StorageStatsManager.swift in Sources */,
D73E5F7F2C6AA066007EB227 /* DamusAliases.swift in Sources */, D73E5F7F2C6AA066007EB227 /* DamusAliases.swift in Sources */,
4C1A9A2A29DDF54400516EAC /* DamusVideoPlayerView.swift in Sources */, 4C1A9A2A29DDF54400516EAC /* DamusVideoPlayerView.swift in Sources */,
D73BDB152D71216500D69970 /* UserRelayListManager.swift in Sources */, D73BDB152D71216500D69970 /* UserRelayListManager.swift in Sources */,
@@ -6185,6 +6218,7 @@
4C12536A2A76D3850004F4B8 /* RelaysChangedNotify.swift in Sources */, 4C12536A2A76D3850004F4B8 /* RelaysChangedNotify.swift in Sources */,
4C30AC8029A6A53F00E2BD5A /* ProfilePicturesView.swift in Sources */, 4C30AC8029A6A53F00E2BD5A /* ProfilePicturesView.swift in Sources */,
D7373BAA2B68A65A00F7783D /* PurpleAccountUpdateNotify.swift in Sources */, D7373BAA2B68A65A00F7783D /* PurpleAccountUpdateNotify.swift in Sources */,
D78778272F49478200DA73E4 /* StorageSettingsView.swift in Sources */,
5C6E1DAD2A193EC2008FC15A /* GradientButtonStyle.swift in Sources */, 5C6E1DAD2A193EC2008FC15A /* GradientButtonStyle.swift in Sources */,
3CCD1E6A2A874C4E0099A953 /* Nip98HTTPAuth.swift in Sources */, 3CCD1E6A2A874C4E0099A953 /* Nip98HTTPAuth.swift in Sources */,
5C4FA7FF2DC5119300CE658C /* FollowPackPreview.swift in Sources */, 5C4FA7FF2DC5119300CE658C /* FollowPackPreview.swift in Sources */,
@@ -6400,6 +6434,7 @@
D7DB1FEE2D5AC51B00CF06DA /* NIP44v2EncryptionTests.swift in Sources */, D7DB1FEE2D5AC51B00CF06DA /* NIP44v2EncryptionTests.swift in Sources */,
4C4F14A72A2A61A30045A0B9 /* NostrScriptTests.swift in Sources */, 4C4F14A72A2A61A30045A0B9 /* NostrScriptTests.swift in Sources */,
D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */, D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */,
D774A5CC2F4F9679006A4D64 /* StorageStatsManagerTests.swift in Sources */,
4C3EA67B28FF7B3900C48A62 /* InvoiceTests.swift in Sources */, 4C3EA67B28FF7B3900C48A62 /* InvoiceTests.swift in Sources */,
4C363A9E2828A822006E126D /* ReplyTests.swift in Sources */, 4C363A9E2828A822006E126D /* ReplyTests.swift in Sources */,
3A96E3FE2D6BCE3800AE1630 /* RepostedTests.swift in Sources */, 3A96E3FE2D6BCE3800AE1630 /* RepostedTests.swift in Sources */,
@@ -6556,6 +6591,7 @@
82D6FAFA2CD99F7900C925F4 /* UnmuteThreadNotify.swift in Sources */, 82D6FAFA2CD99F7900C925F4 /* UnmuteThreadNotify.swift in Sources */,
82D6FAFB2CD99F7900C925F4 /* ReconnectRelaysNotify.swift in Sources */, 82D6FAFB2CD99F7900C925F4 /* ReconnectRelaysNotify.swift in Sources */,
3ACF94432DA9FCAB00971A4E /* NIP05DomainTimelineView.swift in Sources */, 3ACF94432DA9FCAB00971A4E /* NIP05DomainTimelineView.swift in Sources */,
D78778242F49476700DA73E4 /* StorageStatsManager.swift in Sources */,
82D6FAFC2CD99F7900C925F4 /* PurpleAccountUpdateNotify.swift in Sources */, 82D6FAFC2CD99F7900C925F4 /* PurpleAccountUpdateNotify.swift in Sources */,
82D6FAFD2CD99F7900C925F4 /* IdType.swift in Sources */, 82D6FAFD2CD99F7900C925F4 /* IdType.swift in Sources */,
82D6FAFE2CD99F7900C925F4 /* Pubkey.swift in Sources */, 82D6FAFE2CD99F7900C925F4 /* Pubkey.swift in Sources */,
@@ -6713,6 +6749,7 @@
82D6FB822CD99F7900C925F4 /* Reply.swift in Sources */, 82D6FB822CD99F7900C925F4 /* Reply.swift in Sources */,
82D6FB832CD99F7900C925F4 /* SearchModel.swift in Sources */, 82D6FB832CD99F7900C925F4 /* SearchModel.swift in Sources */,
82D6FB842CD99F7900C925F4 /* NostrFilter+Hashable.swift in Sources */, 82D6FB842CD99F7900C925F4 /* NostrFilter+Hashable.swift in Sources */,
D7B62B9A2F4CE59B001EE26F /* NostrDBDetailView.swift in Sources */,
82D6FB852CD99F7900C925F4 /* Contacts.swift in Sources */, 82D6FB852CD99F7900C925F4 /* Contacts.swift in Sources */,
82D6FB862CD99F7900C925F4 /* CreateAccountModel.swift in Sources */, 82D6FB862CD99F7900C925F4 /* CreateAccountModel.swift in Sources */,
82D6FB872CD99F7900C925F4 /* HomeModel.swift in Sources */, 82D6FB872CD99F7900C925F4 /* HomeModel.swift in Sources */,
@@ -6747,6 +6784,7 @@
82D6FB9E2CD99F7900C925F4 /* ContentFilters.swift in Sources */, 82D6FB9E2CD99F7900C925F4 /* ContentFilters.swift in Sources */,
3A515C512DF4E100002D3B34 /* TrustedNetworkRepliesTip.swift in Sources */, 3A515C512DF4E100002D3B34 /* TrustedNetworkRepliesTip.swift in Sources */,
82D6FB9F2CD99F7900C925F4 /* DamusCacheManager.swift in Sources */, 82D6FB9F2CD99F7900C925F4 /* DamusCacheManager.swift in Sources */,
D774A5C92F4F93F5006A4D64 /* StorageStatsViewHelper.swift in Sources */,
82D6FBA02CD99F7900C925F4 /* NotificationsManager.swift in Sources */, 82D6FBA02CD99F7900C925F4 /* NotificationsManager.swift in Sources */,
D755B28E2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */, D755B28E2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */,
82D6FBA12CD99F7900C925F4 /* Contacts+.swift in Sources */, 82D6FBA12CD99F7900C925F4 /* Contacts+.swift in Sources */,
@@ -6787,6 +6825,7 @@
82D6FBC12CD99F7900C925F4 /* RelayURL.swift in Sources */, 82D6FBC12CD99F7900C925F4 /* RelayURL.swift in Sources */,
D76BE18C2E0CF3DA004AD0C6 /* Interests.swift in Sources */, D76BE18C2E0CF3DA004AD0C6 /* Interests.swift in Sources */,
82D6FBC22CD99F7900C925F4 /* NostrEvent+.swift in Sources */, 82D6FBC22CD99F7900C925F4 /* NostrEvent+.swift in Sources */,
D78778262F49478200DA73E4 /* StorageSettingsView.swift in Sources */,
82D6FBC32CD99F7900C925F4 /* NIP98AuthenticatedRequest.swift in Sources */, 82D6FBC32CD99F7900C925F4 /* NIP98AuthenticatedRequest.swift in Sources */,
82D6FBC42CD99F7900C925F4 /* NostrAuth.swift in Sources */, 82D6FBC42CD99F7900C925F4 /* NostrAuth.swift in Sources */,
82D6FBC52CD99F7900C925F4 /* MakeZapRequest.swift in Sources */, 82D6FBC52CD99F7900C925F4 /* MakeZapRequest.swift in Sources */,
@@ -6825,6 +6864,7 @@
82D6FBEA2CD99F7900C925F4 /* FullScreenCarouselView.swift in Sources */, 82D6FBEA2CD99F7900C925F4 /* FullScreenCarouselView.swift in Sources */,
D7F360272CEBBDC0009D34DA /* DamusVideoControlsView.swift in Sources */, D7F360272CEBBDC0009D34DA /* DamusVideoControlsView.swift in Sources */,
82D6FBEB2CD99F7900C925F4 /* ProfilePicImageView.swift in Sources */, 82D6FBEB2CD99F7900C925F4 /* ProfilePicImageView.swift in Sources */,
D774A5CF2F4FBDEA006A4D64 /* NdbDatabase+UI.swift in Sources */,
82D6FBEC2CD99F7900C925F4 /* ImageContainerView.swift in Sources */, 82D6FBEC2CD99F7900C925F4 /* ImageContainerView.swift in Sources */,
5C8F97492EB4620A009399B1 /* Glow.swift in Sources */, 5C8F97492EB4620A009399B1 /* Glow.swift in Sources */,
82D6FBED2CD99F7900C925F4 /* MediaView.swift in Sources */, 82D6FBED2CD99F7900C925F4 /* MediaView.swift in Sources */,
@@ -7020,6 +7060,7 @@
4C3624792D5EA20200DD066E /* bolt11.c in Sources */, 4C3624792D5EA20200DD066E /* bolt11.c in Sources */,
4C3624782D5EA1FE00DD066E /* error.c in Sources */, 4C3624782D5EA1FE00DD066E /* error.c in Sources */,
D776BE422F232B17002DA1C9 /* EntityPreloader.swift in Sources */, D776BE422F232B17002DA1C9 /* EntityPreloader.swift in Sources */,
D774A5D12F4FBDEA006A4D64 /* NdbDatabase+UI.swift in Sources */,
4C3624772D5EA1FA00DD066E /* nostr_bech32.c in Sources */, 4C3624772D5EA1FA00DD066E /* nostr_bech32.c in Sources */,
4C3624762D5EA1F600DD066E /* content_parser.c in Sources */, 4C3624762D5EA1F600DD066E /* content_parser.c in Sources */,
4C3624752D5EA1E000DD066E /* block.c in Sources */, 4C3624752D5EA1E000DD066E /* block.c in Sources */,
@@ -7166,6 +7207,7 @@
D73E5E912C6A97F4007EB227 /* CustomizeZapModel.swift in Sources */, D73E5E912C6A97F4007EB227 /* CustomizeZapModel.swift in Sources */,
D73E5E922C6A97F4007EB227 /* EventGroup.swift in Sources */, D73E5E922C6A97F4007EB227 /* EventGroup.swift in Sources */,
D73E5E932C6A97F4007EB227 /* ZapGroup.swift in Sources */, D73E5E932C6A97F4007EB227 /* ZapGroup.swift in Sources */,
D774A5C82F4F93F5006A4D64 /* StorageStatsViewHelper.swift in Sources */,
D73E5E942C6A97F4007EB227 /* NotificationStatusModel.swift in Sources */, D73E5E942C6A97F4007EB227 /* NotificationStatusModel.swift in Sources */,
3A515C542DF5371D002D3B34 /* TrustedNetworkButtonTipViewStyle.swift in Sources */, 3A515C542DF5371D002D3B34 /* TrustedNetworkButtonTipViewStyle.swift in Sources */,
D73E5E952C6A97F4007EB227 /* ThreadModel.swift in Sources */, D73E5E952C6A97F4007EB227 /* ThreadModel.swift in Sources */,
@@ -7222,6 +7264,7 @@
D77DA2C42F19CA48000B7093 /* AsyncStreamUtilities.swift in Sources */, D77DA2C42F19CA48000B7093 /* AsyncStreamUtilities.swift in Sources */,
D73E5EB42C6A97F4007EB227 /* NoteContent.swift in Sources */, D73E5EB42C6A97F4007EB227 /* NoteContent.swift in Sources */,
D73E5EB52C6A97F4007EB227 /* LongformEvent.swift in Sources */, D73E5EB52C6A97F4007EB227 /* LongformEvent.swift in Sources */,
D78778222F49476700DA73E4 /* StorageStatsManager.swift in Sources */,
D73E5EB62C6A97F4007EB227 /* PushNotificationClient.swift in Sources */, D73E5EB62C6A97F4007EB227 /* PushNotificationClient.swift in Sources */,
D706C5B92D602A110027C627 /* QueueableNotify.swift in Sources */, D706C5B92D602A110027C627 /* QueueableNotify.swift in Sources */,
D71AD8FD2CEC176A002E2C3C /* AppAccessibilityIdentifiers.swift in Sources */, D71AD8FD2CEC176A002E2C3C /* AppAccessibilityIdentifiers.swift in Sources */,
@@ -7298,6 +7341,7 @@
D73E5EFA2C6A97F4007EB227 /* NotificationItemView.swift in Sources */, D73E5EFA2C6A97F4007EB227 /* NotificationItemView.swift in Sources */,
5C8F97272EB460CA009399B1 /* LiveStreamBanner.swift in Sources */, 5C8F97272EB460CA009399B1 /* LiveStreamBanner.swift in Sources */,
D73E5EFB2C6A97F4007EB227 /* ProfilePicturesView.swift in Sources */, D73E5EFB2C6A97F4007EB227 /* ProfilePicturesView.swift in Sources */,
D78778282F49478200DA73E4 /* StorageSettingsView.swift in Sources */,
D73E5EFC2C6A97F4007EB227 /* DamusAppNotificationView.swift in Sources */, D73E5EFC2C6A97F4007EB227 /* DamusAppNotificationView.swift in Sources */,
D73E5EFD2C6A97F4007EB227 /* InnerTimelineView.swift in Sources */, D73E5EFD2C6A97F4007EB227 /* InnerTimelineView.swift in Sources */,
D73E5EFE2C6A97F4007EB227 /* (null) in Sources */, D73E5EFE2C6A97F4007EB227 /* (null) in Sources */,
@@ -7437,6 +7481,7 @@
D73E5F6C2C6A97F5007EB227 /* RepostsView.swift in Sources */, D73E5F6C2C6A97F5007EB227 /* RepostsView.swift in Sources */,
D734B1462CCC19B1000B5C97 /* DamusFullScreenCover.swift in Sources */, D734B1462CCC19B1000B5C97 /* DamusFullScreenCover.swift in Sources */,
D73BDB142D71216500D69970 /* UserRelayListManager.swift in Sources */, D73BDB142D71216500D69970 /* UserRelayListManager.swift in Sources */,
D7B62B9C2F4CE59B001EE26F /* NostrDBDetailView.swift in Sources */,
D73E5F6D2C6A97F5007EB227 /* Launch.storyboard in Sources */, D73E5F6D2C6A97F5007EB227 /* Launch.storyboard in Sources */,
D73E5F6F2C6A97F5007EB227 /* RelayFilterView.swift in Sources */, D73E5F6F2C6A97F5007EB227 /* RelayFilterView.swift in Sources */,
5CFDE6E72EF4F782004E8661 /* TenorModels.swift in Sources */, 5CFDE6E72EF4F782004E8661 /* TenorModels.swift in Sources */,
@@ -7687,6 +7732,7 @@
D798D2232B0859B700234419 /* KeychainStorage.swift in Sources */, D798D2232B0859B700234419 /* KeychainStorage.swift in Sources */,
D74AAFC32B153395006CF0F4 /* HeadlessDamusState.swift in Sources */, D74AAFC32B153395006CF0F4 /* HeadlessDamusState.swift in Sources */,
D78F08122D7F78F900FC6C75 /* Response.swift in Sources */, D78F08122D7F78F900FC6C75 /* Response.swift in Sources */,
D774A5CE2F4FBDEA006A4D64 /* NdbDatabase+UI.swift in Sources */,
D7CCFC102B05880F00323D86 /* Id.swift in Sources */, D7CCFC102B05880F00323D86 /* Id.swift in Sources */,
D7CB5D532B1174E900AD4105 /* DeepLPlan.swift in Sources */, D7CB5D532B1174E900AD4105 /* DeepLPlan.swift in Sources */,
D7EDED282B1180940018B19C /* ImageUploadModel.swift in Sources */, D7EDED282B1180940018B19C /* ImageUploadModel.swift in Sources */,

View File

@@ -0,0 +1,154 @@
//
// StorageStatsManager.swift
// damus
//
// Created by Daniel DAquino on 2026-02-20.
//
import Foundation
import Kingfisher
/// Storage statistics for various Damus data stores
struct StorageStats: Hashable {
/// Detailed breakdown of NostrDB storage by kind, indices, and other
let nostrdbDetails: NdbStats?
/// Size of the main NostrDB database file in bytes (total)
let nostrdbSize: UInt64
/// Size of the snapshot NostrDB database file in bytes
let snapshotSize: UInt64
/// Size of the Kingfisher image cache in bytes
let imageCacheSize: UInt64
/// Total storage used across all data stores
var totalSize: UInt64 {
return nostrdbSize + snapshotSize + imageCacheSize
}
/// Calculate the percentage of total storage used by a specific size
/// - Parameter size: The size to calculate percentage for
/// - Returns: Percentage value between 0.0 and 100.0
func percentage(for size: UInt64) -> Double {
guard totalSize > 0 else { return 0.0 }
return Double(size) / Double(totalSize) * 100.0
}
}
/// Manager for calculating storage statistics across Damus data stores
struct StorageStatsManager {
static let shared = StorageStatsManager()
private init() {}
/// Calculate storage statistics for all Damus data stores
///
/// This method runs all file operations on a background thread to avoid blocking
/// the main thread. It calculates:
/// - NostrDB database file size
/// - Detailed NostrDB breakdown (if ndb instance provided)
/// - Snapshot database file size
/// - Kingfisher image cache size
///
/// - Parameter ndb: Optional Ndb instance to get detailed storage breakdown
/// - Returns: StorageStats containing all calculated sizes
/// - Throws: Error if critical file operations fail
func calculateStorageStats(ndb: Ndb? = nil) async throws -> StorageStats {
// Run all file operations on background thread
return try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
do {
let nostrdbSize = self.getNostrDBSize()
let snapshotSize = self.getSnapshotDBSize()
// Get detailed NostrDB stats if ndb instance provided
let nostrdbDetails: NdbStats? = ndb?.getStats(physicalSize: nostrdbSize)
// Kingfisher cache size requires async callback
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
let imageCacheSize: UInt64
switch result {
case .success(let size):
imageCacheSize = UInt64(size)
case .failure(let error):
Log.error("Failed to calculate Kingfisher cache size: %@", for: .storage, error.localizedDescription)
imageCacheSize = 0
}
let stats = StorageStats(
nostrdbDetails: nostrdbDetails,
nostrdbSize: nostrdbSize,
snapshotSize: snapshotSize,
imageCacheSize: imageCacheSize
)
continuation.resume(returning: stats)
}
} catch {
continuation.resume(throwing: error)
}
}
}
}
/// Get the size of the main NostrDB database file
/// - Returns: Size in bytes, or 0 if file doesn't exist or error occurs
private func getNostrDBSize() -> UInt64 {
guard let dbPath = Ndb.db_path else {
Log.error("Failed to get NostrDB path", for: .storage)
return 0
}
let dataFilePath = "\(dbPath)/\(Ndb.main_db_file_name)"
return getFileSize(at: dataFilePath, description: "NostrDB")
}
/// Get the size of the snapshot NostrDB database file
/// - Returns: Size in bytes, or 0 if file doesn't exist or error occurs
private func getSnapshotDBSize() -> UInt64 {
guard let snapshotPath = Ndb.snapshot_db_path else {
Log.error("Failed to get snapshot DB path", for: .storage)
return 0
}
let dataFilePath = "\(snapshotPath)/\(Ndb.main_db_file_name)"
return getFileSize(at: dataFilePath, description: "Snapshot DB")
}
/// Get the size of a file at the specified path
/// - Parameters:
/// - path: Full path to the file
/// - description: Human-readable description for logging
/// - Returns: Size in bytes, or 0 if file doesn't exist or error occurs
private func getFileSize(at path: String, description: String) -> UInt64 {
guard FileManager.default.fileExists(atPath: path) else {
Log.info("%@ file does not exist at path: %@", for: .storage, description, path)
return 0
}
do {
let attributes = try FileManager.default.attributesOfItem(atPath: path)
guard let fileSize = attributes[.size] as? UInt64 else {
Log.error("Failed to get size attribute for %@", for: .storage, description)
return 0
}
return fileSize
} catch {
Log.error("Failed to get file size for %@: %@", for: .storage, description, error.localizedDescription)
return 0
}
}
/// Format bytes into a human-readable string
/// - Parameter bytes: Number of bytes
/// - Returns: Formatted string (e.g., "45.3 MB", "1.2 GB")
static func formatBytes(_ bytes: UInt64) -> String {
let formatter = ByteCountFormatter()
formatter.allowedUnits = [.useAll]
formatter.countStyle = .file
formatter.includesUnit = true
formatter.isAdaptive = true
return formatter.string(fromByteCount: Int64(bytes))
}
}

View File

@@ -0,0 +1,169 @@
//
// StorageStatsViewHelper.swift
// damus
//
// Created by Daniel D'Aquino on 2026-02-25.
//
import Foundation
import SwiftUI
/// Shared helper functions for storage statistics views
/// Consolidates common logic between StorageSettingsView and NostrDBDetailView
enum StorageStatsViewHelper {
// MARK: - Category Ranges
/// Computes cumulative ranges for angle selection in pie charts (iOS 17+)
/// - Parameter categories: Array of storage categories
/// - Returns: Array of tuples containing category ID and cumulative range
static func computeCategoryRanges(for categories: [StorageCategory]) -> [(category: String, range: Range<Double>)] {
var total: UInt64 = 0
return categories.map { category in
let newTotal = total + category.size
let result = (category: category.id, range: Double(total)..<Double(newTotal))
total = newTotal
return result
}
}
// MARK: - Storage Stats Loading
/// Load storage statistics asynchronously
/// - Parameter ndb: The NostrDB instance
/// - Returns: Calculated storage statistics
/// - Throws: Error if storage calculation fails
@concurrent
static func loadStorageStatsAsync(ndb: Ndb) async throws -> StorageStats {
return try await StorageStatsManager.shared.calculateStorageStats(ndb: ndb)
}
// MARK: - Export Preparation
/// Prepare export text for storage statistics on background thread
/// - Parameters:
/// - stats: The storage statistics to export
/// - formatter: Closure that formats the stats into text
/// - Returns: Formatted text ready for export
@concurrent
static func prepareExportText(
stats: StorageStats,
formatter: @escaping @concurrent (StorageStats) async -> String
) async -> String {
return await formatter(stats)
}
// MARK: - Text Formatting
/// Format storage statistics as exportable text
/// - Parameter stats: The storage statistics to format
/// - Returns: Formatted text representation of storage stats
@concurrent
static func formatStorageStatsAsText(_ stats: StorageStats) async -> String {
// Build categories list
let categories = [
StorageCategory(
id: "nostrdb",
title: NSLocalizedString("NostrDB", comment: "Label for main NostrDB database"),
icon: "internaldrive.fill",
color: .blue,
size: stats.nostrdbSize
),
StorageCategory(
id: "snapshot",
title: NSLocalizedString("Snapshot Database", comment: "Label for snapshot database"),
icon: "doc.on.doc.fill",
color: .purple,
size: stats.snapshotSize
),
StorageCategory(
id: "cache",
title: NSLocalizedString("Image Cache", comment: "Label for Kingfisher image cache"),
icon: "photo.fill",
color: .orange,
size: stats.imageCacheSize
)
]
var text = "Damus Storage Statistics\n"
text += "Generated: \(Date().formatted(date: .abbreviated, time: .shortened))\n"
text += String(repeating: "=", count: 50) + "\n\n"
// Top-level Categories
text += "Storage Breakdown:\n"
text += String(repeating: "-", count: 50) + "\n"
for category in categories {
let percentage = stats.percentage(for: category.size)
let titlePadded = category.title.padding(toLength: 25, withPad: " ", startingAt: 0)
let sizePadded = StorageStatsManager.formatBytes(category.size).padding(toLength: 10, withPad: " ", startingAt: 0)
text += "\(titlePadded) \(sizePadded) (\(String(format: "%.1f", percentage))%)\n"
}
text += String(repeating: "-", count: 50) + "\n"
let totalTitlePadded = "Total Storage".padding(toLength: 25, withPad: " ", startingAt: 0)
let totalSizePadded = StorageStatsManager.formatBytes(stats.totalSize).padding(toLength: 10, withPad: " ", startingAt: 0)
text += "\(totalTitlePadded) \(totalSizePadded)\n\n"
// Add NostrDB detailed breakdown if available
if let details = stats.nostrdbDetails {
text += await formatNostrDBDetails(details: details)
}
return text
}
/// Format NostrDB statistics as exportable text
/// - Parameter stats: The storage statistics containing NostrDB details
/// - Returns: Formatted text representation of NostrDB stats breakdown
@concurrent
static func formatNostrDBStatsAsText(_ stats: StorageStats) async -> String {
guard let details = stats.nostrdbDetails else {
return "NostrDB details not available"
}
var text = "Damus NostrDB Detailed Statistics\n"
text += "Generated: \(Date().formatted(date: .abbreviated, time: .shortened))\n"
text += String(repeating: "=", count: 50) + "\n\n"
text += await formatNostrDBDetails(details: details)
return text
}
// MARK: - Private Helpers
/// Format NostrDB details section
/// - Parameter details: The NostrDB statistics details
/// - Returns: Formatted text representation of NostrDB details
@concurrent
private static func formatNostrDBDetails(details: NdbStats) async -> String {
var text = String(repeating: "=", count: 50) + "\n\n"
text += "NostrDB Detailed Breakdown:\n"
text += String(repeating: "-", count: 50) + "\n"
// Per-database breakdown (sorted by size, already done in getStats)
if !details.databaseStats.isEmpty {
text += "\nDatabases:\n"
for dbStat in details.databaseStats {
let percentage = details.totalSize > 0 ? Double(dbStat.totalSize) / Double(details.totalSize) * 100.0 : 0.0
let dbNamePadded = dbStat.database.displayName.padding(toLength: 30, withPad: " ", startingAt: 0)
let sizePadded = StorageStatsManager.formatBytes(dbStat.totalSize).padding(toLength: 12, withPad: " ", startingAt: 0)
text += "\(dbNamePadded) \(sizePadded) (\(String(format: "%.1f", percentage))%)\n"
// Only show keys/values breakdown if both exist
if dbStat.keySize > 0 && dbStat.valueSize > 0 {
text += " Keys: \(StorageStatsManager.formatBytes(dbStat.keySize)), Values: \(StorageStatsManager.formatBytes(dbStat.valueSize))\n"
}
}
}
text += "\n" + String(repeating: "-", count: 50) + "\n"
let nostrdbTitlePadded = "NostrDB Total".padding(toLength: 30, withPad: " ", startingAt: 0)
let nostrdbSizePadded = StorageStatsManager.formatBytes(details.totalSize).padding(toLength: 12, withPad: " ", startingAt: 0)
text += "\(nostrdbTitlePadded) \(nostrdbSizePadded)\n"
return text
}
}

View File

@@ -7,16 +7,6 @@
import SwiftUI import SwiftUI
fileprivate let CACHE_CLEAR_BUTTON_RESET_TIME_IN_SECONDS: Double = 60
fileprivate let MINIMUM_CACHE_CLEAR_BUTTON_DELAY_IN_SECONDS: Double = 1
/// A simple type to keep track of the cache clearing state
fileprivate enum CacheClearingState {
case not_cleared
case clearing
case cleared
}
struct ResizedEventPreview: View { struct ResizedEventPreview: View {
let damus_state: DamusState let damus_state: DamusState
@ObservedObject var settings: UserSettingsStore @ObservedObject var settings: UserSettingsStore
@@ -59,8 +49,6 @@ struct AppearanceSettingsView: View {
let damus_state: DamusState let damus_state: DamusState
@ObservedObject var settings: UserSettingsStore @ObservedObject var settings: UserSettingsStore
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@State fileprivate var cache_clearing_state: CacheClearingState = .not_cleared
@State var showing_cache_clear_alert: Bool = false
@State var showing_enable_animation_alert: Bool = false @State var showing_enable_animation_alert: Bool = false
@State var enable_animation_toggle_is_user_initiated: Bool = true @State var enable_animation_toggle_is_user_initiated: Bool = true
@@ -142,8 +130,6 @@ struct AppearanceSettingsView: View {
.tag(uploader.model.tag) .tag(uploader.model.tag)
} }
} }
self.ClearCacheButton
} }
// MARK: - GIFs // MARK: - GIFs
@@ -192,30 +178,6 @@ struct AppearanceSettingsView: View {
} }
} }
func clear_cache_button_action() {
cache_clearing_state = .clearing
let group = DispatchGroup()
group.enter()
DamusCacheManager.shared.clear_cache(damus_state: self.damus_state, completion: {
group.leave()
})
// Make clear cache button take at least a second or so to avoid issues with labor perception bias (https://growth.design/case-studies/labor-perception-bias)
group.enter()
DispatchQueue.main.asyncAfter(deadline: .now() + MINIMUM_CACHE_CLEAR_BUTTON_DELAY_IN_SECONDS) {
group.leave()
}
group.notify(queue: .main) {
cache_clearing_state = .cleared
DispatchQueue.main.asyncAfter(deadline: .now() + CACHE_CLEAR_BUTTON_RESET_TIME_IN_SECONDS) {
cache_clearing_state = .not_cleared
}
}
}
var EnableAnimationsToggle: some View { var EnableAnimationsToggle: some View {
Toggle(NSLocalizedString("Animations", comment: "Toggle to enable or disable image animation"), isOn: $settings.enable_animation) Toggle(NSLocalizedString("Animations", comment: "Toggle to enable or disable image animation"), isOn: $settings.enable_animation)
.toggleStyle(.switch) .toggleStyle(.switch)
@@ -231,7 +193,9 @@ struct AppearanceSettingsView: View {
Alert(title: Text("Confirmation", comment: "Confirmation dialog title"), Alert(title: Text("Confirmation", comment: "Confirmation dialog title"),
message: Text("Changing this setting will cause the cache to be cleared. This will free space, but images may take longer to load again. Are you sure you want to proceed?", comment: "Message explaining consequences of changing the 'enable animation' setting"), message: Text("Changing this setting will cause the cache to be cleared. This will free space, but images may take longer to load again. Are you sure you want to proceed?", comment: "Message explaining consequences of changing the 'enable animation' setting"),
primaryButton: .default(Text("OK", comment: "Button label indicating user wants to proceed.")) { primaryButton: .default(Text("OK", comment: "Button label indicating user wants to proceed.")) {
self.clear_cache_button_action() Task.detached(priority: .utility, operation: {
await DamusCacheManager.shared.clear_cache(damus_state: self.damus_state, completion: {})
})
}, },
secondaryButton: .cancel() { secondaryButton: .cancel() {
// Toggle back if user cancels action // Toggle back if user cancels action
@@ -241,33 +205,6 @@ struct AppearanceSettingsView: View {
) )
} }
} }
var ClearCacheButton: some View {
Button(action: { self.showing_cache_clear_alert = true }, label: {
HStack(spacing: 6) {
switch cache_clearing_state {
case .not_cleared:
Text("Clear Cache", comment: "Button to clear image cache.")
case .clearing:
ProgressView()
Text("Clearing Cache", comment: "Loading message indicating that the cache is being cleared.")
case .cleared:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Cache has been cleared", comment: "Message indicating that the cache was successfully cleared.")
}
}
})
.disabled(self.cache_clearing_state != .not_cleared)
.alert(isPresented: $showing_cache_clear_alert) {
Alert(title: Text("Confirmation", comment: "Confirmation dialog title"),
message: Text("Are you sure you want to clear the cache? This will free space, but images may take longer to load again.", comment: "Message explaining what it means to clear the cache, asking if user wants to proceed."),
primaryButton: .default(Text("OK", comment: "Button label indicating user wants to proceed.")) {
self.clear_cache_button_action()
},
secondaryButton: .cancel())
}
}
} }

View File

@@ -32,6 +32,7 @@ struct ConfigView: View {
private let translationTitle = NSLocalizedString("Translation", comment: "Section header for text and appearance settings") private let translationTitle = NSLocalizedString("Translation", comment: "Section header for text and appearance settings")
private let reactionsTitle = NSLocalizedString("Reactions", comment: "Section header for reactions settings") private let reactionsTitle = NSLocalizedString("Reactions", comment: "Section header for reactions settings")
private let developerTitle = NSLocalizedString("Developer", comment: "Section header for developer settings") private let developerTitle = NSLocalizedString("Developer", comment: "Section header for developer settings")
private let storageTitle = NSLocalizedString("Storage", comment: "Section header for storage usage statistics")
private let firstAidTitle = NSLocalizedString("First Aid", comment: "Section header for first aid tools and settings") private let firstAidTitle = NSLocalizedString("First Aid", comment: "Section header for first aid tools and settings")
private let signOutTitle = NSLocalizedString("Sign out", comment: "Sidebar menu label to sign out of the account.") private let signOutTitle = NSLocalizedString("Sign out", comment: "Sidebar menu label to sign out of the account.")
private let deleteAccountTitle = NSLocalizedString("Delete Account", comment: "Button to delete the user's account.") private let deleteAccountTitle = NSLocalizedString("Delete Account", comment: "Button to delete the user's account.")
@@ -104,6 +105,12 @@ struct ConfigView: View {
IconLabel(developerTitle,img_name:"magic-stick2.fill",color:DamusColors.adaptableBlack) IconLabel(developerTitle,img_name:"magic-stick2.fill",color:DamusColors.adaptableBlack)
} }
} }
// Storage
if showSettingsButton(title: storageTitle){
NavigationLink(value: Route.StorageSettings(settings: settings)){
IconLabel(storageTitle, img_name: "disk", color: .gray)
}
}
//First Aid //First Aid
if showSettingsButton(title: firstAidTitle){ if showSettingsButton(title: firstAidTitle){
NavigationLink(value: Route.FirstAidSettings(settings: settings)){ NavigationLink(value: Route.FirstAidSettings(settings: settings)){

View File

@@ -0,0 +1,245 @@
//
// NostrDBDetailView.swift
// damus
//
// Created by Daniel D'Aquino on 2026-02-23.
//
import SwiftUI
import Charts
/// Detail view displaying NostrDB storage breakdown by kind, indices, and other categories
struct NostrDBDetailView: View {
let damus_state: DamusState
@ObservedObject var settings: UserSettingsStore
let initialStats: StorageStats
@State private var stats: StorageStats
@State private var isLoading: Bool = false
@State private var error: String?
@State private var selectedAngle: Double?
@State private var showShareSheet: Bool = false
@State private var exportText: String?
@State private var isPreparingExport: Bool = false
@Environment(\.dismiss) var dismiss
init(damus_state: DamusState, settings: UserSettingsStore, stats: StorageStats) {
self.damus_state = damus_state
self.settings = settings
self.initialStats = stats
self._stats = State(initialValue: stats)
}
/// Storage categories with cumulative ranges for angle selection (iOS 17+)
private var categoryRanges: [(category: String, range: Range<Double>)] {
guard stats.nostrdbDetails != nil else { return [] }
return StorageStatsViewHelper.computeCategoryRanges(for: detailedCategories)
}
/// Selected storage category based on pie chart interaction (iOS 17+)
private var selectedCategory: StorageCategory? {
guard let selectedAngle = selectedAngle else { return nil }
if let selectedIndex = categoryRanges.firstIndex(where: { $0.range.contains(selectedAngle) }) {
return detailedCategories[selectedIndex]
}
return nil
}
/// Detailed categories showing per-database breakdown
private var detailedCategories: [StorageCategory] {
guard let details = stats.nostrdbDetails else { return [] }
var result: [StorageCategory] = []
// Per-database categories (sorted by size descending in getStats)
for dbStat in details.databaseStats {
result.append(StorageCategory(
id: dbStat.database.id,
title: dbStat.database.displayName,
icon: dbStat.database.icon,
color: dbStat.database.color,
size: dbStat.totalSize
))
}
return result
}
var body: some View {
Form {
// Chart Section (iOS 17+ only)
if stats.nostrdbDetails != nil {
if #available(iOS 17.0, *) {
Section {
StoragePieChart(
categories: detailedCategories,
selectedAngle: $selectedAngle,
selectedCategory: selectedCategory,
totalSize: stats.nostrdbDetails?.totalSize ?? stats.nostrdbSize
)
.frame(height: 300)
.padding(.vertical)
}
}
// Detailed Categories List
Section {
ForEach(detailedCategories) { category in
if #available(iOS 17.0, *) {
StorageCategoryRow(
category: category,
percentage: percentageOfNostrDB(for: category.size),
isSelected: selectedCategory?.id == category.id
)
} else {
StorageCategoryRow(
category: category,
percentage: percentageOfNostrDB(for: category.size),
isSelected: false
)
}
}
}
// NostrDB Total
Section {
HStack {
Text("NostrDB Total", comment: "Label for total NostrDB storage")
.font(.headline)
Spacer()
Text(StorageStatsManager.formatBytes(stats.nostrdbSize))
.foregroundColor(.secondary)
.font(.headline)
}
}
}
// Loading state
if isLoading {
Section {
HStack {
Spacer()
ProgressView()
Spacer()
}
}
}
// Error state
if let error = error {
Section {
Text(error)
.foregroundColor(.red)
.font(.caption)
}
}
}
.padding(.bottom, 50)
.navigationTitle(NSLocalizedString("NostrDB Details", comment: "Navigation title for NostrDB detail view"))
.toolbar {
if stats.nostrdbDetails != nil {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { Task { await prepareExport() } }) {
if isPreparingExport {
ProgressView()
} else {
Image(systemName: "square.and.arrow.up")
}
}
.disabled(isPreparingExport)
}
}
}
.sheet(isPresented: $showShareSheet) {
if let exportText = exportText {
TextShareSheet(activityItems: [exportText])
}
}
.refreshable {
await loadStorageStatsAsync()
}
.onReceive(handle_notify(.switched_timeline)) { _ in
dismiss()
}
}
/// Prepare export text on background thread before showing share sheet
@concurrent
private func prepareExport() async {
// Atomically check/export all needed @State on MainActor
let (shouldProceed, statsSnapshot): (Bool, StorageStats?) = await MainActor.run {
let hasDetails = stats.nostrdbDetails != nil
let notAlreadyPreparing = !isPreparingExport
if hasDetails && notAlreadyPreparing {
isPreparingExport = true
return (true, stats)
} else {
return (false, nil)
}
}
guard shouldProceed, let statsSnapshot else { return }
// Format text off-main
let text = await StorageStatsViewHelper.formatNostrDBStatsAsText(statsSnapshot)
// Update UI on main thread
await MainActor.run {
self.exportText = text
self.isPreparingExport = false
self.showShareSheet = true
}
}
/// Calculate percentage of NostrDB size
private func percentageOfNostrDB(for size: UInt64) -> Double {
guard stats.nostrdbSize > 0 else { return 0.0 }
return Double(size) / Double(stats.nostrdbSize) * 100.0
}
/// Load storage statistics asynchronously (for refreshable)
private func loadStorageStatsAsync() async {
await MainActor.run {
isLoading = true
error = nil
}
do {
let calculatedStats = try await StorageStatsViewHelper.loadStorageStatsAsync(ndb: damus_state.ndb)
await MainActor.run {
self.stats = calculatedStats
self.isLoading = false
}
} catch {
await MainActor.run {
self.error = String(format: NSLocalizedString("Failed to calculate storage: %@", comment: "Error message when storage calculation fails"), error.localizedDescription)
self.isLoading = false
}
}
}
}
// MARK: - Preview
#Preview("NostrDB Detail") {
NavigationStack {
NostrDBDetailView(
damus_state: test_damus_state,
settings: test_damus_state.settings,
stats: StorageStats(
nostrdbDetails: NdbStats(
databaseStats: [
NdbDatabaseStats(database: .other, keySize: 0, valueSize: 2000000000),
NdbDatabaseStats(database: .note, keySize: 50000, valueSize: 200000),
NdbDatabaseStats(database: .noteBlocks, keySize: 100000, valueSize: 50000),
NdbDatabaseStats(database: .profile, keySize: 25000, valueSize: 100000),
NdbDatabaseStats(database: .noteId, keySize: 75000, valueSize: 75000)
]
),
nostrdbSize: 2500000000,
snapshotSize: 100000,
imageCacheSize: 5000000
)
)
}
}

View File

@@ -0,0 +1,457 @@
//
// StorageSettingsView.swift
// damus
//
// Created by Daniel DAquino on 2026-02-20.
//
import SwiftUI
import Charts
fileprivate let CACHE_CLEAR_BUTTON_RESET_TIME_IN_SECONDS: Double = 60
fileprivate let MINIMUM_CACHE_CLEAR_BUTTON_DELAY_IN_SECONDS: Double = 1
/// A simple type to keep track of the cache clearing state
fileprivate enum CacheClearingState {
case not_cleared
case clearing
case cleared
}
/// Storage category for display in list and chart
struct StorageCategory: Identifiable {
let id: String
let title: String
let icon: String
let color: Color
let size: UInt64
var range: Range<Double> {
return 0..<Double(size)
}
}
/// Settings view displaying storage usage statistics for Damus data stores
struct StorageSettingsView: View {
let damus_state: DamusState
@ObservedObject var settings: UserSettingsStore
@Environment(\.dismiss) var dismiss
@State private var stats: StorageStats?
@State private var isLoading: Bool = false
@State private var error: String?
@State private var selectedAngle: Double?
@State private var showShareSheet: Bool = false
@State private var exportText: String?
@State private var isPreparingExport: Bool = false
@State fileprivate var cache_clearing_state: CacheClearingState = .not_cleared
@State var showing_cache_clear_alert: Bool = false
/// Storage categories with cumulative ranges for angle selection (iOS 17+)
private var categoryRanges: [(category: String, range: Range<Double>)] {
guard let stats = stats else { return [] }
return StorageStatsViewHelper.computeCategoryRanges(for: categories)
}
/// Selected storage category based on pie chart interaction (iOS 17+)
private var selectedCategory: StorageCategory? {
guard let selectedAngle = selectedAngle else { return nil }
if let selectedIndex = categoryRanges.firstIndex(where: { $0.range.contains(selectedAngle) }) {
return categories[selectedIndex]
}
return nil
}
/// All storage categories for display (top-level view)
private var categories: [StorageCategory] {
guard let stats = stats else { return [] }
return [
StorageCategory(
id: "nostrdb",
title: NSLocalizedString("NostrDB", comment: "Label for main NostrDB database"),
icon: "internaldrive.fill",
color: .blue,
size: stats.nostrdbSize
),
StorageCategory(
id: "snapshot",
title: NSLocalizedString("Snapshot Database", comment: "Label for snapshot database"),
icon: "doc.on.doc.fill",
color: .purple,
size: stats.snapshotSize
),
StorageCategory(
id: "cache",
title: NSLocalizedString("Image Cache", comment: "Label for Kingfisher image cache"),
icon: "photo.fill",
color: .orange,
size: stats.imageCacheSize
)
]
}
var body: some View {
Form {
// Chart Section (iOS 17+ only)
if let stats = stats {
if #available(iOS 17.0, *) {
Section {
StoragePieChart(
categories: categories,
selectedAngle: $selectedAngle,
selectedCategory: selectedCategory,
totalSize: stats.totalSize
)
.frame(height: 300)
.padding(.vertical)
}
}
// Categories List
Section {
ForEach(categories) { category in
if category.id == "nostrdb", stats.nostrdbDetails != nil {
// NostrDB is drillable when we have detailed stats
NavigationLink(value: Route.NostrDBStorageDetail(stats: stats)) {
if #available(iOS 17.0, *) {
StorageCategoryRow(
category: category,
percentage: stats.percentage(for: category.size),
isSelected: selectedCategory?.id == category.id
)
} else {
StorageCategoryRow(
category: category,
percentage: stats.percentage(for: category.size),
isSelected: false
)
}
}
} else {
// Other categories are not drillable
if #available(iOS 17.0, *) {
StorageCategoryRow(
category: category,
percentage: stats.percentage(for: category.size),
isSelected: selectedCategory?.id == category.id
)
} else {
StorageCategoryRow(
category: category,
percentage: stats.percentage(for: category.size),
isSelected: false
)
}
}
}
}
// Total at bottom
Section {
HStack {
Text("Total Storage", comment: "Label for total storage used")
.font(.headline)
Spacer()
Text(StorageStatsManager.formatBytes(stats.totalSize))
.foregroundColor(.secondary)
.font(.headline)
}
}
// Clear Cache Section
Section {
self.ClearCacheButton
}
}
// Loading state
if isLoading {
Section {
HStack {
Spacer()
ProgressView()
Spacer()
}
}
}
// Error state
if let error = error {
Section {
Text(error)
.foregroundColor(.red)
.font(.caption)
}
}
}
.padding(.bottom, 50)
.navigationTitle(NSLocalizedString("Storage", comment: "Navigation title for storage settings"))
.toolbar {
if stats != nil {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { Task { await prepareExport() } }) {
if isPreparingExport {
ProgressView()
} else {
Image(systemName: "square.and.arrow.up")
}
}
.disabled(isPreparingExport)
}
}
}
.sheet(isPresented: $showShareSheet) {
if let exportText = exportText {
TextShareSheet(activityItems: [exportText])
}
}
.refreshable {
await loadStorageStatsAsync()
}
.onAppear {
if stats == nil {
loadStorageStats()
}
}
.onReceive(handle_notify(.switched_timeline)) { _ in
dismiss()
}
}
/// Prepare export text on background thread before showing share sheet
@concurrent
private func prepareExport() async {
// Capture all relevant @State in one MainActor.run
let (shouldProceed, statsSnapshot): (Bool, StorageStats?) = await MainActor.run {
let hasStats = stats != nil
let notAlreadyPreparing = !isPreparingExport
if hasStats && notAlreadyPreparing {
isPreparingExport = true
return (true, stats)
} else {
return (false, nil)
}
}
guard shouldProceed, let statsSnapshot else { return }
// Format text on background thread using shared helper
let text = await StorageStatsViewHelper.formatStorageStatsAsText(statsSnapshot)
// Update UI on main thread
await MainActor.run {
self.exportText = text
self.isPreparingExport = false
self.showShareSheet = true
}
}
/// Load storage statistics on a background thread (for onAppear)
private func loadStorageStats() {
guard !isLoading else { return }
isLoading = true
error = nil
Task {
await loadStorageStatsAsync()
}
}
/// Load storage statistics asynchronously (for refreshable)
@concurrent
private func loadStorageStatsAsync() async {
await MainActor.run {
isLoading = true
error = nil
}
do {
let calculatedStats = try await StorageStatsViewHelper.loadStorageStatsAsync(ndb: damus_state.ndb)
await MainActor.run {
self.stats = calculatedStats
self.isLoading = false
}
} catch {
await MainActor.run {
self.error = String(format: NSLocalizedString("Failed to calculate storage: %@", comment: "Error message when storage calculation fails"), error.localizedDescription)
self.isLoading = false
}
}
}
/// Clear cache button action with loading state management
func clear_cache_button_action() {
cache_clearing_state = .clearing
let group = DispatchGroup()
group.enter()
DamusCacheManager.shared.clear_cache(damus_state: self.damus_state, completion: {
group.leave()
})
// Make clear cache button take at least a second or so to avoid issues with labor perception bias (https://growth.design/case-studies/labor-perception-bias)
group.enter()
DispatchQueue.main.asyncAfter(deadline: .now() + MINIMUM_CACHE_CLEAR_BUTTON_DELAY_IN_SECONDS) {
group.leave()
}
group.notify(queue: .main) {
cache_clearing_state = .cleared
// Refresh storage stats after clearing cache
loadStorageStats()
DispatchQueue.main.asyncAfter(deadline: .now() + CACHE_CLEAR_BUTTON_RESET_TIME_IN_SECONDS) {
cache_clearing_state = .not_cleared
}
}
}
/// Clear cache button view with confirmation dialog
var ClearCacheButton: some View {
Button(action: { self.showing_cache_clear_alert = true }, label: {
HStack(spacing: 6) {
switch cache_clearing_state {
case .not_cleared:
Text("Clear Cache", comment: "Button to clear image cache.")
case .clearing:
ProgressView()
Text("Clearing Cache", comment: "Loading message indicating that the cache is being cleared.")
case .cleared:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Cache has been cleared", comment: "Message indicating that the cache was successfully cleared.")
}
}
})
.disabled(self.cache_clearing_state != .not_cleared)
.alert(isPresented: $showing_cache_clear_alert) {
Alert(title: Text("Confirmation", comment: "Confirmation dialog title"),
message: Text("Are you sure you want to clear the cache? This will free space, but images may take longer to load again.", comment: "Message explaining what it means to clear the cache, asking if user wants to proceed."),
primaryButton: .default(Text("OK", comment: "Button label indicating user wants to proceed.")) {
self.clear_cache_button_action()
},
secondaryButton: .cancel())
}
}
}
/// Pie chart displaying storage usage distribution (iOS 17+)
@available(iOS 17.0, *)
struct StoragePieChart: View {
let categories: [StorageCategory]
@Binding var selectedAngle: Double?
let selectedCategory: StorageCategory?
let totalSize: UInt64
var body: some View {
Chart(categories) { category in
SectorMark(
angle: .value("Size", category.size),
innerRadius: .ratio(0.618),
angularInset: 1.5
)
.cornerRadius(4)
.foregroundStyle(category.color)
.opacity(selectedCategory == nil || selectedCategory?.id == category.id ? 1.0 : 0.5)
}
.chartAngleSelection(value: $selectedAngle)
.chartBackground { chartProxy in
GeometryReader { geometry in
if let anchor = chartProxy.plotFrame {
let frame = geometry[anchor]
centerLabel
.position(x: frame.midX, y: frame.midY)
}
}
}
.chartLegend(.hidden)
}
/// Center label showing selected category or total
private var centerLabel: some View {
VStack(spacing: 4) {
if let selected = selectedCategory {
Image(systemName: selected.icon)
.font(.title2)
.foregroundColor(selected.color)
Text(selected.title)
.font(.headline)
.multilineTextAlignment(.center)
Text(StorageStatsManager.formatBytes(selected.size))
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("Total", comment: "Label for total storage in pie chart center")
.font(.headline)
Text(StorageStatsManager.formatBytes(totalSize))
.font(.title2)
.bold()
}
}
.frame(maxWidth: 120)
}
}
/// Row displaying a storage category with icon, name, size, and percentage
struct StorageCategoryRow: View {
let category: StorageCategory
let percentage: Double
let isSelected: Bool
var body: some View {
HStack(spacing: 12) {
Image(systemName: category.icon)
.foregroundColor(category.color)
.frame(width: 24)
.font(.title3)
VStack(alignment: .leading, spacing: 2) {
Text(category.title)
.font(.body)
Text(String(format: "%.1f%%", percentage))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Text(StorageStatsManager.formatBytes(category.size))
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
.opacity(isSelected ? 1.0 : 0.9)
}
}
/// Text-based ShareSheet wrapper for SwiftUI
struct TextShareSheet: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(
activityItems: activityItems,
applicationActivities: nil
)
return controller
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
// No updates needed
}
}
// MARK: - Preview
#Preview("Storage Settings") {
NavigationStack {
StorageSettingsView(
damus_state: test_damus_state,
settings: test_damus_state.settings
)
}
}

View File

@@ -32,6 +32,8 @@ enum Route: Hashable {
case SearchSettings(settings: UserSettingsStore) case SearchSettings(settings: UserSettingsStore)
case DeveloperSettings(settings: UserSettingsStore) case DeveloperSettings(settings: UserSettingsStore)
case FirstAidSettings(settings: UserSettingsStore) case FirstAidSettings(settings: UserSettingsStore)
case StorageSettings(settings: UserSettingsStore)
case NostrDBStorageDetail(stats: StorageStats)
case Thread(thread: ThreadModel) case Thread(thread: ThreadModel)
case LoadableNostrEvent(note_reference: LoadableNostrEventViewModel.NoteReference) case LoadableNostrEvent(note_reference: LoadableNostrEventViewModel.NoteReference)
case Reposts(reposts: EventsModel) case Reposts(reposts: EventsModel)
@@ -100,6 +102,10 @@ enum Route: Hashable {
DeveloperSettingsView(settings: settings, damus_state: damusState) DeveloperSettingsView(settings: settings, damus_state: damusState)
case .FirstAidSettings(settings: let settings): case .FirstAidSettings(settings: let settings):
FirstAidSettingsView(damus_state: damusState, settings: settings) FirstAidSettingsView(damus_state: damusState, settings: settings)
case .StorageSettings(settings: let settings):
StorageSettingsView(damus_state: damusState, settings: settings)
case .NostrDBStorageDetail(stats: let stats):
NostrDBDetailView(damus_state: damusState, settings: damusState.settings, stats: stats)
case .Thread(let thread): case .Thread(let thread):
ChatroomThreadView(damus: damusState, thread: thread) ChatroomThreadView(damus: damusState, thread: thread)
//ThreadView(state: damusState, thread: thread) //ThreadView(state: damusState, thread: thread)
@@ -204,6 +210,11 @@ enum Route: Hashable {
hasher.combine("developerSettings") hasher.combine("developerSettings")
case .FirstAidSettings: case .FirstAidSettings:
hasher.combine("firstAidSettings") hasher.combine("firstAidSettings")
case .StorageSettings:
hasher.combine("storageSettings")
case .NostrDBStorageDetail(let stats):
hasher.combine("nostrDBStorageDetail")
hasher.combine(stats)
case .Thread(let threadModel): case .Thread(let threadModel):
hasher.combine("thread") hasher.combine("thread")
hasher.combine(threadModel.original_event.id) hasher.combine(threadModel.original_event.id)

View File

@@ -0,0 +1,537 @@
//
// StorageStatsManagerTests.swift
// damusTests
//
// Created by OpenCode on 2026-02-25.
//
import XCTest
@testable import damus
import Kingfisher
/// Comprehensive test suite for storage usage calculation logic
///
/// Tests cover:
/// - StorageStats calculations (total size, percentages)
/// - File size calculations with temporary test files
/// - Async storage stats calculations
/// - Byte formatting utilities
/// - Ndb.getStats() database statistics
/// - Integration between components
/// - Thread safety and error handling
final class StorageStatsManagerTests: XCTestCase {
var tempDirectory: URL!
var mockNostrDBPath: String!
var mockSnapshotPath: String!
override func setUp() {
super.setUp()
// Create temporary directory for test files
tempDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent("StorageStatsManagerTests-\(UUID().uuidString)")
try? FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
// Create mock database directories
let nostrDBDir = tempDirectory.appendingPathComponent("nostrdb")
let snapshotDir = tempDirectory.appendingPathComponent("snapshot")
try? FileManager.default.createDirectory(at: nostrDBDir, withIntermediateDirectories: true)
try? FileManager.default.createDirectory(at: snapshotDir, withIntermediateDirectories: true)
mockNostrDBPath = nostrDBDir.path
mockSnapshotPath = snapshotDir.path
}
override func tearDown() {
// Clean up temporary files
if let tempDirectory = tempDirectory {
try? FileManager.default.removeItem(at: tempDirectory)
}
tempDirectory = nil
mockNostrDBPath = nil
mockSnapshotPath = nil
super.tearDown()
}
// MARK: - Helper Methods
/// Create a temporary file with specified size
/// - Parameters:
/// - path: Full path for the file
/// - size: Size in bytes
private func createTestFile(at path: String, size: UInt64) throws {
let data = Data(repeating: 0, count: Int(size))
try data.write(to: URL(fileURLWithPath: path))
}
/// Get file size using FileManager (reference implementation)
private func getActualFileSize(at path: String) -> UInt64? {
guard FileManager.default.fileExists(atPath: path) else { return nil }
do {
let attributes = try FileManager.default.attributesOfItem(atPath: path)
return attributes[.size] as? UInt64
} catch {
return nil
}
}
// MARK: - 1. StorageStats Structure Tests
/// Test that totalSize correctly sums all storage components
func testTotalSizeCalculation() {
let stats = StorageStats(
nostrdbDetails: nil,
nostrdbSize: 1000,
snapshotSize: 500,
imageCacheSize: 250
)
XCTAssertEqual(stats.totalSize, 1750, "Total size should sum all components")
}
/// Test percentage calculation accuracy
func testPercentageCalculation() {
let stats = StorageStats(
nostrdbDetails: nil,
nostrdbSize: 600,
snapshotSize: 300,
imageCacheSize: 100
)
// Total = 1000, so 600 should be 60%
let nostrdbPercentage = stats.percentage(for: 600)
XCTAssertEqual(nostrdbPercentage, 60.0, accuracy: 0.01, "NostrDB should be 60% of total")
let snapshotPercentage = stats.percentage(for: 300)
XCTAssertEqual(snapshotPercentage, 30.0, accuracy: 0.01, "Snapshot should be 30% of total")
let cachePercentage = stats.percentage(for: 100)
XCTAssertEqual(cachePercentage, 10.0, accuracy: 0.01, "Cache should be 10% of total")
}
/// Test percentage calculation when total is zero (edge case)
func testPercentageWithZeroTotal() {
let stats = StorageStats(
nostrdbDetails: nil,
nostrdbSize: 0,
snapshotSize: 0,
imageCacheSize: 0
)
let percentage = stats.percentage(for: 100)
XCTAssertEqual(percentage, 0.0, "Percentage should be 0 when total is 0")
}
/// Test that StorageStats conforms to Hashable properly
func testStorageStatsHashableConformance() {
let stats1 = StorageStats(
nostrdbDetails: nil,
nostrdbSize: 1000,
snapshotSize: 500,
imageCacheSize: 250
)
let stats2 = StorageStats(
nostrdbDetails: nil,
nostrdbSize: 1000,
snapshotSize: 500,
imageCacheSize: 250
)
let stats3 = StorageStats(
nostrdbDetails: nil,
nostrdbSize: 2000,
snapshotSize: 500,
imageCacheSize: 250
)
// Equal stats should be equal and have same hash
XCTAssertEqual(stats1, stats2, "Identical stats should be equal")
XCTAssertEqual(stats1.hashValue, stats2.hashValue, "Equal stats should have same hash")
// Different stats should not be equal
XCTAssertNotEqual(stats1, stats3, "Different stats should not be equal")
// Should work in Set
let set: Set<StorageStats> = [stats1, stats2, stats3]
XCTAssertEqual(set.count, 2, "Set should contain 2 unique stats")
}
// MARK: - 2. File Size Calculation Tests
/// Test file size calculation with an existing file
func testGetFileSizeWithExistingFile() throws {
let testFilePath = tempDirectory.appendingPathComponent("test-file.dat").path
let expectedSize: UInt64 = 1024 * 1024 // 1 MB
// Create test file with known size
try createTestFile(at: testFilePath, size: expectedSize)
// Verify file was created correctly
let actualSize = getActualFileSize(at: testFilePath)
XCTAssertNotNil(actualSize, "Test file should exist")
XCTAssertEqual(actualSize, expectedSize, "Test file should have expected size")
}
/// Test file size calculation when file doesn't exist (should return 0)
func testGetFileSizeWithNonexistentFile() {
let nonexistentPath = tempDirectory.appendingPathComponent("nonexistent.dat").path
// Verify file doesn't exist
XCTAssertFalse(FileManager.default.fileExists(atPath: nonexistentPath), "File should not exist")
let size = getActualFileSize(at: nonexistentPath)
XCTAssertNil(size, "Size should be nil for nonexistent file")
}
/// Test NostrDB file size calculation with valid path
func testGetNostrDBSizeWithValidPath() throws {
let dbFilePath = "\(mockNostrDBPath!)/\(Ndb.main_db_file_name)"
let expectedSize: UInt64 = 5 * 1024 * 1024 // 5 MB
// Create mock database file
try createTestFile(at: dbFilePath, size: expectedSize)
// Verify file size can be retrieved
let actualSize = getActualFileSize(at: dbFilePath)
XCTAssertNotNil(actualSize, "DB file should exist")
XCTAssertEqual(actualSize, expectedSize, "DB file should have expected size")
}
/// Test snapshot database file size calculation with valid path
func testGetSnapshotDBSizeWithValidPath() throws {
let dbFilePath = "\(mockSnapshotPath!)/\(Ndb.main_db_file_name)"
let expectedSize: UInt64 = 2 * 1024 * 1024 // 2 MB
// Create mock snapshot database file
try createTestFile(at: dbFilePath, size: expectedSize)
// Verify file size can be retrieved
let actualSize = getActualFileSize(at: dbFilePath)
XCTAssertNotNil(actualSize, "Snapshot DB file should exist")
XCTAssertEqual(actualSize, expectedSize, "Snapshot DB file should have expected size")
}
// MARK: - 3. Byte Formatting Tests
/// Test formatting of zero bytes
func testFormatBytesZero() {
let formatted = StorageStatsManager.formatBytes(0)
// ByteCountFormatter may format as "Zero bytes", "0 bytes", "0 KB", etc.
// We just verify it's a valid non-empty string
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
// Most common formats include "0" or "Zero"
let containsZero = formatted.contains("0") || formatted.uppercased().contains("ZERO")
XCTAssertTrue(containsZero, "Zero bytes should contain '0' or 'Zero', got: \(formatted)")
}
/// Test formatting of small byte values (< 1 KB)
func testFormatBytesSmall() {
let formatted = StorageStatsManager.formatBytes(512)
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
// Should contain a numeric value
XCTAssertTrue(formatted.contains("512") || formatted.contains("0.5"), "Should contain size value")
}
/// Test formatting of kilobyte values
func testFormatBytesKilobytes() {
let oneKB: UInt64 = 1024
let formatted = StorageStatsManager.formatBytes(oneKB * 5) // 5 KB
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
// Should mention KB or kilobytes
XCTAssertTrue(formatted.uppercased().contains("KB") || formatted.uppercased().contains("K"),
"Should indicate kilobytes: \(formatted)")
}
/// Test formatting of megabyte values
func testFormatBytesMegabytes() {
let oneMB: UInt64 = 1024 * 1024
let formatted = StorageStatsManager.formatBytes(oneMB * 10) // 10 MB
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
// Should mention MB or megabytes
XCTAssertTrue(formatted.uppercased().contains("MB") || formatted.uppercased().contains("M"),
"Should indicate megabytes: \(formatted)")
}
/// Test formatting of gigabyte values
func testFormatBytesGigabytes() {
let oneGB: UInt64 = 1024 * 1024 * 1024
let formatted = StorageStatsManager.formatBytes(oneGB * 2) // 2 GB
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
// Should mention GB or gigabytes
XCTAssertTrue(formatted.uppercased().contains("GB") || formatted.uppercased().contains("G"),
"Should indicate gigabytes: \(formatted)")
}
/// Test formatting of very large values
func testFormatBytesLarge() {
let oneTB: UInt64 = 1024 * 1024 * 1024 * 1024
let formatted = StorageStatsManager.formatBytes(oneTB)
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
// Should handle terabyte values gracefully
XCTAssertTrue(formatted.uppercased().contains("TB") || formatted.uppercased().contains("T") ||
formatted.uppercased().contains("GB") || formatted.uppercased().contains("G"),
"Should format large values: \(formatted)")
}
// MARK: - 4. Async Storage Stats Calculation Tests
/// Test storage stats calculation without Ndb instance
func testCalculateStorageStatsWithoutNdb() async throws {
// Note: This test verifies the calculation succeeds and returns valid stats
// We don't check exact values since they depend on actual system state
let stats = try await StorageStatsManager.shared.calculateStorageStats(ndb: nil)
// Verify stats structure is valid
XCTAssertNotNil(stats, "Stats should not be nil")
XCTAssertNil(stats.nostrdbDetails, "Details should be nil when no Ndb provided")
// All sizes should be non-negative
XCTAssertGreaterThanOrEqual(stats.nostrdbSize, 0, "NostrDB size should be non-negative")
XCTAssertGreaterThanOrEqual(stats.snapshotSize, 0, "Snapshot size should be non-negative")
XCTAssertGreaterThanOrEqual(stats.imageCacheSize, 0, "Image cache size should be non-negative")
// Total should equal sum
let expectedTotal = stats.nostrdbSize + stats.snapshotSize + stats.imageCacheSize
XCTAssertEqual(stats.totalSize, expectedTotal, "Total should equal sum of components")
}
// MARK: - 5. NdbDatabaseStats Tests
/// Test NdbDatabaseStats total size calculation
func testNdbDatabaseStatsCalculations() {
let dbStats = NdbDatabaseStats(
database: .note,
keySize: 1000,
valueSize: 5000
)
XCTAssertEqual(dbStats.totalSize, 6000, "Total should be key + value size")
XCTAssertEqual(dbStats.database, .note, "Database type should be preserved")
XCTAssertEqual(dbStats.keySize, 1000, "Key size should be preserved")
XCTAssertEqual(dbStats.valueSize, 5000, "Value size should be preserved")
}
/// Test NdbStats total size calculation
func testNdbStatsTotalCalculation() {
let stats = NdbStats(databaseStats: [
NdbDatabaseStats(database: .note, keySize: 1000, valueSize: 5000),
NdbDatabaseStats(database: .profile, keySize: 500, valueSize: 2000),
NdbDatabaseStats(database: .noteId, keySize: 200, valueSize: 800)
])
// Total should be sum of all database totals
// (1000+5000) + (500+2000) + (200+800) = 9500
XCTAssertEqual(stats.totalSize, 9500, "Total should sum all database sizes")
}
/// Test NdbStats with empty database list
func testNdbStatsEmpty() {
let stats = NdbStats(databaseStats: [])
XCTAssertEqual(stats.totalSize, 0, "Empty stats should have zero total")
XCTAssertTrue(stats.databaseStats.isEmpty, "Database stats should be empty")
}
/// Test NdbDatabaseStats hashable conformance
func testNdbDatabaseStatsHashableConformance() {
let stats1 = NdbDatabaseStats(database: .note, keySize: 1000, valueSize: 5000)
let stats2 = NdbDatabaseStats(database: .note, keySize: 1000, valueSize: 5000)
let stats3 = NdbDatabaseStats(database: .profile, keySize: 1000, valueSize: 5000)
XCTAssertEqual(stats1, stats2, "Identical stats should be equal")
XCTAssertNotEqual(stats1, stats3, "Different database type should not be equal")
// Should work in Set
let set: Set<NdbDatabaseStats> = [stats1, stats2, stats3]
XCTAssertEqual(set.count, 2, "Set should contain 2 unique stats")
}
/// Test NdbStats hashable conformance
func testNdbStatsHashableConformance() {
let dbStats1 = NdbDatabaseStats(database: .note, keySize: 1000, valueSize: 5000)
let dbStats2 = NdbDatabaseStats(database: .profile, keySize: 500, valueSize: 2000)
let stats1 = NdbStats(databaseStats: [dbStats1, dbStats2])
let stats2 = NdbStats(databaseStats: [dbStats1, dbStats2])
let stats3 = NdbStats(databaseStats: [dbStats1])
XCTAssertEqual(stats1, stats2, "Identical stats should be equal")
XCTAssertNotEqual(stats1, stats3, "Different database count should not be equal")
// Should work in Set
let set: Set<NdbStats> = [stats1, stats2, stats3]
XCTAssertEqual(set.count, 2, "Set should contain 2 unique stats")
}
// MARK: - 6. NdbDatabase Enum Tests
/// Test NdbDatabase display names
func testNdbDatabaseDisplayNames() {
// Display names include the C enum names in parentheses
XCTAssertEqual(NdbDatabase.note.displayName, "Notes (NDB_DB_NOTE)", "Note database display name")
XCTAssertEqual(NdbDatabase.profile.displayName, "Profiles (NDB_DB_PROFILE)", "Profile database display name")
XCTAssertEqual(NdbDatabase.noteBlocks.displayName, "Note Blocks", "Note blocks display name")
XCTAssertEqual(NdbDatabase.noteId.displayName, "Note ID Index", "Note ID index display name")
XCTAssertEqual(NdbDatabase.meta.displayName, "Metadata (NDB_DB_META)", "Metadata display name")
XCTAssertEqual(NdbDatabase.other.displayName, "Other Data", "Other data display name")
}
/// Test NdbDatabase icons
func testNdbDatabaseIcons() {
// Verify each database has an icon (non-empty string)
XCTAssertFalse(NdbDatabase.note.icon.isEmpty, "Note should have icon")
XCTAssertFalse(NdbDatabase.profile.icon.isEmpty, "Profile should have icon")
XCTAssertFalse(NdbDatabase.noteBlocks.icon.isEmpty, "Note blocks should have icon")
XCTAssertFalse(NdbDatabase.other.icon.isEmpty, "Other should have icon")
}
/// Test NdbDatabase colors
func testNdbDatabaseColors() {
// Verify each database has a color assigned
// We can't easily compare Color values, but we can verify they return Color instances
_ = NdbDatabase.note.color
_ = NdbDatabase.profile.color
_ = NdbDatabase.noteBlocks.color
_ = NdbDatabase.other.color
// If we get here without crashes, colors are working
XCTAssertTrue(true, "All database colors should be accessible")
}
/// Test NdbDatabase initialization from index
func testNdbDatabaseFromIndex() {
// Test valid indices
let db0 = NdbDatabase(fromIndex: 0)
XCTAssertNotEqual(db0, .other, "Index 0 should map to a valid database")
let db1 = NdbDatabase(fromIndex: 1)
XCTAssertNotEqual(db1, .other, "Index 1 should map to a valid database")
// Test invalid index (should default to .other)
let dbInvalid = NdbDatabase(fromIndex: 9999)
XCTAssertEqual(dbInvalid, .other, "Invalid index should default to .other")
}
// MARK: - 7. Integration Tests
/// Test complete storage stats flow with real-ish data
func testStorageStatsIntegrationFlow() async throws {
// This test verifies the entire flow works end-to-end
// We use actual calculation but don't assert specific values
let stats = try await StorageStatsManager.shared.calculateStorageStats(ndb: nil)
// Verify structure
XCTAssertNotNil(stats, "Stats should be calculated")
// Verify all components are accessible
let _ = stats.nostrdbSize
let _ = stats.snapshotSize
let _ = stats.imageCacheSize
let _ = stats.totalSize
// Verify percentage calculation works
if stats.totalSize > 0 {
let percentage = stats.percentage(for: stats.nostrdbSize)
XCTAssertGreaterThanOrEqual(percentage, 0.0, "Percentage should be non-negative")
XCTAssertLessThanOrEqual(percentage, 100.0, "Percentage should not exceed 100%")
}
// Verify formatting works
let formatted = StorageStatsManager.formatBytes(stats.totalSize)
XCTAssertFalse(formatted.isEmpty, "Formatted size should not be empty")
}
/// Test concurrent stats calculations (thread safety)
func testConcurrentStatsCalculations() async throws {
let iterations = 5
// Launch multiple concurrent calculations
try await withThrowingTaskGroup(of: StorageStats.self) { group in
for _ in 0..<iterations {
group.addTask {
return try await StorageStatsManager.shared.calculateStorageStats(ndb: nil)
}
}
var results: [StorageStats] = []
for try await stats in group {
results.append(stats)
}
XCTAssertEqual(results.count, iterations, "Should complete all calculations")
// All results should have valid structure
for stats in results {
XCTAssertGreaterThanOrEqual(stats.nostrdbSize, 0, "NostrDB size should be valid")
XCTAssertGreaterThanOrEqual(stats.snapshotSize, 0, "Snapshot size should be valid")
XCTAssertGreaterThanOrEqual(stats.imageCacheSize, 0, "Cache size should be valid")
}
}
}
/// Test storage stats with extreme UInt64 values, including sum at UInt64 boundary (no overflow)
func testStorageStatsExtremeValues() {
// Case: Sum at UInt64 boundary (no overflow)
// UInt64.max - 2 + 1 + 1 == UInt64.max
let maxStats = StorageStats(
nostrdbDetails: nil,
nostrdbSize: UInt64.max - 2,
snapshotSize: 1,
imageCacheSize: 1
)
// Verify correct summation at UInt64 boundary
XCTAssertEqual(maxStats.totalSize, UInt64.max, "Total should be exactly UInt64.max at boundary; no overflow should occur")
// Verify percentage calculation for each component
XCTAssertEqual(maxStats.percentage(for: UInt64.max - 2), (Double(UInt64.max - 2) / Double(UInt64.max)) * 100.0, accuracy: 0.0001)
XCTAssertEqual(maxStats.percentage(for: 1), (1.0 / Double(UInt64.max)) * 100.0, accuracy: 0.0001)
// All zeros case (already tested elsewhere, but included for completeness)
let zeroStats = StorageStats(
nostrdbDetails: nil,
nostrdbSize: 0,
snapshotSize: 0,
imageCacheSize: 0
)
XCTAssertEqual(zeroStats.totalSize, 0, "Zero stats should have zero total")
XCTAssertEqual(zeroStats.percentage(for: 0), 0.0, "Zero percentage for zero total")
// If overflow handling should be explicitly tested, add a comment. With current implementation, overflow cannot occur for UInt64 sums with three terms.
// If more than three terms or arbitrary user input are ever summed, consider adding explicit overflow guards.
}
/// Test byte formatter with various edge cases
func testFormatBytesEdgeCases() {
// Powers of 1024
let formatted1K = StorageStatsManager.formatBytes(1024)
XCTAssertFalse(formatted1K.isEmpty, "Should format 1KB")
let formatted1M = StorageStatsManager.formatBytes(1024 * 1024)
XCTAssertFalse(formatted1M.isEmpty, "Should format 1MB")
let formatted1G = StorageStatsManager.formatBytes(1024 * 1024 * 1024)
XCTAssertFalse(formatted1G.isEmpty, "Should format 1GB")
// Odd values
let formatted999 = StorageStatsManager.formatBytes(999)
XCTAssertFalse(formatted999.isEmpty, "Should format 999 bytes")
let formatted1023 = StorageStatsManager.formatBytes(1023)
XCTAssertFalse(formatted1023.isEmpty, "Should format 1023 bytes")
}
}

View File

@@ -84,8 +84,8 @@ class Ndb {
return remove_file_prefix(containerURL.appendingPathComponent("snapshot", conformingTo: .directory).absoluteString) return remove_file_prefix(containerURL.appendingPathComponent("snapshot", conformingTo: .directory).absoluteString)
} }
static private let main_db_file_name: String = "data.mdb" static let main_db_file_name: String = "data.mdb"
static private let db_files: [String] = ["data.mdb", "lock.mdb"] static let db_files: [String] = ["data.mdb", "lock.mdb"]
static var empty: Ndb { static var empty: Ndb {
print("txn: NOSTRDB EMPTY") print("txn: NOSTRDB EMPTY")
@@ -1151,6 +1151,72 @@ extension Ndb {
} }
} }
extension Ndb {
/// Get detailed storage statistics for this database
///
/// This method calls the C `ndb_stat` function to retrieve per-database
/// storage statistics from the underlying LMDB storage. Each database
/// (notes, profiles, indices, etc.) is reported with its key and value sizes.
///
/// Any unaccounted space between the sum of database stats and the physical
/// file size is reported as "Other Data".
///
/// - Parameter physicalSize: The physical file size in bytes from the filesystem
/// - Returns: NdbStats with detailed per-database breakdown, or nil if stat collection fails
func getStats(physicalSize: UInt64) -> NdbStats? {
// All of this must be done under withNdb to avoid races with close()/ndb_destroy
let copiedStats: ([NdbDatabaseStats], UInt64)? = try? withNdb({
var stat = ndb_stat()
// Call C ndb_stat function
let result = ndb_stat(self.ndb.ndb, &stat)
guard result != 0 else {
Log.error("ndb_stat failed", for: .storage)
return nil
}
var databaseStats: [NdbDatabaseStats] = []
var accountedSize: UInt64 = 0
// Extract per-database stats from stat.dbs array
withUnsafePointer(to: &stat.dbs) { dbsPtr in
let dbsBuffer = UnsafeRawPointer(dbsPtr).assumingMemoryBound(to: ndb_stat_counts.self)
for dbIndex in 0..<Int(NDB_DBS.rawValue) {
let dbStat = dbsBuffer[dbIndex]
// Skip databases with no data
guard dbStat.key_size > 0 || dbStat.value_size > 0 else { continue }
// Get database type from index
let database = NdbDatabase(fromIndex: dbIndex)
let dbStats = NdbDatabaseStats(
database: database,
keySize: UInt64(dbStat.key_size),
valueSize: UInt64(dbStat.value_size)
)
databaseStats.append(dbStats)
accountedSize += dbStats.totalSize
}
}
return (databaseStats, accountedSize)
})
guard let (databaseStatsRaw, accountedSize) = copiedStats else { return nil }
var databaseStats = databaseStatsRaw
// Add "Other Data" for any unaccounted space
if physicalSize > accountedSize {
let otherSize = physicalSize - accountedSize
databaseStats.append(NdbDatabaseStats(
database: .other,
keySize: 0,
valueSize: otherSize
))
}
// Sort by total size descending to show largest databases first
databaseStats.sort { $0.totalSize > $1.totalSize }
return NdbStats(databaseStats: databaseStats)
}
}
/// This callback "trampoline" function will be called when new notes arrive for NostrDB subscriptions. /// This callback "trampoline" function will be called when new notes arrive for NostrDB subscriptions.
/// ///
/// This is needed as a separate global function in order to allow us to pass it to the C code as a callback (We can't pass native Swift fuctions directly as callbacks). /// This is needed as a separate global function in order to allow us to pass it to the C code as a callback (We can't pass native Swift fuctions directly as callbacks).
@@ -1180,3 +1246,73 @@ func getDebugCheckedRoot<T: FlatBufferObject>(byteBuffer: inout ByteBuffer) thro
func remove_file_prefix(_ str: String) -> String { func remove_file_prefix(_ str: String) -> String {
return str.replacingOccurrences(of: "file://", with: "") return str.replacingOccurrences(of: "file://", with: "")
} }
// MARK: - NostrDB Storage Statistics
/// NostrDB database types corresponding to the ndb_dbs C enum
enum NdbDatabase: Int, Hashable, CaseIterable, Identifiable {
case note = 0 // NDB_DB_NOTE
case meta = 1 // NDB_DB_META
case profile = 2 // NDB_DB_PROFILE
case noteId = 3 // NDB_DB_NOTE_ID
case profileKey = 4 // NDB_DB_PROFILE_PK
case ndbMeta = 5 // NDB_DB_NDB_META
case profileSearch = 6 // NDB_DB_PROFILE_SEARCH
case profileLastFetch = 7 // NDB_DB_PROFILE_LAST_FETCH
case noteKind = 8 // NDB_DB_NOTE_KIND
case noteText = 9 // NDB_DB_NOTE_TEXT
case noteBlocks = 10 // NDB_DB_NOTE_BLOCKS
case noteTags = 11 // NDB_DB_NOTE_TAGS
case notePubkey = 12 // NDB_DB_NOTE_PUBKEY
case notePubkeyKind = 13 // NDB_DB_NOTE_PUBKEY_KIND
case noteRelayKind = 14 // NDB_DB_NOTE_RELAY_KIND
case noteRelays = 15 // NDB_DB_NOTE_RELAYS
case other // For unaccounted data
var id: String {
return String(self.rawValue)
}
/// Database index matching the C ndb_dbs enum
var index: Int {
return self.rawValue
}
/// Initialize from database index (matching ndb_dbs C enum order)
init(fromIndex index: Int) {
if let db = NdbDatabase(rawValue: index) {
self = db
} else {
self = .other
}
}
}
/// Per-database storage statistics from NostrDB
struct NdbDatabaseStats: Hashable {
/// Database type
let database: NdbDatabase
/// Total key bytes for this database
let keySize: UInt64
/// Total value bytes for this database
let valueSize: UInt64
/// Total storage used by this database (keys + values)
var totalSize: UInt64 {
return keySize + valueSize
}
}
/// Detailed NostrDB storage statistics with per-database breakdown
struct NdbStats: Hashable {
/// Per-database breakdown of storage (notes, profiles, indices, etc.)
let databaseStats: [NdbDatabaseStats]
/// Total storage across all databases
var totalSize: UInt64 {
return databaseStats.reduce(0) { $0 + $1.totalSize }
}
}

View File

@@ -0,0 +1,91 @@
//
// NdbDatabase+UI.swift
// (UI/Features target)
//
// This extension adds UI-specific properties to NdbDatabase for presentation purposes.
// It should only be included in targets involving SwiftUI/UI presentation.
//
import SwiftUI
extension NdbDatabase {
/// Human-readable database name
var displayName: String {
switch self {
case .note:
return NSLocalizedString("Notes (NDB_DB_NOTE)", comment: "Database name for notes")
case .meta:
return NSLocalizedString("Metadata (NDB_DB_META)", comment: "Database name for metadata")
case .profile:
return NSLocalizedString("Profiles (NDB_DB_PROFILE)", comment: "Database name for profiles")
case .noteId:
return NSLocalizedString("Note ID Index", comment: "Database name for note ID index")
case .profileKey:
return NSLocalizedString("Profile Key Index", comment: "Database name for profile key index")
case .ndbMeta:
return NSLocalizedString("NostrDB Metadata", comment: "Database name for NostrDB metadata")
case .profileSearch:
return NSLocalizedString("Profile Search Index", comment: "Database name for profile search")
case .profileLastFetch:
return NSLocalizedString("Profile Last Fetch", comment: "Database name for profile last fetch")
case .noteKind:
return NSLocalizedString("Note Kind Index", comment: "Database name for note kind index")
case .noteText:
return NSLocalizedString("Note Text Index", comment: "Database name for note text index")
case .noteBlocks:
return NSLocalizedString("Note Blocks", comment: "Database name for note blocks")
case .noteTags:
return NSLocalizedString("Note Tags Index", comment: "Database name for note tags index")
case .notePubkey:
return NSLocalizedString("Note Pubkey Index", comment: "Database name for note pubkey index")
case .notePubkeyKind:
return NSLocalizedString("Note Pubkey+Kind Index", comment: "Database name for note pubkey+kind index")
case .noteRelayKind:
return NSLocalizedString("Note Relay+Kind Index", comment: "Database name for note relay+kind index")
case .noteRelays:
return NSLocalizedString("Note Relays", comment: "Database name for note relays")
case .other:
return NSLocalizedString("Other Data", comment: "Database name for other/unaccounted data")
}
}
/// SF Symbol icon name for this database type
var icon: String {
switch self {
case .note:
return "text.bubble.fill"
case .profile:
return "person.circle.fill"
case .meta, .ndbMeta:
return "info.circle.fill"
case .noteBlocks:
return "square.stack.3d.up.fill"
case .noteId, .profileKey, .profileSearch, .noteKind, .noteText, .noteTags, .notePubkey, .notePubkeyKind, .noteRelayKind:
return "list.bullet.indent"
case .noteRelays:
return "antenna.radiowaves.left.and.right"
case .profileLastFetch, .other:
return "internaldrive.fill"
}
}
/// Color for chart and UI display
var color: Color {
switch self {
case .note:
return .green
case .profile:
return .blue
case .noteBlocks:
return .purple
case .meta, .ndbMeta:
return .orange
case .noteId, .profileKey, .profileSearch, .noteKind, .noteText, .noteTags, .notePubkey, .notePubkeyKind, .noteRelayKind:
return .gray
case .noteRelays:
return .cyan
case .profileLastFetch, .other:
return .secondary
}
}
}