diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index f34a3d34..7129ae7e 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -41,7 +41,6 @@ 3A92C0FF2DE16E9800CEEBAC /* 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 */; }; - EBCC3486DE53D8DB2532B98E /* LoadableNostrEventViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AE7EE3216D7983A50BE2D9 /* LoadableNostrEventViewModelTests.swift */; }; 3A96E3FE2D6BCE3800AE1630 /* RepostedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A96E3FD2D6BCE3800AE1630 /* RepostedTests.swift */; }; 3AA247FF297E3D900090C62D /* RepostsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA247FE297E3D900090C62D /* RepostsView.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 */; }; D773BC5F2C6D538500349F0A /* 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 */; }; D776BE412F232B17002DA1C9 /* 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 */; }; D7870BC12AC4750B0080BA88 /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC02AC4750B0080BA88 /* MentionView.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 */; }; D78BA6652DD7DFB9000AE62C /* 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 */; }; D7ADD3E02B538D4200F104C4 /* DamusPurpleURLSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3DF2B538D4200F104C4 /* DamusPurpleURLSheetView.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 */; }; D7B76C912C82507F003A16CB /* NIP98AuthenticatedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.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 */; }; E4FA1C032A24BB7F00482697 /* SearchSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FA1C022A24BB7F00482697 /* SearchSettingsView.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 */; }; F71694EC2A662292001F4053 /* SuggestedUsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694EB2A662292001F4053 /* SuggestedUsersViewModel.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 = ""; }; 3A92C0FD2DE16E9800CEEBAC /* FaviconCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconCache.swift; sourceTree = ""; }; 3A92C1012DE17ACA00CEEBAC /* NIP05DomainTimelineHeaderViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP05DomainTimelineHeaderViewTests.swift; sourceTree = ""; }; - C0AE7EE3216D7983A50BE2D9 /* LoadableNostrEventViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableNostrEventViewModelTests.swift; sourceTree = ""; }; 3A93342929884CA600D6A8F3 /* pl-PL */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pl-PL"; path = "pl-PL.lproj/InfoPlist.strings"; sourceTree = ""; }; 3A93342A29884CA600D6A8F3 /* pl-PL */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pl-PL"; path = "pl-PL.lproj/Localizable.strings"; sourceTree = ""; }; 3A93342B29884CA600D6A8F3 /* pl-PL */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "pl-PL"; path = "pl-PL.lproj/Localizable.stringsdict"; sourceTree = ""; }; @@ -2763,6 +2779,7 @@ BA693073295D649800ADDB87 /* UserSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsStore.swift; sourceTree = ""; }; BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectWalletView.swift; sourceTree = ""; }; BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KingfisherImageProvider.swift; sourceTree = ""; }; + C0AE7EE3216D7983A50BE2D9 /* LoadableNostrEventViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableNostrEventViewModelTests.swift; sourceTree = ""; }; D2277EE92A089BD5006C3807 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; }; D5C1AFBE2E5DF7E60092F72F /* ContactCardManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCardManager.swift; sourceTree = ""; }; D5C1AFC32E5DFF700092F72F /* ContactCardManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCardManagerMock.swift; sourceTree = ""; }; @@ -2843,6 +2860,9 @@ D76BE18B2E0CF3D5004AD0C6 /* Interests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Interests.swift; sourceTree = ""; }; D77135D22E7B766300E7639F /* DataExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtensions.swift; sourceTree = ""; }; D773BC5E2C6D538500349F0A /* CommentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentItem.swift; sourceTree = ""; }; + D774A5C72F4F93F5006A4D64 /* StorageStatsViewHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageStatsViewHelper.swift; sourceTree = ""; }; + D774A5CB2F4F9679006A4D64 /* StorageStatsManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageStatsManagerTests.swift; sourceTree = ""; }; + D774A5CD2F4FBDEA006A4D64 /* NdbDatabase+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NdbDatabase+UI.swift"; sourceTree = ""; }; D776BE3F2F232B17002DA1C9 /* EntityPreloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityPreloader.swift; sourceTree = ""; }; D776BE432F233012002DA1C9 /* EntityPreloaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityPreloaderTests.swift; sourceTree = ""; }; D77A96BE2F3131BE00CC3246 /* RelayHintsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayHintsTests.swift; sourceTree = ""; }; @@ -2854,6 +2874,8 @@ D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContentViewTests.swift; sourceTree = ""; }; D7870BC02AC4750B0080BA88 /* MentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionView.swift; sourceTree = ""; }; D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventLoaderView.swift; sourceTree = ""; }; + D78778212F49476700DA73E4 /* StorageStatsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageStatsManager.swift; sourceTree = ""; }; + D78778252F49478200DA73E4 /* StorageSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageSettingsView.swift; sourceTree = ""; }; D78BA6642DD7DFB9000AE62C /* InterestSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterestSelectionView.swift; sourceTree = ""; }; D78CD5972B8990300014D539 /* DamusAppNotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusAppNotificationView.swift; sourceTree = ""; }; D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorMath.swift; sourceTree = ""; }; @@ -2874,6 +2896,7 @@ D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURL.swift; sourceTree = ""; }; D7ADD3DF2B538D4200F104C4 /* DamusPurpleURLSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURLSheetView.swift; sourceTree = ""; }; D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleVerifyNpubView.swift; sourceTree = ""; }; + D7B62B992F4CE59B001EE26F /* NostrDBDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrDBDetailView.swift; sourceTree = ""; }; D7BEE6F82D37B37400CF659F /* DraftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftTests.swift; sourceTree = ""; }; D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP98AuthenticatedRequest.swift; sourceTree = ""; }; D7CB5D3D2B116DAD00AD4105 /* NotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsManager.swift; sourceTree = ""; }; @@ -3406,6 +3429,7 @@ 4C78EFD92A707C4D007E8197 /* secp256k1.h */, D798D2272B085CDA00234419 /* NdbNote+.swift */, 4CF480582B633F3800F2B2C0 /* NdbBlock.swift */, + D774A5CD2F4FBDEA006A4D64 /* NdbDatabase+UI.swift */, ); path = nostrdb; sourceTree = ""; @@ -3894,6 +3918,7 @@ 4CE6DEF627F7A08200C66700 /* damusTests */ = { isa = PBXGroup; children = ( + D774A5CB2F4F9679006A4D64 /* StorageStatsManagerTests.swift */, D77A96BE2F3131BE00CC3246 /* RelayHintsTests.swift */, D776BE432F233012002DA1C9 /* EntityPreloaderTests.swift */, D77DA2CD2F1C2596000B7093 /* SubscriptionManagerNegentropyTests.swift */, @@ -4516,6 +4541,8 @@ 5C78A7912E3036DA00CF177D /* Views */ = { isa = PBXGroup; children = ( + D7B62B992F4CE59B001EE26F /* NostrDBDetailView.swift */, + D78778252F49478200DA73E4 /* StorageSettingsView.swift */, 4C15C7142A55DE7A00D0A0DB /* ReactionsSettingsView.swift */, 4C1A9A1C29DDCF9B00516EAC /* NotificationSettingsView.swift */, 4C1A9A1E29DDD24B00516EAC /* AppearanceSettingsView.swift */, @@ -5001,6 +5028,8 @@ 5C78A7BD2E306D6000CF177D /* Storage */ = { isa = PBXGroup; children = ( + D774A5C72F4F93F5006A4D64 /* StorageStatsViewHelper.swift */, + D78778212F49476700DA73E4 /* StorageStatsManager.swift */, D7CCDB882F034FBB00218972 /* DatabaseSnapshotManager.swift */, D7EDED322B12ACAE0018B19C /* DamusUserDefaults.swift */, D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */, @@ -5892,6 +5921,7 @@ 4CC6193A29DC777C006A86D1 /* RelayBootstrap.swift in Sources */, 4C285C8A2838B985008A31F1 /* ProfilePictureSelector.swift in Sources */, 4CFD502F2A2DA45800A229DB /* MediaView.swift in Sources */, + D7B62B9B2F4CE59B001EE26F /* NostrDBDetailView.swift in Sources */, D7373BA62B688EA300F7783D /* DamusPurpleTranslationSetupView.swift in Sources */, D776BE402F232B17002DA1C9 /* EntityPreloader.swift in Sources */, 4C9F18E429ABDE6D008C55EC /* MaybeAnonPfpView.swift in Sources */, @@ -5974,6 +6004,7 @@ 4C363A9A28283854006E126D /* Reply.swift in Sources */, BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */, D7ADD3E02B538D4200F104C4 /* DamusPurpleURLSheetView.swift in Sources */, + D774A5D02F4FBDEA006A4D64 /* NdbDatabase+UI.swift in Sources */, 4CFF8F6729CC9E3A008DB934 /* FullScreenCarouselView.swift in Sources */, 4CA927632A290EB10098A105 /* EventTop.swift in Sources */, 4C90BD18283A9EE5008EE7EF /* LoginView.swift in Sources */, @@ -6094,6 +6125,7 @@ D73B74E12D8365BA0067BDBC /* ExtraFonts.swift in Sources */, 4C7D09622A098D0E00943473 /* WalletConnect.swift in Sources */, 4C64987E286D082C00EAE2B3 /* DirectMessagesModel.swift in Sources */, + D774A5CA2F4F93F5006A4D64 /* StorageStatsViewHelper.swift in Sources */, 4C12535E2A76CA870004F4B8 /* SwitchedTimelineNotify.swift in Sources */, D74F430A2B23F0BE00425B75 /* DamusPurple.swift in Sources */, 5C4FA7ED2DC29AE900CE658C /* FollowPackEvent.swift in Sources */, @@ -6119,6 +6151,7 @@ 4C011B5F2BD0A56A002F2F9B /* ChatroomThreadView.swift in Sources */, 4C9F18E229AA9B6C008C55EC /* CustomizeZapView.swift in Sources */, 4C2859602A12A2BE004746F7 /* SupporterBadge.swift in Sources */, + D78778232F49476700DA73E4 /* StorageStatsManager.swift in Sources */, D73E5F7F2C6AA066007EB227 /* DamusAliases.swift in Sources */, 4C1A9A2A29DDF54400516EAC /* DamusVideoPlayerView.swift in Sources */, D73BDB152D71216500D69970 /* UserRelayListManager.swift in Sources */, @@ -6185,6 +6218,7 @@ 4C12536A2A76D3850004F4B8 /* RelaysChangedNotify.swift in Sources */, 4C30AC8029A6A53F00E2BD5A /* ProfilePicturesView.swift in Sources */, D7373BAA2B68A65A00F7783D /* PurpleAccountUpdateNotify.swift in Sources */, + D78778272F49478200DA73E4 /* StorageSettingsView.swift in Sources */, 5C6E1DAD2A193EC2008FC15A /* GradientButtonStyle.swift in Sources */, 3CCD1E6A2A874C4E0099A953 /* Nip98HTTPAuth.swift in Sources */, 5C4FA7FF2DC5119300CE658C /* FollowPackPreview.swift in Sources */, @@ -6400,6 +6434,7 @@ D7DB1FEE2D5AC51B00CF06DA /* NIP44v2EncryptionTests.swift in Sources */, 4C4F14A72A2A61A30045A0B9 /* NostrScriptTests.swift in Sources */, D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */, + D774A5CC2F4F9679006A4D64 /* StorageStatsManagerTests.swift in Sources */, 4C3EA67B28FF7B3900C48A62 /* InvoiceTests.swift in Sources */, 4C363A9E2828A822006E126D /* ReplyTests.swift in Sources */, 3A96E3FE2D6BCE3800AE1630 /* RepostedTests.swift in Sources */, @@ -6556,6 +6591,7 @@ 82D6FAFA2CD99F7900C925F4 /* UnmuteThreadNotify.swift in Sources */, 82D6FAFB2CD99F7900C925F4 /* ReconnectRelaysNotify.swift in Sources */, 3ACF94432DA9FCAB00971A4E /* NIP05DomainTimelineView.swift in Sources */, + D78778242F49476700DA73E4 /* StorageStatsManager.swift in Sources */, 82D6FAFC2CD99F7900C925F4 /* PurpleAccountUpdateNotify.swift in Sources */, 82D6FAFD2CD99F7900C925F4 /* IdType.swift in Sources */, 82D6FAFE2CD99F7900C925F4 /* Pubkey.swift in Sources */, @@ -6713,6 +6749,7 @@ 82D6FB822CD99F7900C925F4 /* Reply.swift in Sources */, 82D6FB832CD99F7900C925F4 /* SearchModel.swift in Sources */, 82D6FB842CD99F7900C925F4 /* NostrFilter+Hashable.swift in Sources */, + D7B62B9A2F4CE59B001EE26F /* NostrDBDetailView.swift in Sources */, 82D6FB852CD99F7900C925F4 /* Contacts.swift in Sources */, 82D6FB862CD99F7900C925F4 /* CreateAccountModel.swift in Sources */, 82D6FB872CD99F7900C925F4 /* HomeModel.swift in Sources */, @@ -6747,6 +6784,7 @@ 82D6FB9E2CD99F7900C925F4 /* ContentFilters.swift in Sources */, 3A515C512DF4E100002D3B34 /* TrustedNetworkRepliesTip.swift in Sources */, 82D6FB9F2CD99F7900C925F4 /* DamusCacheManager.swift in Sources */, + D774A5C92F4F93F5006A4D64 /* StorageStatsViewHelper.swift in Sources */, 82D6FBA02CD99F7900C925F4 /* NotificationsManager.swift in Sources */, D755B28E2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */, 82D6FBA12CD99F7900C925F4 /* Contacts+.swift in Sources */, @@ -6787,6 +6825,7 @@ 82D6FBC12CD99F7900C925F4 /* RelayURL.swift in Sources */, D76BE18C2E0CF3DA004AD0C6 /* Interests.swift in Sources */, 82D6FBC22CD99F7900C925F4 /* NostrEvent+.swift in Sources */, + D78778262F49478200DA73E4 /* StorageSettingsView.swift in Sources */, 82D6FBC32CD99F7900C925F4 /* NIP98AuthenticatedRequest.swift in Sources */, 82D6FBC42CD99F7900C925F4 /* NostrAuth.swift in Sources */, 82D6FBC52CD99F7900C925F4 /* MakeZapRequest.swift in Sources */, @@ -6825,6 +6864,7 @@ 82D6FBEA2CD99F7900C925F4 /* FullScreenCarouselView.swift in Sources */, D7F360272CEBBDC0009D34DA /* DamusVideoControlsView.swift in Sources */, 82D6FBEB2CD99F7900C925F4 /* ProfilePicImageView.swift in Sources */, + D774A5CF2F4FBDEA006A4D64 /* NdbDatabase+UI.swift in Sources */, 82D6FBEC2CD99F7900C925F4 /* ImageContainerView.swift in Sources */, 5C8F97492EB4620A009399B1 /* Glow.swift in Sources */, 82D6FBED2CD99F7900C925F4 /* MediaView.swift in Sources */, @@ -7020,6 +7060,7 @@ 4C3624792D5EA20200DD066E /* bolt11.c in Sources */, 4C3624782D5EA1FE00DD066E /* error.c in Sources */, D776BE422F232B17002DA1C9 /* EntityPreloader.swift in Sources */, + D774A5D12F4FBDEA006A4D64 /* NdbDatabase+UI.swift in Sources */, 4C3624772D5EA1FA00DD066E /* nostr_bech32.c in Sources */, 4C3624762D5EA1F600DD066E /* content_parser.c in Sources */, 4C3624752D5EA1E000DD066E /* block.c in Sources */, @@ -7166,6 +7207,7 @@ D73E5E912C6A97F4007EB227 /* CustomizeZapModel.swift in Sources */, D73E5E922C6A97F4007EB227 /* EventGroup.swift in Sources */, D73E5E932C6A97F4007EB227 /* ZapGroup.swift in Sources */, + D774A5C82F4F93F5006A4D64 /* StorageStatsViewHelper.swift in Sources */, D73E5E942C6A97F4007EB227 /* NotificationStatusModel.swift in Sources */, 3A515C542DF5371D002D3B34 /* TrustedNetworkButtonTipViewStyle.swift in Sources */, D73E5E952C6A97F4007EB227 /* ThreadModel.swift in Sources */, @@ -7222,6 +7264,7 @@ D77DA2C42F19CA48000B7093 /* AsyncStreamUtilities.swift in Sources */, D73E5EB42C6A97F4007EB227 /* NoteContent.swift in Sources */, D73E5EB52C6A97F4007EB227 /* LongformEvent.swift in Sources */, + D78778222F49476700DA73E4 /* StorageStatsManager.swift in Sources */, D73E5EB62C6A97F4007EB227 /* PushNotificationClient.swift in Sources */, D706C5B92D602A110027C627 /* QueueableNotify.swift in Sources */, D71AD8FD2CEC176A002E2C3C /* AppAccessibilityIdentifiers.swift in Sources */, @@ -7298,6 +7341,7 @@ D73E5EFA2C6A97F4007EB227 /* NotificationItemView.swift in Sources */, 5C8F97272EB460CA009399B1 /* LiveStreamBanner.swift in Sources */, D73E5EFB2C6A97F4007EB227 /* ProfilePicturesView.swift in Sources */, + D78778282F49478200DA73E4 /* StorageSettingsView.swift in Sources */, D73E5EFC2C6A97F4007EB227 /* DamusAppNotificationView.swift in Sources */, D73E5EFD2C6A97F4007EB227 /* InnerTimelineView.swift in Sources */, D73E5EFE2C6A97F4007EB227 /* (null) in Sources */, @@ -7437,6 +7481,7 @@ D73E5F6C2C6A97F5007EB227 /* RepostsView.swift in Sources */, D734B1462CCC19B1000B5C97 /* DamusFullScreenCover.swift in Sources */, D73BDB142D71216500D69970 /* UserRelayListManager.swift in Sources */, + D7B62B9C2F4CE59B001EE26F /* NostrDBDetailView.swift in Sources */, D73E5F6D2C6A97F5007EB227 /* Launch.storyboard in Sources */, D73E5F6F2C6A97F5007EB227 /* RelayFilterView.swift in Sources */, 5CFDE6E72EF4F782004E8661 /* TenorModels.swift in Sources */, @@ -7687,6 +7732,7 @@ D798D2232B0859B700234419 /* KeychainStorage.swift in Sources */, D74AAFC32B153395006CF0F4 /* HeadlessDamusState.swift in Sources */, D78F08122D7F78F900FC6C75 /* Response.swift in Sources */, + D774A5CE2F4FBDEA006A4D64 /* NdbDatabase+UI.swift in Sources */, D7CCFC102B05880F00323D86 /* Id.swift in Sources */, D7CB5D532B1174E900AD4105 /* DeepLPlan.swift in Sources */, D7EDED282B1180940018B19C /* ImageUploadModel.swift in Sources */, diff --git a/damus/Core/Storage/StorageStatsManager.swift b/damus/Core/Storage/StorageStatsManager.swift new file mode 100644 index 00000000..846f06df --- /dev/null +++ b/damus/Core/Storage/StorageStatsManager.swift @@ -0,0 +1,154 @@ +// +// StorageStatsManager.swift +// damus +// +// Created by Daniel D’Aquino 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)) + } +} diff --git a/damus/Core/Storage/StorageStatsViewHelper.swift b/damus/Core/Storage/StorageStatsViewHelper.swift new file mode 100644 index 00000000..f5dc5680 --- /dev/null +++ b/damus/Core/Storage/StorageStatsViewHelper.swift @@ -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)] { + var total: UInt64 = 0 + return categories.map { category in + let newTotal = total + category.size + let result = (category: category.id, range: Double(total).. 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 + } +} diff --git a/damus/Features/Settings/Views/AppearanceSettingsView.swift b/damus/Features/Settings/Views/AppearanceSettingsView.swift index dfafa56b..e73d0aca 100644 --- a/damus/Features/Settings/Views/AppearanceSettingsView.swift +++ b/damus/Features/Settings/Views/AppearanceSettingsView.swift @@ -7,16 +7,6 @@ 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 { let damus_state: DamusState @ObservedObject var settings: UserSettingsStore @@ -59,8 +49,6 @@ struct AppearanceSettingsView: View { let damus_state: DamusState @ObservedObject var settings: UserSettingsStore @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 enable_animation_toggle_is_user_initiated: Bool = true @@ -142,8 +130,6 @@ struct AppearanceSettingsView: View { .tag(uploader.model.tag) } } - - self.ClearCacheButton } // 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 { Toggle(NSLocalizedString("Animations", comment: "Toggle to enable or disable image animation"), isOn: $settings.enable_animation) .toggleStyle(.switch) @@ -231,7 +193,9 @@ struct AppearanceSettingsView: View { 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"), 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() { // 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()) - } - } } diff --git a/damus/Features/Settings/Views/ConfigView.swift b/damus/Features/Settings/Views/ConfigView.swift index d3f5b5e8..4703c5a5 100644 --- a/damus/Features/Settings/Views/ConfigView.swift +++ b/damus/Features/Settings/Views/ConfigView.swift @@ -32,6 +32,7 @@ struct ConfigView: View { 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 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 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.") @@ -104,6 +105,12 @@ struct ConfigView: View { 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 if showSettingsButton(title: firstAidTitle){ NavigationLink(value: Route.FirstAidSettings(settings: settings)){ diff --git a/damus/Features/Settings/Views/NostrDBDetailView.swift b/damus/Features/Settings/Views/NostrDBDetailView.swift new file mode 100644 index 00000000..28d286fa --- /dev/null +++ b/damus/Features/Settings/Views/NostrDBDetailView.swift @@ -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)] { + 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 + ) + ) + } +} diff --git a/damus/Features/Settings/Views/StorageSettingsView.swift b/damus/Features/Settings/Views/StorageSettingsView.swift new file mode 100644 index 00000000..c2a03afc --- /dev/null +++ b/damus/Features/Settings/Views/StorageSettingsView.swift @@ -0,0 +1,457 @@ +// +// StorageSettingsView.swift +// damus +// +// Created by Daniel D’Aquino 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 { + return 0..)] { + 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 + ) + } +} diff --git a/damus/Shared/Utilities/Router.swift b/damus/Shared/Utilities/Router.swift index 03685af7..d5d09d31 100644 --- a/damus/Shared/Utilities/Router.swift +++ b/damus/Shared/Utilities/Router.swift @@ -32,6 +32,8 @@ enum Route: Hashable { case SearchSettings(settings: UserSettingsStore) case DeveloperSettings(settings: UserSettingsStore) case FirstAidSettings(settings: UserSettingsStore) + case StorageSettings(settings: UserSettingsStore) + case NostrDBStorageDetail(stats: StorageStats) case Thread(thread: ThreadModel) case LoadableNostrEvent(note_reference: LoadableNostrEventViewModel.NoteReference) case Reposts(reposts: EventsModel) @@ -100,6 +102,10 @@ enum Route: Hashable { DeveloperSettingsView(settings: settings, damus_state: damusState) case .FirstAidSettings(settings: let 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): ChatroomThreadView(damus: damusState, thread: thread) //ThreadView(state: damusState, thread: thread) @@ -204,6 +210,11 @@ enum Route: Hashable { hasher.combine("developerSettings") case .FirstAidSettings: hasher.combine("firstAidSettings") + case .StorageSettings: + hasher.combine("storageSettings") + case .NostrDBStorageDetail(let stats): + hasher.combine("nostrDBStorageDetail") + hasher.combine(stats) case .Thread(let threadModel): hasher.combine("thread") hasher.combine(threadModel.original_event.id) diff --git a/damusTests/StorageStatsManagerTests.swift b/damusTests/StorageStatsManagerTests.swift new file mode 100644 index 00000000..1bb5edec --- /dev/null +++ b/damusTests/StorageStatsManagerTests.swift @@ -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 = [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 = [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 = [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.. 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.. 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 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(byteBuffer: inout ByteBuffer) thro func remove_file_prefix(_ str: String) -> String { 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 } + } +} + diff --git a/nostrdb/NdbDatabase+UI.swift b/nostrdb/NdbDatabase+UI.swift new file mode 100644 index 00000000..e58fc9ba --- /dev/null +++ b/nostrdb/NdbDatabase+UI.swift @@ -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 + } + } +}