Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
31e281ce73
|
|||
|
963da2d4eb
|
|||
| af3bb212c3 | |||
| f3361e6eae | |||
| 7f2c575f20 | |||
| ec28822451 | |||
| 795fce1b65 | |||
| 65e767b774 | |||
| af9956de8a | |||
| 7be75f37c6 | |||
| 84ef5ecf53 | |||
| f440f37cbf | |||
| b59816e180 | |||
| 609cdcc5f9 | |||
| 59498e3256 | |||
| 546e9eec32 | |||
| cfafcffde2 | |||
| 4099827169 | |||
| 32c0177049 | |||
| 2e1a98ff19 | |||
| 7fa044d205 |
+128
@@ -1,3 +1,131 @@
|
||||
## [1.16.1] - 2026-02-17
|
||||
|
||||
### Added
|
||||
|
||||
- Added a view for quotes notes that could not be loaded, including actionable items (Daniel D’Aquino)
|
||||
- Added relay hint support for nevent, nprofile, naddr links and event tag references (reposts, quotes, replies) (alltheseas)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue where notes would keep loading indefinitely in some cases (Daniel D’Aquino)
|
||||
- Fixed Lightning invoice parsing and fetching for all amounts (alltheseas)
|
||||
|
||||
|
||||
|
||||
[1.16.1]: https://github.com/damus-io/damus/releases/tag/v1.16.1
|
||||
|
||||
|
||||
## [1.16] - 2026-01-28
|
||||
|
||||
### Added
|
||||
|
||||
- Added live stream timeline (ericholguin)
|
||||
- Added live chat timeline (ericholguin)
|
||||
- Added ability to create live chat event (ericholguin)
|
||||
- Damus Labs Toggle (ericholguin)
|
||||
- Added Damus Labs (ericholguin)
|
||||
- Add Timeline switcher button for NIP-81-favorites (Askia Linder)
|
||||
- Added the ability to load saved notes if device is offline (Daniel D’Aquino)
|
||||
- Notes now load offline (Daniel D’Aquino)
|
||||
- Added support for scanning nprofile QR codes (Terry Yiu)
|
||||
- Add nip50 search filters and queries (William Casarin)
|
||||
- Add ndb_filter_init_with (William Casarin)
|
||||
- Add ndb_filter_is_subset_of (William Casarin)
|
||||
- Add ndb_filter_eq for filter equality testing (William Casarin)
|
||||
- Add method for parsing filter json (William Casarin)
|
||||
- Add ndb_filter_json method for creating json filters (William Casarin)
|
||||
- Add ndb_unsubscribe to unsubscribe from subscriptions (William Casarin)
|
||||
- Add general created_at query plan for timelines (William Casarin)
|
||||
- Add ndb_poll_for_notes (William Casarin)
|
||||
- Added filter subscriptions (William Casarin)
|
||||
- Add initial rust library (William Casarin)
|
||||
- Added relay count and relay view to events (Terry Yiu)
|
||||
- Add relay hints to tags and identifiers (Terry Yiu)
|
||||
- Added focus mode with auto-hide navigation for longform reading (alltheseas)
|
||||
- Added sepia mode and line height settings for longform articles (alltheseas)
|
||||
- Added estimated read time to longform preview (alltheseas)
|
||||
- Added reading progress bar for longform articles (alltheseas)
|
||||
- Added automatic conversion of pasted npub/nprofile to human-readable mentions in post composer (alltheseas)
|
||||
- Added hashtag spam filter setting to hide posts with too many hashtags (alltheseas)
|
||||
- Profile metadata preloading for improved timeline performance (Daniel D’Aquino)
|
||||
- Added a pull to refresh feature on DMs that allows users to resync DMs with their relays (Daniel D’Aquino)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved performance around note content views to prevent hangs (Daniel D’Aquino)
|
||||
- Highlight note search results (alltheseas)
|
||||
- Improved draft saving feature to prevent data loss if app closes too quickly (Daniel D’Aquino)
|
||||
- Changed Damus Purple Side View logo and text (ericholguin)
|
||||
- Placed the Favorites feature behind a feature flag (Daniel D’Aquino)
|
||||
- Tweaked since optimization filter to capture notes that would otherwise be lost (Daniel D’Aquino)
|
||||
- Optimized network bandwidth usage and improved timeline performance (Daniel D’Aquino)
|
||||
- Increased transaction list limit to 50 transactions (Daniel D’Aquino)
|
||||
- Improved loading UX in the home timeline (Daniel D’Aquino)
|
||||
- Added UX hint to make it easier to load new notes (Daniel D’Aquino)
|
||||
- Switched to the local relay model (Daniel D’Aquino)
|
||||
- Reduced default zap amount and deduplicated from preset zap amount items (Terry Yiu)
|
||||
- Use NostrDB for rendering note contents (Daniel D’Aquino)
|
||||
- Changed abbreviated pubkey format to npub1...xyz for better readability (alltheseas)
|
||||
- Changed focus mode to only hide navigation on scroll down (alltheseas)
|
||||
- Removed card styling from longform preview in full article view (alltheseas)
|
||||
- Improved storage efficiency for NostrDB on extensions (Daniel D’Aquino)
|
||||
- Changed load media UI (ericholguin)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed broken automatic translations (alltheseas)
|
||||
- Fixed an issue where notifications view would occasionally appear blank when the app started. (alltheseas)
|
||||
- Fixed incorrect behaviour on the post editor that would cause the text cursor to occasionally jump beyond the correct location in some editing operations. (alltheseas)
|
||||
- Fixed several crashes throughout the app (Daniel D’Aquino)
|
||||
- Fixed an issue where an empty dot would appear on some thread chat views (alltheseas)
|
||||
- Ensure mention profile prefetch covers mention_index blocks (alltheseas)
|
||||
- Fixed an issue where the mute list view may occasionally freeze the app (Daniel D’Aquino)
|
||||
- Fix mention pills falling back to @npub text when profile metadata is missing (alltheseas)
|
||||
- Fixed an occasional random crash related to viewing profiles (Daniel D’Aquino)
|
||||
- Improved robustness in the part of the code that streams notes from nostrdb (Daniel D’Aquino)
|
||||
- Added performance improvements to timeline scrolling (Daniel D’Aquino)
|
||||
- Improved security around note validation (Daniel D’Aquino)
|
||||
- Fixed an issue where the app would crash when swapping between apps (Daniel D’Aquino)
|
||||
- Fixed memory error in nostrdb (Daniel D’Aquino)
|
||||
- Fixed bug where non-bech32 damus io urls would cause corruption (William Casarin)
|
||||
- Fix aspect ratio on pasted or uploaded images (askeew)
|
||||
- Fixed note content rendering to not remove whitespace before hashtag (Terry Yiu)
|
||||
- Fixed background crashes with error code 0xdead10cc (Daniel D’Aquino)
|
||||
- Fixed crashes that happened when the app went into background mode (Daniel D’Aquino)
|
||||
- Added more guards to prevent accidental overrides of the user's mutelist (alltheseas)
|
||||
- Fixed instances where a profile would not display profile name and picture for a few seconds (alltheseas)
|
||||
- Longform article links now open correctly when shared as nevent URLs (alltheseas)
|
||||
- Longform articles now open at the top instead of midway through (alltheseas)
|
||||
- Fixed tab bar staying hidden when switching from longform to non-longform event (alltheseas)
|
||||
- Fixed stretched/cut-off images in longform notes (alltheseas)
|
||||
- Fixed mentions unlinking when typing text before them (alltheseas)
|
||||
- Fixed cursor jumping behind first letter when typing a new note (alltheseas)
|
||||
- Fixed an issue that would occasionally cause the app to freeze (Daniel D’Aquino)
|
||||
- Fix issue where your own replies were sometimes not trusted (alltheseas)
|
||||
- Fix issue where search results were out of order (alltheseas)
|
||||
- Fixed repost notifications not appearing in notifications tab (alltheseas)
|
||||
- Fixed a crash that occurred when clicking "follow all" during onboarding. (Daniel D’Aquino)
|
||||
|
||||
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed "Load new content" button (Daniel D’Aquino)
|
||||
- Wallet view no longer hangs on loading placeholder (Daniel D’Aquino)
|
||||
- Fixed issue where the app would occasionally launch an empty universe view (Daniel D’Aquino)
|
||||
- Profile action sheet buttons now center properly when fewer than 5 buttons are displayed (Daniel D’Aquino)
|
||||
- Fixed an issue where DMs may not appear for users with a large contact list (Daniel D’Aquino)
|
||||
- Fixed an issue that could cause certain networking operations to hang indefinitely (Daniel D’Aquino)
|
||||
- Fixed a race condition in the networking logic that could cause notes to get missed in certain rare scenarios (Daniel D’Aquino)
|
||||
- Fixed a crash on iOS 17 that would happen on startup (Daniel D’Aquino)
|
||||
|
||||
|
||||
[1.16]: https://github.com/damus-io/damus/releases/tag/v1.16
|
||||
|
||||
|
||||
## [1.15] - 2025-07-11
|
||||
|
||||
**Note:** This version was only released on TestFlight, and never officially released on the App Store.
|
||||
|
||||
@@ -625,6 +625,18 @@
|
||||
5CF2DCCC2AA3AF0B00984B8D /* RelayPicView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF2DCCB2AA3AF0B00984B8D /* RelayPicView.swift */; };
|
||||
5CF2DCCE2AABE1A500984B8D /* DamusLightGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF2DCCD2AABE1A500984B8D /* DamusLightGradient.swift */; };
|
||||
5CF72FC229B9142F00124A13 /* ShareAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF72FC129B9142F00124A13 /* ShareAction.swift */; };
|
||||
5CFDE6E52EF4F782004E8661 /* TenorModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6E42EF4F77E004E8661 /* TenorModels.swift */; };
|
||||
5CFDE6E62EF4F782004E8661 /* TenorModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6E42EF4F77E004E8661 /* TenorModels.swift */; };
|
||||
5CFDE6E72EF4F782004E8661 /* TenorModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6E42EF4F77E004E8661 /* TenorModels.swift */; };
|
||||
5CFDE6ED2EF4F7B2004E8661 /* GIFPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */; };
|
||||
5CFDE6EE2EF4F7B2004E8661 /* GIFPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */; };
|
||||
5CFDE6EF2EF4F7B2004E8661 /* GIFPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */; };
|
||||
5CFDE6F12EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F02EF4F7E2004E8661 /* TenorAPIClient.swift */; };
|
||||
5CFDE6F22EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F02EF4F7E2004E8661 /* TenorAPIClient.swift */; };
|
||||
5CFDE6F32EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F02EF4F7E2004E8661 /* TenorAPIClient.swift */; };
|
||||
5CFDE6F52EF4F93D004E8661 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F42EF4F939004E8661 /* Secrets.swift */; };
|
||||
5CFDE6F62EF4F93D004E8661 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F42EF4F939004E8661 /* Secrets.swift */; };
|
||||
5CFDE6F72EF4F93D004E8661 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F42EF4F939004E8661 /* Secrets.swift */; };
|
||||
6439E014296790CF0020672B /* ProfilePicImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6439E013296790CF0020672B /* ProfilePicImageView.swift */; };
|
||||
643EA5C8296B764E005081BB /* RelayFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643EA5C7296B764E005081BB /* RelayFilterView.swift */; };
|
||||
647D9A8D2968520300A295DE /* SideMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647D9A8C2968520300A295DE /* SideMenuView.swift */; };
|
||||
@@ -1700,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 */; };
|
||||
@@ -1718,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 */; };
|
||||
@@ -1767,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 */; };
|
||||
@@ -1943,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 */; };
|
||||
@@ -2706,6 +2736,10 @@
|
||||
5CF2DCCB2AA3AF0B00984B8D /* RelayPicView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayPicView.swift; sourceTree = "<group>"; };
|
||||
5CF2DCCD2AABE1A500984B8D /* DamusLightGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusLightGradient.swift; sourceTree = "<group>"; };
|
||||
5CF72FC129B9142F00124A13 /* ShareAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAction.swift; sourceTree = "<group>"; };
|
||||
5CFDE6E42EF4F77E004E8661 /* TenorModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenorModels.swift; sourceTree = "<group>"; };
|
||||
5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFPickerView.swift; sourceTree = "<group>"; };
|
||||
5CFDE6F02EF4F7E2004E8661 /* TenorAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenorAPIClient.swift; sourceTree = "<group>"; };
|
||||
5CFDE6F42EF4F939004E8661 /* Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = "<group>"; };
|
||||
6439E013296790CF0020672B /* ProfilePicImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePicImageView.swift; sourceTree = "<group>"; };
|
||||
643EA5C7296B764E005081BB /* RelayFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterView.swift; sourceTree = "<group>"; };
|
||||
647D9A8C2968520300A295DE /* SideMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideMenuView.swift; sourceTree = "<group>"; };
|
||||
@@ -2745,6 +2779,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -2825,6 +2860,9 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -2836,6 +2874,8 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -2856,6 +2896,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -3388,6 +3429,7 @@
|
||||
4C78EFD92A707C4D007E8197 /* secp256k1.h */,
|
||||
D798D2272B085CDA00234419 /* NdbNote+.swift */,
|
||||
4CF480582B633F3800F2B2C0 /* NdbBlock.swift */,
|
||||
D774A5CD2F4FBDEA006A4D64 /* NdbDatabase+UI.swift */,
|
||||
);
|
||||
path = nostrdb;
|
||||
sourceTree = "<group>";
|
||||
@@ -3843,6 +3885,7 @@
|
||||
4CE6DEE527F7A08100C66700 /* damus */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5CFDE6F42EF4F939004E8661 /* Secrets.swift */,
|
||||
5C78A7932E30387400CF177D /* Shared */,
|
||||
5C78A7792E22FDFE00CF177D /* Features */,
|
||||
5C78A7752E22F84A00CF177D /* Core */,
|
||||
@@ -3875,6 +3918,7 @@
|
||||
4CE6DEF627F7A08200C66700 /* damusTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D774A5CB2F4F9679006A4D64 /* StorageStatsManagerTests.swift */,
|
||||
D77A96BE2F3131BE00CC3246 /* RelayHintsTests.swift */,
|
||||
D776BE432F233012002DA1C9 /* EntityPreloaderTests.swift */,
|
||||
D77DA2CD2F1C2596000B7093 /* SubscriptionManagerNegentropyTests.swift */,
|
||||
@@ -3931,6 +3975,7 @@
|
||||
64D0A2B0F048CC8D494945E6 /* RepostNotificationTests.swift */,
|
||||
4C0ED07E2D7A1E260020D8A2 /* Benchmarking.swift */,
|
||||
3A92C1012DE17ACA00CEEBAC /* NIP05DomainTimelineHeaderViewTests.swift */,
|
||||
C0AE7EE3216D7983A50BE2D9 /* LoadableNostrEventViewModelTests.swift */,
|
||||
);
|
||||
path = damusTests;
|
||||
sourceTree = "<group>";
|
||||
@@ -4496,6 +4541,8 @@
|
||||
5C78A7912E3036DA00CF177D /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D7B62B992F4CE59B001EE26F /* NostrDBDetailView.swift */,
|
||||
D78778252F49478200DA73E4 /* StorageSettingsView.swift */,
|
||||
4C15C7142A55DE7A00D0A0DB /* ReactionsSettingsView.swift */,
|
||||
4C1A9A1C29DDCF9B00516EAC /* NotificationSettingsView.swift */,
|
||||
4C1A9A1E29DDD24B00516EAC /* AppearanceSettingsView.swift */,
|
||||
@@ -4610,6 +4657,7 @@
|
||||
5C78A79C2E303CA300CF177D /* Media */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5CFDE6E32EF4F773004E8661 /* GIF */,
|
||||
5C78A79D2E303D2600CF177D /* Models */,
|
||||
4CFF8F6129CC9A80008DB934 /* Images */,
|
||||
4C1A9A2829DDF53B00516EAC /* Video */,
|
||||
@@ -4980,6 +5028,8 @@
|
||||
5C78A7BD2E306D6000CF177D /* Storage */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D774A5C72F4F93F5006A4D64 /* StorageStatsViewHelper.swift */,
|
||||
D78778212F49476700DA73E4 /* StorageStatsManager.swift */,
|
||||
D7CCDB882F034FBB00218972 /* DatabaseSnapshotManager.swift */,
|
||||
D7EDED322B12ACAE0018B19C /* DamusUserDefaults.swift */,
|
||||
D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */,
|
||||
@@ -5150,6 +5200,16 @@
|
||||
path = Detail;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5CFDE6E32EF4F773004E8661 /* GIF */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5CFDE6F02EF4F7E2004E8661 /* TenorAPIClient.swift */,
|
||||
5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */,
|
||||
5CFDE6E42EF4F77E004E8661 /* TenorModels.swift */,
|
||||
);
|
||||
path = GIF;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7C0F392D29B57C8F0039859C /* Extensions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -5861,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 */,
|
||||
@@ -5888,12 +5949,14 @@
|
||||
4C3D52B6298DB4E6001C5831 /* ZapEvent.swift in Sources */,
|
||||
4CF4804D2B631C0100F2B2C0 /* amount.c in Sources */,
|
||||
647D9A8D2968520300A295DE /* SideMenuView.swift in Sources */,
|
||||
5CFDE6EF2EF4F7B2004E8661 /* GIFPickerView.swift in Sources */,
|
||||
D7AAD0012E0387B800FB7699 /* LnurlAmountView.swift in Sources */,
|
||||
F7F0BA272978E54D009531F3 /* ParticipantsView.swift in Sources */,
|
||||
4CF0ABE32981BC7D00D66079 /* UserView.swift in Sources */,
|
||||
4CE0E2AF29A2E82100DB4CA2 /* EventHolder.swift in Sources */,
|
||||
B51C1CEA2B55A60A00E312A9 /* AddMuteItemView.swift in Sources */,
|
||||
5C8F97332EB46126009399B1 /* LiveStreamViewers.swift in Sources */,
|
||||
5CFDE6F22EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */,
|
||||
4C5D5C992A6AF8F80024563C /* NdbNote.swift in Sources */,
|
||||
4CF0ABF029857E9200D66079 /* Bech32Object.swift in Sources */,
|
||||
D7D68FFA2C9E01BE0015A515 /* KFClickable.swift in Sources */,
|
||||
@@ -5941,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 */,
|
||||
@@ -6061,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 */,
|
||||
@@ -6086,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 */,
|
||||
@@ -6117,6 +6183,7 @@
|
||||
50088DA129E8271A008A1FDF /* WebSocket.swift in Sources */,
|
||||
5C7389B12B6EFA7100781E0A /* ProxyView.swift in Sources */,
|
||||
4C1253542A76C7D60004F4B8 /* LogoutNotify.swift in Sources */,
|
||||
5CFDE6F52EF4F93D004E8661 /* Secrets.swift in Sources */,
|
||||
5C513FCC2984ACA60072348F /* QRCodeView.swift in Sources */,
|
||||
4CC14FF52A740BB7007AEB17 /* NoteId.swift in Sources */,
|
||||
4C19AE512A5CEF7C00C90DB7 /* NostrScript.swift in Sources */,
|
||||
@@ -6151,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 */,
|
||||
@@ -6295,6 +6363,7 @@
|
||||
4C1A9A2129DDD3E100516EAC /* KeySettingsView.swift in Sources */,
|
||||
D723C38E2AB8D83400065664 /* ContentFilters.swift in Sources */,
|
||||
4C011B5E2BD0A56A002F2F9B /* ChatEventView.swift in Sources */,
|
||||
5CFDE6E62EF4F782004E8661 /* TenorModels.swift in Sources */,
|
||||
4C32B95A2A9AD44700DC3548 /* Verifiable.swift in Sources */,
|
||||
4C73C5142A4437C10062CAC0 /* ZapUserView.swift in Sources */,
|
||||
501F8C802A0220E1001AFC1D /* KeychainStorage.swift in Sources */,
|
||||
@@ -6365,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 */,
|
||||
@@ -6400,6 +6470,7 @@
|
||||
4C684A552A7E91FE005E6031 /* LargeEventTests.swift in Sources */,
|
||||
E02B54182B4DFADA0077FF42 /* Bech32ObjectTests.swift in Sources */,
|
||||
3A92C1022DE17ACA00CEEBAC /* NIP05DomainTimelineHeaderViewTests.swift in Sources */,
|
||||
EBCC3486DE53D8DB2532B98E /* LoadableNostrEventViewModelTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -6498,6 +6569,7 @@
|
||||
3ACF94482DAA006500971A4E /* NIP05DomainEventsModel.swift in Sources */,
|
||||
82D6FAE92CD99F7900C925F4 /* NewMutesNotify.swift in Sources */,
|
||||
82D6FAEA2CD99F7900C925F4 /* NewUnmutesNotify.swift in Sources */,
|
||||
5CFDE6F12EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */,
|
||||
82D6FAEB2CD99F7900C925F4 /* Notify.swift in Sources */,
|
||||
82D6FAEC2CD99F7900C925F4 /* OnlyZapsNotify.swift in Sources */,
|
||||
82D6FAED2CD99F7900C925F4 /* PostNotify.swift in Sources */,
|
||||
@@ -6519,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 */,
|
||||
@@ -6600,6 +6673,7 @@
|
||||
82D6FB3C2CD99F7900C925F4 /* AnyEncodable.swift in Sources */,
|
||||
82D6FB3D2CD99F7900C925F4 /* Zap.swift in Sources */,
|
||||
82D6FB3E2CD99F7900C925F4 /* NIPURLBuilder.swift in Sources */,
|
||||
5CFDE6ED2EF4F7B2004E8661 /* GIFPickerView.swift in Sources */,
|
||||
82D6FB3F2CD99F7900C925F4 /* TimeAgo.swift in Sources */,
|
||||
82D6FB402CD99F7900C925F4 /* Parser.swift in Sources */,
|
||||
82D6FB412CD99F7900C925F4 /* InsertSort.swift in Sources */,
|
||||
@@ -6675,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 */,
|
||||
@@ -6709,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 */,
|
||||
@@ -6749,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 */,
|
||||
@@ -6787,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 */,
|
||||
@@ -6815,6 +6893,7 @@
|
||||
82D6FC002CD99F7900C925F4 /* ProfilePicturesView.swift in Sources */,
|
||||
82D6FC012CD99F7900C925F4 /* DamusAppNotificationView.swift in Sources */,
|
||||
3A92C1002DE16E9800CEEBAC /* FaviconCache.swift in Sources */,
|
||||
5CFDE6E52EF4F782004E8661 /* TenorModels.swift in Sources */,
|
||||
82D6FC022CD99F7900C925F4 /* InnerTimelineView.swift in Sources */,
|
||||
82D6FC032CD99F7900C925F4 /* PostingTimelineView.swift in Sources */,
|
||||
82D6FC042CD99F7900C925F4 /* ZapsView.swift in Sources */,
|
||||
@@ -6882,6 +6961,7 @@
|
||||
82D6FC362CD99F7900C925F4 /* EventMenu.swift in Sources */,
|
||||
D7AAD0002E0387B800FB7699 /* LnurlAmountView.swift in Sources */,
|
||||
82D6FC372CD99F7900C925F4 /* EventMutingContainerView.swift in Sources */,
|
||||
5CFDE6F62EF4F93D004E8661 /* Secrets.swift in Sources */,
|
||||
82D6FC382CD99F7900C925F4 /* ZapEvent.swift in Sources */,
|
||||
82D6FC392CD99F7900C925F4 /* TextEvent.swift in Sources */,
|
||||
82D6FC3A2CD99F7900C925F4 /* WideEventView.swift in Sources */,
|
||||
@@ -6980,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 */,
|
||||
@@ -7004,6 +7085,7 @@
|
||||
D73E5E2B2C6A97F4007EB227 /* PostNotify.swift in Sources */,
|
||||
D73E5E2C2C6A97F4007EB227 /* PresentSheetNotify.swift in Sources */,
|
||||
D73E5E2D2C6A97F4007EB227 /* ProfileUpdatedNotify.swift in Sources */,
|
||||
5CFDE6F32EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */,
|
||||
3A515C522DF4E100002D3B34 /* TrustedNetworkRepliesTip.swift in Sources */,
|
||||
D73E5E2E2C6A97F4007EB227 /* ReportNotify.swift in Sources */,
|
||||
D73E5E2F2C6A97F4007EB227 /* ScrollToTopNotify.swift in Sources */,
|
||||
@@ -7047,6 +7129,7 @@
|
||||
D77DA2CA2F19D480000B7093 /* NegentropyUtilities.swift in Sources */,
|
||||
D73E5E552C6A97F4007EB227 /* TranslateView.swift in Sources */,
|
||||
D73E5E562C6A97F4007EB227 /* SelectableText.swift in Sources */,
|
||||
5CFDE6EE2EF4F7B2004E8661 /* GIFPickerView.swift in Sources */,
|
||||
D73E5E572C6A97F4007EB227 /* DamusColors.swift in Sources */,
|
||||
D73E5E582C6A97F4007EB227 /* ThiccDivider.swift in Sources */,
|
||||
D73E5E592C6A97F4007EB227 /* IconLabel.swift in Sources */,
|
||||
@@ -7124,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 */,
|
||||
@@ -7180,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 */,
|
||||
@@ -7256,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 */,
|
||||
@@ -7395,8 +7481,10 @@
|
||||
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 */,
|
||||
D703D78A2C670C8A00A400EA /* LibreTranslateServer.swift in Sources */,
|
||||
D703D7602C670AAB00A400EA /* MigratedTypes.swift in Sources */,
|
||||
D73E5F742C6A9890007EB227 /* damusApp.swift in Sources */,
|
||||
@@ -7434,6 +7522,7 @@
|
||||
D73E5E1B2C6A9672007EB227 /* LikeCounter.swift in Sources */,
|
||||
D703D7A92C670E5A00A400EA /* refmap.c in Sources */,
|
||||
D73C7EDC2DE51699001F9392 /* OnboardingContentSettings.swift in Sources */,
|
||||
5CFDE6F72EF4F93D004E8661 /* Secrets.swift in Sources */,
|
||||
D703D77B2C670BF000A400EA /* TableVerifier.swift in Sources */,
|
||||
3ACF94442DA9FCAB00971A4E /* NIP05DomainTimelineView.swift in Sources */,
|
||||
D703D76D2C670B4500A400EA /* ZapDataModel.swift in Sources */,
|
||||
@@ -7643,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 */,
|
||||
|
||||
+17
-3
@@ -440,7 +440,7 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
Task {
|
||||
if await !handle_post_notification(keypair: keypair, postbox: state.nostrNetwork.postbox, events: state.events, post: post) {
|
||||
if await !handle_post_notification(keypair: keypair, postbox: state.nostrNetwork.postbox, events: state.events, post: post, clientTag: state.clientTagComponents) {
|
||||
self.active_sheet = nil
|
||||
}
|
||||
}
|
||||
@@ -1081,13 +1081,27 @@ func handle_follow_notif(state: DamusState, target: FollowTarget) async -> Bool
|
||||
return await handle_follow(state: state, follow: target.follow_ref)
|
||||
}
|
||||
|
||||
func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: EventCache, post: NostrPostResult) async -> Bool {
|
||||
/// Handles a post notification by converting the post to a signed nostr event and broadcasting it.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - keypair: The user's full keypair used to sign the event.
|
||||
/// - postbox: The postbox used to broadcast the event to relays.
|
||||
/// - events: The event cache used to look up referenced events for rebroadcasting.
|
||||
/// - post: The post result, either a post to publish or a cancellation.
|
||||
/// - clientTag: Optional client tag array (e.g., `["client", "Damus"]`) to include in the event,
|
||||
/// identifying which application created the post. Pass `nil` to omit the tag.
|
||||
/// - Returns: `true` if the post was successfully converted and sent, `false` if the post was
|
||||
/// cancelled or if event conversion failed.
|
||||
///
|
||||
/// When successful, this function also rebroadcasts up to 3 referenced events and 3 quoted events
|
||||
/// to help ensure they are available on relays.
|
||||
func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: EventCache, post: NostrPostResult, clientTag: [String]? = nil) async -> Bool {
|
||||
switch post {
|
||||
case .post(let post):
|
||||
//let post = tup.0
|
||||
//let to_relays = tup.1
|
||||
print("post \(post.content)")
|
||||
guard let new_ev = post.to_event(keypair: keypair) else {
|
||||
guard let new_ev = post.to_event(keypair: keypair, clientTag: clientTag) else {
|
||||
return false
|
||||
}
|
||||
await postbox.send(new_ev)
|
||||
|
||||
@@ -416,8 +416,8 @@ extension NostrNetworkManager {
|
||||
/// to all connected relays. The `timeout` parameter is a total deadline for both phases.
|
||||
func lookup(noteId: NoteId, to targetRelays: [RelayURL]? = nil, timeout: Duration? = nil) async throws -> NdbNoteLender? {
|
||||
// Since note ids point to immutable objects, we can do a simple ndb lookup first
|
||||
if let noteKey = try? self.ndb.lookup_note_key(noteId) {
|
||||
return NdbNoteLender(ndb: self.ndb, noteKey: noteKey)
|
||||
if let note = try? self.ndb.lookup_note_and_copy(noteId) {
|
||||
return NdbNoteLender(ownedNdbNote: note)
|
||||
}
|
||||
|
||||
// Not available in local ndb, stream from network
|
||||
@@ -760,4 +760,4 @@ extension NostrNetworkManager {
|
||||
/// Preload metadata for authors and referenced profiles
|
||||
case preload
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,50 @@ enum ValidationResult: Decodable {
|
||||
case bad_sig
|
||||
}
|
||||
|
||||
/// Represents metadata from a NIP-89 client tag (`["client", name, address?, relay?]`).
|
||||
/// Used to identify which application published a nostr event.
|
||||
struct ClientTagMetadata: Equatable {
|
||||
/// The client application name (e.g., "Damus").
|
||||
let name: String
|
||||
/// Optional NIP-89 handler address for the client.
|
||||
let handlerAddress: String?
|
||||
/// Optional relay hint where the handler can be found.
|
||||
let relayHint: String?
|
||||
|
||||
init(name: String, handlerAddress: String? = nil, relayHint: String? = nil) {
|
||||
self.name = name
|
||||
self.handlerAddress = handlerAddress
|
||||
self.relayHint = relayHint
|
||||
}
|
||||
|
||||
/// Parses client tag metadata from tag components array.
|
||||
/// - Parameter tagComponents: Array where index 0 is "client", index 1 is name, etc.
|
||||
/// - Returns: nil if the tag is not a valid client tag.
|
||||
init?(tagComponents: [String]) {
|
||||
guard tagComponents.first == "client", let clientName = tagComponents[safe: 1], !clientName.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
self.name = clientName
|
||||
self.handlerAddress = tagComponents[safe: 2]
|
||||
self.relayHint = tagComponents[safe: 3]
|
||||
}
|
||||
|
||||
/// Converts this metadata back into a tag array suitable for inclusion in an event.
|
||||
var tagValues: [String] {
|
||||
var components = ["client", name]
|
||||
if let handlerAddress, !handlerAddress.isEmpty {
|
||||
components.append(handlerAddress)
|
||||
if let relayHint, !relayHint.isEmpty {
|
||||
components.append(relayHint)
|
||||
}
|
||||
}
|
||||
return components
|
||||
}
|
||||
|
||||
/// The default Damus client tag.
|
||||
static let damus = ClientTagMetadata(name: "Damus")
|
||||
}
|
||||
|
||||
/*
|
||||
class NostrEventOld: Codable, Identifiable, CustomStringConvertible, Equatable, Hashable, Comparable {
|
||||
// TODO: memory mapped db events
|
||||
|
||||
@@ -165,6 +165,14 @@ class DamusState: HeadlessDamusState, ObservableObject {
|
||||
keypair.privkey != nil
|
||||
}
|
||||
|
||||
/// Returns the Damus client tag array if the user has enabled client tag publishing, nil otherwise.
|
||||
var clientTagComponents: [String]? {
|
||||
guard settings.publish_client_tag else {
|
||||
return nil
|
||||
}
|
||||
return ClientTagMetadata.damus.tagValues
|
||||
}
|
||||
|
||||
func close() {
|
||||
print("txn: damus close")
|
||||
Task {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ struct Reposted: View {
|
||||
}
|
||||
|
||||
NavigationLink(value: Route.Reposts(reposts: .reposts(state: damus, target: target.id))) {
|
||||
Text(people_reposted_text(profiles: damus.profiles, pubkey: pubkey, reposts: reposts))
|
||||
Text(verbatim: people_reposted_text(profiles: damus.profiles, pubkey: pubkey, reposts: reposts))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@ struct EventTop: View {
|
||||
ProfileName(is_anon: is_anon)
|
||||
TimeDot()
|
||||
RelativeTime(time: state.events.get_cache_data(event.id).relative_time, size: size, font_size: state.settings.font_size)
|
||||
if let clientTag = event.clientTag {
|
||||
TimeDot()
|
||||
ClientTagLabel(clientTag: clientTag, size: size, font_size: state.settings.font_size)
|
||||
}
|
||||
Spacer()
|
||||
if !options.contains(.no_context_menu) {
|
||||
EventMenuContext(damus: state, event: event)
|
||||
@@ -49,3 +53,17 @@ struct EventTop_Previews: PreviewProvider {
|
||||
EventTop(state: test_damus_state, event: test_note, pubkey: test_note.pubkey, is_anon: false, size: .normal, options: [])
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays the client name that published an event (e.g., "via Damus").
|
||||
struct ClientTagLabel: View {
|
||||
let clientTag: ClientTagMetadata
|
||||
let size: EventViewKind
|
||||
let font_size: Double
|
||||
|
||||
var body: some View {
|
||||
Text(String(format: NSLocalizedString("via %@", comment: "Label indicating which client published the event"), clientTag.name))
|
||||
.font(eventviewsize_to_font(size, font_size: font_size))
|
||||
.foregroundColor(.gray)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ struct EventLoaderView<Content: View>: View {
|
||||
let event_id: NoteId
|
||||
let relayHints: [RelayURL]
|
||||
@State var event: NostrEvent?
|
||||
@State var subscription_uuid: String = UUID().description
|
||||
@State var loadingTask: Task<Void, Never>? = nil
|
||||
@State private var eventNotFound: Bool = false
|
||||
@State private var isReloading: Bool = false
|
||||
let content: (NostrEvent) -> Content
|
||||
|
||||
/// Creates an event loader view.
|
||||
@@ -34,49 +34,114 @@ struct EventLoaderView<Content: View>: View {
|
||||
let event = damus_state.events.lookup(event_id)
|
||||
_event = State(initialValue: event)
|
||||
}
|
||||
|
||||
func unsubscribe() {
|
||||
self.loadingTask?.cancel()
|
||||
}
|
||||
|
||||
func subscribe() {
|
||||
self.loadingTask?.cancel()
|
||||
self.loadingTask = Task {
|
||||
let targetRelays = relayHints.isEmpty ? nil : relayHints
|
||||
#if DEBUG
|
||||
if let targetRelays, !targetRelays.isEmpty {
|
||||
print("[relay-hints] EventLoaderView: Loading event \(event_id.hex().prefix(8))... with \(targetRelays.count) relay hint(s): \(targetRelays.map { $0.absoluteString })")
|
||||
}
|
||||
#endif
|
||||
let lender = try? await damus_state.nostrNetwork.reader.lookup(noteId: self.event_id, to: targetRelays)
|
||||
lender?.justUseACopy({ event = $0 })
|
||||
#if DEBUG
|
||||
if let targetRelays, !targetRelays.isEmpty {
|
||||
print("[relay-hints] EventLoaderView: Event \(event_id.hex().prefix(8))... loaded: \(event != nil)")
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Loads the event from nostrdb or the network using relay hints or the default relay pool.
|
||||
///
|
||||
/// This method attempts to fetch the event via `nostrNetwork.reader.lookup`, which first checks
|
||||
/// nostrdb for a cached copy, then queries the network if not found locally. Network queries use
|
||||
/// either the specified relay hints (if provided) or the user's relay pool (if no hints are provided).
|
||||
/// On success, sets `event` and clears `eventNotFound`. On failure, sets `eventNotFound` to true.
|
||||
///
|
||||
/// Side effects:
|
||||
/// - Updates `event` with the fetched event on success
|
||||
/// - Updates `eventNotFound` flag based on the result
|
||||
/// - Logs debug information when relay hints are used
|
||||
func load() async {
|
||||
let targetRelays = relayHints.isEmpty ? nil : relayHints
|
||||
#if DEBUG
|
||||
if let targetRelays, !targetRelays.isEmpty {
|
||||
print("[relay-hints] EventLoaderView: Loading event \(event_id.hex().prefix(8))... with \(targetRelays.count) relay hint(s): \(targetRelays.map { $0.absoluteString })")
|
||||
}
|
||||
#endif
|
||||
let lender = try? await damus_state.nostrNetwork.reader.lookup(noteId: self.event_id, to: targetRelays)
|
||||
if let foundEvent = lender?.justGetACopy() {
|
||||
event = foundEvent
|
||||
eventNotFound = false
|
||||
}
|
||||
else {
|
||||
// Handle nil case: event was not found
|
||||
eventNotFound = true
|
||||
}
|
||||
#if DEBUG
|
||||
if let targetRelays, !targetRelays.isEmpty {
|
||||
print("[relay-hints] EventLoaderView: Event \(event_id.hex().prefix(8))... loaded: \(event != nil)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func load() {
|
||||
subscribe()
|
||||
/// Retries loading the event and displays loading state during the operation.
|
||||
///
|
||||
/// This method sets the `isReloading` flag to true, calls `load()`, and resets
|
||||
/// the flag when complete. It is typically triggered by user action (e.g., "Try Again" button).
|
||||
///
|
||||
/// Side effects:
|
||||
/// - Updates `isReloading` to true during the operation
|
||||
/// - Delegates to `load()`, which updates `event` and `eventNotFound`
|
||||
/// - Resets `isReloading` to false after completion
|
||||
func retry() async {
|
||||
isReloading = true
|
||||
await load()
|
||||
isReloading = false
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
VStack {
|
||||
if let event {
|
||||
self.content(event)
|
||||
} else if eventNotFound {
|
||||
not_found
|
||||
} else {
|
||||
ProgressView().padding()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
.task {
|
||||
guard event == nil else {
|
||||
return
|
||||
}
|
||||
self.load()
|
||||
await self.load()
|
||||
}
|
||||
}
|
||||
|
||||
var not_found: some View {
|
||||
VStack(spacing: 0) {
|
||||
LoadableNostrEventView.SomethingWrong(
|
||||
imageSystemName: "questionmark.app",
|
||||
heading: NSLocalizedString("Note not found", comment: "Heading for the event loader view in a not found error state."),
|
||||
description: NSLocalizedString("This note may have been deleted, or it might not be available on the relays you're connected to.", comment: "Text for the event loader view when it is unable to find the note the user is looking for"),
|
||||
advice: NSLocalizedString("Try checking your internet connection, expanding your relay list, or contacting the person who quoted this note.", comment: "Tips on what to do if a quoted note cannot be found.")
|
||||
)
|
||||
|
||||
Button(action: {
|
||||
Task {
|
||||
await retry()
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
if !isReloading {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
Text("Try Again", comment: "Button label to retry loading a note that was not found")
|
||||
}
|
||||
else {
|
||||
ProgressView()
|
||||
Text("Retrying…", comment: "Button label for the retry-in-progress state when loading a note")
|
||||
}
|
||||
}
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.secondary)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.disabled(isReloading)
|
||||
.opacity(isReloading ? 0.6 : 1.0)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.secondary.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -21,34 +21,19 @@ class LoadableNostrEventViewModel: ObservableObject {
|
||||
let damus_state: DamusState
|
||||
let note_reference: NoteReference
|
||||
@Published var state: ThreadModelLoadingState = .loading
|
||||
/// The time period after which it will give up loading the view.
|
||||
/// Written in nanoseconds
|
||||
let TIMEOUT: UInt64 = 10 * 1_000_000_000 // 10 seconds
|
||||
|
||||
|
||||
init(damus_state: DamusState, note_reference: NoteReference) {
|
||||
self.damus_state = damus_state
|
||||
self.note_reference = note_reference
|
||||
Task { await self.load() }
|
||||
}
|
||||
|
||||
/// Starts loading the referenced Nostr event and updates the view model's `state` with the result or a timeout outcome.
|
||||
///
|
||||
/// This launches a dedicated task that runs the loading logic and a separate timeout task that cancels the loader after `TIMEOUT`. If the timeout fires, `state` is set to `.not_found`. If the load finishes first, the timeout task is cancelled.
|
||||
func load() async {
|
||||
// Start the loading process in a separate task to manage the timeout independently.
|
||||
let loadTask = Task { @MainActor in
|
||||
self.state = await executeLoadingLogic(note_reference: self.note_reference)
|
||||
}
|
||||
|
||||
// Setup a timer to cancel the load after the timeout period
|
||||
let timeoutTask = Task { @MainActor in
|
||||
try await Task.sleep(nanoseconds: TIMEOUT)
|
||||
loadTask.cancel() // This sends a cancellation signal to the load task.
|
||||
self.state = .not_found
|
||||
}
|
||||
|
||||
await loadTask.value
|
||||
timeoutTask.cancel() // Cancel the timeout task if loading finishes earlier.
|
||||
/// Waits for relay connection then loads the referenced event.
|
||||
///
|
||||
/// Timeout is handled by `awaitConnection()` (30 s built-in).
|
||||
func load() async {
|
||||
await damus_state.nostrNetwork.awaitConnection()
|
||||
self.state = await executeLoadingLogic(note_reference: self.note_reference)
|
||||
}
|
||||
|
||||
/// Loads the Nostr event identified by `noteId`, optionally restricting the lookup to specific relays.
|
||||
|
||||
@@ -259,7 +259,7 @@ struct NoteContentView: View {
|
||||
.accessibilityHidden(true)
|
||||
|
||||
if artifacts.media.count > 1 {
|
||||
Text("\(artifacts.media.count)")
|
||||
Text(verbatim: "\(artifacts.media.count)")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 4)
|
||||
@@ -273,7 +273,7 @@ struct NoteContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Text("Load \(artifacts.media.count) \(pluralizedString(key: "media_count", count: artifacts.media.count))")
|
||||
Text(verbatim: "\(pluralizedString(key: "media_count", count: artifacts.media.count))")
|
||||
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
|
||||
.foregroundStyle(DamusColors.neutral6)
|
||||
|
||||
@@ -304,7 +304,7 @@ struct NoteContentView: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.accessibilityLabel(NSLocalizedString(showLinksDropdown ? "Hide media links" : "Show media links", comment: "Accessibility label for toggle button to show/hide media link list"))
|
||||
.accessibilityLabel(showLinksDropdown ? NSLocalizedString("Hide media links", comment: "Accessibility label for toggle button to hide media link list") : NSLocalizedString("Show media links", comment: "Accessibility label for toggle button to show media link list"))
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
@@ -424,7 +424,7 @@ struct NoteContentView: View {
|
||||
)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.accessibilityLabel(NSLocalizedString("Load \(abbreviateURL(url))", comment: "Accessibility label for button to load specific media item"))
|
||||
.accessibilityLabel(String(format: NSLocalizedString("Load %@", comment: "Accessibility label for button to load specific media item"), abbreviateURL(url)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,7 +632,7 @@ struct BlurOverlayView: View {
|
||||
.foregroundStyle(.white)
|
||||
.bold()
|
||||
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
|
||||
Text(NSLocalizedString("Media from someone you don't follow", comment: "Label on the image blur mask"))
|
||||
Text("Media from someone you don't follow", comment: "Label on the image blur mask")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(Color.white)
|
||||
.font(.title2)
|
||||
@@ -652,7 +652,7 @@ struct BlurOverlayView: View {
|
||||
{
|
||||
switch artifacts.media[0] {
|
||||
case .image(let url), .video(let url):
|
||||
Text(abbreviateURL(url, maxLength: 30))
|
||||
Text(verbatim: "\(abbreviateURL(url, maxLength: 30))")
|
||||
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size * 0.8))
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -80,7 +80,7 @@ struct FollowPackBannerImage: View {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(NSLocalizedString("No cover image", comment: "Text letting user know there is no cover image."))
|
||||
Text("No cover image", comment: "Text letting user know there is no cover image.")
|
||||
.foregroundColor(.gray)
|
||||
.frame(width: 350, height: 180)
|
||||
Divider()
|
||||
|
||||
@@ -28,7 +28,7 @@ struct LabsExplainerView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(NSLocalizedString(labDescription, comment: "Description of the feature."))
|
||||
Text(labDescription)
|
||||
.foregroundColor(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
|
||||
@@ -13,9 +13,11 @@ struct DamusLabsExperiments: View {
|
||||
@ObservedObject var settings: UserSettingsStore
|
||||
@State var show_live_explainer: Bool = false
|
||||
@State var show_favorites_explainer: Bool = false
|
||||
@State var show_gifs_explainer: Bool = false
|
||||
|
||||
let live_label = NSLocalizedString("Live", comment: "Label for a toggle that enables an experimental feature")
|
||||
let favorites_label = NSLocalizedString("Favorites", comment: "Label for a toggle that enables an experimental feature")
|
||||
let gifs_label = NSLocalizedString("GIFs", comment: "Label for a toggle that enables an experimental feature")
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -30,7 +32,7 @@ struct DamusLabsExperiments: View {
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Text(NSLocalizedString("More features coming soon!", comment: ""))
|
||||
Text("More features coming soon!", comment: "Label indicating that more features for Damus Lab experiments are coming soon.")
|
||||
.font(.title2)
|
||||
.foregroundColor(.white)
|
||||
.fontWeight(.bold)
|
||||
@@ -44,6 +46,7 @@ struct DamusLabsExperiments: View {
|
||||
|
||||
LabsToggleView(toggleName: live_label, systemImage: "record.circle", isOn: $settings.live, showInfo: $show_live_explainer)
|
||||
LabsToggleView(toggleName: favorites_label, systemImage: "heart.fill", isOn: $settings.enable_favourites_feature, showInfo: $show_favorites_explainer)
|
||||
LabsToggleView(toggleName: gifs_label, systemImage: "smiley", isOn: $settings.enable_gifs_feature, showInfo: $show_gifs_explainer)
|
||||
|
||||
}
|
||||
.padding([.trailing, .leading], 20)
|
||||
@@ -67,6 +70,12 @@ struct DamusLabsExperiments: View {
|
||||
systemImage: "heart.fill",
|
||||
labDescription: NSLocalizedString("This will allow you to pick users to be part of your favorites list. You can also switch your profile timeline to only see posts from your favorite contacts.", comment: "Damus Labs feature explanation"))
|
||||
}
|
||||
.sheet(isPresented: $show_gifs_explainer) {
|
||||
LabsExplainerView(
|
||||
labName: gifs_label,
|
||||
systemImage: "",
|
||||
labDescription: NSLocalizedString("This will allow you to easily add gifs from Tenor to your posts. You will see the GIF icon in the attachment bar when creating a post. Tapping it will show you all of tenor's featured GIFs. You can also search for GIFs.", comment: "Damus Labs feature explanation"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ struct LabsIntroductionView: View {
|
||||
NavigationLink(destination: DamusPurpleView(damus_state: damus_state)) {
|
||||
HStack(spacing: 10) {
|
||||
Spacer()
|
||||
Text("Learn more about Purple")
|
||||
Text("Learn more about Purple", comment: "Button to learn more about the Damus Purple subscription.")
|
||||
.foregroundColor(Color.white)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ struct LiveChatHomeView: View, KeyboardReadable {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text("Live Chat")
|
||||
Text("Live Chat", comment: "Title for the live stream chat.")
|
||||
.fontWeight(.bold)
|
||||
.padding(5)
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ struct LiveStreamBanner: View {
|
||||
titleImage(url: url, preview: preview)
|
||||
}
|
||||
} else {
|
||||
Text(NSLocalizedString("No cover image", comment: "Text letting user know there is no cover image."))
|
||||
Text("No cover image", comment: "Text letting user know there is no cover image.")
|
||||
.bold()
|
||||
.foregroundColor(.white)
|
||||
.frame(width: UIScreen.main.bounds.width, height: 200)
|
||||
|
||||
@@ -19,12 +19,12 @@ struct LiveStreamStatus: View {
|
||||
.foregroundColor(Color.white)
|
||||
|
||||
if let starts = starts {
|
||||
Text("\(starts)")
|
||||
Text(starts)
|
||||
.foregroundColor(Color.white)
|
||||
.bold()
|
||||
.glow()
|
||||
} else {
|
||||
Text("\(status.rawValue)")
|
||||
Text(status.rawValue)
|
||||
.foregroundColor(Color.white)
|
||||
.bold()
|
||||
}
|
||||
@@ -33,11 +33,11 @@ struct LiveStreamStatus: View {
|
||||
.foregroundColor(Color.red)
|
||||
.glow()
|
||||
|
||||
Text("\(status.rawValue)")
|
||||
Text(status.rawValue)
|
||||
.foregroundColor(DamusColors.adaptableWhite)
|
||||
.bold()
|
||||
case .ended:
|
||||
Text("\(status.rawValue)")
|
||||
Text(status.rawValue)
|
||||
.foregroundColor(DamusColors.adaptableWhite)
|
||||
.bold()
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ struct LiveStreamViewers: View {
|
||||
Image("user")
|
||||
.resizable()
|
||||
.frame(width: 15, height: 15)
|
||||
Text("\(Text(verbatim: viewerCount.formatted()).font(.subheadline.weight(.medium)))", comment: "number")
|
||||
Text(verbatim: viewerCount.formatted()).font(.subheadline.weight(.medium))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, preview ? 2 : 0)
|
||||
|
||||
@@ -43,10 +43,10 @@ struct LiveStreamTimelineView<Content: View>: View {
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Happening Now")
|
||||
Text("Happening Now", comment: "Indicates that live events are happening now.")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
Text("Live events going on right now")
|
||||
Text("Live events going on right now", comment: "Indicates that live events are happening now.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ struct LongformPreviewBody: View {
|
||||
{
|
||||
HStack(spacing: 8) {
|
||||
ReadTime(longform.estimatedReadTimeMinutes)
|
||||
Text("·")
|
||||
Text(verbatim: "·")
|
||||
Words(longform.words)
|
||||
}
|
||||
.font(.footnote)
|
||||
@@ -250,7 +250,7 @@ struct LongformPreviewBody: View {
|
||||
{
|
||||
HStack(spacing: 8) {
|
||||
ReadTime(longform.estimatedReadTimeMinutes)
|
||||
Text("·")
|
||||
Text(verbatim: "·")
|
||||
Words(longform.words)
|
||||
}
|
||||
.font(.footnote)
|
||||
|
||||
@@ -92,7 +92,7 @@ struct MutelistView: View {
|
||||
}
|
||||
}
|
||||
Section(
|
||||
header: Text(NSLocalizedString("Users", comment: "Section header title for a list of muted users.")),
|
||||
header: Text("Users", comment: "Section header title for a list of muted users."),
|
||||
footer: VStack { EmptyView() }.padding(.bottom, paddingBottom)
|
||||
) {
|
||||
ForEach(users, id: \.self) { user in
|
||||
|
||||
@@ -20,14 +20,14 @@ extension OnboardingSuggestionsView {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Title
|
||||
Text(NSLocalizedString("Other preferences", comment: "Screen title for content preferences screen during onboarding"))
|
||||
Text("Other preferences", comment: "Screen title for content preferences screen during onboarding")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top)
|
||||
|
||||
// Instruction subtitle
|
||||
Text(NSLocalizedString("Tweak these settings to better match your preferences", comment: "Instructions for content preferences screen during onboarding"))
|
||||
Text("Tweak these settings to better match your preferences", comment: "Instructions for content preferences screen during onboarding")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -38,7 +38,7 @@ extension OnboardingSuggestionsView {
|
||||
Toggle(NSLocalizedString("Hide notes with #nsfw tags", comment: "Setting to hide notes with not safe for work tags"), isOn: $settings.hide_nsfw_tagged_content)
|
||||
.toggleStyle(.switch)
|
||||
|
||||
Text(NSLocalizedString("Notes with the #nsfw tag usually contains adult content or other \"Not safe for work\" content", comment: "Explanation of what NSFW means"))
|
||||
Text("Notes with the #nsfw tag usually contains adult content or other \"Not safe for work\" content", comment: "Explanation of what NSFW means")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.bottom, 10)
|
||||
@@ -50,7 +50,7 @@ extension OnboardingSuggestionsView {
|
||||
)
|
||||
.toggleStyle(.switch)
|
||||
|
||||
Text(NSLocalizedString("Some profiles tend to have a lot of Bitcoin-related content alongside their topics of interest. Disable this setting if you prefer to filter out follow suggestions that frequently talk about Bitcoin.", comment: "Explanation label for the 'Show Bitcoin-heavy profile suggestions' onboarding toggle setting"))
|
||||
Text("Some profiles tend to have a lot of Bitcoin-related content alongside their topics of interest. Disable this setting if you prefer to filter out follow suggestions that frequently talk about Bitcoin.", comment: "Explanation label for the 'Show Bitcoin-heavy profile suggestions' onboarding toggle setting")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
@@ -66,7 +66,7 @@ extension OnboardingSuggestionsView {
|
||||
Button(action: {
|
||||
self.next_page()
|
||||
}, label: {
|
||||
Text(NSLocalizedString("Next", comment: "Next button title"))
|
||||
Text("Next", comment: "Next button title")
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
})
|
||||
|
||||
@@ -21,14 +21,14 @@ extension OnboardingSuggestionsView {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Title
|
||||
Text(NSLocalizedString("Select Your Interests", comment: "Screen title for interest selection"))
|
||||
Text("Select Your Interests", comment: "Screen title for interest selection")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top)
|
||||
|
||||
// Instruction subtitle
|
||||
Text(NSLocalizedString("Please pick your interests. This will help us recommend accounts to follow.", comment: "Instruction for interest selection"))
|
||||
Text("Please pick your interests. This will help us recommend accounts to follow.", comment: "Instruction for interest selection")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -44,7 +44,7 @@ extension OnboardingSuggestionsView {
|
||||
Button(action: {
|
||||
self.next_page()
|
||||
}, label: {
|
||||
Text(NSLocalizedString("Next", comment: "Next button title"))
|
||||
Text("Next", comment: "Next button title")
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
})
|
||||
|
||||
@@ -67,7 +67,7 @@ class DraftArtifacts: Equatable {
|
||||
func to_nip37_draft(action: PostAction, damus_state: DamusState) async throws -> NIP37Draft? {
|
||||
guard let keypair = damus_state.keypair.to_full() else { return nil }
|
||||
let post = await build_post(state: damus_state, action: action, draft: self)
|
||||
guard let note = post.to_event(keypair: keypair) else { return nil }
|
||||
guard let note = post.to_event(keypair: keypair, clientTag: damus_state.clientTagComponents) else { return nil }
|
||||
return try NIP37Draft(unwrapped_note: note, draft_id: self.id, keypair: keypair)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ struct NostrPost {
|
||||
self.tags = tags
|
||||
}
|
||||
|
||||
func to_event(keypair: FullKeypair) -> NostrEvent? {
|
||||
func to_event(keypair: FullKeypair, clientTag: [String]? = nil) -> NostrEvent? {
|
||||
let post_blocks = self.parse_blocks()
|
||||
let post_tags = self.make_post_tags(post_blocks: post_blocks, tags: self.tags)
|
||||
let content = post_tags.blocks
|
||||
@@ -30,10 +30,13 @@ struct NostrPost {
|
||||
if content.count > 0 {
|
||||
new_tags.append(["comment", content])
|
||||
}
|
||||
addClientTagIfNeeded(clientTag, to: &new_tags)
|
||||
return NostrEvent(content: self.content, keypair: keypair.to_keypair(), kind: self.kind.rawValue, tags: new_tags)
|
||||
}
|
||||
|
||||
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: self.kind.rawValue, tags: post_tags.tags)
|
||||
var final_tags = post_tags.tags
|
||||
addClientTagIfNeeded(clientTag, to: &final_tags)
|
||||
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: self.kind.rawValue, tags: final_tags)
|
||||
}
|
||||
|
||||
func parse_blocks() -> [Block] {
|
||||
@@ -80,6 +83,17 @@ struct NostrPost {
|
||||
}
|
||||
}
|
||||
|
||||
extension NostrPost {
|
||||
/// Appends a client tag to the tags array if one is provided and not already present.
|
||||
fileprivate func addClientTagIfNeeded(_ clientTag: [String]?, to tags: inout [[String]]) {
|
||||
guard let clientTag else { return }
|
||||
guard tags.first(where: { $0.first == "client" }) == nil else {
|
||||
return
|
||||
}
|
||||
tags.append(clientTag)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper structures and functions
|
||||
|
||||
extension NostrPost {
|
||||
|
||||
@@ -63,6 +63,7 @@ struct PostView: View {
|
||||
@FocusState var focus: Bool
|
||||
@State var attach_media: Bool = false
|
||||
@State var attach_camera: Bool = false
|
||||
@State var attach_gif: Bool = false
|
||||
@State var error: String? = nil
|
||||
@State var image_upload_confirm: Bool = false
|
||||
@State var imagePastedFromPasteboard: PreUploadedMedia? = nil
|
||||
@@ -277,10 +278,22 @@ struct PostView: View {
|
||||
})
|
||||
}
|
||||
|
||||
var GIFButton: some View {
|
||||
Button(action: {
|
||||
attach_gif = true
|
||||
}, label: {
|
||||
Image("GIF")
|
||||
.padding(6)
|
||||
})
|
||||
}
|
||||
|
||||
var AttachmentBar: some View {
|
||||
HStack(alignment: .center, spacing: 15) {
|
||||
ImageButton
|
||||
CameraButton
|
||||
if damus_state.settings.enable_gifs_feature {
|
||||
GIFButton
|
||||
}
|
||||
Spacer()
|
||||
AutoSaveIndicatorView(saveViewModel: self.autoSaveModel)
|
||||
}
|
||||
@@ -623,6 +636,14 @@ struct PostView: View {
|
||||
self.attach_media = true
|
||||
}))
|
||||
}
|
||||
.sheet(isPresented: $attach_gif) {
|
||||
GIFPickerView(damus_state: damus_state) { gifURL in
|
||||
let uploadedMedia = UploadedMedia(localURL: gifURL, uploadedURL: gifURL, metadata: nil)
|
||||
uploadedMedias.append(uploadedMedia)
|
||||
post_changed(post: post, media: uploadedMedias)
|
||||
attach_gif = false
|
||||
}
|
||||
}
|
||||
// This alert seeks confirmation about Image-upload when user taps Paste option
|
||||
.alert(NSLocalizedString("Are you sure you want to upload this media?", comment: "Alert message asking if the user wants to upload media."), isPresented: $imageUploadConfirmPasteboard) {
|
||||
Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) {
|
||||
|
||||
@@ -216,7 +216,7 @@ struct EditMetadataView: View {
|
||||
}
|
||||
}
|
||||
}, label: {
|
||||
Text(NSLocalizedString("Save", comment: "Button for saving profile."))
|
||||
Text("Save", comment: "Button for saving profile.")
|
||||
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
|
||||
})
|
||||
.buttonStyle(GradientButtonStyle(padding: 15))
|
||||
|
||||
@@ -57,7 +57,7 @@ struct NDBSearchView: View {
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if !highlightTerms.isEmpty {
|
||||
Text("Search: \(searchQuery)")
|
||||
Text("Search: \(searchQuery)", comment: "Label indicating the search query that resulted in the current list of notes")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
@@ -54,7 +54,7 @@ struct InnerSearchResults: View {
|
||||
let search_model = SearchModel(state: damus_state, search: .filter_hashtag([ht]))
|
||||
return NavigationLink(value: Route.Search(search: search_model)) {
|
||||
HStack {
|
||||
Text("#\(ht)", comment: "Navigation link to search hashtag.")
|
||||
Text(verbatim: "#\(ht)")
|
||||
}
|
||||
.padding(.horizontal, 15)
|
||||
.padding(.vertical, 5)
|
||||
|
||||
@@ -219,6 +219,10 @@ class UserSettingsStore: ObservableObject {
|
||||
@Setting(key: "auto_translate", default_value: true)
|
||||
var auto_translate: Bool
|
||||
|
||||
/// Whether to include a client tag identifying Damus when publishing events.
|
||||
@Setting(key: "publish_client_tag", default_value: true)
|
||||
var publish_client_tag: Bool
|
||||
|
||||
@Setting(key: "show_general_statuses", default_value: true)
|
||||
var show_general_statuses: Bool
|
||||
|
||||
@@ -339,6 +343,15 @@ class UserSettingsStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
var tenor_api_key: String {
|
||||
get {
|
||||
return internal_tenor_api_key ?? ""
|
||||
}
|
||||
set {
|
||||
internal_tenor_api_key = newValue == "" ? nil : newValue
|
||||
}
|
||||
}
|
||||
|
||||
// These internal keys are necessary because entries in the keychain need to be Optional,
|
||||
// but the translation view needs non-Optional String in order to use them as Bindings.
|
||||
@KeychainStorage(account: "deepl_apikey")
|
||||
@@ -353,6 +366,9 @@ class UserSettingsStore: ObservableObject {
|
||||
@KeychainStorage(account: "libretranslate_apikey")
|
||||
var internal_libretranslate_api_key: String?
|
||||
|
||||
@KeychainStorage(account: "tenor_api_key")
|
||||
var internal_tenor_api_key: String?
|
||||
|
||||
@KeychainStorage(account: "nostr_wallet_connect")
|
||||
var nostr_wallet_connect: String? // TODO: strongly type this to WalletConnectURL
|
||||
|
||||
@@ -381,6 +397,10 @@ class UserSettingsStore: ObservableObject {
|
||||
@Setting(key: "labs_experiment_favorites", default_value: false)
|
||||
var enable_favourites_feature: Bool
|
||||
|
||||
/// Whether the app should show the GIF feature (Damus Labs)
|
||||
@Setting(key: "labs_experiment_gifs", default_value: false)
|
||||
var enable_gifs_feature: Bool
|
||||
|
||||
// MARK: Internal, hidden settings
|
||||
|
||||
// TODO: Get rid of this once we have NostrDB query capabilities integrated
|
||||
|
||||
@@ -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
|
||||
@@ -96,7 +84,7 @@ struct AppearanceSettingsView: View {
|
||||
.toggleStyle(.switch)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(String(format: NSLocalizedString("Line height: %.1fx", comment: "Label showing current line height multiplier setting"), settings.longform_line_height))
|
||||
Text(verbatim: "\(String(format: NSLocalizedString("Line height: %.1fx", comment: "Label showing current line height multiplier setting"), settings.longform_line_height))")
|
||||
Slider(value: $settings.longform_line_height, in: 1.2...1.8, step: 0.1)
|
||||
|
||||
// Preview of line height
|
||||
@@ -142,8 +130,15 @@ struct AppearanceSettingsView: View {
|
||||
.tag(uploader.model.tag)
|
||||
}
|
||||
}
|
||||
|
||||
self.ClearCacheButton
|
||||
}
|
||||
|
||||
// MARK: - GIFs
|
||||
if damus_state.settings.enable_gifs_feature {
|
||||
Section(NSLocalizedString("GIFs", comment: "Section title for GIFs configuration.")) {
|
||||
SecureField(NSLocalizedString("Tenor API Key (optional)", comment: "Prompt for optional entry of API Key to use with Tenor."), text: $settings.tenor_api_key)
|
||||
.disableAutocorrection(true)
|
||||
.autocapitalization(UITextAutocapitalizationType.none)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content filters and moderation
|
||||
@@ -159,12 +154,20 @@ struct AppearanceSettingsView: View {
|
||||
.toggleStyle(.switch)
|
||||
if settings.hide_hashtag_spam {
|
||||
VStack(alignment: .leading) {
|
||||
Text(String(format: NSLocalizedString("Maximum hashtags: %d", comment: "Label showing the maximum number of hashtags allowed before a post is hidden"), settings.max_hashtags))
|
||||
Text("\(String(format: NSLocalizedString("Maximum hashtags: %d", comment: "Label showing the maximum number of hashtags allowed before a post is hidden"), settings.max_hashtags))")
|
||||
Slider(value: max_hashtags_binding, in: 1...20, step: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Privacy", comment: "Section header for privacy related settings")) {
|
||||
Toggle(NSLocalizedString("Share Damus client tag", comment: "Setting to publish a client tag indicating Damus posted the note"), isOn: $settings.publish_client_tag)
|
||||
.toggleStyle(.switch)
|
||||
Text("Client tags can help other apps understand new kinds of events. Turn this off if you prefer not to identify Damus when posting.", comment: "Description for the client tag privacy toggle.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// MARK: - Profiles
|
||||
Section(
|
||||
header: Text("Profiles", comment: "Section title for profile view configuration."),
|
||||
@@ -183,30 +186,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)
|
||||
@@ -222,7 +201,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
|
||||
@@ -232,33 +213,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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)){
|
||||
|
||||
@@ -119,7 +119,7 @@ extension FirstAidSettingsView {
|
||||
.foregroundColor(.red)
|
||||
case .confirming_with_user, .in_progress:
|
||||
ProgressView()
|
||||
Text(NSLocalizedString("In progress…", comment: "Loading message indicating that a first aid operation is in progress."))
|
||||
Text("In progress…", comment: "Loading message indicating that a first aid operation is in progress.")
|
||||
case .completed:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<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(NSLocalizedString("Size", comment: "Label for size in disk storage chart"), 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ struct PostingTimelineSwitcherView: View {
|
||||
}
|
||||
.frame(width: 50, height: 35)
|
||||
.menuOrder(.fixed)
|
||||
.accessibilityLabel(NSLocalizedString("Timeline switcher, select \(TimelineSource.follows.description) or \(TimelineSource.favorites.description)", comment: "Accessibility label for the timeline switcher button at the topbar"))
|
||||
.accessibilityLabel(String(format: NSLocalizedString("Timeline switcher, select %@ or %@", comment: "Accessibility label for the timeline switcher button at the topbar"), TimelineSource.follows.description, TimelineSource.favorites.description))
|
||||
}
|
||||
|
||||
@available(iOS 17, *)
|
||||
|
||||
@@ -81,7 +81,7 @@ struct SideMenuView: View {
|
||||
Image(systemName: "flask")
|
||||
.fontWeight(.bold)
|
||||
.tint(DamusColors.adaptableBlack)
|
||||
Text("Labs")
|
||||
Text("Labs", comment: "Sidebar menu label for Damus Labs experimental features.")
|
||||
.font(.title2.weight(.semibold))
|
||||
.foregroundColor(DamusColors.adaptableBlack)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -38,7 +38,7 @@ extension WalletConnect.WalletResponseErr {
|
||||
var humanReadableError: ErrorView.UserPresentableError? {
|
||||
guard let code = self.code else {
|
||||
return .init(
|
||||
user_visible_description: String(format: NSLocalizedString("Your connected wallet raised an unknown error. Message: %s", comment: "Human readable error description for unknown error"), self.message ?? NSLocalizedString("Empty error message", comment: "A human readable placeholder to indicate that the error message is empty")),
|
||||
user_visible_description: String(format: NSLocalizedString("Your connected wallet raised an unknown error. Message: %@", comment: "Human readable error description for unknown error"), self.message ?? NSLocalizedString("Empty error message", comment: "A human readable placeholder to indicate that the error message is empty")),
|
||||
tip: NSLocalizedString("Please contact the developer of your wallet provider for help.", comment: "Human readable error description for an unknown error raised by a wallet provider."),
|
||||
technical_info: "NWC wallet provider returned an error response without a valid reason code. Message: \(self.message ?? "Empty error message")"
|
||||
)
|
||||
|
||||
@@ -10,7 +10,8 @@ import Combine
|
||||
extension WalletConnect {
|
||||
/// Models a response from the NWC provider
|
||||
struct Response: Decodable {
|
||||
let result_type: Response.Result.ResultType
|
||||
/// The type of the result. `nil` if the result type is unsupported/unknown.
|
||||
let result_type: Response.Result.ResultType?
|
||||
let error: WalletResponseErr?
|
||||
let result: Response.Result?
|
||||
|
||||
@@ -21,14 +22,17 @@ extension WalletConnect {
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let result_type_str = try container.decode(String.self, forKey: .result_type)
|
||||
|
||||
guard let result_type = Response.Result.ResultType(rawValue: result_type_str) else {
|
||||
throw DecodingError.typeMismatch(Response.Result.ResultType.self, .init(codingPath: decoder.codingPath, debugDescription: "result_type \(result_type_str) is unknown"))
|
||||
}
|
||||
let result_type = Response.Result.ResultType(rawValue: result_type_str)
|
||||
|
||||
self.result_type = result_type
|
||||
self.error = try container.decodeIfPresent(WalletResponseErr.self, forKey: .error)
|
||||
|
||||
guard let result_type else {
|
||||
// Unknown/unsupported result type — gracefully ignore without an error
|
||||
self.result = nil
|
||||
return
|
||||
}
|
||||
|
||||
guard self.error == nil else {
|
||||
self.result = nil
|
||||
return
|
||||
|
||||
@@ -87,6 +87,10 @@ class WalletModel: ObservableObject {
|
||||
notify(.attached_wallet(nwc))
|
||||
self.connect_state = .existing(nwc)
|
||||
self.previous_state = .existing(nwc)
|
||||
// Reset cached wallet information so the view does not show stale
|
||||
// data from a previously connected wallet while fresh data is loading.
|
||||
self.balance = nil
|
||||
self.transactions = nil
|
||||
}
|
||||
|
||||
/// Handles an NWC response event and updates the model.
|
||||
@@ -133,7 +137,7 @@ class WalletModel: ObservableObject {
|
||||
// This is important to avoid re-rendering the view twice (waste),
|
||||
// and to avoid refreshable tasks to be cancelled before updating everything
|
||||
let balance = try await fetchBalance()
|
||||
let transactions = try await fetchTransactions(from: nil, until: nil, limit: 50, offset: 0, unpaid: false, type: "")
|
||||
let transactions = try await fetchTransactions(from: nil, until: nil, limit: 50, offset: 0, unpaid: false, type: nil)
|
||||
DispatchQueue.main.async {
|
||||
self.balance = balance
|
||||
self.transactions = transactions
|
||||
|
||||
@@ -106,6 +106,11 @@ struct WalletView: View {
|
||||
.task {
|
||||
await self.refreshWalletInformation()
|
||||
}
|
||||
.onReceive(handle_notify(.attached_wallet)) { _ in
|
||||
Task {
|
||||
await self.refreshWalletInformation()
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await self.refreshWalletInformation()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// Secrets.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 12/18/25.
|
||||
//
|
||||
// This file contains a list of secrets imported from environment variables,
|
||||
// where those environment variables cannot be committed to git for security reasons.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum Secrets {
|
||||
static let TENOR_API_KEY: String? = ProcessInfo.processInfo.environment["TENOR_API_KEY"]
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//
|
||||
// GIFPickerView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 12/11/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct GIFPickerView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
let damus_state: DamusState
|
||||
let onGIFSelected: (URL) -> Void
|
||||
|
||||
@StateObject private var viewModel = GIFPickerViewModel()
|
||||
@State private var searchText: String = ""
|
||||
@FocusState private var isSearchFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(spacing: 0) {
|
||||
SearchInput
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
Divider()
|
||||
|
||||
if viewModel.isLoading && viewModel.gifs.isEmpty {
|
||||
loadingView
|
||||
} else if let error = viewModel.error {
|
||||
errorView(error)
|
||||
} else if viewModel.gifs.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
gifGrid
|
||||
}
|
||||
}
|
||||
.navigationTitle(NSLocalizedString("Select GIF", comment: "Title for GIF picker sheet"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(NSLocalizedString("Cancel", comment: "Button to cancel GIF selection")) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadFeatured()
|
||||
}
|
||||
.onChange(of: searchText) { newValue in
|
||||
viewModel.search(query: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
private var SearchInput: some View {
|
||||
HStack {
|
||||
HStack {
|
||||
Image("search")
|
||||
.foregroundColor(.gray)
|
||||
TextField(NSLocalizedString("Search GIFs...", comment: "Placeholder for GIF search field"), text: $searchText)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
.focused($isSearchFocused)
|
||||
|
||||
if !searchText.isEmpty {
|
||||
Button(action: { searchText = "" }) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(.secondary.opacity(0.2))
|
||||
.cornerRadius(20)
|
||||
}
|
||||
}
|
||||
|
||||
private var gifGrid: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [
|
||||
GridItem(.flexible(), spacing: 4),
|
||||
GridItem(.flexible(), spacing: 4)
|
||||
], spacing: 4) {
|
||||
ForEach(viewModel.gifs) { gif in
|
||||
GIFThumbnailView(gif: gif, disable_animation: damus_state.settings.disable_animation)
|
||||
.onTapGesture {
|
||||
if let gifURL = gif.mediumURL ?? gif.fullURL {
|
||||
onGIFSelected(gifURL)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if gif.id == viewModel.gifs.last?.id {
|
||||
Task { await viewModel.loadMore() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
|
||||
if viewModel.isLoading && !viewModel.gifs.isEmpty {
|
||||
ProgressView()
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var loadingView: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
Text("Loading GIFs...", comment: "Loading indicator text for GIF picker")
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private func errorView(_ error: String) -> some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
Text(error)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding()
|
||||
Button(NSLocalizedString("Try Again", comment: "Button to retry loading GIFs")) {
|
||||
Task {
|
||||
if searchText.isEmpty {
|
||||
await viewModel.loadFeatured()
|
||||
} else {
|
||||
viewModel.search(query: searchText)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyView: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
Image(systemName: "photo.on.rectangle.angled")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
Text("No GIFs found", comment: "Message when no GIFs match search")
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GIFThumbnailView: View {
|
||||
let gif: TenorGIFResult
|
||||
let disable_animation: Bool
|
||||
|
||||
var body: some View {
|
||||
if let previewURL = gif.previewURL {
|
||||
KFAnimatedImage(previewURL)
|
||||
.configure { view in
|
||||
view.framePreloadCount = 3
|
||||
}
|
||||
.placeholder {
|
||||
Rectangle()
|
||||
.fill(Color.secondary.opacity(0.2))
|
||||
}
|
||||
.imageContext(.note, disable_animation: disable_animation)
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(height: 120)
|
||||
.clipped()
|
||||
.cornerRadius(8)
|
||||
} else {
|
||||
Rectangle()
|
||||
.fill(Color.secondary.opacity(0.2))
|
||||
.frame(height: 120)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class GIFPickerViewModel: ObservableObject {
|
||||
@Published var gifs: [TenorGIFResult] = []
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var error: String? = nil
|
||||
|
||||
private let api = TenorAPIClient()
|
||||
private var currentQuery: String?
|
||||
private var nextPos: String?
|
||||
private var searchTask: Task<Void, Never>?
|
||||
|
||||
func loadFeatured() async {
|
||||
guard !isLoading else { return }
|
||||
|
||||
isLoading = true
|
||||
error = nil
|
||||
currentQuery = nil
|
||||
|
||||
do {
|
||||
let response = try await api.fetchFeatured()
|
||||
gifs = response.results
|
||||
nextPos = response.next
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func search(query: String) {
|
||||
searchTask?.cancel()
|
||||
|
||||
guard !query.isEmpty else {
|
||||
Task { await loadFeatured() }
|
||||
return
|
||||
}
|
||||
|
||||
searchTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
await performSearch(query: query)
|
||||
}
|
||||
}
|
||||
|
||||
private func performSearch(query: String) async {
|
||||
guard !isLoading else { return }
|
||||
|
||||
isLoading = true
|
||||
error = nil
|
||||
currentQuery = query
|
||||
nextPos = nil
|
||||
|
||||
do {
|
||||
let response = try await api.search(query: query)
|
||||
gifs = response.results
|
||||
nextPos = response.next
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func loadMore() async {
|
||||
guard !isLoading, let nextPos else { return }
|
||||
|
||||
isLoading = true
|
||||
|
||||
do {
|
||||
let response: TenorSearchResponse
|
||||
if let query = currentQuery {
|
||||
response = try await api.search(query: query, pos: nextPos)
|
||||
} else {
|
||||
response = try await api.fetchFeatured(pos: nextPos)
|
||||
}
|
||||
gifs.append(contentsOf: response.results)
|
||||
self.nextPos = response.next
|
||||
} catch {
|
||||
// Don't show error for pagination failures
|
||||
print("Failed to load more GIFs: \(error)")
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
GIFPickerView(damus_state: test_damus_state) { url in
|
||||
print("Selected GIF: \(url)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//
|
||||
// TenorAPIClient.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 12/11/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum TenorAPIError: Error, LocalizedError {
|
||||
case invalidURL
|
||||
case networkError(Error)
|
||||
case decodingError(Error)
|
||||
case missingAPIKey
|
||||
case invalidResponse
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
return NSLocalizedString("Invalid URL", comment: "Error message for invalid Tenor URL")
|
||||
case .networkError(let error):
|
||||
return error.localizedDescription
|
||||
case .decodingError:
|
||||
return NSLocalizedString("Failed to parse GIF data", comment: "Error message for Tenor decoding failure")
|
||||
case .missingAPIKey:
|
||||
return NSLocalizedString("Tenor API key not configured", comment: "Error message for missing Tenor API key")
|
||||
case .invalidResponse:
|
||||
return NSLocalizedString("Invalid response from server", comment: "Error message for invalid Tenor response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actor TenorAPIClient {
|
||||
private let baseURL = "https://tenor.googleapis.com/v2"
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
private var apiKey: String? {
|
||||
let userKey = UserSettingsStore.shared?.tenor_api_key
|
||||
if let userKey, !userKey.isEmpty {
|
||||
return userKey
|
||||
}
|
||||
return Secrets.TENOR_API_KEY
|
||||
}
|
||||
|
||||
func fetchFeatured(limit: Int = 30, pos: String? = nil) async throws -> TenorSearchResponse {
|
||||
guard let apiKey else {
|
||||
throw TenorAPIError.missingAPIKey
|
||||
}
|
||||
|
||||
var components = URLComponents(string: "\(baseURL)/featured")
|
||||
components?.queryItems = [
|
||||
URLQueryItem(name: "key", value: apiKey),
|
||||
URLQueryItem(name: "limit", value: String(limit)),
|
||||
URLQueryItem(name: "media_filter", value: "gif,mediumgif,tinygif"),
|
||||
URLQueryItem(name: "contentfilter", value: "medium")
|
||||
]
|
||||
|
||||
if let pos {
|
||||
components?.queryItems?.append(URLQueryItem(name: "pos", value: pos))
|
||||
}
|
||||
|
||||
guard let url = components?.url else {
|
||||
throw TenorAPIError.invalidURL
|
||||
}
|
||||
|
||||
return try await performRequest(url: url)
|
||||
}
|
||||
|
||||
func search(query: String, limit: Int = 30, pos: String? = nil) async throws -> TenorSearchResponse {
|
||||
guard let apiKey else {
|
||||
throw TenorAPIError.missingAPIKey
|
||||
}
|
||||
|
||||
var components = URLComponents(string: "\(baseURL)/search")
|
||||
components?.queryItems = [
|
||||
URLQueryItem(name: "q", value: query),
|
||||
URLQueryItem(name: "key", value: apiKey),
|
||||
URLQueryItem(name: "limit", value: String(limit)),
|
||||
URLQueryItem(name: "media_filter", value: "gif,mediumgif,tinygif"),
|
||||
URLQueryItem(name: "contentfilter", value: "medium")
|
||||
]
|
||||
|
||||
if let pos {
|
||||
components?.queryItems?.append(URLQueryItem(name: "pos", value: pos))
|
||||
}
|
||||
|
||||
guard let url = components?.url else {
|
||||
throw TenorAPIError.invalidURL
|
||||
}
|
||||
|
||||
return try await performRequest(url: url)
|
||||
}
|
||||
|
||||
private func performRequest(url: URL) async throws -> TenorSearchResponse {
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(from: url)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
(200...299).contains(httpResponse.statusCode) else {
|
||||
throw TenorAPIError.invalidResponse
|
||||
}
|
||||
|
||||
return try decoder.decode(TenorSearchResponse.self, from: data)
|
||||
} catch let error as TenorAPIError {
|
||||
throw error
|
||||
} catch let error as DecodingError {
|
||||
throw TenorAPIError.decodingError(error)
|
||||
} catch {
|
||||
throw TenorAPIError.networkError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// TenorModels.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 12/11/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct TenorSearchResponse: Codable {
|
||||
let results: [TenorGIFResult]
|
||||
let next: String?
|
||||
}
|
||||
|
||||
struct TenorGIFResult: Codable, Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let media_formats: TenorMediaFormats
|
||||
let content_description: String?
|
||||
|
||||
var previewURL: URL? {
|
||||
URL(string: media_formats.tinygif.url)
|
||||
}
|
||||
|
||||
var fullURL: URL? {
|
||||
URL(string: media_formats.gif.url)
|
||||
}
|
||||
|
||||
var mediumURL: URL? {
|
||||
URL(string: media_formats.mediumgif.url)
|
||||
}
|
||||
}
|
||||
|
||||
struct TenorMediaFormats: Codable {
|
||||
let gif: TenorMediaFormat
|
||||
let mediumgif: TenorMediaFormat
|
||||
let tinygif: TenorMediaFormat
|
||||
}
|
||||
|
||||
struct TenorMediaFormat: Codable {
|
||||
let url: String
|
||||
let dims: [Int]
|
||||
let duration: Double?
|
||||
let size: Int?
|
||||
|
||||
var width: Int? {
|
||||
dims.count >= 1 ? dims[0] : nil
|
||||
}
|
||||
|
||||
var height: Int? {
|
||||
dims.count >= 2 ? dims[1] : nil
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>media item</string>
|
||||
<string>Load %d media item</string>
|
||||
<key>other</key>
|
||||
<string>media items</string>
|
||||
<string>Load %d media items</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>viewer_count</key>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@
|
||||
"en-US" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "share extension"
|
||||
"value" : "DamusNotificationService"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
"en-US" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "ShareExtension"
|
||||
"value" : "DamusNotificationService"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,5 +38,5 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
"version" : "1.1"
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
"en-US" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "DamusNotificationService"
|
||||
"value" : "share extension"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
"en-US" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "DamusNotificationService"
|
||||
"value" : "ShareExtension"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,5 +38,5 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
"version" : "1.1"
|
||||
}
|
||||
+256
-11
@@ -10,8 +10,8 @@
|
||||
"(Contents are encrypted)" : {
|
||||
"comment" : "Label on push notification indicating that the contents of the message are encrypted"
|
||||
},
|
||||
"#%@" : {
|
||||
"comment" : "Navigation link to search hashtag."
|
||||
"%@" : {
|
||||
|
||||
},
|
||||
"%@ / %@" : {
|
||||
"comment" : "Amount of money required to subscribe to the Nostr relay. In English, this would look something like '4,000 sats / 30 days', meaning it costs 4000 sats to subscribe to the Nostr relay for 30 days.",
|
||||
@@ -28,7 +28,7 @@
|
||||
"comment" : "Amount of money required to publish to the Nostr relay. In English, this would look something like '10 sats / event', meaning it costs 10 sats to publish one event."
|
||||
},
|
||||
"%@ %@" : {
|
||||
"comment" : "Sentence composed of 2 variables to describe how many imports were performed from loading a NostrScript. In source English, the first variable is the number of imports, and the second variable is 'Import' or 'Imports'.\nSentence composed of 2 variables to describe how many people are following a user. In source English, the first variable is the number of followers, and the second variable is 'Follower' or 'Followers'.\nSentence composed of 2 variables to describe how many people are in the follow pack. In source English, the first variable is the number of users, and the second variable is 'user' or 'users'.\nSentence composed of 2 variables to describe how many profiles a user is following. In source English, the first variable is the number of profiles being followed, and the second variable is 'Following'.\nSentence composed of 2 variables to describe how many quoted reposts. In source English, the first variable is the number of reposts, and the second variable is 'Repost' or 'Reposts'.\nSentence composed of 2 variables to describe how many reactions there are on a post. In source English, the first variable is the number of reactions, and the second variable is 'Reaction' or 'Reactions'.\nSentence composed of 2 variables to describe how many relay servers a user is connected. In source English, the first variable is the number of relay servers, and the second variable is 'Relay' or 'Relays'.\nSentence composed of 2 variables to describe how many relays a note was found on. In source English, the first variable is the number of relays, and the second variable is 'Relay' or 'Relays'.\nSentence composed of 2 variables to describe how many reposts. In source English, the first variable is the number of reposts, and the second variable is 'Repost' or 'Reposts'.\nSentence composed of 2 variables to describe how many zap payments there are on a post. In source English, the first variable is the number of zap payments, and the second variable is 'Zap' or 'Zaps'.",
|
||||
"comment" : "Sentence composed of 2 variables to describe how many imports were performed from loading a NostrScript. In source English, the first variable is the number of imports, and the second variable is 'Import' or 'Imports'.\nSentence composed of 2 variables to describe how many people are following a user. In source English, the first variable is the number of followers, and the second variable is 'Follower' or 'Followers'.\nSentence composed of 2 variables to describe how many people are in the follow pack. In source English, the first variable is the number of users, and the second variable is 'user' or 'users'.\nSentence composed of 2 variables to describe how many people are viewing the live event. In source English, the first variable is the number of viewers, and the second variable is 'viewer' or 'viewers'.\nSentence composed of 2 variables to describe how many profiles a user is following. In source English, the first variable is the number of profiles being followed, and the second variable is 'Following'.\nSentence composed of 2 variables to describe how many quoted reposts. In source English, the first variable is the number of reposts, and the second variable is 'Repost' or 'Reposts'.\nSentence composed of 2 variables to describe how many reactions there are on a post. In source English, the first variable is the number of reactions, and the second variable is 'Reaction' or 'Reactions'.\nSentence composed of 2 variables to describe how many relay servers a user is connected. In source English, the first variable is the number of relay servers, and the second variable is 'Relay' or 'Relays'.\nSentence composed of 2 variables to describe how many relays a note was found on. In source English, the first variable is the number of relays, and the second variable is 'Relay' or 'Relays'.\nSentence composed of 2 variables to describe how many reposts. In source English, the first variable is the number of reposts, and the second variable is 'Repost' or 'Reposts'.\nSentence composed of 2 variables to describe how many zap payments there are on a post. In source English, the first variable is the number of zap payments, and the second variable is 'Zap' or 'Zaps'.",
|
||||
"localizations" : {
|
||||
"en-US" : {
|
||||
"stringUnit" : {
|
||||
@@ -291,6 +291,9 @@
|
||||
"Are you sure you want to upload this media?" : {
|
||||
"comment" : "Alert message asking if the user wants to upload media."
|
||||
},
|
||||
"As a subscriber, you’re getting an early look at new and innovative tools. These are beta features — still being tested and tuned. Try them out, share your thoughts, and help us perfect what’s next." : {
|
||||
"comment" : "Damus Labs explainer"
|
||||
},
|
||||
"As part of your Damus Purple membership, you get complimentary and automated translations. Would you like to enable Damus Purple translations?\n\nTip: You can always change this later in Settings → Translations" : {
|
||||
"comment" : "Message notifying the user that they get auto-translations as part of their service"
|
||||
},
|
||||
@@ -352,7 +355,7 @@
|
||||
"comment" : "User-visible heading for an error message indicating a note has an unknown kind or is unsupported for viewing."
|
||||
},
|
||||
"Cancel" : {
|
||||
"comment" : "Alert button to cancel out of alert for muting a user.\nButton to cancel a repost.\nButton to cancel any interaction with the QRCode link.\nButton to cancel out of alert that creates a new mutelist.\nButton to cancel out of posting a note.\nButton to cancel out of search text entry mode.\nButton to cancel the LNURL payment process.\nButton to cancel the upload.\nCancel button text for dismissing profile status settings view.\nCancel button text for dismissing updating image url.\nCancel deleting bookmarks.\nCancel deleting the user.\nCancel out of logging out the user.\nCancel out of search view.\nCancel the user-requested operation.\nText for button to cancel out of connecting Nostr Wallet Connect lightning wallet."
|
||||
"comment" : "Alert button to cancel out of alert for muting a user.\nButton to cancel GIF selection\nButton to cancel a repost.\nButton to cancel any interaction with the QRCode link.\nButton to cancel out of alert that creates a new mutelist.\nButton to cancel out of posting a note.\nButton to cancel out of search text entry mode.\nButton to cancel the LNURL payment process.\nButton to cancel the upload.\nCancel button text for dismissing profile status settings view.\nCancel button text for dismissing updating image url.\nCancel deleting bookmarks.\nCancel deleting the user.\nCancel out of logging out the user.\nCancel out of search view.\nCancel the user-requested operation.\nText for button to cancel out of connecting Nostr Wallet Connect lightning wallet."
|
||||
},
|
||||
"Cancelled" : {
|
||||
"comment" : "Title indicating that the user has cancelled."
|
||||
@@ -360,6 +363,9 @@
|
||||
"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"
|
||||
},
|
||||
"Chat" : {
|
||||
"comment" : "Placeholder text to prompt entry of chat message."
|
||||
},
|
||||
"Check if the invoice is valid, your wallet is online, configured correctly, and try again. If the error persists, please contact support and/or your wallet provider." : {
|
||||
"comment" : "A human-readable tip guiding the user on what to do when seeing a timeout error while sending a wallet payment."
|
||||
},
|
||||
@@ -396,6 +402,9 @@
|
||||
"Click here if you have a Coinos username and password." : {
|
||||
"comment" : "Button description hint for users who may want to connect via the website."
|
||||
},
|
||||
"Client tags can help other apps understand new kinds of events. Turn this off if you prefer not to identify Damus when posting." : {
|
||||
"comment" : "Description for the client tag privacy toggle."
|
||||
},
|
||||
"Close" : {
|
||||
"comment" : "Button label giving the user the option to close the sheet due to not being logged in.\nButton label giving the user the option to close the sheet from which they shared content\nButton label giving the user the option to close the sheet from which they were trying share.\nButton label giving the user the option to close the sheet from which they were trying to share.\nButton label giving the user the option to close the view when no content is available to share"
|
||||
},
|
||||
@@ -486,6 +495,9 @@
|
||||
"Copy LNURL" : {
|
||||
"comment" : "Context menu option for copying a user's Lightning URL."
|
||||
},
|
||||
"Copy media link" : {
|
||||
"comment" : "Accessibility label for copy media link button"
|
||||
},
|
||||
"Copy note ID" : {
|
||||
"comment" : "Context menu option for copying the ID of the note."
|
||||
},
|
||||
@@ -546,6 +558,9 @@
|
||||
"Damus" : {
|
||||
"comment" : "Name of the app for the title of an internal notification"
|
||||
},
|
||||
"Damus Labs stylized logo" : {
|
||||
"comment" : "Accessibility label for a stylized Damus Labs logo"
|
||||
},
|
||||
"Damus logo" : {
|
||||
"comment" : "Accessibility label for damus logo"
|
||||
},
|
||||
@@ -699,6 +714,9 @@
|
||||
"Expiry date" : {
|
||||
"comment" : "Label for Purple subscription expiry date"
|
||||
},
|
||||
"Failed to calculate storage: %@" : {
|
||||
"comment" : "Error message when storage calculation fails"
|
||||
},
|
||||
"Failed to generate media for upload. Please try again. If error persists, please contact Damus support at support@damus.io" : {
|
||||
"comment" : "Error label forming media for upload after user crops the image."
|
||||
},
|
||||
@@ -711,9 +729,18 @@
|
||||
"Failed to parse" : {
|
||||
"comment" : "NostrScript error message when it fails to parse a script."
|
||||
},
|
||||
"Failed to parse GIF data" : {
|
||||
"comment" : "Error message for Tenor decoding failure"
|
||||
},
|
||||
"Failed to scan QR code, please try again." : {
|
||||
"comment" : "Error message for failed QR scan"
|
||||
},
|
||||
"Favorite" : {
|
||||
"comment" : "Button label that allows the user to favorite the user shown on-screen"
|
||||
},
|
||||
"Favorites" : {
|
||||
"comment" : "Label for a toggle that enables an experimental feature\nShow Notes from your favorites"
|
||||
},
|
||||
"Find a Wallet" : {
|
||||
"comment" : "The heading for one of the \"Why add Zaps?\" boxes"
|
||||
},
|
||||
@@ -778,6 +805,9 @@
|
||||
"Following..." : {
|
||||
"comment" : "Label to indicate that the user is in the process of following another user."
|
||||
},
|
||||
"Follows" : {
|
||||
"comment" : "Show Notes from your following"
|
||||
},
|
||||
"Follows you" : {
|
||||
"comment" : "Text to indicate that a user is following your profile."
|
||||
},
|
||||
@@ -814,12 +844,18 @@
|
||||
"Get paid for being you" : {
|
||||
"comment" : "Description for monetizing one's presence."
|
||||
},
|
||||
"GIFs" : {
|
||||
"comment" : "Label for a toggle that enables an experimental feature\nSection title for GIFs configuration."
|
||||
},
|
||||
"Give thanks" : {
|
||||
"comment" : "Heading explaining a benefit of connecting a lightning wallet."
|
||||
},
|
||||
"Go to the app" : {
|
||||
"comment" : "Button label giving the user the option to go to the app after sharing content"
|
||||
},
|
||||
"Happening Now" : {
|
||||
"comment" : "Indicates that live events are happening now."
|
||||
},
|
||||
"Hashtags" : {
|
||||
"comment" : "Label for filter for seeing only hashtag follows.\nSection header title for a list of hashtags that are muted."
|
||||
},
|
||||
@@ -844,12 +880,18 @@
|
||||
"Hide balance" : {
|
||||
"comment" : "Setting to hide wallet balance."
|
||||
},
|
||||
"Hide media links" : {
|
||||
"comment" : "Accessibility label for toggle button to hide media link list"
|
||||
},
|
||||
"Hide notes with #nsfw tags" : {
|
||||
"comment" : "Setting to hide notes with not safe for work tags\nSetting to hide notes with the #nsfw (not safe for work) tags"
|
||||
},
|
||||
"Hide notifications that tag many profiles" : {
|
||||
"comment" : "Label for notification settings toggle that hides notifications that tag many people."
|
||||
},
|
||||
"Hide posts with too many hashtags" : {
|
||||
"comment" : "Setting to hide notes that contain too many hashtags (spam)"
|
||||
},
|
||||
"Highlight" : {
|
||||
"comment" : "Context menu action to highlight the selected text as context to draft a new note."
|
||||
},
|
||||
@@ -880,6 +922,9 @@
|
||||
"Illegal Content" : {
|
||||
"comment" : "Description of report type for illegal content."
|
||||
},
|
||||
"Image Cache" : {
|
||||
"comment" : "Label for Kingfisher image cache"
|
||||
},
|
||||
"Image is setup" : {
|
||||
"comment" : "Accessibility value on image control"
|
||||
},
|
||||
@@ -919,11 +964,14 @@
|
||||
"Invalid relay address" : {
|
||||
"comment" : "Heading for an error when adding a relay"
|
||||
},
|
||||
"Invalid response from server" : {
|
||||
"comment" : "Error message for invalid Tenor response"
|
||||
},
|
||||
"Invalid Tip Address" : {
|
||||
"comment" : "Title of alerting as invalid tip address."
|
||||
},
|
||||
"Invalid URL" : {
|
||||
"comment" : "Error label when user enters an invalid URL"
|
||||
"comment" : "Error label when user enters an invalid URL\nError message for invalid Tenor URL"
|
||||
},
|
||||
"It seems that you already have a translation service configured. Would you like to switch to Damus Purple as your translator?" : {
|
||||
"comment" : "Confirmation dialog question asking users if they want their translation settings to be automatically switched to the Damus Purple translation service"
|
||||
@@ -934,9 +982,18 @@
|
||||
"Keys" : {
|
||||
"comment" : "Navigation title for managing keys.\nSettings section for managing keys"
|
||||
},
|
||||
"Labs" : {
|
||||
"comment" : "Sidebar menu label for Damus Labs experimental features."
|
||||
},
|
||||
"Labs " : {
|
||||
"comment" : "Feature name"
|
||||
},
|
||||
"Latest transactions" : {
|
||||
"comment" : "Heading for latest wallet transactions list"
|
||||
},
|
||||
"Learn more about Purple" : {
|
||||
"comment" : "Button to learn more about the Damus Purple subscription."
|
||||
},
|
||||
"Learn more about the features" : {
|
||||
"comment" : "Label for a link to the Damus website, to allow the user to learn more about the features of Purple"
|
||||
},
|
||||
@@ -961,17 +1018,32 @@
|
||||
"Likes" : {
|
||||
"comment" : "Setting to enable Like Local Notification"
|
||||
},
|
||||
"Line height: %.1fx" : {
|
||||
"comment" : "Label showing current line height multiplier setting"
|
||||
},
|
||||
"Link to services that support Nostr Wallet Connect like Alby, Coinos and more." : {
|
||||
"comment" : "The description for one of the \"Why add Zaps?\" boxes"
|
||||
},
|
||||
"Link your account" : {
|
||||
"comment" : "The heading for one of the \"Why add Zaps?\" boxes"
|
||||
},
|
||||
"Live" : {
|
||||
"comment" : "Label for a toggle that enables an experimental feature\nSidebar menu label for live events view."
|
||||
},
|
||||
"LIVE" : {
|
||||
"comment" : "Text indicator that the video is a livestream."
|
||||
},
|
||||
"Load media" : {
|
||||
"comment" : "Button to show media in note."
|
||||
"Live Chat" : {
|
||||
"comment" : "Title for the live stream chat."
|
||||
},
|
||||
"Live events going on right now" : {
|
||||
"comment" : "Indicates that live events are happening now."
|
||||
},
|
||||
"Load %@" : {
|
||||
"comment" : "Accessibility label for button to load specific media item"
|
||||
},
|
||||
"Loading GIFs..." : {
|
||||
"comment" : "Loading indicator text for GIF picker"
|
||||
},
|
||||
"Loading thread" : {
|
||||
"comment" : "Accessibility label for the thread view when it is loading"
|
||||
@@ -1015,6 +1087,9 @@
|
||||
"Max weekly budget" : {
|
||||
"comment" : "Label for setting the maximum weekly budget for Coinos wallet"
|
||||
},
|
||||
"Maximum hashtags: %d" : {
|
||||
"comment" : "Label showing the maximum number of hashtags allowed before a post is hidden"
|
||||
},
|
||||
"Maybe later" : {
|
||||
"comment" : "Text for button to disconnect from Nostr Wallet Connect lightning wallet."
|
||||
},
|
||||
@@ -1036,9 +1111,15 @@
|
||||
"Message" : {
|
||||
"comment" : "Button label that allows the user to start a direct message conversation with the user shown on-screen"
|
||||
},
|
||||
"Metadata (NDB_DB_META)" : {
|
||||
"comment" : "Database name for metadata"
|
||||
},
|
||||
"Monthly" : {
|
||||
"comment" : "Monthly renewal of purple subscription"
|
||||
},
|
||||
"More features coming soon!" : {
|
||||
"comment" : "Label indicating that more features for Damus Lab experiments are coming soon."
|
||||
},
|
||||
"Mute" : {
|
||||
"comment" : "Alert button to mute a user.\nButton label that allows the user to mute the user shown on-screen\nButton to mute a profile\nContext menu action to mute the selected word.\nTitle for confirmation dialog to mute a profile."
|
||||
},
|
||||
@@ -1069,6 +1150,9 @@
|
||||
"Name" : {
|
||||
"comment" : "Label to prompt name entry."
|
||||
},
|
||||
"Ndb has been snapshotted successfully" : {
|
||||
"comment" : "Developer settings message indicating that ndb was successfully snapshotted."
|
||||
},
|
||||
"Never" : {
|
||||
"comment" : "Profile status duration setting of never expiring."
|
||||
},
|
||||
@@ -1099,6 +1183,9 @@
|
||||
"No cover image" : {
|
||||
"comment" : "Text letting user know there is no cover image."
|
||||
},
|
||||
"No GIFs found" : {
|
||||
"comment" : "Message when no GIFs match search"
|
||||
},
|
||||
"No image is currently setup" : {
|
||||
"comment" : "Accessibility value on image control"
|
||||
},
|
||||
@@ -1156,6 +1243,18 @@
|
||||
"Nostr Address" : {
|
||||
"comment" : "Label for the Nostr Address section of user profile form."
|
||||
},
|
||||
"NostrDB" : {
|
||||
"comment" : "Label for main NostrDB database"
|
||||
},
|
||||
"NostrDB Details" : {
|
||||
"comment" : "Navigation title for NostrDB detail view"
|
||||
},
|
||||
"NostrDB Metadata" : {
|
||||
"comment" : "Database name for NostrDB metadata"
|
||||
},
|
||||
"NostrDB Total" : {
|
||||
"comment" : "Label for total NostrDB storage"
|
||||
},
|
||||
"NostrScript" : {
|
||||
"comment" : "Navigation title for the view showing NostrScript."
|
||||
},
|
||||
@@ -1168,11 +1267,38 @@
|
||||
"Not now" : {
|
||||
"comment" : "Button to not save key, complete account creation, and start using the app."
|
||||
},
|
||||
"Note Blocks" : {
|
||||
"comment" : "Database name for note blocks"
|
||||
},
|
||||
"Note from a %@ you've muted" : {
|
||||
"comment" : "Text to indicate that what is being shown is a note which has been muted."
|
||||
},
|
||||
"Note ID Index" : {
|
||||
"comment" : "Database name for note ID index"
|
||||
},
|
||||
"Note Kind Index" : {
|
||||
"comment" : "Database name for note kind index"
|
||||
},
|
||||
"Note not found" : {
|
||||
"comment" : "Heading for the thread view in a not found error state."
|
||||
"comment" : "Heading for the event loader view in a not found error state.\nHeading for the thread view in a not found error state."
|
||||
},
|
||||
"Note Pubkey Index" : {
|
||||
"comment" : "Database name for note pubkey index"
|
||||
},
|
||||
"Note Pubkey+Kind Index" : {
|
||||
"comment" : "Database name for note pubkey+kind index"
|
||||
},
|
||||
"Note Relay+Kind Index" : {
|
||||
"comment" : "Database name for note relay+kind index"
|
||||
},
|
||||
"Note Relays" : {
|
||||
"comment" : "Database name for note relays"
|
||||
},
|
||||
"Note Tags Index" : {
|
||||
"comment" : "Database name for note tags index"
|
||||
},
|
||||
"Note Text Index" : {
|
||||
"comment" : "Database name for note text index"
|
||||
},
|
||||
"Note you've muted" : {
|
||||
"comment" : "Label indicating note has been muted\nText to indicate that what is being shown is a note which has been muted."
|
||||
@@ -1180,6 +1306,9 @@
|
||||
"Notes" : {
|
||||
"comment" : "A label indicating that the notes being displayed below it are from a timeline, not search results\nLabel for filter for seeing only notes (instead of notes and replies)."
|
||||
},
|
||||
"Notes (NDB_DB_NOTE)" : {
|
||||
"comment" : "Database name for notes"
|
||||
},
|
||||
"Notes & Replies" : {
|
||||
"comment" : "Label for filter for seeing notes and replies (instead of only notes)."
|
||||
},
|
||||
@@ -1283,6 +1412,9 @@
|
||||
"Orange-pill" : {
|
||||
"comment" : "Button label that allows the user to start a direct message conversation with the user shown on-screen, to orange-pill them (i.e. help them to setup zaps)"
|
||||
},
|
||||
"Other Data" : {
|
||||
"comment" : "Database name for other/unaccounted data"
|
||||
},
|
||||
"Other preferences" : {
|
||||
"comment" : "Screen title for content preferences screen during onboarding"
|
||||
},
|
||||
@@ -1331,6 +1463,9 @@
|
||||
"Please check the address and try again" : {
|
||||
"comment" : "Tip for an error where the relay address being added is invalid"
|
||||
},
|
||||
"Please check your internet connection and restart the app. If the error persists, please go to Settings > First Aid." : {
|
||||
"comment" : "Human readable tips for what to do for a failure to find the relay list"
|
||||
},
|
||||
"Please choose relays from the list below to filter the current feed:" : {
|
||||
"comment" : "Instructions on how to filter a specific timeline feed by choosing relay servers to filter on."
|
||||
},
|
||||
@@ -1403,6 +1538,9 @@
|
||||
"Posts" : {
|
||||
"comment" : "Label for filter for seeing the posts from the people in this follow pack."
|
||||
},
|
||||
"Privacy" : {
|
||||
"comment" : "Section header for privacy related settings"
|
||||
},
|
||||
"Private" : {
|
||||
"comment" : "Button text to indicate that the zap type is a private zap.\nHeading indicating that this application keeps personally identifiable information private. A sentence describing what is done to keep data private comes after this heading.\nPicker option to indicate that a zap should be sent privately and not identify the user to the public."
|
||||
},
|
||||
@@ -1445,12 +1583,24 @@
|
||||
"Profile action sheets allow you to follow, zap, or DM profiles more quickly without having to view their full profile" : {
|
||||
"comment" : "Section footer clarifying what the profile action sheet feature does"
|
||||
},
|
||||
"Profile Key Index" : {
|
||||
"comment" : "Database name for profile key index"
|
||||
},
|
||||
"Profile Last Fetch" : {
|
||||
"comment" : "Database name for profile last fetch"
|
||||
},
|
||||
"Profile picture is setup" : {
|
||||
"comment" : "Accessibility value on profile picture image control"
|
||||
},
|
||||
"Profile Search Index" : {
|
||||
"comment" : "Database name for profile search"
|
||||
},
|
||||
"Profiles" : {
|
||||
"comment" : "Section title for profile view configuration."
|
||||
},
|
||||
"Profiles (NDB_DB_PROFILE)" : {
|
||||
"comment" : "Database name for profiles"
|
||||
},
|
||||
"Public" : {
|
||||
"comment" : "Button text to indicate that the zap type is a public zap.\nPicker option to indicate that a zap should be sent publicly and identify the user as who sent it."
|
||||
},
|
||||
@@ -1472,6 +1622,9 @@
|
||||
"Purple" : {
|
||||
"comment" : "Subscription service name"
|
||||
},
|
||||
"Purple subscribers get first access to new and experimental features — fresh ideas straight from the lab." : {
|
||||
"comment" : "Damus purple subscription pitch"
|
||||
},
|
||||
"Push" : {
|
||||
"comment" : "Option for notification mode setting: Push notification mode"
|
||||
},
|
||||
@@ -1496,6 +1649,9 @@
|
||||
"Reactions" : {
|
||||
"comment" : "Navigation bar title for Reactions view.\nSection header for reactions settings\nTitle of emoji reactions view"
|
||||
},
|
||||
"Reading" : {
|
||||
"comment" : "Section header for reading appearance settings"
|
||||
},
|
||||
"Received an incorrect or unexpected response from the wallet provider. This looks like an issue with your wallet provider." : {
|
||||
"comment" : "A human-readable error message"
|
||||
},
|
||||
@@ -1606,6 +1762,9 @@
|
||||
"Retry" : {
|
||||
"comment" : "Button to retry completing account creation after an error occurred."
|
||||
},
|
||||
"Retrying…" : {
|
||||
"comment" : "Button label for the retry-in-progress state when loading a note"
|
||||
},
|
||||
"Routing" : {
|
||||
"comment" : "Label indicating the routing address for Nostr Wallet Connect payments. In other words, the relay used by the NWC wallet provider"
|
||||
},
|
||||
@@ -1678,12 +1837,18 @@
|
||||
"Search / Universe" : {
|
||||
"comment" : "Section header for search/universe settings"
|
||||
},
|
||||
"Search GIFs..." : {
|
||||
"comment" : "Placeholder for GIF search field"
|
||||
},
|
||||
"Search within settings" : {
|
||||
"comment" : "Text to prompt the user to search settings."
|
||||
},
|
||||
"Search word: %@" : {
|
||||
"comment" : "Navigation link to search for a word."
|
||||
},
|
||||
"Search: %@" : {
|
||||
"comment" : "Label indicating the search query that resulted in the current list of notes"
|
||||
},
|
||||
"Search..." : {
|
||||
"comment" : "Placeholder text to prompt entry of search query."
|
||||
},
|
||||
@@ -1702,6 +1867,9 @@
|
||||
"Select default wallet" : {
|
||||
"comment" : "Prompt selection of user's default wallet"
|
||||
},
|
||||
"Select GIF" : {
|
||||
"comment" : "Title for GIF picker sheet"
|
||||
},
|
||||
"Select your interests" : {
|
||||
"comment" : "Title for a screen asking the user for interests"
|
||||
},
|
||||
@@ -1720,6 +1888,9 @@
|
||||
"Send a message with your zap..." : {
|
||||
"comment" : "Placeholder text for a comment to send as part of a zap to the user."
|
||||
},
|
||||
"Sepia mode for longform articles" : {
|
||||
"comment" : "Setting to enable sepia reading mode for longform articles"
|
||||
},
|
||||
"Server" : {
|
||||
"comment" : "Prompt selection of LibreTranslate server to perform machine translations on notes"
|
||||
},
|
||||
@@ -1741,6 +1912,9 @@
|
||||
"Share" : {
|
||||
"comment" : "Button to share a note\nButton to share an image.\nButton to share the link to a profile.\nSave button text for saving profile status settings."
|
||||
},
|
||||
"Share Damus client tag" : {
|
||||
"comment" : "Setting to publish a client tag indicating Damus posted the note"
|
||||
},
|
||||
"Share externally" : {
|
||||
"comment" : "Accessibility label for external share button"
|
||||
},
|
||||
@@ -1771,6 +1945,9 @@
|
||||
"Show less" : {
|
||||
"comment" : "Button to show less of a long profile description."
|
||||
},
|
||||
"Show media links" : {
|
||||
"comment" : "Accessibility label for toggle button to show media link list"
|
||||
},
|
||||
"Show more" : {
|
||||
"comment" : "Button to show entire note.\nButton to show more of a long profile description."
|
||||
},
|
||||
@@ -1804,9 +1981,21 @@
|
||||
"Sign out" : {
|
||||
"comment" : "Sidebar menu label to sign out of the account."
|
||||
},
|
||||
"Size" : {
|
||||
"comment" : "Label for size in disk storage chart"
|
||||
},
|
||||
"Skip" : {
|
||||
"comment" : "Button to dismiss the suggested users screen"
|
||||
},
|
||||
"Snapshot Database" : {
|
||||
"comment" : "Label for snapshot database"
|
||||
},
|
||||
"Snapshot Ndb to shared container" : {
|
||||
"comment" : "Developer settings button to snapshot ndb to shared container."
|
||||
},
|
||||
"Snapshotting Ndb to shared container" : {
|
||||
"comment" : "Developer settings loading message indicating that ndb is being snapshotted to the shared container."
|
||||
},
|
||||
"SOFTWARE" : {
|
||||
"comment" : "Text label indicating which relay software is used to run this Nostr relay."
|
||||
},
|
||||
@@ -1852,6 +2041,9 @@
|
||||
"Staying humble..." : {
|
||||
"comment" : "Placeholder as an example of what the user could set as their profile status."
|
||||
},
|
||||
"Storage" : {
|
||||
"comment" : "Navigation title for storage settings\nSection header for storage usage statistics"
|
||||
},
|
||||
"Subscriber number" : {
|
||||
"comment" : "Label for Purple account subscriber number"
|
||||
},
|
||||
@@ -1879,6 +2071,9 @@
|
||||
"Supporter Badge" : {
|
||||
"comment" : "Title for supporter badge"
|
||||
},
|
||||
"Switch between posts from your follows or your favorites." : {
|
||||
"comment" : "Description of the tip that informs users that they can switch between posts from your follows or your favorites."
|
||||
},
|
||||
"Syncing" : {
|
||||
"comment" : "Label indicating success in syncing notification preferences"
|
||||
},
|
||||
@@ -1891,6 +2086,12 @@
|
||||
"Tap to load" : {
|
||||
"comment" : "Label for button that allows user to dismiss media content warning and unblur the image"
|
||||
},
|
||||
"Tenor API Key (optional)" : {
|
||||
"comment" : "Prompt for optional entry of API Key to use with Tenor."
|
||||
},
|
||||
"Tenor API key not configured" : {
|
||||
"comment" : "Error message for missing Tenor API key"
|
||||
},
|
||||
"Test (local)" : {
|
||||
"comment" : "Label indicating a local test environment for Damus Purple functionality (Developer feature)\nLabel indicating a local test environment for Push notification functionality (Developer feature)"
|
||||
},
|
||||
@@ -1921,6 +2122,9 @@
|
||||
"The payment request did not receive a response and the request timed-out." : {
|
||||
"comment" : "A human-readable error message"
|
||||
},
|
||||
"The quick brown fox jumps over the lazy dog. This preview shows how your line spacing will appear in longform articles." : {
|
||||
"comment" : "Sample text for line height preview in settings"
|
||||
},
|
||||
"The social network you control" : {
|
||||
"comment" : "Quick description of what Damus is"
|
||||
},
|
||||
@@ -1963,6 +2167,9 @@
|
||||
"This note contains too many items and cannot be rendered" : {
|
||||
"comment" : "Error message indicating that a note is too big and cannot be rendered"
|
||||
},
|
||||
"This note may have been deleted, or it might not be available on the relays you're connected to." : {
|
||||
"comment" : "Text for the event loader view when it is unable to find the note the user is looking for"
|
||||
},
|
||||
"This operation is restricted by your wallet." : {
|
||||
"comment" : "Error description for restricted operation"
|
||||
},
|
||||
@@ -1972,9 +2179,32 @@
|
||||
"This user cannot be zapped because they have not configured zaps on their account yet. Time to orange-pill?" : {
|
||||
"comment" : "Comment explaining why a user cannot be zapped."
|
||||
},
|
||||
"This will allow you to easily add gifs from Tenor to your posts. You will see the GIF icon in the attachment bar when creating a post. Tapping it will show you all of tenor's featured GIFs. You can also search for GIFs." : {
|
||||
"comment" : "Damus Labs feature explanation"
|
||||
},
|
||||
"This will allow you to pick users to be part of your favorites list. You can also switch your profile timeline to only see posts from your favorite contacts." : {
|
||||
"comment" : "Damus Labs feature explanation"
|
||||
},
|
||||
"This will allow you to see all the real-time live streams happening on Nostr! As well as let you view and interact in the Live Chat. Please keep in mind this is still a work in progress and issues are expected. When enabled you will see the Live option in your side menu." : {
|
||||
"comment" : "Damus Labs feature explanation"
|
||||
},
|
||||
"Threads" : {
|
||||
"comment" : "Section header title for a list of threads that are muted."
|
||||
},
|
||||
"Timeline switcher" : {
|
||||
"comment" : "Title of tip that informs users that they can switch timelines."
|
||||
},
|
||||
"Timeline switcher, select %@ or %@" : {
|
||||
"comment" : "Accessibility label for the timeline switcher button at the topbar",
|
||||
"localizations" : {
|
||||
"en-US" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Timeline switcher, select %1$@ or %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"To continue your Purple subscription checkout, please verify your npub by clicking on the button below" : {
|
||||
"comment" : "Instruction on how to verify npub during Damus Purple checkout"
|
||||
},
|
||||
@@ -1993,6 +2223,12 @@
|
||||
"Top Zap" : {
|
||||
"comment" : "Text indicating that this zap is the one with the highest amount of sats."
|
||||
},
|
||||
"Total" : {
|
||||
"comment" : "Label for total storage in pie chart center"
|
||||
},
|
||||
"Total Storage" : {
|
||||
"comment" : "Label for total storage used"
|
||||
},
|
||||
"Translate DMs" : {
|
||||
"comment" : "Toggle to translate direct messages."
|
||||
},
|
||||
@@ -2021,7 +2257,7 @@
|
||||
"comment" : "Human-readable short description of the 'trusted network filter' when it is enabled, and therefore showing content from only the trusted network."
|
||||
},
|
||||
"Try Again" : {
|
||||
"comment" : "Button to retry payment"
|
||||
"comment" : "Button label to retry loading a note that was not found\nButton to retry loading GIFs\nButton to retry payment"
|
||||
},
|
||||
"Try again. If the error persists, please contact your wallet provider and/or our support team." : {
|
||||
"comment" : "A human-readable tip for an error when a payment request cannot be made to a wallet."
|
||||
@@ -2029,6 +2265,9 @@
|
||||
"Try checking the link again, your internet connection, or contact the person who provided you the link for help." : {
|
||||
"comment" : "Tips on what to do if a note cannot be found."
|
||||
},
|
||||
"Try checking your internet connection, expanding your relay list, or contacting the person who quoted this note." : {
|
||||
"comment" : "Tips on what to do if a quoted note cannot be found."
|
||||
},
|
||||
"Try restarting your wallet or contacting support if the problem persists." : {
|
||||
"comment" : "Tip for internal error"
|
||||
},
|
||||
@@ -2125,6 +2364,9 @@
|
||||
"VERSION" : {
|
||||
"comment" : "Text label indicating which version of the relay software is being run for this Nostr relay."
|
||||
},
|
||||
"via %@" : {
|
||||
"comment" : "Label indicating which client published the event"
|
||||
},
|
||||
"View full profile" : {
|
||||
"comment" : "A button label that allows the user to see the full profile of the profile they are previewing"
|
||||
},
|
||||
@@ -2260,7 +2502,7 @@
|
||||
"You unlocked" : {
|
||||
"comment" : "Part 1 of 2 in message 'You unlocked automatic translations' the user gets when they sign up for Damus Purple"
|
||||
},
|
||||
"Your connected wallet raised an unknown error. Message: %s" : {
|
||||
"Your connected wallet raised an unknown error. Message: %@" : {
|
||||
"comment" : "Human readable error description for unknown error"
|
||||
},
|
||||
"Your content is being broadcasted to the network. Please wait." : {
|
||||
@@ -2290,6 +2532,9 @@
|
||||
"Your relay list appears to be broken, so we cannot connect you to your Nostr network." : {
|
||||
"comment" : "Human readable error description for a failure to parse the relay list due to a bad relay list"
|
||||
},
|
||||
"Your relay list could not be found, so we cannot connect you to your Nostr network." : {
|
||||
"comment" : "Human readable error description for a failure to find the relay list"
|
||||
},
|
||||
"Your report will be sent to the relays you are connected to" : {
|
||||
"comment" : "Footer text to inform user what will happen when the report is submitted."
|
||||
},
|
||||
@@ -2342,5 +2587,5 @@
|
||||
"comment" : "Describing the functional benefits of Zaps."
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
"version" : "1.1"
|
||||
}
|
||||
Binary file not shown.
@@ -2,6 +2,38 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>media_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@MEDIA@</string>
|
||||
<key>MEDIA</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>Load %d media item</string>
|
||||
<key>other</key>
|
||||
<string>Load %d media items</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>viewer_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@VIEWERS@</string>
|
||||
<key>VIEWERS</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>viewer</string>
|
||||
<key>other</key>
|
||||
<string>viewers</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>follow_pack_user_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
@@ -98,22 +130,6 @@
|
||||
<string>Imports</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>media_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@MEDIA@</string>
|
||||
<key>MEDIA</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>media item</string>
|
||||
<key>other</key>
|
||||
<string>media items</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>notes_from_three_and_others</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
@@ -386,6 +402,22 @@
|
||||
<string>%d Words</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>read_time</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@MINUTES@</string>
|
||||
<key>MINUTES</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d min read</string>
|
||||
<key>other</key>
|
||||
<string>%d min read</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>zap_notification_no_message</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
|
||||
@@ -38,5 +38,5 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
"version" : "1.1"
|
||||
}
|
||||
@@ -3,10 +3,10 @@
|
||||
"project" : "damus.xcodeproj",
|
||||
"targetLocale" : "en-US",
|
||||
"toolInfo" : {
|
||||
"toolBuildNumber" : "16F6",
|
||||
"toolBuildNumber" : "17C529",
|
||||
"toolID" : "com.apple.dt.xcode",
|
||||
"toolName" : "Xcode",
|
||||
"toolVersion" : "16.4"
|
||||
"toolVersion" : "26.3"
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
@@ -2,6 +2,26 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>follow_pack_user_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@FOLLOW_PACK_USERS@</string>
|
||||
<key>FOLLOW_PACK_USERS</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>użytkownik</string>
|
||||
<key>few</key>
|
||||
<string>użytkowników</string>
|
||||
<key>many</key>
|
||||
<string>użytkowników</string>
|
||||
<key>other</key>
|
||||
<string>użytkownika</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>followed_by_three_and_others</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
@@ -62,6 +82,26 @@
|
||||
<string>Obserwuje</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>hellthread_notifications_disabled</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@HELLTHREAD_PROFILES@</string>
|
||||
<key>HELLTHREAD_PROFILES</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>Ukryj powiadomienia, które oznaczają więcej niż %d profil.</string>
|
||||
<key>few</key>
|
||||
<string>Ukryj powiadomienia, które oznaczają więcej niż %d profile.</string>
|
||||
<key>many</key>
|
||||
<string>Ukryj powiadomienia, które oznaczają więcej niż %d profili.</string>
|
||||
<key>other</key>
|
||||
<string>Ukryj powiadomienia, które oznaczają więcej niż %d profila.</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>imports_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
@@ -82,6 +122,26 @@
|
||||
<string>Importa</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>notes_from_three_and_others</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@OTHERS@</string>
|
||||
<key>OTHERS</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>Notatki od %2$@, %3$@, %4$@ i %1$d innej osoby w Twojej zaufanej sieci.</string>
|
||||
<key>few</key>
|
||||
<string>Notatki od %2$@, %3$@, %4$@ i %1$d innych osób w Twojej zaufanej sieci.</string>
|
||||
<key>many</key>
|
||||
<string>Notatki od %2$@, %3$@, %4$@ i %1$d innych osób w Twojej zaufanej sieci.</string>
|
||||
<key>other</key>
|
||||
<string>Notatki od %2$@, %3$@, %4$@ i %1$d innej osoby w Twojej zaufanej sieci.</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>people_reposted_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
@@ -102,6 +162,26 @@
|
||||
<string>%2$@ i %1$d innej osoby podało dalej</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>quoted_reposts_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@QUOTE_REPOSTS@</string>
|
||||
<key>QUOTE_REPOSTS</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>Cytowanie</string>
|
||||
<key>few</key>
|
||||
<string>Cytowania</string>
|
||||
<key>many</key>
|
||||
<string>Cytowań</string>
|
||||
<key>other</key>
|
||||
<string>Cytowania</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>reacted_tagged_in_3</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
@@ -302,26 +382,6 @@
|
||||
<string>Podane dalej</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>quoted_reposts_count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@QUOTE_REPOSTS@</string>
|
||||
<key>QUOTE_REPOSTS</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>Cytowanie</string>
|
||||
<key>few</key>
|
||||
<string>Cytowania</string>
|
||||
<key>many</key>
|
||||
<string>Cytowań</string>
|
||||
<key>other</key>
|
||||
<string>Cytowania</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>sats</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
|
||||
@@ -76,7 +76,7 @@ final class AutoSaveViewModelTests: XCTestCase {
|
||||
viewModel.needsSaving()
|
||||
|
||||
// Wait for save to complete
|
||||
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
|
||||
try await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
|
||||
|
||||
// Then - should have saved
|
||||
XCTAssertEqual(saveCount, 1)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//
|
||||
// LoadableNostrEventViewModelTests.swift
|
||||
// damusTests
|
||||
//
|
||||
// Created by alltheseas on 2026-02-13.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import damus
|
||||
|
||||
/// Tests for LoadableNostrEventViewModel, verifying that event loading
|
||||
/// waits for relay connection before attempting network lookups.
|
||||
///
|
||||
/// ## Bug replication (issue #3544)
|
||||
///
|
||||
/// Before the fix, `load()` called `executeLoadingLogic()` immediately
|
||||
/// without waiting for relay connection. When the app opened a nevent URL
|
||||
/// or search result before relays connected, `findEvent` would fail and
|
||||
/// the view would show "not found."
|
||||
///
|
||||
/// The observable difference:
|
||||
///
|
||||
/// - **Old code** (no `awaitConnection`): `executeLoadingLogic` runs
|
||||
/// immediately → `findEvent` hits empty ndb and disconnected relays →
|
||||
/// returns nil → state becomes `.not_found` within milliseconds.
|
||||
///
|
||||
/// - **Fixed code** (with `awaitConnection`): `load()` blocks at
|
||||
/// `awaitConnection()` → state stays `.loading` until relays connect
|
||||
/// or the 30 s timeout fires.
|
||||
///
|
||||
/// The fix adds `awaitConnection()` before loading, matching the pattern
|
||||
/// established in `SearchHomeModel.load()` (commit fa4b7a75).
|
||||
@MainActor
|
||||
final class LoadableNostrEventViewModelTests: XCTestCase {
|
||||
|
||||
/// Proves the fix: without a relay connection, `load()` blocks at
|
||||
/// `awaitConnection()` and state remains `.loading`.
|
||||
///
|
||||
/// **Fails with old code (the bug):** Without `awaitConnection()`,
|
||||
/// `executeLoadingLogic` runs immediately on disconnected relays.
|
||||
/// `findEvent` falls through to `streamExistingEvents` (10 s default
|
||||
/// timeout), which eventually returns nil → state becomes `.not_found`.
|
||||
///
|
||||
/// **Passes with fix:** `awaitConnection()` blocks for up to 30 s,
|
||||
/// so state stays `.loading` well past the 11 s check window.
|
||||
///
|
||||
/// The 11 s sleep exceeds the `streamExistingEvents` 10 s timeout,
|
||||
/// ensuring the old code path has fully resolved to `.not_found`.
|
||||
func testLoadBlocksUntilConnected() async throws {
|
||||
let state = generate_test_damus_state(mock_profile_info: nil)
|
||||
|
||||
// Do NOT call connect() — simulates opening a nevent URL
|
||||
// before relays are ready (the exact bug scenario).
|
||||
let vm = LoadableNostrEventViewModel(
|
||||
damus_state: state,
|
||||
note_reference: .note_id(test_note.id, relays: [])
|
||||
)
|
||||
|
||||
// Sleep past the 10 s streamExistingEvents timeout so the old
|
||||
// code path fully resolves, but under the 30 s awaitConnection
|
||||
// timeout so the fix keeps state at .loading.
|
||||
try await Task.sleep(for: .seconds(11))
|
||||
|
||||
// With the fix: awaitConnection() is still blocking → .loading
|
||||
// Without the fix (bug): executeLoadingLogic completed → .not_found
|
||||
switch vm.state {
|
||||
case .loading:
|
||||
break // Correct: awaitConnection is blocking as intended
|
||||
case .not_found:
|
||||
XCTFail("State is .not_found — load() bypassed awaitConnection and ran executeLoadingLogic on disconnected relays (bug #3544)")
|
||||
case .loaded:
|
||||
XCTFail("Should not load without a relay connection")
|
||||
case .unknown_or_unsupported_kind:
|
||||
XCTFail("Unexpected state")
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies that `awaitConnection()` returns immediately when the
|
||||
/// network is already connected, so `load()` proceeds without delay.
|
||||
func testAwaitConnection_ReturnsImmediatelyWhenConnected() async throws {
|
||||
let state = generate_test_damus_state(mock_profile_info: nil)
|
||||
|
||||
try! await state.nostrNetwork.userRelayList.set(userRelayList: NIP65.RelayList())
|
||||
await state.nostrNetwork.connect()
|
||||
|
||||
let start = ContinuousClock.now
|
||||
await state.nostrNetwork.awaitConnection()
|
||||
let elapsed = ContinuousClock.now - start
|
||||
|
||||
XCTAssertLessThan(elapsed, .seconds(1), "awaitConnection should return immediately when already connected")
|
||||
}
|
||||
|
||||
/// Verifies that `awaitConnection()` respects its timeout and does
|
||||
/// not block indefinitely when no connection is established.
|
||||
func testAwaitConnectionTimeout_DoesNotBlockForever() async throws {
|
||||
let state = generate_test_damus_state(mock_profile_info: nil)
|
||||
|
||||
let start = ContinuousClock.now
|
||||
await state.nostrNetwork.awaitConnection(timeout: .milliseconds(200))
|
||||
let elapsed = ContinuousClock.now - start
|
||||
|
||||
XCTAssertLessThan(elapsed, .seconds(2), "awaitConnection should respect timeout")
|
||||
}
|
||||
}
|
||||
@@ -20,15 +20,17 @@ final class LocalizationUtilTests: XCTestCase {
|
||||
["following_count", "Following", "Following", "Following"],
|
||||
["hellthread_notifications_disabled", "Hide notifications that tag more than 0 profiles", "Hide notifications that tag more than 1 profile", "Hide notifications that tag more than 2 profiles"],
|
||||
["imports_count", "Imports", "Import", "Imports"],
|
||||
["media_count", "Load 0 media items", "Load 1 media item", "Load 2 media items"],
|
||||
["quoted_reposts_count", "Quotes", "Quote", "Quotes"],
|
||||
["reactions_count", "Reactions", "Reaction", "Reactions"],
|
||||
["read_time", "0 min read", "1 min read", "2 min read"],
|
||||
["relays_count", "Relays", "Relay", "Relays"],
|
||||
["reposts_count", "Reposts", "Repost", "Reposts"],
|
||||
["sats", "sats", "sat", "sats"],
|
||||
["users_talking_about_it", "0 users talking about it", "1 user talking about it", "2 users talking about it"],
|
||||
["viewer_count", "viewers", "viewer", "viewers"],
|
||||
["word_count", "0 Words", "1 Word", "2 Words"],
|
||||
["zaps_count", "Zaps", "Zap", "Zaps"],
|
||||
["media_count", "media items", "media item", "media items"]
|
||||
["zaps_count", "Zaps", "Zap", "Zaps"]
|
||||
]
|
||||
|
||||
for key in keys {
|
||||
|
||||
@@ -40,4 +40,18 @@ final class NostrEventTests: XCTestCase {
|
||||
let urlInContent2 = "https://cdn.nostr.build/i/5c1d3296f66c2630131bf123106486aeaf051ed8466031c0e0532d70b33cddb2.jpg"
|
||||
XCTAssert(testEvent2.content.contains(urlInContent2), "Issue parsing event. Expected to see '\(urlInContent2)' inside \(testEvent2.content)")
|
||||
}
|
||||
|
||||
func testClientTagParsing() {
|
||||
let tags = [["client", "Custom Client", "addr", "wss://relay.example"], ["p", test_pubkey.hex()]]
|
||||
let event = NostrEvent(content: "hi", keypair: test_keypair, kind: 1, tags: tags)!
|
||||
let metadata = event.clientTag
|
||||
XCTAssertEqual(metadata?.name, "Custom Client")
|
||||
XCTAssertEqual(metadata?.handlerAddress, "addr")
|
||||
XCTAssertEqual(metadata?.relayHint, "wss://relay.example")
|
||||
}
|
||||
|
||||
func testClientTagNilWhenMissing() {
|
||||
let event = NostrEvent(content: "hi", keypair: test_keypair, kind: 1, tags: [])!
|
||||
XCTAssertNil(event.clientTag)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,6 @@ final class ThreadModelTests: XCTestCase {
|
||||
XCTAssertEqual(actionBarModel.boosts, 5)
|
||||
testShouldComplete.fulfill()
|
||||
}
|
||||
wait(for: [testShouldComplete], timeout: 10.0)
|
||||
await fulfillment(of: [testShouldComplete], timeout: 10.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ final class PostViewTests: XCTestCase {
|
||||
XCTAssertEqual(post.content, "nostr:\(test_pubkey.npub)")
|
||||
}
|
||||
|
||||
/// Tests that pasting an npub converts it to a mention link (issue #2289)
|
||||
/// Tests that pasting an npub converts it to a mention link (issue #2289)
|
||||
func testPastedNpubConvertsToMention() {
|
||||
let content = NSMutableAttributedString(string: "Hello ")
|
||||
var resultContent: NSMutableAttributedString?
|
||||
@@ -318,6 +318,25 @@ final class PostViewTests: XCTestCase {
|
||||
|
||||
XCTAssertTrue(shouldChange, "shouldChangeTextIn should return true for regular text")
|
||||
}
|
||||
|
||||
/// Tests that client tags are added to events when provided.
|
||||
func testToEventAddsClientTagWhenProvided() {
|
||||
let post = NostrPost(content: "gm")
|
||||
let clientTag = ["client", "Damus"]
|
||||
let event = post.to_event(keypair: test_keypair_full, clientTag: clientTag)
|
||||
XCTAssertTrue(event?.tags.contains(where: { $0 == clientTag }) ?? false)
|
||||
}
|
||||
|
||||
/// Tests that existing client tags are not duplicated.
|
||||
func testToEventDoesNotDuplicateExistingClientTag() {
|
||||
let existingTags = [["client", "Custom"]]
|
||||
let post = NostrPost(content: "gm", tags: existingTags)
|
||||
let clientTag = ["client", "Damus"]
|
||||
let event = post.to_event(keypair: test_keypair_full, clientTag: clientTag)
|
||||
let clientTagCount = event?.tags.filter { $0.first == "client" }.count
|
||||
XCTAssertEqual(clientTagCount, 1)
|
||||
XCTAssertEqual(event?.tags.first(where: { $0.first == "client" }), existingTags.first)
|
||||
}
|
||||
}
|
||||
|
||||
func checkMentionLinkEditorHandling(
|
||||
@@ -354,4 +373,3 @@ func testAddingStringAfterLink(str: String) {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,4 +206,57 @@ final class WalletConnectTests: XCTestCase {
|
||||
XCTFail("Decoded to the wrong case")
|
||||
}
|
||||
}
|
||||
|
||||
func testEncodingListTransactionsType() throws {
|
||||
func encoded_request_with_type(type: String?) throws -> String {
|
||||
let request = WalletConnect.Request.getTransactionList(
|
||||
from: nil,
|
||||
until: nil,
|
||||
limit: nil,
|
||||
offset: nil,
|
||||
unpaid: nil,
|
||||
type: type,
|
||||
)
|
||||
let encodedData = try JSONEncoder().encode(request)
|
||||
return String(data: encodedData, encoding: .utf8)!
|
||||
}
|
||||
|
||||
var encodedRequest = try encoded_request_with_type(type: nil)
|
||||
XCTAssertFalse(encodedRequest.contains("\"type\""))
|
||||
|
||||
encodedRequest = try encoded_request_with_type(type: "")
|
||||
XCTAssertTrue(encodedRequest.contains("\"type\":\"\""))
|
||||
|
||||
encodedRequest = try encoded_request_with_type(type: "incoming")
|
||||
XCTAssertTrue(encodedRequest.contains("\"type\":\"incoming\""))
|
||||
|
||||
encodedRequest = try encoded_request_with_type(type: "outgoing")
|
||||
XCTAssertTrue(encodedRequest.contains("\"type\":\"outgoing\""))
|
||||
}
|
||||
|
||||
/// Tests that responses with an unknown `result_type` (e.g. `get_info` from a different NWC client)
|
||||
/// are decoded gracefully without throwing an error.
|
||||
func testDecodingUnknownResultTypeIsGraceful() throws {
|
||||
let jsonData = """
|
||||
{
|
||||
"result_type": "get_info",
|
||||
"error": null,
|
||||
"result": {
|
||||
"alias": "some-wallet",
|
||||
"color": "#3399FF",
|
||||
"pubkey": "abc123",
|
||||
"network": "mainnet",
|
||||
"block_height": 800000,
|
||||
"block_hash": "000000",
|
||||
"methods": ["pay_invoice", "get_balance", "get_info"]
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let response = try JSONDecoder().decode(WalletConnect.Response.self, from: jsonData)
|
||||
|
||||
XCTAssertNil(response.result_type, "Unknown result_type should be nil")
|
||||
XCTAssertNil(response.result, "Result should be nil for unknown result_type")
|
||||
XCTAssertNil(response.error, "Error should be nil for a successful get_info response")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ struct ShareExtensionView: View {
|
||||
self.highlighter_state = .not_logged_in
|
||||
return
|
||||
}
|
||||
guard let posted_event = post.to_event(keypair: full_keypair) else {
|
||||
guard let posted_event = post.to_event(keypair: full_keypair, clientTag: state.clientTagComponents) else {
|
||||
self.highlighter_state = .failed(error: "Cannot convert post data into a nostr event")
|
||||
return
|
||||
}
|
||||
|
||||
+138
-2
@@ -84,8 +84,8 @@ class Ndb {
|
||||
return remove_file_prefix(containerURL.appendingPathComponent("snapshot", conformingTo: .directory).absoluteString)
|
||||
}
|
||||
|
||||
static private let main_db_file_name: String = "data.mdb"
|
||||
static private let db_files: [String] = ["data.mdb", "lock.mdb"]
|
||||
static let main_db_file_name: String = "data.mdb"
|
||||
static let db_files: [String] = ["data.mdb", "lock.mdb"]
|
||||
|
||||
static var empty: Ndb {
|
||||
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 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 {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -470,6 +470,19 @@ extension NdbNote {
|
||||
public var references: References<RefId> {
|
||||
References<RefId>(tags: self.tags)
|
||||
}
|
||||
|
||||
/// Parses and returns the client tag metadata if present on this note.
|
||||
var clientTag: ClientTagMetadata? {
|
||||
for tag in tags {
|
||||
guard tag.count >= 2, tag[0].matches_str("client") else {
|
||||
continue
|
||||
}
|
||||
if let metadata = ClientTagMetadata(tagComponents: tag.strings()) {
|
||||
return metadata
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func thread_reply() -> ThreadReply? {
|
||||
if self.known_kind != .highlight {
|
||||
|
||||
+14
-4
@@ -614,10 +614,20 @@ int ndb_filter_end(struct ndb_filter *filter)
|
||||
memmove(filter->elem_buf.p, filter->data_buf.start, data_len);
|
||||
|
||||
// realloc the whole thing
|
||||
rel = realloc(filter->elem_buf.start, elem_len + data_len);
|
||||
if (rel)
|
||||
filter->elem_buf.start = rel;
|
||||
assert(filter->elem_buf.start);
|
||||
size_t new_size = elem_len + data_len;
|
||||
if (new_size == 0) {
|
||||
// Avoid calling realloc with size 0 (implementation-defined behavior)
|
||||
// Explicitly free and set to NULL
|
||||
free(filter->elem_buf.start);
|
||||
filter->elem_buf.start = NULL;
|
||||
} else {
|
||||
rel = realloc(filter->elem_buf.start, new_size);
|
||||
if (rel) {
|
||||
filter->elem_buf.start = rel;
|
||||
}
|
||||
// Assert allocation succeeded for non-zero size
|
||||
assert(filter->elem_buf.start);
|
||||
}
|
||||
filter->elem_buf.end = filter->elem_buf.start + elem_len;
|
||||
filter->elem_buf.p = filter->elem_buf.end;
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ struct ShareExtensionView: View {
|
||||
self.share_state = .not_logged_in
|
||||
return
|
||||
}
|
||||
guard let posted_event = post.to_event(keypair: full_keypair) else {
|
||||
guard let posted_event = post.to_event(keypair: full_keypair, clientTag: state.clientTagComponents) else {
|
||||
self.share_state = .failed(error: "Cannot convert post data into a nostr event")
|
||||
return
|
||||
}
|
||||
@@ -375,4 +375,3 @@ struct ShareExtensionView: View {
|
||||
case posted(event: NostrEvent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user