Compare commits

..

21 Commits

Author SHA1 Message Date
tyiu 31e281ce73 Fix localization issues and export strings for translation
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2026-03-09 21:08:29 -04:00
transifex-integration[bot] 963da2d4eb Translate Localizable.stringsdict in pl_PL
100% translated source file: 'Localizable.stringsdict'
on 'pl_PL'.
2026-03-07 00:23:32 -05:00
alltheseas af3bb212c3 Show client tags on events
Display "via ClientName" indicator beside timestamps when an event has
a client tag, allowing users to see which nostr app published it.

Changelog-Added: Show client name on events when available
Closes: https://github.com/damus-io/damus/issues/3323
Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
2026-02-27 13:19:55 -08:00
alltheseas f3361e6eae Add client tag accessor to NdbNote
Add a clientTag property to NdbNote that parses and returns the client
tag metadata from an event's tags, enabling display of which app
published an event.

Ref: https://github.com/damus-io/damus/issues/3323
Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
2026-02-27 13:19:55 -08:00
alltheseas 7f2c575f20 Expose client tag toggle in settings
Add a Privacy section in Appearance settings allowing users to disable
client tag publishing if they prefer not to identify Damus when posting.

Ref: https://github.com/damus-io/damus/issues/3323
Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
2026-02-27 13:19:55 -08:00
alltheseas ec28822451 Add Damus client tag emission
- Add ClientTagMetadata struct with parsing helpers and documentation
- Append Damus client tags when posting across app, share, and drafts flows
- Gate the behavior behind a new publish_client_tag setting (default on)

Changelog-Added: Add client tag to published events to identify Damus
Ref: https://github.com/damus-io/damus/issues/3323
Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
2026-02-27 13:19:55 -08:00
Daniel D’Aquino 795fce1b65 Add storage usage stats settings view
This commit implements a new Storage settings view that displays storage
usage statistics for NostrDB, snapshot database, and Kingfisher image cache.

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

Changelog-Added: Storage usage statistics view in Settings
Changelog-Changed: Moved clear cache button to storage settings
Closes: https://github.com/damus-io/damus/issues/3649
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-25 15:45:37 -08:00
copilot-swe-agent[bot] 65e767b774 fix: refresh balance and transactions in WalletModel.connect() on wallet switch
When a new wallet is connected, clear stale balance and transaction data
from the previous wallet immediately so the view does not display outdated
information while fresh data is being fetched.

Closes: https://github.com/damus-io/damus/issues/3644
Changelog-Fixed: Wallet view now immediately clears stale data when switching wallets
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Co-authored-by: Daniel D’Aquino <daniel@daquino.me>
Tested-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-25 13:24:11 -08:00
Daniel D’Aquino af9956de8a v1.16.1 changelog
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-23 18:33:55 -08:00
Copilot 7be75f37c6 Fix: Gracefully ignore unsupported NWC response types (e.g. get_info)
When another NWC client (e.g. Alby) connected to the same relay calls
`get_info`, Damus receives the response and previously threw a
DecodingError.typeMismatch, causing an "Oops" error dialog to be shown.

Fix: Make `result_type` optional in `WalletConnect.Response`. Unknown
result types now decode without throwing — `result_type` and `result`
are set to `nil`, and the rest of the existing nil-guarded code paths
handle this silently.

Adds a test to verify `get_info` (and any future unknown result type)
is decoded gracefully.

Closes: #2204
Changelog-Fixed: Fixed issue where the app could display an error message when using another NWC wallet in parallel
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: danieldaquino <24692108+danieldaquino@users.noreply.github.com>
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-23 12:50:25 -08:00
ericholguin 84ef5ecf53 gifs: Tenor GIFs
This PR adds GIFs to Damus using Tenor as the service.
This is a Damus Labs feature to begin with.
In the future we should be able to also query nostr for gif media.

Changelog-Added: Added GIF keyboard support (Damus Labs only)
Signed-off-by: ericholguin <ericholguin@apache.org>
2026-02-20 17:56:04 -08:00
yse f440f37cbf tests: wallet: add encoding test for list_transactions request
This adds a test to verify that the `getTransactionList` behaves as
expected when passing a nil and not-nil type as argument.

Signed-off-by: Hydra Yse <hydra_yse@proton.me>
2026-02-18 22:52:09 -08:00
yse b59816e180 wallet: models: unset type field for list transactions request
Removes the empty `type` field from the wallet's list_transactions
request in favor of a null field, which is parsable by serializers.

Signed-off-by: Hydra Yse <hydra_yse@proton.me>
2026-02-18 22:52:09 -08:00
yse 609cdcc5f9 wallet: errors: add %@ string formatting instead of %s
This fixes a UI issue where the error message from the NWC response
would be incorrectly displayed due to utf-8 formatting.

Signed-off-by: Hydra Yse <hydra_yse@proton.me>
2026-02-18 22:33:33 -08:00
Daniel D’Aquino 59498e3256 Fix double-free crash when creating empty NdbFilter
When ndb_filter_end processes an empty filter (no fields added), it calls
realloc(filter->elem_buf.start, 0) which frees the memory and returns NULL.
The existing code only updated the pointer if realloc
returned non-NULL, leaving elem_buf.start pointing to freed memory. This
caused a double-free crash when ndb_filter_destroy later called free() on
the dangling pointer.

Fix by explicitly setting filter->elem_buf.start to NULL when realloc
returns NULL due to zero-size allocation, and update the assertion to
allow NULL pointers for empty filters. ndb_filter_destroy already checks
for NULL before freeing.

Closes: https://github.com/damus-io/damus/issues/3634
Changelog-Fixed: Fix memory corruption crash when creating empty filters
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-18 16:28:00 -08:00
Daniel D’Aquino 546e9eec32 v1.16 changelog
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-17 04:19:23 -08:00
alltheseas cfafcffde2 fix: wait for relay connection before loading nevent URLs
LoadableNostrEventViewModel.load() now calls awaitConnection() before
executeLoadingLogic(), preventing premature "not found" when opening
nevent URLs or search results before relays finish connecting.

Closes: https://github.com/damus-io/damus/pull/3559

Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
Tested-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-16 19:51:21 -08:00
Daniel D’Aquino 4099827169 Fix fulfillment call in testActionBarModel to use its async version
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-16 19:12:35 -08:00
Daniel D’Aquino 32c0177049 Fix off-by-one-error in testTimerRestartsAfterSave
No user-facing change

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-16 19:12:35 -08:00
Daniel D’Aquino 2e1a98ff19 Add useful view for note-not-found state in quoted notes
Changelog-Added: Added a view for quotes notes that could not be loaded, including actionable items
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-16 13:16:21 -08:00
Daniel D’Aquino 7fa044d205 Fix infinite loading spinner regression
Root cause:
1. `lookup` looks up a note by its note id, and saving its note key
2. `lookup` then returns early (i.e. does not loading anything from the
   network) since it found the note
3. On the view, once it borrows the note from NostrDB (a query using its
   NoteKey), the query fails (Most likely due to transaction inheritance
   and the fact that the inherited transaction may be an older snapshot
   of the database without the note), causing the view loading logic to
   fail silently, leading to the infinite loading spinner

The issue was addressed by performing a single query during lookup and
copying the note contents directly at that point to avoid this
transaction inheritance issue.

In the future we should consider a more comprehensive fix to address
other instances where this may happen. I opened
https://github.com/damus-io/damus/issues/3607 for this future work.

Changelog-Fixed: Fixed an issue where notes would keep loading indefinitely in some cases
Closes: https://github.com/damus-io/damus/issues/3498
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-16 13:16:21 -08:00
74 changed files with 4355 additions and 301 deletions
View File
+128
View File
@@ -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 DAquino)
- 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 DAquino)
- 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 DAquino)
- Notes now load offline (Daniel DAquino)
- 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 DAquino)
- Added a pull to refresh feature on DMs that allows users to resync DMs with their relays (Daniel DAquino)
### Changed
- Improved performance around note content views to prevent hangs (Daniel DAquino)
- Highlight note search results (alltheseas)
- Improved draft saving feature to prevent data loss if app closes too quickly (Daniel DAquino)
- Changed Damus Purple Side View logo and text (ericholguin)
- Placed the Favorites feature behind a feature flag (Daniel DAquino)
- Tweaked since optimization filter to capture notes that would otherwise be lost (Daniel DAquino)
- Optimized network bandwidth usage and improved timeline performance (Daniel DAquino)
- Increased transaction list limit to 50 transactions (Daniel DAquino)
- Improved loading UX in the home timeline (Daniel DAquino)
- Added UX hint to make it easier to load new notes (Daniel DAquino)
- Switched to the local relay model (Daniel DAquino)
- Reduced default zap amount and deduplicated from preset zap amount items (Terry Yiu)
- Use NostrDB for rendering note contents (Daniel DAquino)
- 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 DAquino)
- 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 DAquino)
- 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 DAquino)
- Fix mention pills falling back to @npub text when profile metadata is missing (alltheseas)
- Fixed an occasional random crash related to viewing profiles (Daniel DAquino)
- Improved robustness in the part of the code that streams notes from nostrdb (Daniel DAquino)
- Added performance improvements to timeline scrolling (Daniel DAquino)
- Improved security around note validation (Daniel DAquino)
- Fixed an issue where the app would crash when swapping between apps (Daniel DAquino)
- Fixed memory error in nostrdb (Daniel DAquino)
- 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 DAquino)
- Fixed crashes that happened when the app went into background mode (Daniel DAquino)
- 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 DAquino)
- 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 DAquino)
### Removed
- Removed "Load new content" button (Daniel DAquino)
- Wallet view no longer hangs on loading placeholder (Daniel DAquino)
- Fixed issue where the app would occasionally launch an empty universe view (Daniel DAquino)
- Profile action sheet buttons now center properly when fewer than 5 buttons are displayed (Daniel DAquino)
- Fixed an issue where DMs may not appear for users with a large contact list (Daniel DAquino)
- Fixed an issue that could cause certain networking operations to hang indefinitely (Daniel DAquino)
- Fixed a race condition in the networking logic that could cause notes to get missed in certain rare scenarios (Daniel DAquino)
- Fixed a crash on iOS 17 that would happen on startup (Daniel DAquino)
[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.
+90
View File
@@ -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
View File
@@ -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
}
}
}
+44
View File
@@ -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
+8
View File
@@ -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 DAquino on 2026-02-20.
//
import Foundation
import Kingfisher
/// Storage statistics for various Damus data stores
struct StorageStats: Hashable {
/// Detailed breakdown of NostrDB storage by kind, indices, and other
let nostrdbDetails: NdbStats?
/// Size of the main NostrDB database file in bytes (total)
let nostrdbSize: UInt64
/// Size of the snapshot NostrDB database file in bytes
let snapshotSize: UInt64
/// Size of the Kingfisher image cache in bytes
let imageCacheSize: UInt64
/// Total storage used across all data stores
var totalSize: UInt64 {
return nostrdbSize + snapshotSize + imageCacheSize
}
/// Calculate the percentage of total storage used by a specific size
/// - Parameter size: The size to calculate percentage for
/// - Returns: Percentage value between 0.0 and 100.0
func percentage(for size: UInt64) -> Double {
guard totalSize > 0 else { return 0.0 }
return Double(size) / Double(totalSize) * 100.0
}
}
/// Manager for calculating storage statistics across Damus data stores
struct StorageStatsManager {
static let shared = StorageStatsManager()
private init() {}
/// Calculate storage statistics for all Damus data stores
///
/// This method runs all file operations on a background thread to avoid blocking
/// the main thread. It calculates:
/// - NostrDB database file size
/// - Detailed NostrDB breakdown (if ndb instance provided)
/// - Snapshot database file size
/// - Kingfisher image cache size
///
/// - Parameter ndb: Optional Ndb instance to get detailed storage breakdown
/// - Returns: StorageStats containing all calculated sizes
/// - Throws: Error if critical file operations fail
func calculateStorageStats(ndb: Ndb? = nil) async throws -> StorageStats {
// Run all file operations on background thread
return try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
do {
let nostrdbSize = self.getNostrDBSize()
let snapshotSize = self.getSnapshotDBSize()
// Get detailed NostrDB stats if ndb instance provided
let nostrdbDetails: NdbStats? = ndb?.getStats(physicalSize: nostrdbSize)
// Kingfisher cache size requires async callback
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
let imageCacheSize: UInt64
switch result {
case .success(let size):
imageCacheSize = UInt64(size)
case .failure(let error):
Log.error("Failed to calculate Kingfisher cache size: %@", for: .storage, error.localizedDescription)
imageCacheSize = 0
}
let stats = StorageStats(
nostrdbDetails: nostrdbDetails,
nostrdbSize: nostrdbSize,
snapshotSize: snapshotSize,
imageCacheSize: imageCacheSize
)
continuation.resume(returning: stats)
}
} catch {
continuation.resume(throwing: error)
}
}
}
}
/// Get the size of the main NostrDB database file
/// - Returns: Size in bytes, or 0 if file doesn't exist or error occurs
private func getNostrDBSize() -> UInt64 {
guard let dbPath = Ndb.db_path else {
Log.error("Failed to get NostrDB path", for: .storage)
return 0
}
let dataFilePath = "\(dbPath)/\(Ndb.main_db_file_name)"
return getFileSize(at: dataFilePath, description: "NostrDB")
}
/// Get the size of the snapshot NostrDB database file
/// - Returns: Size in bytes, or 0 if file doesn't exist or error occurs
private func getSnapshotDBSize() -> UInt64 {
guard let snapshotPath = Ndb.snapshot_db_path else {
Log.error("Failed to get snapshot DB path", for: .storage)
return 0
}
let dataFilePath = "\(snapshotPath)/\(Ndb.main_db_file_name)"
return getFileSize(at: dataFilePath, description: "Snapshot DB")
}
/// Get the size of a file at the specified path
/// - Parameters:
/// - path: Full path to the file
/// - description: Human-readable description for logging
/// - Returns: Size in bytes, or 0 if file doesn't exist or error occurs
private func getFileSize(at path: String, description: String) -> UInt64 {
guard FileManager.default.fileExists(atPath: path) else {
Log.info("%@ file does not exist at path: %@", for: .storage, description, path)
return 0
}
do {
let attributes = try FileManager.default.attributesOfItem(atPath: path)
guard let fileSize = attributes[.size] as? UInt64 else {
Log.error("Failed to get size attribute for %@", for: .storage, description)
return 0
}
return fileSize
} catch {
Log.error("Failed to get file size for %@: %@", for: .storage, description, error.localizedDescription)
return 0
}
}
/// Format bytes into a human-readable string
/// - Parameter bytes: Number of bytes
/// - Returns: Formatted string (e.g., "45.3 MB", "1.2 GB")
static func formatBytes(_ bytes: UInt64) -> String {
let formatter = ByteCountFormatter()
formatter.allowedUnits = [.useAll]
formatter.countStyle = .file
formatter.includesUnit = true
formatter.isAdaptive = true
return formatter.string(fromByteCount: Int64(bytes))
}
}
@@ -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)
}
}
+93 -28
View File
@@ -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.
+6 -6
View File
@@ -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)
}
+16 -2
View File
@@ -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 DAquino on 2026-02-20.
//
import SwiftUI
import Charts
fileprivate let CACHE_CLEAR_BUTTON_RESET_TIME_IN_SECONDS: Double = 60
fileprivate let MINIMUM_CACHE_CLEAR_BUTTON_DELAY_IN_SECONDS: Double = 1
/// A simple type to keep track of the cache clearing state
fileprivate enum CacheClearingState {
case not_cleared
case clearing
case cleared
}
/// Storage category for display in list and chart
struct StorageCategory: Identifiable {
let id: String
let title: String
let icon: String
let color: Color
let size: UInt64
var range: Range<Double> {
return 0..<Double(size)
}
}
/// Settings view displaying storage usage statistics for Damus data stores
struct StorageSettingsView: View {
let damus_state: DamusState
@ObservedObject var settings: UserSettingsStore
@Environment(\.dismiss) var dismiss
@State private var stats: StorageStats?
@State private var isLoading: Bool = false
@State private var error: String?
@State private var selectedAngle: Double?
@State private var showShareSheet: Bool = false
@State private var exportText: String?
@State private var isPreparingExport: Bool = false
@State fileprivate var cache_clearing_state: CacheClearingState = .not_cleared
@State var showing_cache_clear_alert: Bool = false
/// Storage categories with cumulative ranges for angle selection (iOS 17+)
private var categoryRanges: [(category: String, range: Range<Double>)] {
guard let stats = stats else { return [] }
return StorageStatsViewHelper.computeCategoryRanges(for: categories)
}
/// Selected storage category based on pie chart interaction (iOS 17+)
private var selectedCategory: StorageCategory? {
guard let selectedAngle = selectedAngle else { return nil }
if let selectedIndex = categoryRanges.firstIndex(where: { $0.range.contains(selectedAngle) }) {
return categories[selectedIndex]
}
return nil
}
/// All storage categories for display (top-level view)
private var categories: [StorageCategory] {
guard let stats = stats else { return [] }
return [
StorageCategory(
id: "nostrdb",
title: NSLocalizedString("NostrDB", comment: "Label for main NostrDB database"),
icon: "internaldrive.fill",
color: .blue,
size: stats.nostrdbSize
),
StorageCategory(
id: "snapshot",
title: NSLocalizedString("Snapshot Database", comment: "Label for snapshot database"),
icon: "doc.on.doc.fill",
color: .purple,
size: stats.snapshotSize
),
StorageCategory(
id: "cache",
title: NSLocalizedString("Image Cache", comment: "Label for Kingfisher image cache"),
icon: "photo.fill",
color: .orange,
size: stats.imageCacheSize
)
]
}
var body: some View {
Form {
// Chart Section (iOS 17+ only)
if let stats = stats {
if #available(iOS 17.0, *) {
Section {
StoragePieChart(
categories: categories,
selectedAngle: $selectedAngle,
selectedCategory: selectedCategory,
totalSize: stats.totalSize
)
.frame(height: 300)
.padding(.vertical)
}
}
// Categories List
Section {
ForEach(categories) { category in
if category.id == "nostrdb", stats.nostrdbDetails != nil {
// NostrDB is drillable when we have detailed stats
NavigationLink(value: Route.NostrDBStorageDetail(stats: stats)) {
if #available(iOS 17.0, *) {
StorageCategoryRow(
category: category,
percentage: stats.percentage(for: category.size),
isSelected: selectedCategory?.id == category.id
)
} else {
StorageCategoryRow(
category: category,
percentage: stats.percentage(for: category.size),
isSelected: false
)
}
}
} else {
// Other categories are not drillable
if #available(iOS 17.0, *) {
StorageCategoryRow(
category: category,
percentage: stats.percentage(for: category.size),
isSelected: selectedCategory?.id == category.id
)
} else {
StorageCategoryRow(
category: category,
percentage: stats.percentage(for: category.size),
isSelected: false
)
}
}
}
}
// Total at bottom
Section {
HStack {
Text("Total Storage", comment: "Label for total storage used")
.font(.headline)
Spacer()
Text(StorageStatsManager.formatBytes(stats.totalSize))
.foregroundColor(.secondary)
.font(.headline)
}
}
// Clear Cache Section
Section {
self.ClearCacheButton
}
}
// Loading state
if isLoading {
Section {
HStack {
Spacer()
ProgressView()
Spacer()
}
}
}
// Error state
if let error = error {
Section {
Text(error)
.foregroundColor(.red)
.font(.caption)
}
}
}
.padding(.bottom, 50)
.navigationTitle(NSLocalizedString("Storage", comment: "Navigation title for storage settings"))
.toolbar {
if stats != nil {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { Task { await prepareExport() } }) {
if isPreparingExport {
ProgressView()
} else {
Image(systemName: "square.and.arrow.up")
}
}
.disabled(isPreparingExport)
}
}
}
.sheet(isPresented: $showShareSheet) {
if let exportText = exportText {
TextShareSheet(activityItems: [exportText])
}
}
.refreshable {
await loadStorageStatsAsync()
}
.onAppear {
if stats == nil {
loadStorageStats()
}
}
.onReceive(handle_notify(.switched_timeline)) { _ in
dismiss()
}
}
/// Prepare export text on background thread before showing share sheet
@concurrent
private func prepareExport() async {
// Capture all relevant @State in one MainActor.run
let (shouldProceed, statsSnapshot): (Bool, StorageStats?) = await MainActor.run {
let hasStats = stats != nil
let notAlreadyPreparing = !isPreparingExport
if hasStats && notAlreadyPreparing {
isPreparingExport = true
return (true, stats)
} else {
return (false, nil)
}
}
guard shouldProceed, let statsSnapshot else { return }
// Format text on background thread using shared helper
let text = await StorageStatsViewHelper.formatStorageStatsAsText(statsSnapshot)
// Update UI on main thread
await MainActor.run {
self.exportText = text
self.isPreparingExport = false
self.showShareSheet = true
}
}
/// Load storage statistics on a background thread (for onAppear)
private func loadStorageStats() {
guard !isLoading else { return }
isLoading = true
error = nil
Task {
await loadStorageStatsAsync()
}
}
/// Load storage statistics asynchronously (for refreshable)
@concurrent
private func loadStorageStatsAsync() async {
await MainActor.run {
isLoading = true
error = nil
}
do {
let calculatedStats = try await StorageStatsViewHelper.loadStorageStatsAsync(ndb: damus_state.ndb)
await MainActor.run {
self.stats = calculatedStats
self.isLoading = false
}
} catch {
await MainActor.run {
self.error = String(format: NSLocalizedString("Failed to calculate storage: %@", comment: "Error message when storage calculation fails"), error.localizedDescription)
self.isLoading = false
}
}
}
/// Clear cache button action with loading state management
func clear_cache_button_action() {
cache_clearing_state = .clearing
let group = DispatchGroup()
group.enter()
DamusCacheManager.shared.clear_cache(damus_state: self.damus_state, completion: {
group.leave()
})
// Make clear cache button take at least a second or so to avoid issues with labor perception bias (https://growth.design/case-studies/labor-perception-bias)
group.enter()
DispatchQueue.main.asyncAfter(deadline: .now() + MINIMUM_CACHE_CLEAR_BUTTON_DELAY_IN_SECONDS) {
group.leave()
}
group.notify(queue: .main) {
cache_clearing_state = .cleared
// Refresh storage stats after clearing cache
loadStorageStats()
DispatchQueue.main.asyncAfter(deadline: .now() + CACHE_CLEAR_BUTTON_RESET_TIME_IN_SECONDS) {
cache_clearing_state = .not_cleared
}
}
}
/// Clear cache button view with confirmation dialog
var ClearCacheButton: some View {
Button(action: { self.showing_cache_clear_alert = true }, label: {
HStack(spacing: 6) {
switch cache_clearing_state {
case .not_cleared:
Text("Clear Cache", comment: "Button to clear image cache.")
case .clearing:
ProgressView()
Text("Clearing Cache", comment: "Loading message indicating that the cache is being cleared.")
case .cleared:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Cache has been cleared", comment: "Message indicating that the cache was successfully cleared.")
}
}
})
.disabled(self.cache_clearing_state != .not_cleared)
.alert(isPresented: $showing_cache_clear_alert) {
Alert(title: Text("Confirmation", comment: "Confirmation dialog title"),
message: Text("Are you sure you want to clear the cache? This will free space, but images may take longer to load again.", comment: "Message explaining what it means to clear the cache, asking if user wants to proceed."),
primaryButton: .default(Text("OK", comment: "Button label indicating user wants to proceed.")) {
self.clear_cache_button_action()
},
secondaryButton: .cancel())
}
}
}
/// Pie chart displaying storage usage distribution (iOS 17+)
@available(iOS 17.0, *)
struct StoragePieChart: View {
let categories: [StorageCategory]
@Binding var selectedAngle: Double?
let selectedCategory: StorageCategory?
let totalSize: UInt64
var body: some View {
Chart(categories) { category in
SectorMark(
angle: .value(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()
}
+14
View File
@@ -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"]
}
+280
View File
@@ -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)")
}
}
+112
View File
@@ -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)
}
}
}
+53
View File
@@ -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
}
}
+11
View File
@@ -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)
+2 -2
View File
@@ -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"
}
@@ -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, youre 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 whats 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"
}
@@ -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"
}
+2 -2
View File
@@ -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"
}
+80 -20
View File
@@ -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>
+1 -1
View File
@@ -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")
}
}
+4 -2
View File
@@ -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 {
+14
View File
@@ -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)
}
}
+20 -2
View File
@@ -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) {
})
}
+537
View File
@@ -0,0 +1,537 @@
//
// StorageStatsManagerTests.swift
// damusTests
//
// Created by OpenCode on 2026-02-25.
//
import XCTest
@testable import damus
import Kingfisher
/// Comprehensive test suite for storage usage calculation logic
///
/// Tests cover:
/// - StorageStats calculations (total size, percentages)
/// - File size calculations with temporary test files
/// - Async storage stats calculations
/// - Byte formatting utilities
/// - Ndb.getStats() database statistics
/// - Integration between components
/// - Thread safety and error handling
final class StorageStatsManagerTests: XCTestCase {
var tempDirectory: URL!
var mockNostrDBPath: String!
var mockSnapshotPath: String!
override func setUp() {
super.setUp()
// Create temporary directory for test files
tempDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent("StorageStatsManagerTests-\(UUID().uuidString)")
try? FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
// Create mock database directories
let nostrDBDir = tempDirectory.appendingPathComponent("nostrdb")
let snapshotDir = tempDirectory.appendingPathComponent("snapshot")
try? FileManager.default.createDirectory(at: nostrDBDir, withIntermediateDirectories: true)
try? FileManager.default.createDirectory(at: snapshotDir, withIntermediateDirectories: true)
mockNostrDBPath = nostrDBDir.path
mockSnapshotPath = snapshotDir.path
}
override func tearDown() {
// Clean up temporary files
if let tempDirectory = tempDirectory {
try? FileManager.default.removeItem(at: tempDirectory)
}
tempDirectory = nil
mockNostrDBPath = nil
mockSnapshotPath = nil
super.tearDown()
}
// MARK: - Helper Methods
/// Create a temporary file with specified size
/// - Parameters:
/// - path: Full path for the file
/// - size: Size in bytes
private func createTestFile(at path: String, size: UInt64) throws {
let data = Data(repeating: 0, count: Int(size))
try data.write(to: URL(fileURLWithPath: path))
}
/// Get file size using FileManager (reference implementation)
private func getActualFileSize(at path: String) -> UInt64? {
guard FileManager.default.fileExists(atPath: path) else { return nil }
do {
let attributes = try FileManager.default.attributesOfItem(atPath: path)
return attributes[.size] as? UInt64
} catch {
return nil
}
}
// MARK: - 1. StorageStats Structure Tests
/// Test that totalSize correctly sums all storage components
func testTotalSizeCalculation() {
let stats = StorageStats(
nostrdbDetails: nil,
nostrdbSize: 1000,
snapshotSize: 500,
imageCacheSize: 250
)
XCTAssertEqual(stats.totalSize, 1750, "Total size should sum all components")
}
/// Test percentage calculation accuracy
func testPercentageCalculation() {
let stats = StorageStats(
nostrdbDetails: nil,
nostrdbSize: 600,
snapshotSize: 300,
imageCacheSize: 100
)
// Total = 1000, so 600 should be 60%
let nostrdbPercentage = stats.percentage(for: 600)
XCTAssertEqual(nostrdbPercentage, 60.0, accuracy: 0.01, "NostrDB should be 60% of total")
let snapshotPercentage = stats.percentage(for: 300)
XCTAssertEqual(snapshotPercentage, 30.0, accuracy: 0.01, "Snapshot should be 30% of total")
let cachePercentage = stats.percentage(for: 100)
XCTAssertEqual(cachePercentage, 10.0, accuracy: 0.01, "Cache should be 10% of total")
}
/// Test percentage calculation when total is zero (edge case)
func testPercentageWithZeroTotal() {
let stats = StorageStats(
nostrdbDetails: nil,
nostrdbSize: 0,
snapshotSize: 0,
imageCacheSize: 0
)
let percentage = stats.percentage(for: 100)
XCTAssertEqual(percentage, 0.0, "Percentage should be 0 when total is 0")
}
/// Test that StorageStats conforms to Hashable properly
func testStorageStatsHashableConformance() {
let stats1 = StorageStats(
nostrdbDetails: nil,
nostrdbSize: 1000,
snapshotSize: 500,
imageCacheSize: 250
)
let stats2 = StorageStats(
nostrdbDetails: nil,
nostrdbSize: 1000,
snapshotSize: 500,
imageCacheSize: 250
)
let stats3 = StorageStats(
nostrdbDetails: nil,
nostrdbSize: 2000,
snapshotSize: 500,
imageCacheSize: 250
)
// Equal stats should be equal and have same hash
XCTAssertEqual(stats1, stats2, "Identical stats should be equal")
XCTAssertEqual(stats1.hashValue, stats2.hashValue, "Equal stats should have same hash")
// Different stats should not be equal
XCTAssertNotEqual(stats1, stats3, "Different stats should not be equal")
// Should work in Set
let set: Set<StorageStats> = [stats1, stats2, stats3]
XCTAssertEqual(set.count, 2, "Set should contain 2 unique stats")
}
// MARK: - 2. File Size Calculation Tests
/// Test file size calculation with an existing file
func testGetFileSizeWithExistingFile() throws {
let testFilePath = tempDirectory.appendingPathComponent("test-file.dat").path
let expectedSize: UInt64 = 1024 * 1024 // 1 MB
// Create test file with known size
try createTestFile(at: testFilePath, size: expectedSize)
// Verify file was created correctly
let actualSize = getActualFileSize(at: testFilePath)
XCTAssertNotNil(actualSize, "Test file should exist")
XCTAssertEqual(actualSize, expectedSize, "Test file should have expected size")
}
/// Test file size calculation when file doesn't exist (should return 0)
func testGetFileSizeWithNonexistentFile() {
let nonexistentPath = tempDirectory.appendingPathComponent("nonexistent.dat").path
// Verify file doesn't exist
XCTAssertFalse(FileManager.default.fileExists(atPath: nonexistentPath), "File should not exist")
let size = getActualFileSize(at: nonexistentPath)
XCTAssertNil(size, "Size should be nil for nonexistent file")
}
/// Test NostrDB file size calculation with valid path
func testGetNostrDBSizeWithValidPath() throws {
let dbFilePath = "\(mockNostrDBPath!)/\(Ndb.main_db_file_name)"
let expectedSize: UInt64 = 5 * 1024 * 1024 // 5 MB
// Create mock database file
try createTestFile(at: dbFilePath, size: expectedSize)
// Verify file size can be retrieved
let actualSize = getActualFileSize(at: dbFilePath)
XCTAssertNotNil(actualSize, "DB file should exist")
XCTAssertEqual(actualSize, expectedSize, "DB file should have expected size")
}
/// Test snapshot database file size calculation with valid path
func testGetSnapshotDBSizeWithValidPath() throws {
let dbFilePath = "\(mockSnapshotPath!)/\(Ndb.main_db_file_name)"
let expectedSize: UInt64 = 2 * 1024 * 1024 // 2 MB
// Create mock snapshot database file
try createTestFile(at: dbFilePath, size: expectedSize)
// Verify file size can be retrieved
let actualSize = getActualFileSize(at: dbFilePath)
XCTAssertNotNil(actualSize, "Snapshot DB file should exist")
XCTAssertEqual(actualSize, expectedSize, "Snapshot DB file should have expected size")
}
// MARK: - 3. Byte Formatting Tests
/// Test formatting of zero bytes
func testFormatBytesZero() {
let formatted = StorageStatsManager.formatBytes(0)
// ByteCountFormatter may format as "Zero bytes", "0 bytes", "0 KB", etc.
// We just verify it's a valid non-empty string
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
// Most common formats include "0" or "Zero"
let containsZero = formatted.contains("0") || formatted.uppercased().contains("ZERO")
XCTAssertTrue(containsZero, "Zero bytes should contain '0' or 'Zero', got: \(formatted)")
}
/// Test formatting of small byte values (< 1 KB)
func testFormatBytesSmall() {
let formatted = StorageStatsManager.formatBytes(512)
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
// Should contain a numeric value
XCTAssertTrue(formatted.contains("512") || formatted.contains("0.5"), "Should contain size value")
}
/// Test formatting of kilobyte values
func testFormatBytesKilobytes() {
let oneKB: UInt64 = 1024
let formatted = StorageStatsManager.formatBytes(oneKB * 5) // 5 KB
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
// Should mention KB or kilobytes
XCTAssertTrue(formatted.uppercased().contains("KB") || formatted.uppercased().contains("K"),
"Should indicate kilobytes: \(formatted)")
}
/// Test formatting of megabyte values
func testFormatBytesMegabytes() {
let oneMB: UInt64 = 1024 * 1024
let formatted = StorageStatsManager.formatBytes(oneMB * 10) // 10 MB
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
// Should mention MB or megabytes
XCTAssertTrue(formatted.uppercased().contains("MB") || formatted.uppercased().contains("M"),
"Should indicate megabytes: \(formatted)")
}
/// Test formatting of gigabyte values
func testFormatBytesGigabytes() {
let oneGB: UInt64 = 1024 * 1024 * 1024
let formatted = StorageStatsManager.formatBytes(oneGB * 2) // 2 GB
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
// Should mention GB or gigabytes
XCTAssertTrue(formatted.uppercased().contains("GB") || formatted.uppercased().contains("G"),
"Should indicate gigabytes: \(formatted)")
}
/// Test formatting of very large values
func testFormatBytesLarge() {
let oneTB: UInt64 = 1024 * 1024 * 1024 * 1024
let formatted = StorageStatsManager.formatBytes(oneTB)
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
// Should handle terabyte values gracefully
XCTAssertTrue(formatted.uppercased().contains("TB") || formatted.uppercased().contains("T") ||
formatted.uppercased().contains("GB") || formatted.uppercased().contains("G"),
"Should format large values: \(formatted)")
}
// MARK: - 4. Async Storage Stats Calculation Tests
/// Test storage stats calculation without Ndb instance
func testCalculateStorageStatsWithoutNdb() async throws {
// Note: This test verifies the calculation succeeds and returns valid stats
// We don't check exact values since they depend on actual system state
let stats = try await StorageStatsManager.shared.calculateStorageStats(ndb: nil)
// Verify stats structure is valid
XCTAssertNotNil(stats, "Stats should not be nil")
XCTAssertNil(stats.nostrdbDetails, "Details should be nil when no Ndb provided")
// All sizes should be non-negative
XCTAssertGreaterThanOrEqual(stats.nostrdbSize, 0, "NostrDB size should be non-negative")
XCTAssertGreaterThanOrEqual(stats.snapshotSize, 0, "Snapshot size should be non-negative")
XCTAssertGreaterThanOrEqual(stats.imageCacheSize, 0, "Image cache size should be non-negative")
// Total should equal sum
let expectedTotal = stats.nostrdbSize + stats.snapshotSize + stats.imageCacheSize
XCTAssertEqual(stats.totalSize, expectedTotal, "Total should equal sum of components")
}
// MARK: - 5. NdbDatabaseStats Tests
/// Test NdbDatabaseStats total size calculation
func testNdbDatabaseStatsCalculations() {
let dbStats = NdbDatabaseStats(
database: .note,
keySize: 1000,
valueSize: 5000
)
XCTAssertEqual(dbStats.totalSize, 6000, "Total should be key + value size")
XCTAssertEqual(dbStats.database, .note, "Database type should be preserved")
XCTAssertEqual(dbStats.keySize, 1000, "Key size should be preserved")
XCTAssertEqual(dbStats.valueSize, 5000, "Value size should be preserved")
}
/// Test NdbStats total size calculation
func testNdbStatsTotalCalculation() {
let stats = NdbStats(databaseStats: [
NdbDatabaseStats(database: .note, keySize: 1000, valueSize: 5000),
NdbDatabaseStats(database: .profile, keySize: 500, valueSize: 2000),
NdbDatabaseStats(database: .noteId, keySize: 200, valueSize: 800)
])
// Total should be sum of all database totals
// (1000+5000) + (500+2000) + (200+800) = 9500
XCTAssertEqual(stats.totalSize, 9500, "Total should sum all database sizes")
}
/// Test NdbStats with empty database list
func testNdbStatsEmpty() {
let stats = NdbStats(databaseStats: [])
XCTAssertEqual(stats.totalSize, 0, "Empty stats should have zero total")
XCTAssertTrue(stats.databaseStats.isEmpty, "Database stats should be empty")
}
/// Test NdbDatabaseStats hashable conformance
func testNdbDatabaseStatsHashableConformance() {
let stats1 = NdbDatabaseStats(database: .note, keySize: 1000, valueSize: 5000)
let stats2 = NdbDatabaseStats(database: .note, keySize: 1000, valueSize: 5000)
let stats3 = NdbDatabaseStats(database: .profile, keySize: 1000, valueSize: 5000)
XCTAssertEqual(stats1, stats2, "Identical stats should be equal")
XCTAssertNotEqual(stats1, stats3, "Different database type should not be equal")
// Should work in Set
let set: Set<NdbDatabaseStats> = [stats1, stats2, stats3]
XCTAssertEqual(set.count, 2, "Set should contain 2 unique stats")
}
/// Test NdbStats hashable conformance
func testNdbStatsHashableConformance() {
let dbStats1 = NdbDatabaseStats(database: .note, keySize: 1000, valueSize: 5000)
let dbStats2 = NdbDatabaseStats(database: .profile, keySize: 500, valueSize: 2000)
let stats1 = NdbStats(databaseStats: [dbStats1, dbStats2])
let stats2 = NdbStats(databaseStats: [dbStats1, dbStats2])
let stats3 = NdbStats(databaseStats: [dbStats1])
XCTAssertEqual(stats1, stats2, "Identical stats should be equal")
XCTAssertNotEqual(stats1, stats3, "Different database count should not be equal")
// Should work in Set
let set: Set<NdbStats> = [stats1, stats2, stats3]
XCTAssertEqual(set.count, 2, "Set should contain 2 unique stats")
}
// MARK: - 6. NdbDatabase Enum Tests
/// Test NdbDatabase display names
func testNdbDatabaseDisplayNames() {
// Display names include the C enum names in parentheses
XCTAssertEqual(NdbDatabase.note.displayName, "Notes (NDB_DB_NOTE)", "Note database display name")
XCTAssertEqual(NdbDatabase.profile.displayName, "Profiles (NDB_DB_PROFILE)", "Profile database display name")
XCTAssertEqual(NdbDatabase.noteBlocks.displayName, "Note Blocks", "Note blocks display name")
XCTAssertEqual(NdbDatabase.noteId.displayName, "Note ID Index", "Note ID index display name")
XCTAssertEqual(NdbDatabase.meta.displayName, "Metadata (NDB_DB_META)", "Metadata display name")
XCTAssertEqual(NdbDatabase.other.displayName, "Other Data", "Other data display name")
}
/// Test NdbDatabase icons
func testNdbDatabaseIcons() {
// Verify each database has an icon (non-empty string)
XCTAssertFalse(NdbDatabase.note.icon.isEmpty, "Note should have icon")
XCTAssertFalse(NdbDatabase.profile.icon.isEmpty, "Profile should have icon")
XCTAssertFalse(NdbDatabase.noteBlocks.icon.isEmpty, "Note blocks should have icon")
XCTAssertFalse(NdbDatabase.other.icon.isEmpty, "Other should have icon")
}
/// Test NdbDatabase colors
func testNdbDatabaseColors() {
// Verify each database has a color assigned
// We can't easily compare Color values, but we can verify they return Color instances
_ = NdbDatabase.note.color
_ = NdbDatabase.profile.color
_ = NdbDatabase.noteBlocks.color
_ = NdbDatabase.other.color
// If we get here without crashes, colors are working
XCTAssertTrue(true, "All database colors should be accessible")
}
/// Test NdbDatabase initialization from index
func testNdbDatabaseFromIndex() {
// Test valid indices
let db0 = NdbDatabase(fromIndex: 0)
XCTAssertNotEqual(db0, .other, "Index 0 should map to a valid database")
let db1 = NdbDatabase(fromIndex: 1)
XCTAssertNotEqual(db1, .other, "Index 1 should map to a valid database")
// Test invalid index (should default to .other)
let dbInvalid = NdbDatabase(fromIndex: 9999)
XCTAssertEqual(dbInvalid, .other, "Invalid index should default to .other")
}
// MARK: - 7. Integration Tests
/// Test complete storage stats flow with real-ish data
func testStorageStatsIntegrationFlow() async throws {
// This test verifies the entire flow works end-to-end
// We use actual calculation but don't assert specific values
let stats = try await StorageStatsManager.shared.calculateStorageStats(ndb: nil)
// Verify structure
XCTAssertNotNil(stats, "Stats should be calculated")
// Verify all components are accessible
let _ = stats.nostrdbSize
let _ = stats.snapshotSize
let _ = stats.imageCacheSize
let _ = stats.totalSize
// Verify percentage calculation works
if stats.totalSize > 0 {
let percentage = stats.percentage(for: stats.nostrdbSize)
XCTAssertGreaterThanOrEqual(percentage, 0.0, "Percentage should be non-negative")
XCTAssertLessThanOrEqual(percentage, 100.0, "Percentage should not exceed 100%")
}
// Verify formatting works
let formatted = StorageStatsManager.formatBytes(stats.totalSize)
XCTAssertFalse(formatted.isEmpty, "Formatted size should not be empty")
}
/// Test concurrent stats calculations (thread safety)
func testConcurrentStatsCalculations() async throws {
let iterations = 5
// Launch multiple concurrent calculations
try await withThrowingTaskGroup(of: StorageStats.self) { group in
for _ in 0..<iterations {
group.addTask {
return try await StorageStatsManager.shared.calculateStorageStats(ndb: nil)
}
}
var results: [StorageStats] = []
for try await stats in group {
results.append(stats)
}
XCTAssertEqual(results.count, iterations, "Should complete all calculations")
// All results should have valid structure
for stats in results {
XCTAssertGreaterThanOrEqual(stats.nostrdbSize, 0, "NostrDB size should be valid")
XCTAssertGreaterThanOrEqual(stats.snapshotSize, 0, "Snapshot size should be valid")
XCTAssertGreaterThanOrEqual(stats.imageCacheSize, 0, "Cache size should be valid")
}
}
}
/// Test storage stats with extreme UInt64 values, including sum at UInt64 boundary (no overflow)
func testStorageStatsExtremeValues() {
// Case: Sum at UInt64 boundary (no overflow)
// UInt64.max - 2 + 1 + 1 == UInt64.max
let maxStats = StorageStats(
nostrdbDetails: nil,
nostrdbSize: UInt64.max - 2,
snapshotSize: 1,
imageCacheSize: 1
)
// Verify correct summation at UInt64 boundary
XCTAssertEqual(maxStats.totalSize, UInt64.max, "Total should be exactly UInt64.max at boundary; no overflow should occur")
// Verify percentage calculation for each component
XCTAssertEqual(maxStats.percentage(for: UInt64.max - 2), (Double(UInt64.max - 2) / Double(UInt64.max)) * 100.0, accuracy: 0.0001)
XCTAssertEqual(maxStats.percentage(for: 1), (1.0 / Double(UInt64.max)) * 100.0, accuracy: 0.0001)
// All zeros case (already tested elsewhere, but included for completeness)
let zeroStats = StorageStats(
nostrdbDetails: nil,
nostrdbSize: 0,
snapshotSize: 0,
imageCacheSize: 0
)
XCTAssertEqual(zeroStats.totalSize, 0, "Zero stats should have zero total")
XCTAssertEqual(zeroStats.percentage(for: 0), 0.0, "Zero percentage for zero total")
// If overflow handling should be explicitly tested, add a comment. With current implementation, overflow cannot occur for UInt64 sums with three terms.
// If more than three terms or arbitrary user input are ever summed, consider adding explicit overflow guards.
}
/// Test byte formatter with various edge cases
func testFormatBytesEdgeCases() {
// Powers of 1024
let formatted1K = StorageStatsManager.formatBytes(1024)
XCTAssertFalse(formatted1K.isEmpty, "Should format 1KB")
let formatted1M = StorageStatsManager.formatBytes(1024 * 1024)
XCTAssertFalse(formatted1M.isEmpty, "Should format 1MB")
let formatted1G = StorageStatsManager.formatBytes(1024 * 1024 * 1024)
XCTAssertFalse(formatted1G.isEmpty, "Should format 1GB")
// Odd values
let formatted999 = StorageStatsManager.formatBytes(999)
XCTAssertFalse(formatted999.isEmpty, "Should format 999 bytes")
let formatted1023 = StorageStatsManager.formatBytes(1023)
XCTAssertFalse(formatted1023.isEmpty, "Should format 1023 bytes")
}
}
+53
View File
@@ -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
View File
@@ -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 }
}
}
+91
View File
@@ -0,0 +1,91 @@
//
// NdbDatabase+UI.swift
// (UI/Features target)
//
// This extension adds UI-specific properties to NdbDatabase for presentation purposes.
// It should only be included in targets involving SwiftUI/UI presentation.
//
import SwiftUI
extension NdbDatabase {
/// Human-readable database name
var displayName: String {
switch self {
case .note:
return NSLocalizedString("Notes (NDB_DB_NOTE)", comment: "Database name for notes")
case .meta:
return NSLocalizedString("Metadata (NDB_DB_META)", comment: "Database name for metadata")
case .profile:
return NSLocalizedString("Profiles (NDB_DB_PROFILE)", comment: "Database name for profiles")
case .noteId:
return NSLocalizedString("Note ID Index", comment: "Database name for note ID index")
case .profileKey:
return NSLocalizedString("Profile Key Index", comment: "Database name for profile key index")
case .ndbMeta:
return NSLocalizedString("NostrDB Metadata", comment: "Database name for NostrDB metadata")
case .profileSearch:
return NSLocalizedString("Profile Search Index", comment: "Database name for profile search")
case .profileLastFetch:
return NSLocalizedString("Profile Last Fetch", comment: "Database name for profile last fetch")
case .noteKind:
return NSLocalizedString("Note Kind Index", comment: "Database name for note kind index")
case .noteText:
return NSLocalizedString("Note Text Index", comment: "Database name for note text index")
case .noteBlocks:
return NSLocalizedString("Note Blocks", comment: "Database name for note blocks")
case .noteTags:
return NSLocalizedString("Note Tags Index", comment: "Database name for note tags index")
case .notePubkey:
return NSLocalizedString("Note Pubkey Index", comment: "Database name for note pubkey index")
case .notePubkeyKind:
return NSLocalizedString("Note Pubkey+Kind Index", comment: "Database name for note pubkey+kind index")
case .noteRelayKind:
return NSLocalizedString("Note Relay+Kind Index", comment: "Database name for note relay+kind index")
case .noteRelays:
return NSLocalizedString("Note Relays", comment: "Database name for note relays")
case .other:
return NSLocalizedString("Other Data", comment: "Database name for other/unaccounted data")
}
}
/// SF Symbol icon name for this database type
var icon: String {
switch self {
case .note:
return "text.bubble.fill"
case .profile:
return "person.circle.fill"
case .meta, .ndbMeta:
return "info.circle.fill"
case .noteBlocks:
return "square.stack.3d.up.fill"
case .noteId, .profileKey, .profileSearch, .noteKind, .noteText, .noteTags, .notePubkey, .notePubkeyKind, .noteRelayKind:
return "list.bullet.indent"
case .noteRelays:
return "antenna.radiowaves.left.and.right"
case .profileLastFetch, .other:
return "internaldrive.fill"
}
}
/// Color for chart and UI display
var color: Color {
switch self {
case .note:
return .green
case .profile:
return .blue
case .noteBlocks:
return .purple
case .meta, .ndbMeta:
return .orange
case .noteId, .profileKey, .profileSearch, .noteKind, .noteText, .noteTags, .notePubkey, .notePubkeyKind, .noteRelayKind:
return .gray
case .noteRelays:
return .cyan
case .profileLastFetch, .other:
return .secondary
}
}
}
+13
View File
@@ -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
View File
@@ -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;
+1 -2
View File
@@ -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)
}
}