Compare commits

..

1 Commits

Author SHA1 Message Date
35c2ddab13 Fix localization bugs and export localizations 2023-01-14 00:46:35 -05:00
303 changed files with 4935 additions and 18896 deletions

View File

@@ -1,6 +1,4 @@
name: Run Test Suite
run-name: Testing ${{ github.ref }} by @${{ github.actor }}
name: Test
on:
push:
branches:
@@ -8,24 +6,12 @@ on:
pull_request:
branches:
- "*"
jobs:
run_tests:
runs-on: macos-12
strategy:
matrix:
include:
- xcode: "14.2"
ios: "16.2"
name: Test iOS (${{ matrix.ios }})
test:
name: Run Tests
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v1
- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: ${{ matrix.xcode }}
- name: Run Tests
run: xcodebuild test -scheme damus -project damus.xcodeproj -destination 'platform=iOS Simulator,name=iPhone 14,OS=${{ matrix.ios }}' | xcpretty && exit ${PIPESTATUS[0]}
- name: Checkout repository
uses: actions/checkout@v1
- name: Running Tests
run: xcodebuild test -scheme damus -project damus.xcodeproj -destination 'platform=iOS Simulator,name=iPhone 14,OS=16.0' | xcpretty && exit ${PIPESTATUS[0]}

View File

@@ -1,230 +1,3 @@
## [1.1.0-3] - 2023-02-20
### Added
- Add a "load more" button instead of always inserting events in timelines (William Casarin)
- Added the ability to select text on posts (OlegAba)
- Added Posts or Post & Replies selector to Profile (ericholguin)
- Improved profile navbar (OlegAba)
### Changed
- Rename global feed to universe (William Casarin)
- Improve look of post view (ericholguin)
- Added a 20MB content length limit for all image files (OlegAba)
- Improved EventActionBar button spacing (Bryan Montz)
- Polished profile key copy buttons, added animation (Bryan Montz)
- Format large numbers of action bar actions (Joel Klabo)
- Improved blur on images, especially in dark mode (Bryan Montz)
### Fixed
- Remove trailing slash when adding a relay (middlingphys)
- Scroll to top of events instead of the bottom (OlegAba)
- Fix lag on startup when you have lots of DMs (William Casarin)
- Fix an issues where dm notifications appear without any new events (William Casarin)
- Fix some hangs when scrolling by images (OlegAba)
- Force default zap amount text field to accept only numbers (Terry Yiu)
[1.1.0-3]: https://github.com/damus-io/damus/releases/tag/v1.1.0-3
## [1.1.0-2] - 2023-02-14
### Added
- Save drafts to posts, replies and DMs (Terry Yiu)
### Fixed
- Ensure stats get updated in realtime on action bars (William Casarin)
- Fix reposts not getting counted properly (William Casarin)
- Fix a bug where zaps on other people's posts weren't showing (William Casarin)
- Fix punctuation getting included in some urls (Gert Goet)
- Improve language detection (Terry Yiu)
- Fix some animated image crashes (William Casarin)
[1.1.0-2]: https://github.com/damus-io/damus/releases/tag/v1.1.0-2
## [1.0.0-15] - 2023-02-10
### Added
- Relay Filtering (William Casarin)
- Japanese translations (Terry Yiu)
- Add password autofill on account login and creation (Terry Yiu)
- Show if relay is paid (William Casarin)
- Add "Follows You" indicator on profile (William Casarin)
- Add screen to select individual relays when posting/broadcasting (Andrii Sievrikov)
- Relay Detail View (Joel Klabo)
- Warn when attempting to post an nsec key (Terry Yiu)
- DeepL translation integration (Terry Yiu)
- Use local authentication (faceid) to access private key (Andrii Sievrikov)
- Add accessibility labels to action bar (Bryan Montz)
- Copy invoice button (Joel Klabo)
- Receive Lightning Zaps (William Casarin)
- Allow text selection in bio (Suhail Saqan)
### Changed
- Show "Follow Back" button on profile page (William Casarin)
- When on your profile page, open relay view instead for your own relays (Terry Yiu)
- Updated QR code view, include profile image, etc (ericholguin)
- Clicking relay numbers now goes to relay config (radixrat)
### Fixed
- Load zaps, likes and reposts when you open a thread (William Casarin)
- Fix bug where sidebar navigation fails to pop when switching timelines (William Casarin)
- Use lnaddress before lnurl for tip addresses to avoid Anigma scamming (William Casarin)
- Fix sidebar navigation bugs (OlegAba)
- Fix issue where navigation fails pop to root when switching timelines (William Casarin)
- Make @ mentions case insensitive (William Casarin)
- Fix some lnurls not getting decoded properly (William Casarin)
- Hide incoming DMs from blocked users (William Casarin)
- Hide blocked users from search results (William Casarin)
- Fix Cash App invoice payments (Rob Seward)
- DM Padding (OlegAba)
- Check for broken lnurls (William Casarin)
[1.0.0-15]: https://github.com/damus-io/damus/releases/tag/v1.0.0-15
## [1.0.0-13] - 2023-01-30
### Added
- LibreTranslate note translations (Terry Yiu)
- Added support for account deletion (William Casarin)
- User tagging and autocompletion in posts (Swift)
### Changed
- Remove redundant logout button from settings (Jonathan Milligan)
- Moved relay config to its own sidebar entry (William Casarin)
- New stylized tabs (ericholguin)
### Fixed
- Fix hidden profile action sheet when clicking ... (William Casarin)
- Fixed height of DM input (Terry Yiu)
- Fixed bug where copying pubkey from context menu only copied your own pubkey (Terry Yiu)
[1.0.0-13]: https://github.com/damus-io/damus/releases/tag/v1.0.0-13
## [1.0.0-12] - 2023-01-28
### Added
- Added Arabic and Portuguese translations (Barodane, Antonio Chagas)
- Add QRCode view for sharing your pubkey (ericholguin)
- Added nostr: uri handling (William Casarin)
### Changed
- Remove markdown link support from posts (Joel Klabo)
### Fixed
- Fixed crash on some SVG profile pictures (OlegAba)
- Localization fixes
- Don't allow blocking yourself (Terry)
- Hide muted users from global (William Casarin)
- Fixed profiles sometimes not loading from other clients (William Casarin)
- Fixed bug where `spam` was always the report type (William Casarin)
[1.0.0-12]: https://github.com/damus-io/damus/releases/tag/v1.0.0-12
## [1.0.0-11] - 2023-01-25
### Added
- Reposts view (Terry Yiu)
- Translations for it_IT, it_CH, fr_FR, de_DE, de_AT and lv_LV (Nicolò Carcagnì, Solobalbo, Gregor, Peter Gerstbach, SYX)
- Added ability to block users (William Casarin)
- Added a way to report content (William Casarin)
- Stretchable profile cover header (Swift)
### Changed
- Bump pfp/banner animated fize size limit to 5MiB/20MiB (William Casarin)
- Updated default boostrap relays (Ricardo Arturo Cabral Mejía)
### Fixed
- allow ws:// relays again (Steven Briscoe)
[1.0.0-11]: https://github.com/damus-io/damus/releases/tag/v1.0.0-11
## [1.0.0-8] - 2023-01-22
### Added
- Show website on profiles (William Casarin)
- Add the ability to choose participants when replying (Joel Klabo)
- Translations for de_AT, de_DE, tr_TR, fr_FR (Gregor, Peter Gerstbach, Taylan Benli, Solobalbo)
- Add DM Message Requests (William Casarin)
### Fixed
- Fix commands and emojis getting included in hashtags (William Casarin)
- Fix duplicate post buttons when swiping tabs (Thomas Rademaker)
- Show embedded note references (William Casarin)
[1.0.0-8]: https://github.com/damus-io/damus/releases/tag/v1.0.0-8
## [1.0.0-7] - 2023-01-20
### Added
- Drastically improved image viewer (OlegAba)
- Added pinch to zoom on images (Swift)
- Add Latin American Spanish translations (Nicolás Valencia)
- Added SVG profile picture support (OlegAba)
### Changed
- Makes both name and username clickable in sidebar to go to profile (Zach Hendel)
- Clicking pfp in sidebar opens profile as well (radixrat)
- Don't blur images if your friend boosted it (ericholguin)
### Fixed
- Fix ... when too many likes/reposts (Joel Klabo)
- Don't show report alert if logged in as a pubkey (Swift)
- Fix padding issue at top of home timeline (Ben Weeks)
- Fix absurdly large sidebar on Mac/iPad (John Bethancourt)
- Fix tab views moving after selecting from search result (OlegAba)
- Make follow/unfollow button a consistent width (OlegAba)
- Don't add events to notifications from buggy relays (William Casarin)
- Fixed some crashes with large images (OlegAba)
- Fix DM sorting on incoming messages (William Casarin)
- Fix text getting truncated next to link previews (William Casarin)
[1.0.0-7]: https://github.com/damus-io/damus/releases/tag/v1.0.0-7
## [1.0.0-6] - 2023-01-13
### Added
@@ -611,3 +384,4 @@
[0.1.2]: https://github.com/damus-io/damus/releases/tag/v0.1.2

View File

@@ -1,4 +1,3 @@
[![Run Test Suite](https://github.com/damus-io/damus/actions/workflows/run-tests.yaml/badge.svg?branch=master)](https://github.com/damus-io/damus/actions/workflows/run-tests.yaml)
# damus
@@ -26,7 +25,7 @@ damus implements the following [Nostr Implementation Possibilities][nips]
## Getting Started on Damus
### Damus iOS
1) Get the Damus app on the iOS App Store: https://apps.apple.com/ca/app/damus/id1628663131
1) Get the Damus app on TestFlight: https://testflight.apple.com/join/CLwjLxWl
#### ⚙️ Settings (gear icon, top right)
- Relays: You can add more relays to send your notes to by tapping the "+".
@@ -49,7 +48,7 @@ damus implements the following [Nostr Implementation Possibilities][nips]
4. Add @ direcly followed by the pubkey (e.g., `@npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s`)
- You can also long-press a Note to grab their User ID aka pubkey or Note ID to link directly to a Note.
- Currently you can't delete your Notes in the iOS app
- Share images by pasting the image url which you can grab from imgbb, imgur, etc. (i.e., `https://i.ibb.co/2SHZbwm/alpha60.jpg`). Currently images only load for people you follow in the 🏠 Personal Feed. Images are not automatically loaded in 🔍 Global Feed
- Share images by pasting the image url which you can grab from imgbb, imgur, etc. (i.e., `(https://i.ibb.co/2SHZbwm/alpha60.jpg)`). Currently images only load for people you follow in the 🏠 Personal Feed. Images are not automatically loaded in 🔍 Global Feed
- Engaging with Notes
- 💬 Replying to a Note: Tap the chat icon underneath the note. This will show up in the users notifications and in your 🏠 Personal and 🔍 Global Feeds
- ♺ Reposts: Tap the repost icon which will show up in your 🏠 Personal and 🔍 Global Feeds
@@ -92,23 +91,15 @@ damus implements the following [Nostr Implementation Possibilities][nips]
## Contributing
Contributors welcome!
### Code
[Email patches][git-send-email] to jb55@jb55.com are preferred, but I accept PRs on GitHub as well.
Contributors welcome! [Email patches][git-send-email] to jb55@jb55.com are preferred, but I accept PRs on github as well.
[git-send-email]: http://git-send-email.io
### Translations
## git log bot
Translators welcome! Join the [Transifex][transifex] project.
npub1fjtdwclt9lspjy8huu3qklr7eklp5uq90u6yh8mec290pqxraccqlufnas
All user-facing strings must have a comment in order to provide context to translators. If a SwiftUI component has a `comment` parameter, use that. Otherwise, wrap your string with `NSLocalizedString` with the `comment` field populated.
[transifex]: https://explore.transifex.com/damus/damus-ios/
### Awards
### Awards
There may be nostr badges awarded for contributors in the future... :)
@@ -116,7 +107,3 @@ First contributors:
1. @randymcmillan
2. @jcarucci27
### git log bot
npub1fjtdwclt9lspjy8huu3qklr7eklp5uq90u6yh8mec290pqxraccqlufnas

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,6 @@
/* Bundle display name */
"CFBundleDisplayName" = "Damus";
/* Bundle name */
"CFBundleName" = "damus";
/* Privacy - Photo Library Additions Usage Description */
"NSPhotoLibraryAddUsageDescription" = "Atļauj Damus piekļuvi saviem fotoattēliem, lai varat saglabāt attēlus.";
"NSPhotoLibraryAddUsageDescription" = "\"Granting Damus access to your photo library allows you to save photos.";

View File

@@ -5,17 +5,19 @@
<key>collapsed_event_view_other_notes</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@NOTES@</string>
<string>··· %#@NOTES@ ···</string>
<key>NOTES</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>zero</key>
<string>0 other notes</string>
<key>one</key>
<string>... %d andere Notiz ...</string>
<string>1 other note</string>
<key>other</key>
<string>... %d andere Notizen ...</string>
<string>%d other notes</string>
</dict>
</dict>
<key>followers_count</key>
@@ -28,10 +30,12 @@
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>zero</key>
<string>Followers</string>
<key>one</key>
<string>Follower</string>
<key>other</key>
<string>Follower</string>
<string>Followers</string>
</dict>
</dict>
<key>reactions_count</key>
@@ -44,10 +48,12 @@
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>zero</key>
<string>Reactions</string>
<key>one</key>
<string>Reaktion</string>
<string>Reaction</string>
<key>other</key>
<string>Reaktionen</string>
<string>Reactions</string>
</dict>
</dict>
<key>relays_count</key>
@@ -60,6 +66,8 @@
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>zero</key>
<string>Relays</string>
<key>one</key>
<string>Relay</string>
<key>other</key>
@@ -69,33 +77,37 @@
<key>replying_to_one_and_others</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@OTHERS@</string>
<string>Replying to %@%#@OTHERS@</string>
<key>OTHERS</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>zero</key>
<string></string>
<key>one</key>
<string>Antwort an %2$@ &amp; %1$d andere</string>
<string> &amp; 1 other</string>
<key>other</key>
<string>Antwort an %2$@ &amp; %1$d andere</string>
<string> &amp; %d others</string>
</dict>
</dict>
<key>replying_to_two_and_others</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@OTHERS@</string>
<string>Replying to %@, %@%#@OTHERS@</string>
<key>OTHERS</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>zero</key>
<string></string>
<key>one</key>
<string>Antwort an %2$@, %3$@ &amp; %1$d andere</string>
<string> &amp; 1 other</string>
<key>other</key>
<string>Antwort an %2$@, %3$@ &amp; %1$d andere</string>
<string> &amp; %d others</string>
</dict>
</dict>
<key>reposts_count</key>
@@ -108,42 +120,30 @@
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>zero</key>
<string>Reposts</string>
<key>one</key>
<string>geteilter Beitrag</string>
<string>Repost</string>
<key>other</key>
<string>geteilte Beiträge</string>
<string>Reposts</string>
</dict>
</dict>
<key>sats_count</key>
<key>tips_count</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%1$#@SATS@</string>
<key>SATS</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>@</string>
<key>one</key>
<string>%2$@ sat</string>
<key>other</key>
<string>%2$@ sats</string>
</dict>
</dict>
<key>zaps_count</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@ZAPS@</string>
<key>ZAPS</key>
<string>%#@TIPS@</string>
<key>TIPS</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>zero</key>
<string>Tips</string>
<key>one</key>
<string>Zap</string>
<string>Tip</string>
<key>other</key>
<string>Zaps</string>
<string>Tips</string>
</dict>
</dict>
</dict>

View File

@@ -0,0 +1,868 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 http://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd">
<file original="damus/en-US.lproj/InfoPlist.strings" datatype="plaintext" source-language="en-US" target-language="es-419">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="14.2" build-num="14C18"/>
</header>
<body>
<trans-unit id="CFBundleDisplayName" xml:space="preserve">
<source>Damus</source>
<note>Bundle display name</note>
</trans-unit>
<trans-unit id="CFBundleName" xml:space="preserve">
<source>damus</source>
<note>Bundle name</note>
</trans-unit>
<trans-unit id="NSPhotoLibraryAddUsageDescription" xml:space="preserve">
<source>"Granting Damus access to your photo library allows you to save photos.</source>
<note>Privacy - Photo Library Additions Usage Description</note>
</trans-unit>
</body>
</file>
<file original="damus/en-US.lproj/Localizable.strings" source-language="en-US" target-language="es-419" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="14.2" build-num="14C18"/>
</header>
<body>
<trans-unit id=" " xml:space="preserve">
<source> </source>
<note>Blank space to separate profile picture from profile editor form.</note>
</trans-unit>
<trans-unit id="%@" xml:space="preserve">
<source>%@</source>
<note>Amount of time that has passed since reply quote event occurred.
Abbreviated version of a nostr public key.</note>
</trans-unit>
<trans-unit id="%@ %@" xml:space="preserve">
<source>%@ %@</source>
<note>Sentence 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'.
Sentence 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'.</note>
</trans-unit>
<trans-unit id="%@. Creating an account doesn't require a phone number, email or name. Get started right away with zero friction." xml:space="preserve">
<source>%@. Creating an account doesn't require a phone number, email or name. Get started right away with zero friction.</source>
<note>Explanation of what is done to keep personally identifiable information private. There is a heading that precedes this explanation which is a variable to this string.</note>
</trans-unit>
<trans-unit id="%@. End-to-End encrypted private messaging. Keep Big Tech out of your DMs" xml:space="preserve">
<source>%@. End-to-End encrypted private messaging. Keep Big Tech out of your DMs</source>
<note>Explanation of what is done to keep private data encrypted. There is a heading that precedes this explanation which is a variable to this string.</note>
</trans-unit>
<trans-unit id="%@. Tip your friend's posts and stack sats with Bitcoin⚡, the native currency of the internet." xml:space="preserve">
<source>%@. Tip your friend's posts and stack sats with Bitcoin⚡, the native currency of the internet.</source>
<note>Explanation of what can be done by users to earn money. There is a heading that precedes this explanation which is a variable to this string.</note>
</trans-unit>
<trans-unit id="%lld" xml:space="preserve">
<source>%lld</source>
<note>Number of reposts.
Number of profiles a user is following.</note>
</trans-unit>
<trans-unit id="%lld/%lld" xml:space="preserve">
<source>%lld/%lld</source>
<note>Fraction of how many of the user's relay servers that are operational.</note>
</trans-unit>
<trans-unit id="'%@' at '%@' will be used for verification" xml:space="preserve">
<source>'%@' at '%@' will be used for verification</source>
<note>Description of how the nip05 identifier would be used for verification.</note>
</trans-unit>
<trans-unit id="'%@' is an invalid nip05 identifier. It should look like an email." xml:space="preserve">
<source>'%@' is an invalid nip05 identifier. It should look like an email.</source>
<note>Description of why the nip05 identifier is invalid.</note>
</trans-unit>
<trans-unit id="(Profile.displayName(profile: profile, pubkey: whos))'s Followers" xml:space="preserve">
<source>(Profile.displayName(profile: profile, pubkey: whos))'s Followers</source>
<note>Navigation bar title for view that shows who is following a user.</note>
</trans-unit>
<trans-unit id="(formattedSats) sat" xml:space="preserve">
<source>(formattedSats) sat</source>
<note>Amount of 1 sat.</note>
</trans-unit>
<trans-unit id="(formattedSats) sats" xml:space="preserve">
<source>(formattedSats) sats</source>
<note>Amount of sats.</note>
</trans-unit>
<trans-unit id="(who) following" xml:space="preserve">
<source>(who) following</source>
<note>Navigation bar title for view that shows who a user is following.</note>
</trans-unit>
<trans-unit id="&lt; e &gt;" xml:space="preserve">
<source>&lt; e &gt;</source>
<note>Placeholder for event mention.</note>
</trans-unit>
<trans-unit id="@" xml:space="preserve">
<source>@</source>
<note>Prefix character to username.</note>
</trans-unit>
<trans-unit id="About" xml:space="preserve">
<source>About</source>
<note>Label to prompt for about text entry for user to describe about themself.</note>
</trans-unit>
<trans-unit id="About Me" xml:space="preserve">
<source>About Me</source>
<note>Label for About Me section of user profile form.</note>
</trans-unit>
<trans-unit id="Absolute Boss" xml:space="preserve">
<source>Absolute Boss</source>
<note>Placeholder text for About Me description.</note>
</trans-unit>
<trans-unit id="Account ID" xml:space="preserve">
<source>Account ID</source>
<note>Label to indicate the public ID of the account.</note>
</trans-unit>
<trans-unit id="Add" xml:space="preserve">
<source>Add</source>
<note>Button to add recommended relay server.
Button to confirm adding user inputted relay.</note>
</trans-unit>
<trans-unit id="Add Relay" xml:space="preserve">
<source>Add Relay</source>
<note>Label for section for adding a relay server.</note>
</trans-unit>
<trans-unit id="Any" xml:space="preserve">
<source>Any</source>
<note>Any amount of sats</note>
</trans-unit>
<trans-unit id="Are you sure you want to repost this?" xml:space="preserve">
<source>Are you sure you want to repost this?</source>
<note>Alert message to ask if user wants to repost a post.</note>
</trans-unit>
<trans-unit id="Banner Image" xml:space="preserve">
<source>Banner Image</source>
<note>Label for Banner Image section of user profile form.</note>
</trans-unit>
<trans-unit id="Before we get started, you'll need to save your account info, otherwise you won't be able to login in the future if you ever uninstall Damus." xml:space="preserve">
<source>Before we get started, you'll need to save your account info, otherwise you won't be able to login in the future if you ever uninstall Damus.</source>
<note>Reminder to user that they should save their account information.</note>
</trans-unit>
<trans-unit id="Bitcoin Beach" xml:space="preserve">
<source>Bitcoin Beach</source>
<note>Dropdown option label for Lightning wallet, Bitcoin Beach.</note>
</trans-unit>
<trans-unit id="Bitcoin Lightning Tips" xml:space="preserve">
<source>Bitcoin Lightning Tips</source>
<note>Label for Bitcoin Lightning Tips section of user profile form.</note>
</trans-unit>
<trans-unit id="Blixt Wallet" xml:space="preserve">
<source>Blixt Wallet</source>
<note>Dropdown option label for Lightning wallet, Blixt Wallet</note>
</trans-unit>
<trans-unit id="Blue Wallet" xml:space="preserve">
<source>Blue Wallet</source>
<note>Dropdown option label for Lightning wallet, Blue Wallet.</note>
</trans-unit>
<trans-unit id="Breez" xml:space="preserve">
<source>Breez</source>
<note>Dropdown option label for Lightning wallet, Breez.</note>
</trans-unit>
<trans-unit id="Broadcast" xml:space="preserve">
<source>Broadcast</source>
<note>Context menu option for broadcasting the user's note to all of the user's connected relay servers.</note>
</trans-unit>
<trans-unit id="Cancel" xml:space="preserve">
<source>Cancel</source>
<note>Button to cancel out of posting a note.
Button to cancel out of reposting a post.
Button to cancel out of view adding user inputted relay.
Cancel out of logging out the user.</note>
</trans-unit>
<trans-unit id="Cash App" xml:space="preserve">
<source>Cash App</source>
<note>Dropdown option label for Lightning wallet, Cash App.</note>
</trans-unit>
<trans-unit id="Chat" xml:space="preserve">
<source>Chat</source>
<note>Navigation bar title for Chatroom view.</note>
</trans-unit>
<trans-unit id="Clear" xml:space="preserve">
<source>Clear</source>
<note>Button for clearing cached data.</note>
</trans-unit>
<trans-unit id="Clear Cache" xml:space="preserve">
<source>Clear Cache</source>
<note>Section title for clearing cached data.</note>
</trans-unit>
<trans-unit id="Copied" xml:space="preserve">
<source>Copied</source>
<note>Label indicating that a user's key was copied.</note>
</trans-unit>
<trans-unit id="Copy" xml:space="preserve">
<source>Copy</source>
<note>Button to copy a relay server address.</note>
</trans-unit>
<trans-unit id="Copy Account ID" xml:space="preserve">
<source>Copy Account ID</source>
<note>Context menu option for copying the ID of the account that created the note.</note>
</trans-unit>
<trans-unit id="Copy Image" xml:space="preserve">
<source>Copy Image</source>
<note>Context menu option to copy an image into clipboard.
Context menu option to copy an image to clipboard.</note>
</trans-unit>
<trans-unit id="Copy Image URL" xml:space="preserve">
<source>Copy Image URL</source>
<note>Context menu option to copy the URL of an image into clipboard.</note>
</trans-unit>
<trans-unit id="Copy LNURL" xml:space="preserve">
<source>Copy LNURL</source>
<note>Context menu option for copying a user's Lightning URL.</note>
</trans-unit>
<trans-unit id="Copy Note ID" xml:space="preserve">
<source>Copy Note ID</source>
<note>Context menu option for copying the ID of the note.</note>
</trans-unit>
<trans-unit id="Copy Note JSON" xml:space="preserve">
<source>Copy Note JSON</source>
<note>Context menu option for copying the JSON text from the note.</note>
</trans-unit>
<trans-unit id="Copy Text" xml:space="preserve">
<source>Copy Text</source>
<note>Context menu option for copying the text from an note.</note>
</trans-unit>
<trans-unit id="Copy User ID" xml:space="preserve">
<source>Copy User ID</source>
<note>Context menu option for copying the ID of the user who created the note.</note>
</trans-unit>
<trans-unit id="Copy invoice" xml:space="preserve">
<source>Copy invoice</source>
<note>Title of section for copying a Lightning invoice identifier.</note>
</trans-unit>
<trans-unit id="Create" xml:space="preserve">
<source>Create</source>
<note>Button to create account.</note>
</trans-unit>
<trans-unit id="Create Account" xml:space="preserve">
<source>Create Account</source>
<note>Button to create an account.</note>
</trans-unit>
<trans-unit id="Creator(s) of Bitcoin. Absolute legend." xml:space="preserve">
<source>Creator(s) of Bitcoin. Absolute legend.</source>
<note>Example description about Bitcoin creator(s), Satoshi Nakamoto.</note>
</trans-unit>
<trans-unit id="DM" xml:space="preserve">
<source>DM</source>
<note>Navigation title for DM view, which is the English abbreviation for Direct Message.</note>
</trans-unit>
<trans-unit id="Damus" xml:space="preserve">
<source>Damus</source>
<note>Name of the app, shown on the first screen when user is not logged in.</note>
</trans-unit>
<trans-unit id="Default Wallet" xml:space="preserve">
<source>Default Wallet</source>
<note>Button to pay a Lightning invoice with the user's default Lightning wallet.</note>
</trans-unit>
<trans-unit id="Delete" xml:space="preserve">
<source>Delete</source>
<note>Button to delete a relay server that the user connects to.</note>
</trans-unit>
<trans-unit id="Dismiss" xml:space="preserve">
<source>Dismiss</source>
<note>Button to dismiss a text field alert.</note>
</trans-unit>
<trans-unit id="Display Name" xml:space="preserve">
<source>Display Name</source>
<note>Label to prompt display name entry.</note>
</trans-unit>
<trans-unit id="Done" xml:space="preserve">
<source>Done</source>
<note>Button to dismiss wallet selection view for paying Lightning invoice.</note>
</trans-unit>
<trans-unit id="Earn Money" xml:space="preserve">
<source>Earn Money</source>
<note>Heading indicating that this application allows users to earn money.</note>
</trans-unit>
<trans-unit id="Edit" xml:space="preserve">
<source>Edit</source>
<note>Button to edit user's profile.</note>
</trans-unit>
<trans-unit id="Encrypted" xml:space="preserve">
<source>Encrypted</source>
<note>Heading indicating that this application keeps private messaging end-to-end encrypted.</note>
</trans-unit>
<trans-unit id="Encrypted DMs" xml:space="preserve">
<source>Encrypted DMs</source>
<note>Navigation title for view of encrypted DMs, where DM is an English abbreviation for Direct Message.</note>
</trans-unit>
<trans-unit id="Enter your account key to login:" xml:space="preserve">
<source>Enter your account key to login:</source>
<note>Prompt for user to enter an account key to login.</note>
</trans-unit>
<trans-unit id="Error: %@" xml:space="preserve">
<source>Error: %@</source>
<note>Error message indicating why saving keys failed.</note>
</trans-unit>
<trans-unit id="Filter State" xml:space="preserve">
<source>Filter State</source>
<note>Filter state for seeing either only posts, or posts &amp; replies.</note>
</trans-unit>
<trans-unit id="Follow" xml:space="preserve">
<source>Follow</source>
<note>Button to follow a user.</note>
</trans-unit>
<trans-unit id="Followers" xml:space="preserve">
<source>Followers</source>
<note>Label describing followers of a user.</note>
</trans-unit>
<trans-unit id="Following" xml:space="preserve">
<source>Following</source>
<note>Text to indicate that the button next to it is in a state that indicates that it is in the process of following a profile.
Part of a larger sentence to describe how many profiles a user is following.</note>
</trans-unit>
<trans-unit id="Following..." xml:space="preserve">
<source>Following...</source>
<note>Label to indicate that the user is in the process of following another user.</note>
</trans-unit>
<trans-unit id="Follows" xml:space="preserve">
<source>Follows</source>
<note>Text to indicate that button next to it is in a state that will follow a profile when tapped.</note>
</trans-unit>
<trans-unit id="Global" xml:space="preserve">
<source>Global</source>
<note>Navigation bar title for Global view where posts from all connected relay servers appear.</note>
</trans-unit>
<trans-unit id="Goto post %@" xml:space="preserve">
<source>Goto post %@</source>
<note>Navigation link to go to post referenced by hex code.</note>
</trans-unit>
<trans-unit id="Goto profile %@" xml:space="preserve">
<source>Goto profile %@</source>
<note>Navigation link to go to profile.</note>
</trans-unit>
<trans-unit id="Home" xml:space="preserve">
<source>Home</source>
<note>Navigation bar title for Home view where posts and replies appear from those who the user is following.</note>
</trans-unit>
<trans-unit id="Invalid key" xml:space="preserve">
<source>Invalid key</source>
<note>Error message indicating that an invalid account key was entered for login.</note>
</trans-unit>
<trans-unit id="LNLink" xml:space="preserve">
<source>LNLink</source>
<note>Dropdown option label for Lightning wallet, LNLink.</note>
</trans-unit>
<trans-unit id="Left Handed" xml:space="preserve">
<source>Left Handed</source>
<note>Moves the post button to the left side of the screen</note>
</trans-unit>
<trans-unit id="Let's go!" xml:space="preserve">
<source>Let's go!</source>
<note>Button to complete account creation and start using the app.</note>
</trans-unit>
<trans-unit id="Lightning Address or LNURL" xml:space="preserve">
<source>Lightning Address or LNURL</source>
<note>Placeholder text for entry of Lightning Address or LNURL.</note>
</trans-unit>
<trans-unit id="Lightning Invoice" xml:space="preserve">
<source>Lightning Invoice</source>
<note>Indicates that the view is for paying a Lightning invoice.</note>
</trans-unit>
<trans-unit id="Local default" xml:space="preserve">
<source>Local default</source>
<note>Dropdown option label for system default for Lightning wallet.</note>
</trans-unit>
<trans-unit id="Login" xml:space="preserve">
<source>Login</source>
<note>Button to log into account.
Button to log into an account.</note>
</trans-unit>
<trans-unit id="Logout" xml:space="preserve">
<source>Logout</source>
<note>Alert for logging out the user.
Button for logging out the user.
Button to logout the user.</note>
</trans-unit>
<trans-unit id="Make sure your nsec account key is saved before you logout or you will lose access to this account" xml:space="preserve">
<source>Make sure your nsec account key is saved before you logout or you will lose access to this account</source>
<note>Reminder message in alert to get customer to verify that their private security account key is saved saved before logging out.</note>
</trans-unit>
<trans-unit id="Muun" xml:space="preserve">
<source>Muun</source>
<note>Dropdown option label for Lightning wallet, Muun.</note>
</trans-unit>
<trans-unit id="NIP-05 Verification" xml:space="preserve">
<source>NIP-05 Verification</source>
<note>Label for NIP-05 Verification section of user profile form.</note>
</trans-unit>
<trans-unit id="Nothing to see here. Check back later!" xml:space="preserve">
<source>Nothing to see here. Check back later!</source>
<note>Indicates that there are no notes in the timeline to view.</note>
</trans-unit>
<trans-unit id="Notifications" xml:space="preserve">
<source>Notifications</source>
<note>Navigation title for notifications.</note>
</trans-unit>
<trans-unit id="Pay" xml:space="preserve">
<source>Pay</source>
<note>Button to pay a Lightning invoice.</note>
</trans-unit>
<trans-unit id="Pay the Lightning invoice" xml:space="preserve">
<source>Pay the Lightning invoice</source>
<note>Navigation bar title for view to pay Lightning invoice.</note>
</trans-unit>
<trans-unit id="Phoenix" xml:space="preserve">
<source>Phoenix</source>
<note>Dropdown option label for Lightning wallet, Phoenix.</note>
</trans-unit>
<trans-unit id="Post" xml:space="preserve">
<source>Post</source>
<note>Button to post a note.</note>
</trans-unit>
<trans-unit id="Posts" xml:space="preserve">
<source>Posts</source>
<note>Label for filter for seeing only posts (instead of posts and replies).</note>
</trans-unit>
<trans-unit id="Posts &amp; Replies" xml:space="preserve">
<source>Posts &amp; Replies</source>
<note>Label for filter for seeing posts and replies (instead of only posts).</note>
</trans-unit>
<trans-unit id="Private" xml:space="preserve">
<source>Private</source>
<note>Heading indicating that this application keeps personally identifiable information private. A sentence describing what is done to keep data private comes after this heading.</note>
</trans-unit>
<trans-unit id="Private Key" xml:space="preserve">
<source>Private Key</source>
<note>Label to indicate that the text below is the user's private key used by only the user themself as a secret to login to access their account.</note>
</trans-unit>
<trans-unit id="PrivateKey" xml:space="preserve">
<source>PrivateKey</source>
<note>Title of the secure field that holds the user's private key.</note>
</trans-unit>
<trans-unit id="Profile" xml:space="preserve">
<source>Profile</source>
<note>Sidebar menu label for Profile view.</note>
</trans-unit>
<trans-unit id="Profile Picture" xml:space="preserve">
<source>Profile Picture</source>
<note>Label for Profile Picture section of user profile form.</note>
</trans-unit>
<trans-unit id="Public Account ID" xml:space="preserve">
<source>Public Account ID</source>
<note>Section title for the user's public account ID.</note>
</trans-unit>
<trans-unit id="Public Key" xml:space="preserve">
<source>Public Key</source>
<note>Label indicating that the text is a user's public account key.</note>
</trans-unit>
<trans-unit id="Public Key?" xml:space="preserve">
<source>Public Key?</source>
<note>Prompt to ask user if the key they entered is a public key.</note>
</trans-unit>
<trans-unit id="Public key" xml:space="preserve">
<source>Public key</source>
<note>Label indicating that the text is a user's public account key.</note>
</trans-unit>
<trans-unit id="Reactions" xml:space="preserve">
<source>Reactions</source>
<note>Navigation bar title for Reactions view.</note>
</trans-unit>
<trans-unit id="Recommended Relays" xml:space="preserve">
<source>Recommended Relays</source>
<note>Section title for recommend relay servers that could be added as part of configuration</note>
</trans-unit>
<trans-unit id="Relay" xml:space="preserve">
<source>Relay</source>
<note>Text field for relay server. Used for testing purposes.</note>
</trans-unit>
<trans-unit id="Relays" xml:space="preserve">
<source>Relays</source>
<note>Sidebar menu label for Relay servers view</note>
</trans-unit>
<trans-unit id="Reply to self" xml:space="preserve">
<source>Reply to self</source>
<note>Label to indicate that the user is replying to themself.</note>
</trans-unit>
<trans-unit id="Replying to %@ &amp; %@" xml:space="preserve">
<source>Replying to %1$@ &amp; %2$@</source>
<note>Label to indicate that the user is replying to 2 users.</note>
</trans-unit>
<trans-unit id="Replying to:" xml:space="preserve">
<source>Replying to:</source>
<note>Indicating that the user is replying to the following listed people.</note>
</trans-unit>
<trans-unit id="Repost" xml:space="preserve">
<source>Repost</source>
<note>Button to confirm reposting a post.
Title of alert for confirming to repost a post.</note>
</trans-unit>
<trans-unit id="Reposted" xml:space="preserve">
<source>Reposted</source>
<note>Text indicating that the post was reposted (i.e. re-shared).</note>
</trans-unit>
<trans-unit id="Reset" xml:space="preserve">
<source>Reset</source>
<note>Section title for resetting the user</note>
</trans-unit>
<trans-unit id="Retry" xml:space="preserve">
<source>Retry</source>
<note>Button to retry completing account creation after an error occurred.</note>
</trans-unit>
<trans-unit id="River" xml:space="preserve">
<source>River</source>
<note>Dropdown option label for Lightning wallet, River</note>
</trans-unit>
<trans-unit id="Satoshi Nakamoto" xml:space="preserve">
<source>Satoshi Nakamoto</source>
<note>Name of Bitcoin creator(s).</note>
</trans-unit>
<trans-unit id="Save" xml:space="preserve">
<source>Save</source>
<note>Button for saving profile.</note>
</trans-unit>
<trans-unit id="Save Image" xml:space="preserve">
<source>Save Image</source>
<note>Context menu option to save an image.</note>
</trans-unit>
<trans-unit id="Search hashtag: #%@" xml:space="preserve">
<source>Search hashtag: #%@</source>
<note>Navigation link to search hashtag.</note>
</trans-unit>
<trans-unit id="Search..." xml:space="preserve">
<source>Search...</source>
<note>Placeholder text to prompt entry of search query.</note>
</trans-unit>
<trans-unit id="Secret Account Login Key" xml:space="preserve">
<source>Secret Account Login Key</source>
<note>Section title for user's secret account login key.</note>
</trans-unit>
<trans-unit id="Select a Lightning wallet" xml:space="preserve">
<source>Select a Lightning wallet</source>
<note>Title of section for selecting a Lightning wallet to pay a Lightning invoice.</note>
</trans-unit>
<trans-unit id="Select default wallet" xml:space="preserve">
<source>Select default wallet</source>
<note>Prompt selection of user's default wallet</note>
</trans-unit>
<trans-unit id="Send a message to start the conversation..." xml:space="preserve">
<source>Send a message to start the conversation...</source>
<note>Text prompt for user to send a message to the other user.</note>
</trans-unit>
<trans-unit id="Settings" xml:space="preserve">
<source>Settings</source>
<note>Navigation title for Settings view.
Sidebar menu label for accessing the app settings</note>
</trans-unit>
<trans-unit id="Share" xml:space="preserve">
<source>Share</source>
<note>Button to share an image.</note>
</trans-unit>
<trans-unit id="Show" xml:space="preserve">
<source>Show</source>
<note>Toggle to show or hide user's secret account login key.</note>
</trans-unit>
<trans-unit id="Show wallet selector" xml:space="preserve">
<source>Show wallet selector</source>
<note>Toggle to show or hide selection of wallet.</note>
</trans-unit>
<trans-unit id="Sign out" xml:space="preserve">
<source>Sign out</source>
<note>Sidebar menu label to sign out of the account.</note>
</trans-unit>
<trans-unit id="Strike" xml:space="preserve">
<source>Strike</source>
<note>Dropdown option label for Lightning wallet, Strike.</note>
</trans-unit>
<trans-unit id="This is a public key, you will not be able to make posts or interact in any way. This is used for viewing accounts from their perspective." xml:space="preserve">
<source>This is a public key, you will not be able to make posts or interact in any way. This is used for viewing accounts from their perspective.</source>
<note>Warning that the inputted account key is a public key and the result of what happens because of it.</note>
</trans-unit>
<trans-unit id="This is an old-style nostr key. We're not sure if it's a pubkey or private key. Please toggle the button below if this a public key." xml:space="preserve">
<source>This is an old-style nostr key. We're not sure if it's a pubkey or private key. Please toggle the button below if this a public key.</source>
<note>Warning that the inputted account key for login is an old-style and asking user to verify if it is a public key.</note>
</trans-unit>
<trans-unit id="This is your account ID, you can give this to your friends so that they can follow you. Click to copy." xml:space="preserve">
<source>This is your account ID, you can give this to your friends so that they can follow you. Click to copy.</source>
<note>Label to describe that a public key is the user's account ID and what they can do with it.</note>
</trans-unit>
<trans-unit id="This is your secret account key. You need this to access your account. Don't share this with anyone! Save it in a password manager and keep it safe!" xml:space="preserve">
<source>This is your secret account key. You need this to access your account. Don't share this with anyone! Save it in a password manager and keep it safe!</source>
<note>Label to describe that a private key is the user's secret account key and what they should do with it.</note>
</trans-unit>
<trans-unit id="Thread" xml:space="preserve">
<source>Thread</source>
<note>Navigation bar title for note thread.
Navigation bar title for threaded event detail view.</note>
</trans-unit>
<trans-unit id="Type your post here..." xml:space="preserve">
<source>Type your post here...</source>
<note>Text box prompt to ask user to type their post.</note>
</trans-unit>
<trans-unit id="Unfollow" xml:space="preserve">
<source>Unfollow</source>
<note>Button to unfollow a user.</note>
</trans-unit>
<trans-unit id="Unfollowing" xml:space="preserve">
<source>Unfollowing</source>
<note>Text to indicate that the button next to it is in a state that indicates that it is in the process of unfollowing a profile.</note>
</trans-unit>
<trans-unit id="Unfollowing..." xml:space="preserve">
<source>Unfollowing...</source>
<note>Label to indicate that the user is in the process of unfollowing another user.</note>
</trans-unit>
<trans-unit id="Unfollows" xml:space="preserve">
<source>Unfollows</source>
<note>Text to indicate that the button next to it is in a state that will unfollow a profile when tapped.</note>
</trans-unit>
<trans-unit id="Username" xml:space="preserve">
<source>Username</source>
<note>Label for Username section of user profile form.
Label to prompt username entry.</note>
</trans-unit>
<trans-unit id="Wallet" xml:space="preserve">
<source>Wallet</source>
<note>Sidebar menu label for Wallet view.</note>
</trans-unit>
<trans-unit id="Wallet Of Satoshi" xml:space="preserve">
<source>Wallet Of Satoshi</source>
<note>Dropdown option label for Lightning wallet, Wallet Of Satoshi.</note>
</trans-unit>
<trans-unit id="Wallet Selector" xml:space="preserve">
<source>Wallet Selector</source>
<note>Section title for selection of wallet.</note>
</trans-unit>
<trans-unit id="Website" xml:space="preserve">
<source>Website</source>
<note>Label for Website section of user profile form.</note>
</trans-unit>
<trans-unit id="Welcome to the social network %@ control." xml:space="preserve">
<source>Welcome to the social network %@ control.</source>
<note>Welcoming message to the reader. The variable is 'you', the reader.</note>
</trans-unit>
<trans-unit id="Welcome, %@!" xml:space="preserve">
<source>Welcome, %@!</source>
<note>Text to welcome user.</note>
</trans-unit>
<trans-unit id="Your Name" xml:space="preserve">
<source>Your Name</source>
<note>Label for Your Name section of user profile form.</note>
</trans-unit>
<trans-unit id="Zebedee" xml:space="preserve">
<source>Zebedee</source>
<note>Dropdown option label for Lightning wallet, Zebedee.</note>
</trans-unit>
<trans-unit id="Zeus LN" xml:space="preserve">
<source>Zeus LN</source>
<note>Dropdown option label for Lightning wallet, Zeus LN.</note>
</trans-unit>
<trans-unit id="collapsed_event_view_other_notes" translate="no" xml:space="preserve">
<source>collapsed_event_view_other_notes</source>
<note>Text to indicate that the thread was collapsed and that there are other notes to view if tapped. (Key in .stringsdict)</note>
</trans-unit>
<trans-unit id="followers_count" translate="no" xml:space="preserve">
<source>followers_count</source>
<note>Part of a larger sentence to describe how many people are following a user. (Key in .stringsdict)</note>
</trans-unit>
<trans-unit id="https://example.com/pic.jpg" xml:space="preserve">
<source>https://example.com/pic.jpg</source>
<note>Placeholder example text for profile picture URL.</note>
</trans-unit>
<trans-unit id="https://jb55.com" xml:space="preserve">
<source>https://jb55.com</source>
<note>Placeholder example text for website URL for user profile.</note>
</trans-unit>
<trans-unit id="jb55@jb55.com" xml:space="preserve">
<source>jb55@jb55.com</source>
<note>Placeholder example text for identifier used for NIP-05 verification.</note>
</trans-unit>
<trans-unit id="none" xml:space="preserve">
<source>none</source>
<note>No search results.</note>
</trans-unit>
<trans-unit id="now" xml:space="preserve">
<source>now</source>
<note>String indicating that a given timestamp just occurred</note>
</trans-unit>
<trans-unit id="nsec1..." xml:space="preserve">
<source>nsec1...</source>
<note>Prompt for user to enter in an account key to login. This text shows the characters the key could start with if it was a private key.</note>
</trans-unit>
<trans-unit id="optional" xml:space="preserve">
<source>optional</source>
<note>Label indicating that a form input is optional.</note>
</trans-unit>
<trans-unit id="reactions_count" translate="no" xml:space="preserve">
<source>reactions_count</source>
<note>Part of a larger sentence to describe how many reactions there are on a post. (Key in .stringsdict)</note>
</trans-unit>
<trans-unit id="relays_count" translate="no" xml:space="preserve">
<source>relays_count</source>
<note>Part of a larger sentence to describe how many relay servers a user is connected. (Key in .stringsdict)</note>
</trans-unit>
<trans-unit id="replying_to_one_and_others" translate="no" xml:space="preserve">
<source>replying_to_one_and_others</source>
<note>Label to indicate that the user is replying to 1 user and others. (Key in .stringsdict)</note>
</trans-unit>
<trans-unit id="replying_to_two_and_others" translate="no" xml:space="preserve">
<source>replying_to_two_and_others</source>
<note>Label to indicate that the user is replying to 2 users and others. (Key in .stringsdict)</note>
</trans-unit>
<trans-unit id="reposts_count" translate="no" xml:space="preserve">
<source>reposts_count</source>
<note>Part of a larger sentence to describe how many reposts there are. (Key in .stringsdict)</note>
</trans-unit>
<trans-unit id="satoshi" xml:space="preserve">
<source>satoshi</source>
<note>Example username of Bitcoin creator(s), Satoshi Nakamoto.</note>
</trans-unit>
<trans-unit id="tips_count" translate="no" xml:space="preserve">
<source>tips_count</source>
<note>Part of a larger sentence to describe how many tip payments there are on a post. (Key in .stringsdict)</note>
</trans-unit>
<trans-unit id="u{00A0}" xml:space="preserve">
<source>u{00A0}</source>
<note>Non-breaking space character to fill in blank space next to event action button icons.</note>
</trans-unit>
<trans-unit id="wss://some.relay.com" xml:space="preserve">
<source>wss://some.relay.com</source>
<note>Placeholder example for relay server address.</note>
</trans-unit>
<trans-unit id="you" xml:space="preserve">
<source>you</source>
<note>You, in this context, is the person who controls their own social network. You is used in the context of a larger sentence that welcomes the reader to the social network that they control themself.</note>
</trans-unit>
</body>
</file>
<file original="damus/en-US.lproj/Localizable.stringsdict" source-language="en-US" target-language="es-419" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="14.2" build-num="14C18"/>
</header>
<body>
<trans-unit id="/collapsed_event_view_other_notes:dict/NOTES:dict/one:dict/:string" xml:space="preserve">
<source>1 other note</source>
<target>1 other note</target>
<note>Text to indicate that the thread was collapsed and that there are other notes to view if tapped.</note>
</trans-unit>
<trans-unit id="/collapsed_event_view_other_notes:dict/NOTES:dict/other:dict/:string" xml:space="preserve">
<source>%d other notes</source>
<target>%d other notes</target>
<note>Text to indicate that the thread was collapsed and that there are other notes to view if tapped.</note>
</trans-unit>
<trans-unit id="/collapsed_event_view_other_notes:dict/NOTES:dict/zero:dict/:string" xml:space="preserve">
<source>0 other notes</source>
<target>0 other notes</target>
<note>Text to indicate that the thread was collapsed and that there are other notes to view if tapped.</note>
</trans-unit>
<trans-unit id="/collapsed_event_view_other_notes:dict/NSStringLocalizedFormatKey:dict/:string" xml:space="preserve">
<source>··· %#@NOTES@ ···</source>
<target>··· %#@NOTES@ ···</target>
<note>Text to indicate that the thread was collapsed and that there are other notes to view if tapped.</note>
</trans-unit>
<trans-unit id="/followers_count:dict/FOLLOWERS:dict/one:dict/:string" xml:space="preserve">
<source>Follower</source>
<note>Part of a larger sentence to describe how many people are following a user.</note>
</trans-unit>
<trans-unit id="/followers_count:dict/FOLLOWERS:dict/other:dict/:string" xml:space="preserve">
<source>Followers</source>
<note>Part of a larger sentence to describe how many people are following a user.</note>
</trans-unit>
<trans-unit id="/followers_count:dict/FOLLOWERS:dict/zero:dict/:string" xml:space="preserve">
<source>Followers</source>
<note>Part of a larger sentence to describe how many people are following a user.</note>
</trans-unit>
<trans-unit id="/followers_count:dict/NSStringLocalizedFormatKey:dict/:string" xml:space="preserve">
<source>%#@FOLLOWERS@</source>
<note>Part of a larger sentence to describe how many people are following a user.</note>
</trans-unit>
<trans-unit id="/reactions_count:dict/NSStringLocalizedFormatKey:dict/:string" xml:space="preserve">
<source>%#@REACTIONS@</source>
<note>Part of a larger sentence to describe how many reactions there are on a post.</note>
</trans-unit>
<trans-unit id="/reactions_count:dict/REACTIONS:dict/one:dict/:string" xml:space="preserve">
<source>Reaction</source>
<note>Part of a larger sentence to describe how many reactions there are on a post.</note>
</trans-unit>
<trans-unit id="/reactions_count:dict/REACTIONS:dict/other:dict/:string" xml:space="preserve">
<source>Reactions</source>
<note>Part of a larger sentence to describe how many reactions there are on a post.</note>
</trans-unit>
<trans-unit id="/reactions_count:dict/REACTIONS:dict/zero:dict/:string" xml:space="preserve">
<source>Reactions</source>
<note>Part of a larger sentence to describe how many reactions there are on a post.</note>
</trans-unit>
<trans-unit id="/relays_count:dict/NSStringLocalizedFormatKey:dict/:string" xml:space="preserve">
<source>%#@RELAYS@</source>
<note>Part of a larger sentence to describe how many relay servers a user is connected.</note>
</trans-unit>
<trans-unit id="/relays_count:dict/RELAYS:dict/one:dict/:string" xml:space="preserve">
<source>Relay</source>
<note>Part of a larger sentence to describe how many relay servers a user is connected.</note>
</trans-unit>
<trans-unit id="/relays_count:dict/RELAYS:dict/other:dict/:string" xml:space="preserve">
<source>Relays</source>
<note>Part of a larger sentence to describe how many relay servers a user is connected.</note>
</trans-unit>
<trans-unit id="/relays_count:dict/RELAYS:dict/zero:dict/:string" xml:space="preserve">
<source>Relays</source>
<note>Part of a larger sentence to describe how many relay servers a user is connected.</note>
</trans-unit>
<trans-unit id="/replying_to_one_and_others:dict/NSStringLocalizedFormatKey:dict/:string" xml:space="preserve">
<source>Replying to %@%#@OTHERS@</source>
<target>Replying to %@%#@OTHERS@</target>
<note>Label to indicate that the user is replying to 1 user and others.</note>
</trans-unit>
<trans-unit id="/replying_to_one_and_others:dict/OTHERS:dict/one:dict/:string" xml:space="preserve">
<source> &amp; 1 other</source>
<target> &amp; 1 other</target>
<note>Label to indicate that the user is replying to 1 user and others.</note>
</trans-unit>
<trans-unit id="/replying_to_one_and_others:dict/OTHERS:dict/other:dict/:string" xml:space="preserve">
<source> &amp; %d others</source>
<target> &amp; %d others</target>
<note>Label to indicate that the user is replying to 1 user and others.</note>
</trans-unit>
<trans-unit id="/replying_to_one_and_others:dict/OTHERS:dict/zero:dict/:string" xml:space="preserve">
<source/>
<target/>
<note>Label to indicate that the user is replying to 1 user and others.</note>
</trans-unit>
<trans-unit id="/replying_to_two_and_others:dict/NSStringLocalizedFormatKey:dict/:string" xml:space="preserve">
<source>Replying to %@, %@%#@OTHERS@</source>
<target>Replying to %@, %@%#@OTHERS@</target>
<note>Label to indicate that the user is replying to 2 users and others.</note>
</trans-unit>
<trans-unit id="/replying_to_two_and_others:dict/OTHERS:dict/one:dict/:string" xml:space="preserve">
<source> &amp; 1 other</source>
<target> &amp; 1 other</target>
<note>Label to indicate that the user is replying to 2 users and others.</note>
</trans-unit>
<trans-unit id="/replying_to_two_and_others:dict/OTHERS:dict/other:dict/:string" xml:space="preserve">
<source> &amp; %d others</source>
<target> &amp; %d others</target>
<note>Label to indicate that the user is replying to 2 users and others.</note>
</trans-unit>
<trans-unit id="/replying_to_two_and_others:dict/OTHERS:dict/zero:dict/:string" xml:space="preserve">
<source/>
<target/>
<note>Label to indicate that the user is replying to 2 users and others.</note>
</trans-unit>
<trans-unit id="/reposts_count:dict/NSStringLocalizedFormatKey:dict/:string" xml:space="preserve">
<source>%#@REPOSTS@</source>
<note>Part of a larger sentence to describe how many reposts there are.</note>
</trans-unit>
<trans-unit id="/reposts_count:dict/REPOSTS:dict/one:dict/:string" xml:space="preserve">
<source>Repost</source>
<note>Part of a larger sentence to describe how many reposts there are.</note>
</trans-unit>
<trans-unit id="/reposts_count:dict/REPOSTS:dict/other:dict/:string" xml:space="preserve">
<source>Reposts</source>
<note>Part of a larger sentence to describe how many reposts there are.</note>
</trans-unit>
<trans-unit id="/reposts_count:dict/REPOSTS:dict/zero:dict/:string" xml:space="preserve">
<source>Reposts</source>
<note>Part of a larger sentence to describe how many reposts there are.</note>
</trans-unit>
<trans-unit id="/tips_count:dict/NSStringLocalizedFormatKey:dict/:string" xml:space="preserve">
<source>%#@TIPS@</source>
<note>Part of a larger sentence to describe how many tip payments there are on a post.</note>
</trans-unit>
<trans-unit id="/tips_count:dict/TIPS:dict/one:dict/:string" xml:space="preserve">
<source>Tip</source>
<note>Part of a larger sentence to describe how many tip payments there are on a post.</note>
</trans-unit>
<trans-unit id="/tips_count:dict/TIPS:dict/other:dict/:string" xml:space="preserve">
<source>Tips</source>
<note>Part of a larger sentence to describe how many tip payments there are on a post.</note>
</trans-unit>
<trans-unit id="/tips_count:dict/TIPS:dict/zero:dict/:string" xml:space="preserve">
<source>Tips</source>
<note>Part of a larger sentence to describe how many tip payments there are on a post.</note>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -0,0 +1,6 @@
/* Bundle display name */
"CFBundleDisplayName" = "Damus";
/* Bundle name */
"CFBundleName" = "damus";
/* Privacy - Photo Library Additions Usage Description */
"NSPhotoLibraryAddUsageDescription" = "\"Granting Damus access to your photo library allows you to save photos.";

View File

@@ -5,17 +5,19 @@
<key>collapsed_event_view_other_notes</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@NOTES@</string>
<string>··· %#@NOTES@ ···</string>
<key>NOTES</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>zero</key>
<string>0 other notes</string>
<key>one</key>
<string>... %d andere notitie ...</string>
<string>1 other note</string>
<key>other</key>
<string>... %d andere notities ...</string>
<string>%d other notes</string>
</dict>
</dict>
<key>followers_count</key>
@@ -28,10 +30,12 @@
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>zero</key>
<string>Followers</string>
<key>one</key>
<string>Volger</string>
<string>Follower</string>
<key>other</key>
<string>Volgers</string>
<string>Followers</string>
</dict>
</dict>
<key>reactions_count</key>
@@ -44,10 +48,12 @@
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>zero</key>
<string>Reactions</string>
<key>one</key>
<string>Reactie</string>
<string>Reaction</string>
<key>other</key>
<string>Reacties</string>
<string>Reactions</string>
</dict>
</dict>
<key>relays_count</key>
@@ -60,6 +66,8 @@
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>zero</key>
<string>Relays</string>
<key>one</key>
<string>Relay</string>
<key>other</key>
@@ -69,33 +77,37 @@
<key>replying_to_one_and_others</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@OTHERS@</string>
<string>Replying to %@%#@OTHERS@</string>
<key>OTHERS</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>zero</key>
<string></string>
<key>one</key>
<string>Antwoord aan %2$@ en %1$d andere gebruiker</string>
<string> &amp; 1 other</string>
<key>other</key>
<string>Antwoord aan %2$@ en %1$d andere gebruikers</string>
<string> &amp; %d others</string>
</dict>
</dict>
<key>replying_to_two_and_others</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@OTHERS@</string>
<string>Replying to %@, %@%#@OTHERS@</string>
<key>OTHERS</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>zero</key>
<string></string>
<key>one</key>
<string>Antwoord aan %2$@, %3$@ en %1$d andere gebruiker</string>
<string> &amp; 1 other</string>
<key>other</key>
<string>Antwoord aan %2$@, %3$@ en %1$d andere gebruikers</string>
<string> &amp; %d others</string>
</dict>
</dict>
<key>reposts_count</key>
@@ -108,42 +120,30 @@
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>zero</key>
<string>Reposts</string>
<key>one</key>
<string>Herplaatsing</string>
<string>Repost</string>
<key>other</key>
<string>Herplaatsingen</string>
<string>Reposts</string>
</dict>
</dict>
<key>sats_count</key>
<key>tips_count</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%1$#@SATS@</string>
<key>SATS</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>@</string>
<key>one</key>
<string>%2$@ sat</string>
<key>other</key>
<string>%2$@ sats</string>
</dict>
</dict>
<key>zaps_count</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@ZAPS@</string>
<key>ZAPS</key>
<string>%#@TIPS@</string>
<key>TIPS</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>zero</key>
<string>Tips</string>
<key>one</key>
<string>Zap</string>
<string>Tip</string>
<key>other</key>
<string>Zaps</string>
<string>Tips</string>
</dict>
</dict>
</dict>

View File

@@ -0,0 +1,12 @@
{
"developmentRegion" : "en-US",
"project" : "damus.xcodeproj",
"targetLocale" : "es-419",
"toolInfo" : {
"toolBuildNumber" : "14C18",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "14.2"
},
"version" : "1.0"
}

View File

@@ -22,14 +22,6 @@ static inline int is_whitespace(char c) {
return c == ' ' || c == '\t' || c == '\n' || c == '\v' || c == '\f' || c == '\r';
}
static inline int is_boundary(char c) {
return !isalnum(c);
}
static inline int is_invalid_url_ending(char c) {
return c == '!' || c == '?' || c == ')' || c == '.' || c == ',' || c == ';';
}
static void make_cursor(struct cursor *c, const u8 *content, size_t len)
{
c->start = content;
@@ -37,33 +29,16 @@ static void make_cursor(struct cursor *c, const u8 *content, size_t len)
c->p = content;
}
static int consume_until_boundary(struct cursor *cur) {
char c;
while (cur->p < cur->end) {
c = *cur->p;
if (is_boundary(c))
return 1;
cur->p++;
}
return 1;
}
static int consume_until_whitespace(struct cursor *cur, int or_end) {
char c;
bool consumedAtLeastOne = false;
while (cur->p < cur->end) {
c = *cur->p;
if (is_whitespace(c))
return consumedAtLeastOne;
return 1;
cur->p++;
consumedAtLeastOne = true;
}
return or_end;
@@ -170,7 +145,7 @@ static int parse_hashtag(struct cursor *cur, struct block *block) {
return 0;
}
consume_until_boundary(cur);
consume_until_whitespace(cur, 1);
block->type = BLOCK_HASHTAG;
block->block.str.start = (const char*)(start + 1);
@@ -225,9 +200,6 @@ static int parse_url(struct cursor *cur, struct block *block) {
return 0;
}
// strip any unwanted characters
while(is_invalid_url_ending(peek_char(cur, -1))) cur->p--;
block->type = BLOCK_URL;
block->block.str.start = (const char *)start;
block->block.str.end = (const char *)cur->p;

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/onevcat/Kingfisher",
"state" : {
"revision" : "415b1d97fb38bda1e5a6b2dde63354720832110b",
"version" : "7.6.1"
"revision" : "017f94ccfdacabb1ae7f45b75b4217b24c06e6ac",
"version" : "7.4.0"
}
},
{

View File

@@ -1,98 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1420"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
BuildableName = "damus.app"
BlueprintName = "damus"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEF227F7A08200C66700"
BuildableName = "damusTests.xctest"
BlueprintName = "damusTests"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEFC27F7A08200C66700"
BuildableName = "damusUITests.xctest"
BlueprintName = "damusUITests"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
BuildableName = "damus.app"
BlueprintName = "damus"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
BuildableName = "damus.app"
BlueprintName = "damus"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -11,6 +11,24 @@
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xC5",
"green" : "0x43",
"red" : "0xCC"
}
},
"idiom" : "universal"
}
],
"info" : {

View File

@@ -11,6 +11,24 @@
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x00",
"green" : "0x00",
"red" : "0x00"
}
},
"idiom" : "universal"
}
],
"info" : {

View File

@@ -11,6 +11,24 @@
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"green" : "0x4D",
"red" : "0x4B"
}
},
"idiom" : "universal"
}
],
"info" : {

View File

@@ -11,6 +11,24 @@
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x1E",
"green" : "0x1C",
"red" : "0x1C"
}
},
"idiom" : "universal"
}
],
"info" : {

View File

@@ -11,6 +11,24 @@
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x4F",
"green" : "0xC3",
"red" : "0x66"
}
},
"idiom" : "universal"
}
],
"info" : {

View File

@@ -11,6 +11,24 @@
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xF4",
"green" : "0xEE",
"red" : "0xEE"
}
},
"idiom" : "universal"
}
],
"info" : {

View File

@@ -11,6 +11,24 @@
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x5F",
"green" : "0x5F",
"red" : "0x5F"
}
},
"idiom" : "universal"
}
],
"info" : {

View File

@@ -11,6 +11,24 @@
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xC5",
"green" : "0x43",
"red" : "0xCC"
}
},
"idiom" : "universal"
}
],
"info" : {

View File

@@ -11,6 +11,24 @@
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"green" : "0xFF",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic-copy.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic-key.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 B

View File

@@ -0,0 +1,52 @@
{
"images" : [
{
"filename" : "ic-message-black.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "ic-message-white 1.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic-nipverified.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 950 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic-qr.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 B

View File

@@ -2,7 +2,16 @@
"images" : [
{
"filename" : "profile-banner.jpeg",
"idiom" : "universal"
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

View File

@@ -2,7 +2,16 @@
"images" : [
{
"filename" : "bbw.jpg",
"idiom" : "universal"
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

View File

@@ -2,7 +2,16 @@
"images" : [
{
"filename" : "bitcoin-p2p.png",
"idiom" : "universal"
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

View File

@@ -2,7 +2,16 @@
"images" : [
{
"filename" : "blixt-wallet.png",
"idiom" : "universal"
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

View File

@@ -2,7 +2,16 @@
"images" : [
{
"filename" : "bluewallet.png",
"idiom" : "universal"
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

View File

@@ -2,7 +2,16 @@
"images" : [
{
"filename" : "breez.jpg",
"idiom" : "universal"
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

View File

@@ -2,7 +2,16 @@
"images" : [
{
"filename" : "cashapp.png",
"idiom" : "universal"
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

View File

@@ -2,7 +2,16 @@
"images" : [
{
"filename" : "digital-nomad.png",
"idiom" : "universal"
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

View File

@@ -2,7 +2,16 @@
"images" : [
{
"filename" : "encrypted-message.png",
"idiom" : "universal"
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic-lightning.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic-tick.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 B

View File

@@ -2,7 +2,16 @@
"images" : [
{
"filename" : "lnlink.png",
"idiom" : "universal"
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

View File

@@ -2,7 +2,16 @@
"images" : [
{
"filename" : "damus-nobg.png",
"idiom" : "universal"
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

View File

@@ -2,7 +2,16 @@
"images" : [
{
"filename" : "muun.png",
"idiom" : "universal"
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

View File

@@ -2,7 +2,16 @@
"images" : [
{
"filename" : "phoenix.png",
"idiom" : "universal"
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

View File

@@ -2,7 +2,16 @@
"images" : [
{
"filename" : "river.png",
"idiom" : "universal"
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

View File

@@ -2,7 +2,16 @@
"images" : [
{
"filename" : "strike.png",
"idiom" : "universal"
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

View File

@@ -2,7 +2,16 @@
"images" : [
{
"filename" : "undercover.png",
"idiom" : "universal"
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

View File

@@ -2,7 +2,16 @@
"images" : [
{
"filename" : "walletofsatoshi.png",
"idiom" : "universal"
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

View File

@@ -2,7 +2,16 @@
"images" : [
{
"filename" : "zebedee.png",
"idiom" : "universal"
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

View File

@@ -2,7 +2,16 @@
"images" : [
{
"filename" : "zeus.png",
"idiom" : "universal"
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

View File

@@ -1,60 +0,0 @@
//
// CustomPicker.swift
// damus
//
// Created by Eric Holguin on 1/22/23.
//
import SwiftUI
let RECTANGLE_GRADIENT = LinearGradient(gradient: Gradient(colors: [
Color("DamusPurple"),
Color("DamusBlue")
]), startPoint: .leading, endPoint: .trailing)
struct CustomPicker<SelectionValue: Hashable, Content: View>: View {
@Environment(\.colorScheme) var colorScheme
@Namespace var picker
@Binding var selection: SelectionValue
@ViewBuilder let content: Content
public var body: some View {
let contentMirror = Mirror(reflecting: content)
let blocksCount = Mirror(reflecting: contentMirror.descendant("value")!).children.count
HStack {
ForEach(0..<blocksCount, id: \.self) { index in
let tupleBlock = contentMirror.descendant("value", ".\(index)")
let text = Mirror(reflecting: tupleBlock!).descendant("content") as! Text
let tag = Mirror(reflecting: tupleBlock!).descendant("modifier", "value", "tagged") as! SelectionValue
Button {
withAnimation(.spring()) {
selection = tag
}
} label: {
text
.padding(EdgeInsets(top: 15, leading: 0, bottom: 10, trailing: 0))
.font(.system(size: 14, weight: .heavy))
}
.background(
Group {
if tag == selection {
Rectangle().fill(RECTANGLE_GRADIENT).frame(height: 2.5)
.matchedGeometryEffect(id: "selector", in: picker)
.cornerRadius(2.5)
}
},
alignment: .bottom
)
.frame(maxWidth: .infinity)
.accentColor(tag == selection ? textColor() : .gray)
}
}
}
func textColor() -> Color {
colorScheme == .light ? Color("DamusBlack") : Color("DamusWhite")
}
}

View File

@@ -1,39 +0,0 @@
//
// Highlight.swift
// damus
//
// Created by William Casarin on 2023-01-23.
//
import Foundation
import SwiftUI
enum Highlight {
case none
case main
case reply
case custom(Color, Float)
var is_main: Bool {
if case .main = self {
return true
}
return false
}
var is_none: Bool {
if case .none = self {
return true
}
return false
}
var is_replied_to: Bool {
switch self {
case .reply: return true
default: return false
}
}
}

View File

@@ -12,14 +12,14 @@ import Kingfisher
struct ShareSheet: UIViewControllerRepresentable {
typealias Callback = (_ activityType: UIActivity.ActivityType?, _ completed: Bool, _ returnedItems: [Any]?, _ error: Error?) -> Void
let activityItems: [URL?]
let activityItems: [URL]
let callback: Callback? = nil
let applicationActivities: [UIActivity]? = nil
let excludedActivityTypes: [UIActivity.ActivityType]? = nil
func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(
activityItems: activityItems as [Any],
activityItems: activityItems,
applicationActivities: applicationActivities)
controller.excludedActivityTypes = excludedActivityTypes
controller.completionWithItemsHandler = callback
@@ -32,7 +32,7 @@ struct ShareSheet: UIViewControllerRepresentable {
}
struct ImageContextMenuModifier: ViewModifier {
let url: URL?
let url: URL
let image: UIImage?
@Binding var showShareSheet: Bool
@@ -64,12 +64,8 @@ struct ImageContextMenuModifier: ViewModifier {
}
}
private struct ImageContainerView: View {
let url: URL?
@State private var image: UIImage?
@State private var showShareSheet = false
struct ImageViewer: View {
let urls: [URL]
private struct ImageHandler: ImageModifier {
@Binding var handler: UIImage?
@@ -79,110 +75,45 @@ private struct ImageContainerView: View {
return image
}
}
var body: some View {
KFAnimatedImage(url)
.imageContext(.note)
.configure { view in
view.framePreloadCount = 3
}
.imageModifier(ImageHandler(handler: $image))
.clipped()
.modifier(ImageContextMenuModifier(url: url, image: image, showShareSheet: $showShareSheet))
.sheet(isPresented: $showShareSheet) {
ShareSheet(activityItems: [url])
}
}
}
struct ImageView: View {
@State private var image: UIImage?
@State private var showShareSheet = false
let urls: [URL?]
@Environment(\.presentationMode) var presentationMode
@State private var selectedIndex = 0
@State var showMenu = true
var navBarView: some View {
VStack {
HStack {
Text(urls[selectedIndex]?.lastPathComponent ?? "")
.bold()
Spacer()
Button(action: {
presentationMode.wrappedValue.dismiss()
}, label: {
Image(systemName: "xmark")
})
}
.padding()
Divider()
.ignoresSafeArea()
func onShared(completed: Bool) -> Void {
if (completed) {
showShareSheet = false
}
.background(.regularMaterial)
}
var tabViewIndicator: some View {
HStack(spacing: 10) {
ForEach(urls.indices, id: \.self) { index in
Capsule()
.fill(index == selectedIndex ? Color(UIColor.label) : Color.secondary)
.frame(width: 7, height: 7)
}
}
.padding()
.background(.regularMaterial)
.clipShape(Capsule())
}
var body: some View {
ZStack {
Color(.systemBackground)
.ignoresSafeArea()
TabView(selection: $selectedIndex) {
ForEach(urls.indices, id: \.self) { index in
ZoomableScrollView {
ImageContainerView(url: urls[index])
.aspectRatio(contentMode: .fit)
.padding(.top, Theme.safeAreaInsets?.top)
.padding(.bottom, Theme.safeAreaInsets?.bottom)
}
.modifier(SwipeToDismissModifier(minDistance: 50, onDismiss: {
presentationMode.wrappedValue.dismiss()
}))
.ignoresSafeArea()
.tag(index)
TabView {
ForEach(urls, id: \.absoluteString) { url in
VStack{
Text(url.lastPathComponent)
KFAnimatedImage(url)
.configure { view in
view.framePreloadCount = 3
}
.cacheOriginalImage()
.imageModifier(ImageHandler(handler: $image))
.loadDiskFileSynchronously()
.scaleFactor(UIScreen.main.scale)
.fade(duration: 0.1)
.aspectRatio(contentMode: .fit)
.tabItem {
Text(url.absoluteString)
}
.id(url.absoluteString)
.modifier(ImageContextMenuModifier(url: url, image: image, showShareSheet: $showShareSheet))
.sheet(isPresented: $showShareSheet) {
ShareSheet(activityItems: [url])
}
}
}
.ignoresSafeArea()
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.gesture(TapGesture(count: 2).onEnded {
// Prevents menu from hiding on double tap
})
.gesture(TapGesture(count: 1).onEnded {
showMenu.toggle()
})
.overlay(
VStack {
if showMenu {
navBarView
Spacer()
if (urls.count > 1) {
tabViewIndicator
}
}
}
.animation(.easeInOut, value: showMenu)
.padding(.bottom, Theme.safeAreaInsets?.bottom)
)
}
.tabViewStyle(PageTabViewStyle())
}
}
@@ -199,13 +130,14 @@ struct ImageCarousel: View {
.foregroundColor(Color.clear)
.overlay {
KFAnimatedImage(url)
.imageContext(.note)
.cancelOnDisappear(true)
.configure { view in
view.framePreloadCount = 3
}
.cacheOriginalImage()
.loadDiskFileSynchronously()
.scaleFactor(UIScreen.main.scale)
.fade(duration: 0.1)
.aspectRatio(contentMode: .fit)
.cornerRadius(10)
.tabItem {
Text(url.absoluteString)
}
@@ -218,11 +150,11 @@ struct ImageCarousel: View {
}
}
}
.fullScreenCover(isPresented: $open_sheet) {
ImageView(urls: urls)
.cornerRadius(10)
.sheet(isPresented: $open_sheet) {
ImageViewer(urls: urls)
}
.frame(height: 200)
.clipped()
.onTapGesture {
open_sheet = true
}
@@ -232,6 +164,6 @@ struct ImageCarousel: View {
struct ImageCarousel_Previews: PreviewProvider {
static var previews: some View {
ImageCarousel(urls: [URL(string: "https://jb55.com/red-me.jpg")!,URL(string: "https://jb55.com/red-me.jpg")!])
ImageCarousel(urls: [URL(string: "https://jb55.com/red-me.jpg")!])
}
}

View File

@@ -7,84 +7,6 @@
import SwiftUI
struct InvoiceView: View {
@Environment(\.colorScheme) var colorScheme
@Environment(\.openURL) private var openURL
let our_pubkey: String
let invoice: Invoice
@State var showing_select_wallet: Bool = false
@State var copied = false
var CopyButton: some View {
Button {
copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
copied = false
}
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
UIPasteboard.general.string = invoice.string
} label: {
if !copied {
Image(systemName: "doc.on.clipboard")
.foregroundColor(.gray)
} else {
Image(systemName: "checkmark.circle")
.foregroundColor(Color("DamusGreen"))
}
}
}
var PayButton: some View {
Button {
if should_show_wallet_selector(our_pubkey) {
showing_select_wallet = true
} else {
open_with_wallet(wallet: get_default_wallet(our_pubkey).model, invoice: invoice.string)
}
} label: {
RoundedRectangle(cornerRadius: 20, style: .circular)
.foregroundColor(colorScheme == .light ? .black : .white)
.overlay {
Text("Pay", comment: "Button to pay a Lightning invoice.")
.fontWeight(.medium)
.foregroundColor(colorScheme == .light ? .white : .black)
}
}
.onTapGesture {
// Temporary solution so that the "pay" button can be clicked (Yes we need an empty tap gesture)
print("pay button tap")
}
}
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 10)
.foregroundColor(.secondary.opacity(0.1))
VStack(alignment: .leading, spacing: 12) {
HStack {
Label("", systemImage: "bolt.fill")
.foregroundColor(.orange)
Text("Lightning Invoice", comment: "Indicates that the view is for paying a Lightning invoice.")
Spacer()
CopyButton
}
Divider()
Text(invoice.description_string)
Text(invoice.amount.amount_sats_str())
.font(.title)
PayButton
.frame(height: 50)
.zIndex(10.0)
}
.padding(30)
}
.sheet(isPresented: $showing_select_wallet, onDismiss: {showing_select_wallet = false}) {
SelectWalletView(showingSelectWallet: $showing_select_wallet, our_pubkey: our_pubkey, invoice: invoice.string)
}
}
}
func open_with_wallet(wallet: Wallet.Model, invoice: String) {
if let url = URL(string: "\(wallet.link)\(invoice)"), UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url)
@@ -106,12 +28,68 @@ func open_with_wallet(wallet: Wallet.Model, invoice: String) {
}
}
struct InvoiceView: View {
@Environment(\.colorScheme) var colorScheme
@Environment(\.openURL) private var openURL
let invoice: Invoice
@State var showing_select_wallet: Bool = false
@ObservedObject var user_settings = UserSettingsStore()
var PayButton: some View {
Button {
if user_settings.show_wallet_selector {
showing_select_wallet = true
} else {
open_with_wallet(wallet: user_settings.default_wallet.model, invoice: invoice.string)
}
} label: {
RoundedRectangle(cornerRadius: 20)
.foregroundColor(colorScheme == .light ? .black : .white)
.overlay {
Text("Pay", comment: "Button to pay a Lightning invoice.")
.fontWeight(.medium)
.foregroundColor(colorScheme == .light ? .white : .black)
}
}
//.buttonStyle(.bordered)
.onTapGesture {
// Temporary solution so that the "pay" button can be clicked (Yes we need an empty tap gesture)
}
}
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 10)
.foregroundColor(.secondary.opacity(0.1))
VStack(alignment: .leading, spacing: 12) {
HStack {
Label("", systemImage: "bolt.fill")
.foregroundColor(.orange)
Text("Lightning Invoice", comment: "Indicates that the view is for paying a Lightning invoice.")
}
Divider()
Text(invoice.description)
Text(invoice.amount.amount_sats_str())
.font(.title)
PayButton
.frame(height: 50)
.zIndex(10.0)
}
.padding(30)
}
.sheet(isPresented: $showing_select_wallet, onDismiss: {showing_select_wallet = false}) {
SelectWalletView(showingSelectWallet: $showing_select_wallet, invoice: invoice.string).environmentObject(user_settings)
}
}
}
let test_invoice = Invoice(description: .description("this is a description"), amount: .specific(10000), string: "lnbc100n1p357sl0sp5t9n56wdztun39lgdqlr30xqwksg3k69q4q2rkr52aplujw0esn0qpp5mrqgljk62z20q4nvgr6lzcyn6fhylzccwdvu4k77apg3zmrkujjqdpzw35xjueqd9ejqcfqv3jhxcmjd9c8g6t0dcxqyjw5qcqpjrzjqt56h4gvp5yx36u2uzqa6qwcsk3e2duunfxppzj9vhypc3wfe2wswz607uqq3xqqqsqqqqqqqqqqqlqqyg9qyysgqagx5h20aeulj3gdwx3kxs8u9f4mcakdkwuakasamm9562ffyr9en8yg20lg0ygnr9zpwp68524kmda0t5xp2wytex35pu8hapyjajxqpsql29r", expiry: 604800, payment_hash: Data(), created_at: 1666139119)
let test_invoice = Invoice(description: "this is a description", amount: .specific(10000), string: "lnbc100n1p357sl0sp5t9n56wdztun39lgdqlr30xqwksg3k69q4q2rkr52aplujw0esn0qpp5mrqgljk62z20q4nvgr6lzcyn6fhylzccwdvu4k77apg3zmrkujjqdpzw35xjueqd9ejqcfqv3jhxcmjd9c8g6t0dcxqyjw5qcqpjrzjqt56h4gvp5yx36u2uzqa6qwcsk3e2duunfxppzj9vhypc3wfe2wswz607uqq3xqqqsqqqqqqqqqqqlqqyg9qyysgqagx5h20aeulj3gdwx3kxs8u9f4mcakdkwuakasamm9562ffyr9en8yg20lg0ygnr9zpwp68524kmda0t5xp2wytex35pu8hapyjajxqpsql29r", expiry: 604800, payment_hash: Data(), created_at: 1666139119)
struct InvoiceView_Previews: PreviewProvider {
static var previews: some View {
InvoiceView(our_pubkey: "", invoice: test_invoice)
.frame(width: 300, height: 200)
InvoiceView(invoice: test_invoice)
.frame(width: 200, height: 200)
}
}

View File

@@ -8,7 +8,6 @@
import SwiftUI
struct InvoicesView: View {
let our_pubkey: String
var invoices: [Invoice]
@State var open_sheet: Bool = false
@@ -17,7 +16,7 @@ struct InvoicesView: View {
var body: some View {
TabView {
ForEach(invoices, id: \.string) { invoice in
InvoiceView(our_pubkey: our_pubkey, invoice: invoice)
InvoiceView(invoice: invoice)
.tabItem {
Text(invoice.string)
}
@@ -31,7 +30,7 @@ struct InvoicesView: View {
struct InvoicesView_Previews: PreviewProvider {
static var previews: some View {
InvoicesView(our_pubkey: "", invoices: [Invoice.init(description: .description("description"), amount: .specific(10000), string: "invstr", expiry: 100000, payment_hash: Data(), created_at: 1000000)])
InvoicesView(invoices: [Invoice.init(description: "description", amount: .specific(10000), string: "invstr", expiry: 100000, payment_hash: Data(), created_at: 1000000)])
.frame(width: 300)
}
}

View File

@@ -1,96 +0,0 @@
//
// SelectableText.swift
// damus
//
// Created by Oleg Abalonski on 2/16/23.
//
import UIKit
import SwiftUI
struct SelectableText: View {
let attributedString: AttributedString
@State private var selectedTextHeight: CGFloat = .zero
@State private var selectedTextWidth: CGFloat = .zero
var body: some View {
GeometryReader { geo in
TextViewRepresentable(
attributedString: attributedString,
textColor: UIColor.label,
font: UIFont.preferredFont(forTextStyle: .title2),
fixedWidth: selectedTextWidth,
height: $selectedTextHeight
)
.onAppear {
self.selectedTextWidth = geo.size.width
}
.onChange(of: geo.size) { newSize in
self.selectedTextWidth = newSize.width
}
}
.frame(height: selectedTextHeight)
}
}
fileprivate struct TextViewRepresentable: UIViewRepresentable {
let attributedString: AttributedString
let textColor: UIColor
let font: UIFont
let fixedWidth: CGFloat
@Binding var height: CGFloat
func makeUIView(context: UIViewRepresentableContext<Self>) -> UITextView {
let view = UITextView()
view.isEditable = false
view.dataDetectorTypes = .all
view.isSelectable = true
view.textContainer.lineFragmentPadding = 0
view.textContainerInset = .zero
return view
}
func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext<Self>) {
let mutableAttributedString = createNSAttributedString()
uiView.attributedText = mutableAttributedString
let newHeight = mutableAttributedString.height(containerWidth: fixedWidth)
DispatchQueue.main.async {
height = newHeight
}
}
func createNSAttributedString() -> NSMutableAttributedString {
let mutableAttributedString = NSMutableAttributedString(attributedString)
let myAttribute = [
NSAttributedString.Key.font: font,
NSAttributedString.Key.foregroundColor: textColor
]
mutableAttributedString.addAttributes(
myAttribute,
range: NSRange.init(location: 0, length: mutableAttributedString.length)
)
return mutableAttributedString
}
}
fileprivate extension NSAttributedString {
func height(containerWidth: CGFloat) -> CGFloat {
let rect = self.boundingRect(
with: CGSize.init(width: containerWidth, height: CGFloat.greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading],
context: nil
)
return ceil(rect.size.height)
}
}

View File

@@ -1,145 +0,0 @@
//
// TranslateButton.swift
// damus
//
// Created by William Casarin on 2023-02-02.
//
import SwiftUI
import NaturalLanguage
struct TranslateView: View {
let damus_state: DamusState
let event: NostrEvent
@State var checkingTranslationStatus: Bool = false
@State var currentLanguage: String = "en"
@State var noteLanguage: String? = nil
@State var translated_note: String? = nil
@State var show_translated_note: Bool = false
@State var translated_artifacts: NoteArtifacts? = nil
var TranslateButton: some View {
Button(NSLocalizedString("Translate Note", comment: "Button to translate note from different language.")) {
show_translated_note = true
}
.translate_button_style()
}
func Translated(lang: String, artifacts: NoteArtifacts) -> some View {
return Group {
Button(NSLocalizedString("Translated from \(lang)", comment: "Button to indicate that the note has been translated from a different language.")) {
show_translated_note = false
}
.translate_button_style()
SelectableText(attributedString: artifacts.content)
}
}
func CheckingStatus(lang: String) -> some View {
return Button(NSLocalizedString("Translating from \(lang)...", comment: "Button to indicate that the note is in the process of being translated from a different language.")) {
show_translated_note = false
}
.translate_button_style()
}
func MainContent(note_lang: String) -> some View {
return Group {
let languageName = Locale.current.localizedString(forLanguageCode: note_lang)
if let lang = languageName, show_translated_note {
if checkingTranslationStatus {
CheckingStatus(lang: lang)
} else if let artifacts = translated_artifacts {
Translated(lang: lang, artifacts: artifacts)
}
} else {
TranslateButton
}
}
}
var body: some View {
Group {
if let note_lang = noteLanguage, noteLanguage != currentLanguage {
MainContent(note_lang: note_lang)
} else {
Text("")
}
}
.task {
guard noteLanguage == nil && !checkingTranslationStatus && damus_state.settings.can_translate(damus_state.pubkey) else {
return
}
checkingTranslationStatus = true
if #available(iOS 16, *) {
currentLanguage = Locale.current.language.languageCode?.identifier ?? "en"
} else {
currentLanguage = Locale.current.languageCode ?? "en"
}
// Rely on Apple's NLLanguageRecognizer to tell us which language it thinks the note is in
// and filter on only the text portions of the content as URLs and hashtags confuse the language recognizer.
let originalBlocks = event.blocks(damus_state.keypair.privkey)
let originalOnlyText = originalBlocks.compactMap { $0.is_text }.joined(separator: " ")
// Only accept language recognition hypothesis if there's at least a 50% probability that it's accurate.
let languageRecognizer = NLLanguageRecognizer()
languageRecognizer.processString(originalOnlyText)
noteLanguage = languageRecognizer.languageHypotheses(withMaximum: 1).first(where: { $0.value >= 0.5 })?.key.rawValue ?? currentLanguage
if let lang = noteLanguage, noteLanguage != currentLanguage {
// If the detected dominant language is a variant, remove the variant component and just take the language part as translation services typically only supports the variant-less language.
if #available(iOS 16, *) {
noteLanguage = Locale.LanguageCode(stringLiteral: lang).identifier(.alpha2)
} else {
noteLanguage = NSLocale(localeIdentifier: lang).languageCode
}
}
guard let note_lang = noteLanguage else {
noteLanguage = currentLanguage
translated_note = nil
checkingTranslationStatus = false
return
}
if note_lang != currentLanguage {
do {
// If the note language is different from our language, send a translation request.
let translator = Translator(damus_state.settings)
let originalContent = event.get_content(damus_state.keypair.privkey)
translated_note = try await translator.translate(originalContent, from: note_lang, to: currentLanguage)
if originalContent == translated_note {
// If the translation is the same as the original, don't bother showing it.
noteLanguage = currentLanguage
translated_note = nil
}
} catch {
// If for whatever reason we're not able to figure out the language of the note, or translate the note, fail gracefully and do not retry. It's not the end of the world. Don't want to take down someone's translation server with an accidental denial of service attack.
noteLanguage = currentLanguage
translated_note = nil
}
}
if let translated = translated_note {
// Render translated note.
let translatedBlocks = event.get_blocks(content: translated)
translated_artifacts = render_blocks(blocks: translatedBlocks, profiles: damus_state.profiles, privkey: damus_state.keypair.privkey)
}
checkingTranslationStatus = false
}
}
}
struct TranslateView_Previews: PreviewProvider {
static var previews: some View {
let ds = test_damus_state()
TranslateView(damus_state: ds, event: test_event)
}
}

View File

@@ -1,38 +0,0 @@
//
// UserView.swift
// damus
//
// Created by William Casarin on 2023-01-25.
//
import SwiftUI
struct UserView: View {
let damus_state: DamusState
let pubkey: String
var body: some View {
NavigationLink(destination: ProfileView(damus_state: damus_state, pubkey: pubkey)) {
ProfilePicView(pubkey: pubkey, size: PFP_SIZE, highlight: .none, profiles: damus_state.profiles)
VStack(alignment: .leading) {
let profile = damus_state.profiles.lookup(id: pubkey)
ProfileName(pubkey: pubkey, profile: profile, damus: damus_state, show_friend_confirmed: false, show_nip5_domain: false)
if let about = profile?.about {
Text(about)
.lineLimit(3)
.font(.footnote)
}
}
Spacer()
}
.buttonStyle(PlainButtonStyle())
}
}
struct UserView_Previews: PreviewProvider {
static var previews: some View {
UserView(damus_state: test_damus_state(), pubkey: "pk")
}
}

View File

@@ -1,38 +0,0 @@
//
// WebsiteLink.swift
// damus
//
// Created by William Casarin on 2023-01-22.
//
import SwiftUI
struct WebsiteLink: View {
let url: URL
@Environment(\.openURL) var openURL
var body: some View {
HStack {
Image(systemName: "link")
.foregroundColor(.gray)
.font(.footnote)
Button(action: {
openURL(url)
}, label: {
Text(link_text)
.font(.footnote)
})
}
}
var link_text: String {
url.host ?? url.absoluteString
}
}
struct WebsiteLink_Previews: PreviewProvider {
static var previews: some View {
WebsiteLink(url: URL(string: "https://jb55.com")!)
}
}

View File

@@ -1,189 +0,0 @@
//
// ZapButton.swift
// damus
//
// Created by William Casarin on 2023-01-17.
//
import SwiftUI
enum ZappingEventType {
case failed(ZappingError)
case got_zap_invoice(String)
}
enum ZappingError {
case fetching_invoice
case bad_lnurl
}
struct ZappingEvent {
let is_custom: Bool
let type: ZappingEventType
let event: NostrEvent
}
struct ZapButton: View {
let damus_state: DamusState
let event: NostrEvent
let lnurl: String
@ObservedObject var bar: ActionBarModel
@State var zapping: Bool = false
@State var invoice: String = ""
@State var slider_value: Double = 0.0
@State var slider_visible: Bool = false
@State var showing_select_wallet: Bool = false
@State var showing_zap_customizer: Bool = false
@State var is_charging: Bool = false
var zap_img: String {
if bar.zapped {
return "bolt.fill"
}
if !zapping {
return "bolt"
}
return "bolt.horizontal.fill"
}
var zap_color: Color? {
if bar.zapped {
return Color.orange
}
if is_charging {
return Color.yellow
}
if !zapping {
return nil
}
return Color.yellow
}
var body: some View {
HStack(spacing: 4) {
Image(systemName: zap_img)
.foregroundColor(zap_color == nil ? Color.gray : zap_color!)
.font(.footnote.weight(.medium))
.onTapGesture {
if bar.zapped {
//notify(.delete, bar.our_tip)
} else if !zapping {
self.showing_zap_customizer = true
//send_zap(damus_state: damus_state, event: event, lnurl: lnurl, is_custom: false)
//self.zapping = true
}
}
.onLongPressGesture(minimumDuration: 0, pressing: { is_charing in
self.is_charging = is_charging
}, perform: {
self.showing_zap_customizer = true
})
.accessibilityLabel(NSLocalizedString("Zap", comment: "Accessibility label for zap button"))
if bar.zap_total > 0 {
Text(verbatim: format_msats_abbrev(bar.zap_total))
.font(.footnote)
.foregroundColor(bar.zapped ? Color.orange : Color.gray)
}
}
.sheet(isPresented: $showing_zap_customizer) {
CustomizeZapView(state: damus_state, event: event, lnurl: lnurl)
}
.sheet(isPresented: $showing_select_wallet, onDismiss: {showing_select_wallet = false}) {
SelectWalletView(showingSelectWallet: $showing_select_wallet, our_pubkey: damus_state.pubkey, invoice: invoice)
}
.onReceive(handle_notify(.zapping)) { notif in
let zap_ev = notif.object as! ZappingEvent
guard zap_ev.event.id == self.event.id else {
return
}
guard !zap_ev.is_custom else {
return
}
switch zap_ev.type {
case .failed:
break
case .got_zap_invoice(let inv):
if should_show_wallet_selector(damus_state.pubkey) {
self.invoice = inv
self.showing_select_wallet = true
} else {
open_with_wallet(wallet: get_default_wallet(damus_state.pubkey).model, invoice: inv)
}
}
self.zapping = false
}
}
}
struct ZapButton_Previews: PreviewProvider {
static var previews: some View {
let bar = ActionBarModel(likes: 0, boosts: 0, zaps: 10, zap_total: 15623414, our_like: nil, our_boost: nil, our_zap: nil)
ZapButton(damus_state: test_damus_state(), event: test_event, lnurl: "lnurl", bar: bar)
}
}
func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_custom: Bool, comment: String?, amount_sats: Int?, zap_type: ZapType) {
guard let privkey = damus_state.keypair.privkey else {
return
}
// Only take the first 10 because reasons
let relays = Array(damus_state.pool.descriptors.prefix(10))
let target = ZapTarget.note(id: event.id, author: event.pubkey)
let content = comment ?? ""
let zapreq = make_zap_request_event(pubkey: damus_state.pubkey, privkey: privkey, content: content, relays: relays, target: target, is_anon: zap_type == .anon)
Task {
var mpayreq = damus_state.lnurls.lookup(target.pubkey)
if mpayreq == nil {
mpayreq = await fetch_static_payreq(lnurl)
}
guard let payreq = mpayreq else {
// TODO: show error
DispatchQueue.main.async {
let typ = ZappingEventType.failed(.bad_lnurl)
let ev = ZappingEvent(is_custom: is_custom, type: typ, event: event)
notify(.zapping, ev)
}
return
}
DispatchQueue.main.async {
damus_state.lnurls.endpoints[target.pubkey] = payreq
}
let zap_amount = amount_sats ?? get_default_zap_amount(pubkey: damus_state.pubkey) ?? 1000
guard let inv = await fetch_zap_invoice(payreq, zapreq: zapreq, sats: zap_amount, zap_type: zap_type, comment: comment) else {
DispatchQueue.main.async {
let typ = ZappingEventType.failed(.fetching_invoice)
let ev = ZappingEvent(is_custom: is_custom, type: typ, event: event)
notify(.zapping, ev)
}
return
}
DispatchQueue.main.async {
let ev = ZappingEvent(is_custom: is_custom, type: .got_zap_invoice(inv), event: event)
notify(.zapping, ev)
}
}
return
}

View File

@@ -1,152 +0,0 @@
//
// ZoomableScrollView.swift
// damus
//
// Created by Oleg Abalonski on 1/25/23.
//
import SwiftUI
struct ZoomableScrollView<Content: View>: UIViewRepresentable {
private var content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
func makeUIView(context: Context) -> UIScrollView {
let scrollView = GesturedScrollView()
scrollView.delegate = context.coordinator
scrollView.maximumZoomScale = 20
scrollView.minimumZoomScale = 1
scrollView.bouncesZoom = true
scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false
let hostedView = context.coordinator.hostingController.view!
hostedView.translatesAutoresizingMaskIntoConstraints = true
hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hostedView.frame = scrollView.bounds
hostedView.backgroundColor = .clear
scrollView.addSubview(hostedView)
return scrollView
}
func makeCoordinator() -> Coordinator {
return Coordinator(hostingController: UIHostingController(rootView: self.content, ignoreSafeArea: true))
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
context.coordinator.hostingController.rootView = self.content
assert(context.coordinator.hostingController.view.superview == uiView)
}
class Coordinator: NSObject, UIScrollViewDelegate {
var hostingController: UIHostingController<Content>
init(hostingController: UIHostingController<Content>) {
self.hostingController = hostingController
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return hostingController.view
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
let viewSize = hostingController.view.frame.size
guard let imageSize = scrollView.subviews[0].subviews.last?.frame.size else { return }
if scrollView.zoomScale > 1 {
let ratioW = viewSize.width / imageSize.width
let ratioH = viewSize.height / imageSize.height
let ratio = ratioW < ratioH ? ratioW:ratioH
let newWidth = imageSize.width * ratio
let newHeight = imageSize.height * ratio
let left = 0.5 * (newWidth * scrollView.zoomScale > viewSize.width ? (newWidth - viewSize.width) : (scrollView.frame.width - scrollView.contentSize.width))
let top = 0.5 * (newHeight * scrollView.zoomScale > viewSize.height ? (newHeight - viewSize.height) : (scrollView.frame.height - scrollView.contentSize.height))
scrollView.contentInset = UIEdgeInsets(top: top, left: left, bottom: top, right: left)
} else {
scrollView.contentInset = .zero
}
}
}
}
fileprivate class GesturedScrollView: UIScrollView, UIGestureRecognizerDelegate {
let doubleTapGesture: UITapGestureRecognizer
override init(frame: CGRect) {
doubleTapGesture = UITapGestureRecognizer()
super.init(frame: frame)
doubleTapGesture.addTarget(self, action: #selector(handleDoubleTap))
doubleTapGesture.numberOfTapsRequired = 2
addGestureRecognizer(doubleTapGesture)
doubleTapGesture.delegate = self
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
if self.zoomScale == 1 {
let pointInView = gesture.location(in: self.subviews.first)
let newZoomScale = self.maximumZoomScale / 4.0
let scrollViewSize = self.bounds.size
let width = scrollViewSize.width / newZoomScale
let height = scrollViewSize.height / newZoomScale
let originX = pointInView.x - (width / 2.0)
let originY = pointInView.y - (height / 2.0)
let zoomRect = CGRect(x: originX, y: originY, width: width, height: height)
self.zoom(to: zoomRect, animated: true)
} else {
self.setZoomScale(self.minimumZoomScale, animated: true)
}
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return gestureRecognizer == doubleTapGesture
}
}
fileprivate extension UIHostingController {
convenience init(rootView: Content, ignoreSafeArea: Bool) {
self.init(rootView: rootView)
if ignoreSafeArea {
disableSafeArea()
}
}
func disableSafeArea() {
guard let viewClass = object_getClass(view) else { return }
let viewSubclassName = String(cString: class_getName(viewClass)).appending("_IgnoreSafeArea")
if let viewSubclass = NSClassFromString(viewSubclassName) {
object_setClass(view, viewSubclass)
}
else {
guard let viewClassNameUtf8 = (viewSubclassName as NSString).utf8String else { return }
guard let viewSubclass = objc_allocateClassPair(viewClass, viewClassNameUtf8, 0) else { return }
if let method = class_getInstanceMethod(UIView.self, #selector(getter: UIView.safeAreaInsets)) {
let safeAreaInsets: @convention(block) (AnyObject) -> UIEdgeInsets = { _ in
return .zero
}
class_addMethod(viewSubclass, #selector(getter: UIView.safeAreaInsets), imp_implementationWithBlock(safeAreaInsets), method_getTypeEncoding(method))
}
objc_registerClassPair(viewSubclass)
object_setClass(view, viewSubclass)
}
}
}

View File

@@ -7,14 +7,15 @@
import SwiftUI
import Starscream
import Kingfisher
var BOOTSTRAP_RELAYS = [
"wss://relay.damus.io",
"wss://eden.nostr.land",
"wss://relay.snort.social",
"wss://offchain.pub",
"wss://nos.lol",
"wss://relay.current.fyi",
"wss://nostr-relay.wlvs.space",
"wss://nostr.fmt.wiz.biz",
"wss://relay.nostr.bg",
"wss://nostr.oxtr.dev",
"wss://nostr.v0l.io",
"wss://brb.io",
]
@@ -25,18 +26,12 @@ struct TimestampedProfile {
enum Sheets: Identifiable {
case post
case report(ReportTarget)
case reply(NostrEvent)
case event(NostrEvent)
case filter
var id: String {
switch self {
case .report: return "report"
case .post: return "post"
case .reply(let ev): return "reply-" + ev.id
case .event(let ev): return "event-" + ev.id
case .filter: return "filter"
}
}
}
@@ -76,7 +71,6 @@ struct ContentView: View {
@State var damus_state: DamusState? = nil
@State var selected_timeline: Timeline? = .home
@State var is_thread_open: Bool = false
@State var is_deleted_account: Bool = false
@State var is_profile_open: Bool = false
@State var event: NostrEvent? = nil
@State var active_profile: String? = nil
@@ -85,14 +79,11 @@ struct ContentView: View {
@State var profile_open: Bool = false
@State var thread_open: Bool = false
@State var search_open: Bool = false
@State var blocking: String? = nil
@State var confirm_block: Bool = false
@State var user_blocked_confirm: Bool = false
@State var confirm_overwrite_mutelist: Bool = false
@State var filter_state : FilterState = .posts_and_replies
@State private var isSideBarOpened = false
@StateObject var home: HomeModel = HomeModel()
@StateObject var user_settings = UserSettingsStore()
// connect retry timer
let timer = Timer.publish(every: 4, on: .main, in: .common).autoconnect()
@@ -102,72 +93,49 @@ struct ContentView: View {
var PostingTimelineView: some View {
VStack {
ZStack {
TabView(selection: $filter_state) {
contentTimelineView(filter: FilterState.posts.filter)
.tag(FilterState.posts)
.id(FilterState.posts)
contentTimelineView(filter: FilterState.posts_and_replies.filter)
.tag(FilterState.posts_and_replies)
.id(FilterState.posts_and_replies)
}
.tabViewStyle(.page(indexDisplayMode: .never))
if privkey != nil {
PostButtonContainer(is_left_handed: damus_state?.settings.left_handed ?? false) {
self.active_sheet = .post
}
}
TabView(selection: $filter_state) {
contentTimelineView(filter: FilterState.posts.filter)
.tag(FilterState.posts)
.id(FilterState.posts)
contentTimelineView(filter: FilterState.posts_and_replies.filter)
.tag(FilterState.posts_and_replies)
.id(FilterState.posts_and_replies)
}
.tabViewStyle(.page(indexDisplayMode: .never))
}
.safeAreaInset(edge: .top, spacing: 0) {
.safeAreaInset(edge: .top) {
VStack(spacing: 0) {
CustomPicker(selection: $filter_state, content: {
Text("Posts", comment: "Label for filter for seeing only posts (instead of posts and replies).").tag(FilterState.posts)
Text("Posts & Replies", comment: "Label for filter for seeing posts and replies (instead of only posts).").tag(FilterState.posts_and_replies)
})
FiltersView
//.frame(maxWidth: 275)
.padding()
Divider()
.frame(height: 1)
}
.background(colorScheme == .dark ? Color.black : Color.white)
}
.ignoresSafeArea(.keyboard)
}
func contentTimelineView(filter: (@escaping (NostrEvent) -> Bool)) -> some View {
ZStack {
if let damus = self.damus_state {
TimelineView(events: home.events, loading: $home.loading, damus: damus, show_friend_icon: false, filter: filter)
TimelineView(events: $home.events, loading: $home.loading, damus: damus, show_friend_icon: false, filter: filter)
}
if privkey != nil {
PostButtonContainer(userSettings: user_settings) {
self.active_sheet = .post
}
}
}
}
func popToRoot() {
profile_open = false
thread_open = false
search_open = false
isSideBarOpened = false
}
var timelineNavItem: some View {
VStack {
switch selected_timeline {
case .home:
Image("damus-home")
.resizable()
.frame(width:30,height:30)
.shadow(color: Color("DamusPurple"), radius: 2)
case .dms:
Text("DMs", comment: "Toolbar label for DMs view, where DM is the English abbreviation for Direct Message.")
.bold()
case .notifications:
Text("Notifications", comment: "Toolbar label for Notifications view.")
.bold()
case .search:
Text("Universe 🛸", comment: "Toolbar label for the universal view where posts from all connected relay servers appear.")
.bold()
case .none:
Text(verbatim: "")
var FiltersView: some View {
VStack{
Picker(NSLocalizedString("Filter State", comment: "Filter state for seeing either only posts, or posts & replies."), selection: $filter_state) {
Text("Posts", comment: "Label for filter for seeing only posts (instead of posts and replies).").tag(FilterState.posts)
Text("Posts & Replies", comment: "Label for filter for seeing posts and replies (instead of only posts).").tag(FilterState.posts_and_replies)
}
.pickerStyle(.segmented)
}
}
@@ -190,10 +158,9 @@ struct ContentView: View {
PostingTimelineView
case .notifications:
VStack(spacing: 0) {
Divider()
NotificationsView(state: damus, notifications: home.notifications)
}
TimelineView(events: $home.notifications, loading: $home.loading, damus: damus, show_friend_icon: true, filter: { _ in true })
.navigationTitle(NSLocalizedString("Notifications", comment: "Navigation title for notifications."))
case .dms:
DirectMessagesView(damus_state: damus_state!)
.environmentObject(home.dms)
@@ -202,21 +169,26 @@ struct ContentView: View {
EmptyView()
}
}
.navigationBarTitle(selected_timeline == .home ? NSLocalizedString("Home", comment: "Navigation bar title for Home view where posts and replies appear from those who the user is following.") : NSLocalizedString("Universe 🛸", comment: "Navigation bar title for universal view where posts from all connected relay servers appear."), displayMode: .inline)
.navigationBarTitle(selected_timeline == .home ? NSLocalizedString("Home", comment: "Navigation bar title for Home view where posts and replies appear from those who the user is following.") : NSLocalizedString("Global", comment: "Navigation bar title for Global view where posts from all connected relay servers appear."), displayMode: .inline)
.toolbar {
ToolbarItem(placement: .principal) {
timelineNavItem
.opacity(isSideBarOpened ? 0 : 1)
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
if selected_timeline == .home {
Image("damus-home")
.resizable()
.frame(width:30,height:30)
.shadow(color: Color("DamusPurple"), radius: 2)
} else {
Text("Global")
}
}
}
.ignoresSafeArea(.keyboard)
}
var MaybeSearchView: some View {
Group {
if let search = self.active_search {
SearchView(appstate: damus_state!, search: SearchModel(contacts: damus_state!.contacts, pool: damus_state!.pool, search: search))
SearchView(appstate: damus_state!, search: SearchModel(pool: damus_state!.pool, search: search))
} else {
EmptyView()
}
@@ -245,70 +217,46 @@ struct ContentView: View {
}
}
func MaybeReportView(target: ReportTarget) -> some View {
Group {
if let ds = damus_state {
if let sec = ds.keypair.privkey {
ReportView(pool: ds.pool, target: target, privkey: sec)
} else {
EmptyView()
}
} else {
EmptyView()
}
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if let damus = self.damus_state {
NavigationView {
ZStack {
TabView { // Prevents navbar appearance change on scroll
VStack {
MainContent(damus: damus)
.toolbar() {
ToolbarItem(placement: .navigationBarLeading) {
Button {
isSideBarOpened.toggle()
} label: {
ProfilePicView(pubkey: damus_state!.pubkey, size: 32, highlight: .none, profiles: damus_state!.profiles)
.opacity(isSideBarOpened ? 0 : 1)
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
if let picture = damus_state?.profiles.lookup(id: pubkey)?.picture {
ProfilePicView(pubkey: damus_state!.pubkey, size: 32, highlight: .none, profiles: damus_state!.profiles, picture: picture)
} else {
Image(systemName: "person.fill")
}
}
.disabled(isSideBarOpened)
}
ToolbarItem(placement: .navigationBarTrailing) {
HStack(alignment: .center) {
if home.signal.signal != home.signal.max_signal {
NavigationLink(destination: RelayConfigView(state: damus_state!)) {
Text("\(home.signal.signal)/\(home.signal.max_signal)", comment: "Fraction of how many of the user's relay servers that are operational.")
.font(.callout)
.foregroundColor(.gray)
}
}
// maybe expand this to other timelines in the future
if selected_timeline == .search {
Button(action: {
//isFilterVisible.toggle()
self.active_sheet = .filter
}) {
// checklist, checklist.checked, lisdt.bullet, list.bullet.circle, line.3.horizontal.decrease..., line.3.horizontail.decrease
Label(NSLocalizedString("Filter", comment: "Button label text for filtering relay servers."), systemImage: "line.3.horizontal.decrease")
.foregroundColor(.gray)
//.contentShape(Rectangle())
}
Text("\(home.signal.signal)/\(home.signal.max_signal)", comment: "Fraction of how many of the user's relay servers that are operational.")
.font(.callout)
.foregroundColor(.gray)
}
}
}
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
Color.clear
.overlay(
SideMenuView(damus_state: damus, isSidebarVisible: $isSideBarOpened)
)
}
.overlay(
SideMenuView(damus_state: damus, isSidebarVisible: $isSideBarOpened.animation())
)
.navigationBarHidden(isSideBarOpened ? true: false) // Would prefer a different way of doing this.
}
.navigationViewStyle(.stack)
@@ -318,27 +266,15 @@ struct ContentView: View {
}
.onAppear() {
self.connect()
//KingfisherManager.shared.cache.clearDiskCache()
setup_notifications()
}
.sheet(item: $active_sheet) { item in
switch item {
case .report(let target):
MaybeReportView(target: target)
case .post:
PostView(replying_to: nil, references: [], damus_state: damus_state!)
PostView(replying_to: nil, references: [])
case .reply(let event):
ReplyView(replying_to: event, damus: damus_state!)
case .event:
EventDetailView()
case .filter:
let timeline = selected_timeline ?? .home
if #available(iOS 16.0, *) {
RelayFilterView(state: damus_state!, timeline: timeline)
.presentationDetents([.height(550)])
.presentationDragIndicator(.visible)
} else {
RelayFilterView(state: damus_state!, timeline: timeline)
}
}
}
.onOpenURL { url in
@@ -382,18 +318,6 @@ struct ContentView: View {
}
.onReceive(handle_notify(.like)) { like in
}
.onReceive(handle_notify(.deleted_account)) { notif in
self.is_deleted_account = true
}
.onReceive(handle_notify(.report)) { notif in
let target = notif.object as! ReportTarget
self.active_sheet = .report(target)
}
.onReceive(handle_notify(.block)) { notif in
let pubkey = notif.object as! String
self.blocking = pubkey
self.confirm_block = true
}
.onReceive(handle_notify(.broadcast_event)) { obj in
let ev = obj.object as! NostrEvent
self.damus_state?.pool.send(.event(ev))
@@ -457,8 +381,6 @@ struct ContentView: View {
let post_res = obj.object as! NostrPostResult
switch post_res {
case .post(let post):
//let post = tup.0
//let to_relays = tup.1
print("post \(post.content)")
let new_ev = post_to_event(post: post, privkey: privkey, pubkey: pubkey)
self.damus_state?.pool.send(.event(new_ev))
@@ -470,100 +392,9 @@ struct ContentView: View {
.onReceive(timer) { n in
self.damus_state?.pool.connect_to_disconnected()
}
.onReceive(handle_notify(.new_mutes)) { notif in
home.filter_muted()
}
.alert(NSLocalizedString("Deleted Account", comment: "Alert message to indicate this is a deleted account"), isPresented: $is_deleted_account) {
Button(NSLocalizedString("Logout", comment: "Button to close the alert that informs that the current account has been deleted.")) {
is_deleted_account = false
notify(.logout, ())
}
}
.alert(NSLocalizedString("User blocked", comment: "Alert message to indicate the user has been blocked"), isPresented: $user_blocked_confirm, actions: {
Button(NSLocalizedString("Thanks!", comment: "Button to close out of alert that informs that the action to block a user was successful.")) {
user_blocked_confirm = false
}
}, message: {
if let pubkey = self.blocking {
let profile = damus_state!.profiles.lookup(id: pubkey)
let name = Profile.displayName(profile: profile, pubkey: pubkey)
Text("\(name) has been blocked", comment: "Alert message that informs a user was blocked.")
} else {
Text("User has been blocked", comment: "Alert message that informs a user was blocked.")
}
})
.alert(NSLocalizedString("Create new mutelist", comment: "Title of alert prompting the user to create a new mutelist."), isPresented: $confirm_overwrite_mutelist, actions: {
Button(NSLocalizedString("Cancel", comment: "Button to cancel out of alert that creates a new mutelist.")) {
confirm_overwrite_mutelist = false
confirm_block = false
}
Button(NSLocalizedString("Yes, Overwrite", comment: "Text of button that confirms to overwrite the existing mutelist.")) {
guard let ds = damus_state else {
return
}
guard let keypair = ds.keypair.to_full() else {
return
}
guard let pubkey = blocking else {
return
}
guard let mutelist = create_or_update_mutelist(keypair: keypair, mprev: nil, to_add: pubkey) else {
return
}
damus_state?.contacts.set_mutelist(mutelist)
ds.pool.send(.event(mutelist))
confirm_overwrite_mutelist = false
confirm_block = false
user_blocked_confirm = true
}
}, message: {
Text("No block list found, create a new one? This will overwrite any previous block lists.", comment: "Alert message prompt that asks if the user wants to create a new block list, overwriting previous block lists.")
})
.alert(NSLocalizedString("Block User", comment: "Title of alert for blocking a user."), isPresented: $confirm_block, actions: {
Button(NSLocalizedString("Cancel", comment: "Alert button to cancel out of alert for blocking a user."), role: .cancel) {
confirm_block = false
}
Button(NSLocalizedString("Block", comment: "Alert button to block a user."), role: .destructive) {
guard let ds = damus_state else {
return
}
if ds.contacts.mutelist == nil {
confirm_overwrite_mutelist = true
} else {
guard let keypair = ds.keypair.to_full() else {
return
}
guard let pubkey = blocking else {
return
}
guard let ev = create_or_update_mutelist(keypair: keypair, mprev: ds.contacts.mutelist, to_add: pubkey) else {
return
}
damus_state?.contacts.set_mutelist(ev)
ds.pool.send(.event(ev))
}
}
}, message: {
if let pubkey = blocking {
let profile = damus_state?.profiles.lookup(id: pubkey)
let name = Profile.displayName(profile: profile, pubkey: pubkey)
Text("Block \(name)?", comment: "Alert message prompt to ask if a user should be blocked.")
} else {
Text("Could not find user to block...", comment: "Alert message to indicate that the blocked user could not be found.")
}
})
}
func switch_timeline(_ timeline: Timeline) {
self.popToRoot()
NotificationCenter.default.post(name: .switched_timeline, object: timeline)
if timeline == self.selected_timeline {
@@ -589,34 +420,21 @@ struct ContentView: View {
func connect() {
let pool = RelayPool()
let metadatas = RelayMetadatas()
let relay_filters = RelayFilters(our_pubkey: pubkey)
let new_relay_filters = load_relay_filters(pubkey) == nil
for relay in BOOTSTRAP_RELAYS {
if let url = URL(string: relay) {
add_new_relay(relay_filters: relay_filters, metadatas: metadatas, pool: pool, url: url, info: .rw, new_relay_filters: new_relay_filters)
}
add_relay(pool, relay)
}
pool.register_handler(sub_id: sub_id, handler: home.handle_event)
self.damus_state = DamusState(pool: pool,
keypair: keypair,
likes: EventCounter(our_pubkey: pubkey),
boosts: EventCounter(our_pubkey: pubkey),
contacts: Contacts(our_pubkey: pubkey),
tips: TipCounter(our_pubkey: pubkey),
profiles: Profiles(),
dms: home.dms,
previews: PreviewCache(),
zaps: Zaps(our_pubkey: pubkey),
lnurls: LNUrls(),
settings: UserSettingsStore(),
relay_filters: relay_filters,
relay_metadata: metadatas,
drafts: Drafts(),
events: EventCache()
self.damus_state = DamusState(pool: pool, keypair: keypair,
likes: EventCounter(our_pubkey: pubkey),
boosts: EventCounter(our_pubkey: pubkey),
contacts: Contacts(our_pubkey: pubkey),
tips: TipCounter(our_pubkey: pubkey),
profiles: Profiles(),
dms: home.dms,
previews: PreviewCache()
)
home.damus_state = self.damus_state!

View File

@@ -15,6 +15,8 @@
</array>
</dict>
</array>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>&quot;Granting Damus access to your photo library allows you to save photos.</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>river</string>
@@ -24,6 +26,7 @@
<string>zeusln</string>
<string>zebedee</string>
<string>lightning</string>
<string>squarecash</string>
<string>phoenix</string>
<string>lnlink</string>
<string>strike</string>

View File

@@ -11,43 +11,30 @@ import Foundation
class ActionBarModel: ObservableObject {
@Published var our_like: NostrEvent?
@Published var our_boost: NostrEvent?
@Published var our_zap: Zap?
@Published var our_tip: NostrEvent?
@Published var likes: Int
@Published var boosts: Int
@Published var zaps: Int
@Published var zap_total: Int64
@Published var tips: Int64
static func empty() -> ActionBarModel {
return ActionBarModel(likes: 0, boosts: 0, zaps: 0, zap_total: 0, our_like: nil, our_boost: nil, our_zap: nil)
return ActionBarModel(likes: 0, boosts: 0, tips: 0, our_like: nil, our_boost: nil, our_tip: nil)
}
init(likes: Int, boosts: Int, zaps: Int, zap_total: Int64, our_like: NostrEvent?, our_boost: NostrEvent?, our_zap: Zap?) {
init(likes: Int, boosts: Int, tips: Int64, our_like: NostrEvent?, our_boost: NostrEvent?, our_tip: NostrEvent?) {
self.likes = likes
self.boosts = boosts
self.zaps = zaps
self.zap_total = zap_total
self.tips = tips
self.our_like = our_like
self.our_boost = our_boost
self.our_zap = our_zap
}
func update(damus: DamusState, evid: String) {
self.likes = damus.likes.counts[evid] ?? 0
self.boosts = damus.boosts.counts[evid] ?? 0
self.zaps = damus.zaps.event_counts[evid] ?? 0
self.zap_total = damus.zaps.event_totals[evid] ?? 0
self.our_like = damus.likes.our_events[evid]
self.our_boost = damus.boosts.our_events[evid]
self.our_zap = damus.zaps.our_zaps[evid]?.first
self.objectWillChange.send()
self.our_tip = our_tip
}
var is_empty: Bool {
return likes == 0 && boosts == 0 && zaps == 0
return likes == 0 && boosts == 0 && tips == 0
}
var zapped: Bool {
return our_zap != nil
var tipped: Bool {
return our_tip != nil
}
var liked: Bool {

View File

@@ -1,50 +0,0 @@
//
// BookmarksManager.swift
// damus
//
// Created by Joel Klabo on 2/18/23.
//
import Foundation
class BookmarksManager {
private let userDefaults = UserDefaults.standard
private let pubkey: String
init(pubkey: String) {
self.pubkey = pubkey
}
var bookmarks: [String] {
get {
return userDefaults.stringArray(forKey: storageKey()) ?? []
}
set {
let uniqueBookmarks = Array(Set(newValue))
if uniqueBookmarks != bookmarks {
userDefaults.set(uniqueBookmarks, forKey: storageKey())
}
}
}
func isBookmarked(_ string: String) -> Bool {
return bookmarks.contains(string)
}
func updateBookmark(_ string: String) {
if isBookmarked(string) {
bookmarks = bookmarks.filter { $0 != string }
} else {
bookmarks.append(string)
}
}
func clearAll() {
bookmarks = []
}
private func storageKey() -> String {
pk_setting_key(pubkey, key: "bookmarks")
}
}

View File

@@ -11,51 +11,13 @@ import Foundation
class Contacts {
private var friends: Set<String> = Set()
private var friend_of_friends: Set<String> = Set()
private var muted: Set<String> = Set()
let our_pubkey: String
var event: NostrEvent?
var mutelist: NostrEvent?
init(our_pubkey: String) {
self.our_pubkey = our_pubkey
}
func is_muted(_ pk: String) -> Bool {
return muted.contains(pk)
}
func set_mutelist(_ ev: NostrEvent) {
let oldlist = self.mutelist
self.mutelist = ev
let old = Set(oldlist?.referenced_pubkeys.map({ $0.ref_id }) ?? [])
let new = Set(ev.referenced_pubkeys.map({ $0.ref_id }))
let diff = old.symmetricDifference(new)
var new_mutes = Array<String>()
var new_unmutes = Array<String>()
for d in diff {
if new.contains(d) {
new_mutes.append(d)
} else {
new_unmutes.append(d)
}
}
// TODO: set local mutelist here
self.muted = Set(ev.referenced_pubkeys.map({ $0.ref_id }))
if new_mutes.count > 0 {
notify(.new_mutes, new_mutes)
}
if new_unmutes.count > 0 {
notify(.new_unmutes, new_unmutes)
}
}
func get_friendosphere() -> [String] {
var fs = get_friend_list()
fs.append(contentsOf: get_friend_of_friend_list())

View File

@@ -18,23 +18,12 @@ struct DamusState {
let profiles: Profiles
let dms: DirectMessagesModel
let previews: PreviewCache
let zaps: Zaps
let lnurls: LNUrls
let settings: UserSettingsStore
let relay_filters: RelayFilters
let relay_metadata: RelayMetadatas
let drafts: Drafts
let events: EventCache
var pubkey: String {
return keypair.pubkey
}
var is_privkey_user: Bool {
keypair.privkey != nil
}
static var empty: DamusState {
return DamusState.init(pool: RelayPool(), keypair: Keypair(pubkey: "", privkey: ""), likes: EventCounter(our_pubkey: ""), boosts: EventCounter(our_pubkey: ""), contacts: Contacts(our_pubkey: ""), tips: TipCounter(our_pubkey: ""), profiles: Profiles(), dms: DirectMessagesModel(our_pubkey: ""), previews: PreviewCache(), zaps: Zaps(our_pubkey: ""), lnurls: LNUrls(), settings: UserSettingsStore(), relay_filters: RelayFilters(our_pubkey: ""), relay_metadata: RelayMetadatas(), drafts: Drafts(), events: EventCache())
return DamusState.init(pool: RelayPool(), keypair: Keypair(pubkey: "", privkey: ""), likes: EventCounter(our_pubkey: ""), boosts: EventCounter(our_pubkey: ""), contacts: Contacts(our_pubkey: ""), tips: TipCounter(our_pubkey: ""), profiles: Profiles(), dms: DirectMessagesModel(), previews: PreviewCache())
}
}

View File

@@ -1,35 +0,0 @@
//
// DeepLPlan.swift
// damus
//
// Created by Terry Yiu on 2/3/23.
//
import Foundation
enum DeepLPlan: String, CaseIterable, Identifiable {
var id: String { self.rawValue }
struct Model: Identifiable, Hashable {
var id: String { self.tag }
var tag: String
var displayName: String
var url: String
}
case free
case pro
var model: Model {
switch self {
case .free:
return .init(tag: self.rawValue, displayName: NSLocalizedString("Free", comment: "Dropdown option for selecting Free plan for DeepL translation service."), url: "https://api-free.deepl.com")
case .pro:
return .init(tag: self.rawValue, displayName: NSLocalizedString("Pro", comment: "Dropdown option for selecting Pro plan for DeepL translation service."), url: "https://api.deepl.com")
}
}
static var allModels: [Model] {
return Self.allCases.map { $0.model }
}
}

View File

@@ -8,38 +8,13 @@
import Foundation
class DirectMessageModel: ObservableObject {
@Published var events: [NostrEvent] {
didSet {
is_request = determine_is_request()
}
}
@Published var draft: String
@Published var events: [NostrEvent]
var is_request: Bool
var our_pubkey: String
func determine_is_request() -> Bool {
for event in events {
if event.pubkey == our_pubkey {
return false
}
}
return true
}
init(events: [NostrEvent], our_pubkey: String) {
init(events: [NostrEvent]) {
self.events = events
self.is_request = false
self.our_pubkey = our_pubkey
self.draft = ""
}
init(our_pubkey: String) {
init() {
self.events = []
self.is_request = false
self.our_pubkey = our_pubkey
self.draft = ""
}
}

View File

@@ -10,26 +10,13 @@ import Foundation
class DirectMessagesModel: ObservableObject {
@Published var dms: [(String, DirectMessageModel)] = []
@Published var loading: Bool = false
let our_pubkey: String
init(our_pubkey: String) {
self.our_pubkey = our_pubkey
}
var message_requests: [(String, DirectMessageModel)] {
return dms.filter { dm in dm.1.is_request }
}
var friend_dms: [(String, DirectMessageModel)] {
return dms.filter { dm in !dm.1.is_request }
}
func lookup_or_create(_ pubkey: String) -> DirectMessageModel {
if let dm = lookup(pubkey) {
return dm
}
let new = DirectMessageModel(our_pubkey: our_pubkey)
let new = DirectMessageModel()
dms.append((pubkey, new))
return new
}

View File

@@ -1,13 +0,0 @@
//
// DraftsModel.swift
// damus
//
// Created by Terry Yiu on 2/12/23.
//
import Foundation
class Drafts: ObservableObject {
@Published var post: NSMutableAttributedString = NSMutableAttributedString(string: "")
@Published var replies: [NostrEvent: NSMutableAttributedString] = [:]
}

View File

@@ -9,63 +9,9 @@ import Foundation
class EventsModel: ObservableObject {
let state: DamusState
let target: String
let kind: NostrKind
let sub_id = UUID().uuidString
let profiles_id = UUID().uuidString
var has_event: Set<String> = Set()
@Published var events: [NostrEvent] = []
init(state: DamusState, target: String, kind: NostrKind) {
self.state = state
self.target = target
self.kind = kind
}
private func get_filter() -> NostrFilter {
var filter = NostrFilter.filter_kinds([kind.rawValue])
filter.referenced_ids = [target]
filter.limit = 500
return filter
}
func subscribe() {
state.pool.subscribe(sub_id: sub_id,
filters: [get_filter()],
handler: handle_nostr_event)
}
func unsubscribe() {
state.pool.unsubscribe(sub_id: sub_id)
}
private func handle_event(relay_id: String, ev: NostrEvent) {
guard ev.kind == kind.rawValue else {
return
}
guard last_etag(tags: ev.tags) == target else {
return
}
if insert_uniq_sorted_event(events: &self.events, new_ev: ev, cmp: { a, b in a.created_at < b.created_at } ) {
objectWillChange.send()
}
}
func handle_nostr_event(relay_id: String, ev: NostrConnectionEvent) {
guard case .nostr_event(let nev) = ev else {
return
}
switch nev {
case .event(_, let ev):
handle_event(relay_id: relay_id, ev: ev)
case .notice(_):
break
case .eose(_):
load_profiles(profiles_subid: profiles_id, relay_id: relay_id, load: .from_events(events), damus_state: state)
}
init() {
}
}

View File

@@ -51,7 +51,11 @@ class FollowersModel: ObservableObject {
if has_contact.contains(ev.pubkey) {
return
}
process_contact_event(state: damus_state, ev: ev)
process_contact_event(
pool: damus_state.pool,
contacts: damus_state.contacts,
pubkey: damus_state.pubkey, ev: ev
)
contacts?.append(ev.pubkey)
has_contact.insert(ev.pubkey)
}
@@ -82,7 +86,7 @@ class FollowersModel: ObservableObject {
if ev.known_kind == .contacts {
handle_contact_event(ev)
} else if ev.known_kind == .metadata {
process_metadata_event(our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev)
process_metadata_event(profiles: damus_state.profiles, ev: ev)
}
case .notice(let msg):

View File

@@ -60,7 +60,7 @@ class FollowingModel {
switch nev {
case .event(_, let ev):
if ev.kind == 0 {
process_metadata_event(our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev)
process_metadata_event(profiles: damus_state.profiles, ev: ev)
}
case .notice(let msg):
print("followingmodel notice: \(msg)")

View File

@@ -38,9 +38,6 @@ class HomeModel: ObservableObject {
var channels: [String: NostrEvent] = [:]
var last_event_of_kind: [String: [Int: NostrEvent]] = [:]
var done_init: Bool = false
var incoming_dms: [NostrEvent] = []
let dm_debouncer = Debouncer(interval: 0.5)
var should_debounce_dms = true
let home_subid = UUID().description
let contacts_subid = UUID().description
@@ -50,33 +47,23 @@ class HomeModel: ObservableObject {
let profiles_subid = UUID().description
@Published var new_events: NewEventsBits = NewEventsBits()
@Published var notifications = NotificationsModel()
@Published var dms: DirectMessagesModel
@Published var events = EventHolder()
@Published var notifications: [NostrEvent] = []
@Published var dms: DirectMessagesModel = DirectMessagesModel()
@Published var events: [NostrEvent] = []
@Published var loading: Bool = false
@Published var signal: SignalModel = SignalModel()
init() {
self.damus_state = DamusState.empty
self.dms = DirectMessagesModel(our_pubkey: "")
}
init(damus_state: DamusState) {
self.damus_state = damus_state
self.dms = DirectMessagesModel(our_pubkey: damus_state.pubkey)
self.setup_debouncer()
}
var pool: RelayPool {
return damus_state.pool
}
func setup_debouncer() {
// turn off debouncer after initial load
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
self.should_debounce_dms = false
}
}
func has_sub_id_event(sub_id: String, ev_id: String) -> Bool {
if !has_event.keys.contains(sub_id) {
@@ -109,8 +96,6 @@ class HomeModel: ObservableObject {
handle_contact_event(sub_id: sub_id, relay_id: relay_id, ev: ev)
case .metadata:
handle_metadata_event(ev)
case .list:
handle_list_event(ev)
case .boost:
handle_boost_event(sub_id: sub_id, ev)
case .like:
@@ -123,64 +108,9 @@ class HomeModel: ObservableObject {
handle_channel_create(ev)
case .channel_meta:
handle_channel_meta(ev)
case .zap:
handle_zap_event(ev)
case .zap_request:
break
}
}
func handle_zap_event_with_zapper(_ ev: NostrEvent, our_pubkey: String, zapper: String) {
guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper) else {
return
}
damus_state.zaps.add_zap(zap: zap)
guard zap.target.pubkey == our_pubkey else {
return
}
if !notifications.insert_zap(zap) {
return
}
handle_last_event(ev: ev, timeline: .notifications)
return
}
func handle_zap_event(_ ev: NostrEvent) {
// These are zap notifications
guard let ptag = event_tag(ev, name: "p") else {
return
}
if let local_zapper = damus_state.profiles.lookup_zapper(pubkey: ptag) {
handle_zap_event_with_zapper(ev, our_pubkey: damus_state.pubkey, zapper: local_zapper)
return
}
guard let profile = damus_state.profiles.lookup(id: ptag) else {
return
}
guard let lnurl = profile.lnurl else {
return
}
Task {
guard let zapper = await fetch_zapper_from_lnurl(lnurl) else {
return
}
DispatchQueue.main.async {
self.damus_state.profiles.zappers[ptag] = zapper
self.handle_zap_event_with_zapper(ev, our_pubkey: self.damus_state.pubkey, zapper: zapper)
}
}
}
func handle_channel_create(_ ev: NostrEvent) {
guard ev.is_valid else {
return
@@ -192,12 +122,6 @@ class HomeModel: ObservableObject {
func handle_channel_meta(_ ev: NostrEvent) {
}
func filter_muted() {
events.filter { !damus_state.contacts.is_muted($0.pubkey) }
self.dms.dms = dms.dms.filter { !damus_state.contacts.is_muted($0.0) }
notifications.filter { !damus_state.contacts.is_muted($0.pubkey) }
}
func handle_delete_event(_ ev: NostrEvent) {
guard ev.is_valid else {
return
@@ -207,7 +131,7 @@ class HomeModel: ObservableObject {
}
func handle_contact_event(sub_id: String, relay_id: String, ev: NostrEvent) {
process_contact_event(state: self.damus_state, ev: ev)
process_contact_event(pool: damus_state.pool, contacts: damus_state.contacts, pubkey: damus_state.pubkey, ev: ev)
if sub_id == init_subid {
pool.send(.unsubscribe(init_subid), to: [relay_id])
@@ -227,7 +151,7 @@ class HomeModel: ObservableObject {
guard inner_ev.is_valid else {
return
}
if inner_ev.is_textlike {
handle_text_event(sub_id: sub_id, ev)
}
@@ -243,7 +167,6 @@ class HomeModel: ObservableObject {
case .success(let n):
let boosted = Counted(event: ev, id: e, total: n)
notify(.boosted, boosted)
notify(.update_stats, e)
}
}
@@ -253,14 +176,14 @@ class HomeModel: ObservableObject {
return
}
// CHECK SIGS ON THESE
switch damus_state.likes.add_event(ev, target: e.ref_id) {
case .already_counted:
break
case .success(let n):
handle_notification(ev: ev)
let liked = Counted(event: ev, id: e.ref_id, total: n)
notify(.liked, liked)
notify(.update_stats, e.ref_id)
}
}
@@ -301,7 +224,7 @@ class HomeModel: ObservableObject {
switch ev {
case .event(let sub_id, let ev):
// globally handle likes
let always_process = sub_id == notifications_subid || sub_id == contacts_subid || sub_id == home_subid || sub_id == dms_subid || sub_id == init_subid || ev.known_kind == .like || ev.known_kind == .boost || ev.known_kind == .zap || ev.known_kind == .contacts || ev.known_kind == .metadata
let always_process = sub_id == notifications_subid || sub_id == contacts_subid || sub_id == home_subid || sub_id == dms_subid || sub_id == init_subid || ev.known_kind == .like || ev.known_kind == .contacts || ev.known_kind == .metadata
if !always_process {
// TODO: other views like threads might have their own sub ids, so ignore those events... or should we?
return
@@ -315,11 +238,10 @@ class HomeModel: ObservableObject {
case .eose(let sub_id):
if sub_id == dms_subid {
var dms = dms.dms.flatMap { $0.1.events }
dms.append(contentsOf: incoming_dms)
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(dms), damus_state: damus_state)
let dms = dms.dms.flatMap { $0.1.events }
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, events: dms, damus_state: damus_state)
} else if sub_id == notifications_subid {
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_keys(notifications.uniq_pubkeys()), damus_state: damus_state)
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, events: notifications, damus_state: damus_state)
}
self.loading = false
@@ -350,11 +272,7 @@ class HomeModel: ObservableObject {
var our_contacts_filter = NostrFilter.filter_kinds([3, 0])
our_contacts_filter.authors = [damus_state.pubkey]
var our_blocklist_filter = NostrFilter.filter_kinds([30000])
our_blocklist_filter.parameter = ["mute"]
our_blocklist_filter.authors = [damus_state.pubkey]
var dms_filter = NostrFilter.filter_kinds([
NostrKind.dm.rawValue,
])
@@ -372,6 +290,7 @@ class HomeModel: ObservableObject {
// TODO: separate likes?
var home_filter = NostrFilter.filter_kinds([
NostrKind.text.rawValue,
NostrKind.chat.rawValue,
NostrKind.like.rawValue,
NostrKind.boost.rawValue,
])
@@ -381,16 +300,16 @@ class HomeModel: ObservableObject {
var notifications_filter = NostrFilter.filter_kinds([
NostrKind.text.rawValue,
NostrKind.chat.rawValue,
NostrKind.like.rawValue,
NostrKind.boost.rawValue,
NostrKind.zap.rawValue,
])
notifications_filter.pubkeys = [damus_state.pubkey]
notifications_filter.limit = 100
var home_filters = [home_filter]
var notifications_filters = [notifications_filter]
var contacts_filters = [contacts_filter, our_contacts_filter, our_blocklist_filter]
var contacts_filters = [contacts_filter, our_contacts_filter]
var dms_filters = [dms_filter, our_dms_filter]
let last_of_kind = relay_id.flatMap { last_event_of_kind[$0] } ?? [:]
@@ -414,32 +333,9 @@ class HomeModel: ObservableObject {
pool.send(.subscribe(.init(filters: dms_filters, sub_id: dms_subid)))
}
}
func handle_list_event(_ ev: NostrEvent) {
// we only care about our lists
guard ev.pubkey == damus_state.pubkey else {
return
}
if let mutelist = damus_state.contacts.mutelist {
if ev.created_at <= mutelist.created_at {
return
}
}
guard let name = get_referenced_ids(tags: ev.tags, key: "d").first else {
return
}
guard name.ref_id == "mute" else {
return
}
damus_state.contacts.set_mutelist(ev)
}
func handle_metadata_event(_ ev: NostrEvent) {
process_metadata_event(our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev)
process_metadata_event(profiles: damus_state.profiles, ev: ev)
}
func get_last_event_of_kind(relay_id: String, kind: Int) -> NostrEvent? {
@@ -451,70 +347,93 @@ class HomeModel: ObservableObject {
return m[kind]
}
func handle_notification(ev: NostrEvent) {
guard event_has_our_pubkey(ev, our_pubkey: self.damus_state.pubkey) else {
return
}
damus_state.events.insert(ev)
if let inner_ev = ev.inner_event {
damus_state.events.insert(inner_ev)
}
if !notifications.insert_event(ev) {
return
}
handle_last_event(ev: ev, timeline: .notifications)
}
func handle_last_event(ev: NostrEvent, timeline: Timeline, shouldNotify: Bool = true) {
if let new_bits = handle_last_events(new_events: self.new_events, ev: ev, timeline: timeline, shouldNotify: shouldNotify) {
new_events = new_bits
let last_ev = get_last_event(timeline)
if last_ev == nil || last_ev!.created_at < ev.created_at {
save_last_event(ev, timeline: timeline)
if shouldNotify {
new_events = NewEventsBits(prev: new_events, setting: timeline)
}
}
}
func insert_home_event(_ ev: NostrEvent) {
if events.insert(ev) {
func handle_notification(ev: NostrEvent) {
if !insert_uniq_sorted_event(events: &notifications, new_ev: ev, cmp: { $0.created_at > $1.created_at }) {
return
}
handle_last_event(ev: ev, timeline: .notifications)
}
func insert_home_event(_ ev: NostrEvent) -> Bool {
let ok = insert_uniq_sorted_event(events: &self.events, new_ev: ev, cmp: { $0.created_at > $1.created_at })
if ok {
handle_last_event(ev: ev, timeline: .home)
}
return ok
}
func should_hide_event(_ ev: NostrEvent) -> Bool {
return !ev.should_show_event
}
func handle_text_event(sub_id: String, _ ev: NostrEvent) {
guard should_show_event(contacts: damus_state.contacts, ev: ev) else {
if should_hide_event(ev) {
return
}
damus_state.events.insert(ev)
if sub_id == home_subid {
insert_home_event(ev)
let _ = insert_home_event(ev)
} else if sub_id == notifications_subid {
handle_notification(ev: ev)
}
}
func handle_dm(_ ev: NostrEvent) {
guard should_show_event(contacts: damus_state.contacts, ev: ev) else {
return
}
if !should_debounce_dms {
self.incoming_dms.append(ev)
if let notifs = handle_incoming_dms(prev_events: self.new_events, dms: self.dms, our_pubkey: self.damus_state.pubkey, evs: self.incoming_dms) {
self.new_events = notifs
var inserted = false
var found = false
let ours = ev.pubkey == self.damus_state.pubkey
var i = 0
var the_pk = ev.pubkey
if ours {
if let ref_pk = ev.referenced_pubkeys.first {
the_pk = ref_pk.ref_id
} else {
// self dm!?
print("TODO: handle self dm?")
}
self.incoming_dms = []
return
}
incoming_dms.append(ev)
dm_debouncer.debounce {
if let notifs = handle_incoming_dms(prev_events: self.new_events, dms: self.dms, our_pubkey: self.damus_state.pubkey, evs: self.incoming_dms) {
self.new_events = notifs
for (pk, _) in dms.dms {
if pk == the_pk {
found = true
inserted = insert_uniq_sorted_event(events: &(dms.dms[i].1.events), new_ev: ev) {
$0.created_at < $1.created_at
}
break
}
i += 1
}
if !found {
inserted = true
let model = DirectMessageModel(events: [ev])
dms.dms.append((the_pk, model))
}
if inserted {
handle_last_event(ev: ev, timeline: .dms, shouldNotify: !ours)
dms.dms = dms.dms.sorted { a, b in
if a.1.events.count > 0 && b.1.events.count > 0 {
return a.1.events.last!.created_at > b.1.events.last!.created_at
}
return false
}
self.incoming_dms = []
}
}
}
@@ -620,17 +539,10 @@ func print_filters(relay_id: String?, filters groups: [[NostrFilter]]) {
print("-----")
}
func process_metadata_event(our_pubkey: String, profiles: Profiles, ev: NostrEvent) {
func process_metadata_event(profiles: Profiles, ev: NostrEvent) {
guard let profile: Profile = decode_data(Data(ev.content.utf8)) else {
return
}
if our_pubkey == ev.pubkey && (profile.deleted ?? false) {
DispatchQueue.main.async {
notify(.deleted_account, ())
}
return
}
var old_nip05: String? = nil
if let mprof = profiles.lookup_with_timestamp(id: ev.pubkey) {
@@ -660,14 +572,14 @@ func process_metadata_event(our_pubkey: String, profiles: Profiles, ev: NostrEve
// load pfps asap
let picture = tprof.profile.picture ?? robohash(ev.pubkey)
if URL(string: picture) != nil {
if let _ = URL(string: picture) {
DispatchQueue.main.async {
notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile))
}
}
let banner = tprof.profile.banner ?? ""
if URL(string: banner) != nil {
if let _ = URL(string: banner) {
DispatchQueue.main.async {
notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile))
}
@@ -680,31 +592,31 @@ func robohash(_ pk: String) -> String {
return "https://robohash.org/" + pk
}
func load_our_stuff(state: DamusState, ev: NostrEvent) {
guard ev.pubkey == state.pubkey else {
func load_our_stuff(pool: RelayPool, contacts: Contacts, pubkey: String, ev: NostrEvent) {
guard ev.pubkey == pubkey else {
return
}
// only use new stuff
if let current_ev = state.contacts.event {
if let current_ev = contacts.event {
guard ev.created_at > current_ev.created_at else {
return
}
}
let m_old_ev = state.contacts.event
state.contacts.event = ev
let m_old_ev = contacts.event
contacts.event = ev
load_our_contacts(contacts: state.contacts, our_pubkey: state.pubkey, m_old_ev: m_old_ev, ev: ev)
load_our_relays(state: state, m_old_ev: m_old_ev, ev: ev)
load_our_contacts(contacts: contacts, our_pubkey: pubkey, m_old_ev: m_old_ev, ev: ev)
load_our_relays(contacts: contacts, our_pubkey: pubkey, pool: pool, m_old_ev: m_old_ev, ev: ev)
}
func process_contact_event(state: DamusState, ev: NostrEvent) {
load_our_stuff(state: state, ev: ev)
add_contact_if_friend(contacts: state.contacts, ev: ev)
func process_contact_event(pool: RelayPool, contacts: Contacts, pubkey: String, ev: NostrEvent) {
load_our_stuff(pool: pool, contacts: contacts, pubkey: pubkey, ev: ev)
add_contact_if_friend(contacts: contacts, ev: ev)
}
func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
func load_our_relays(contacts: Contacts, our_pubkey: String, pool: RelayPool, m_old_ev: NostrEvent?, ev: NostrEvent) {
let bootstrap_dict: [String: RelayInfo] = [:]
let old_decoded = m_old_ev.flatMap { decode_json_relays($0.content) } ?? BOOTSTRAP_RELAYS.reduce(into: bootstrap_dict) { (d, r) in
d[r] = .rw
@@ -728,15 +640,14 @@ func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
let diff = old.symmetricDifference(new)
let new_relay_filters = load_relay_filters(state.pubkey) == nil
for d in diff {
changed = true
if new.contains(d) {
if let url = URL(string: d) {
add_new_relay(relay_filters: state.relay_filters, metadatas: state.relay_metadata, pool: state.pool, url: url, info: decoded[d] ?? .rw, new_relay_filters: new_relay_filters)
try? pool.add_relay(url, info: decoded[d] ?? .rw)
}
} else {
state.pool.remove_relay(d)
pool.remove_relay(d)
}
}
@@ -745,155 +656,4 @@ func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
}
}
func add_new_relay(relay_filters: RelayFilters, metadatas: RelayMetadatas, pool: RelayPool, url: URL, info: RelayInfo, new_relay_filters: Bool) {
try? pool.add_relay(url, info: info)
let relay_id = url.absoluteString
guard metadatas.lookup(relay_id: relay_id) == nil else {
return
}
Task.detached(priority: .background) {
guard let meta = try? await fetch_relay_metadata(relay_id: relay_id) else {
return
}
DispatchQueue.main.async {
metadatas.insert(relay_id: relay_id, metadata: meta)
// if this is the first time adding filters, we should filter non-paid relays
if new_relay_filters && !meta.is_paid {
relay_filters.insert(timeline: .search, relay_id: relay_id)
}
}
}
}
func fetch_relay_metadata(relay_id: String) async throws -> RelayMetadata? {
var urlString = relay_id.replacingOccurrences(of: "wss://", with: "https://")
urlString = urlString.replacingOccurrences(of: "ws://", with: "http://")
guard let url = URL(string: urlString) else {
return nil
}
var request = URLRequest(url: url)
request.setValue("application/nostr+json", forHTTPHeaderField: "Accept")
var res: (Data, URLResponse)? = nil
res = try await URLSession.shared.data(for: request)
guard let data = res?.0 else {
return nil
}
let nip11 = try JSONDecoder().decode(RelayMetadata.self, from: data)
return nip11
}
func process_relay_metadata() {
}
@discardableResult
func handle_incoming_dm(ev: NostrEvent, our_pubkey: String, dms: DirectMessagesModel, prev_events: NewEventsBits) -> (Bool, NewEventsBits?) {
var inserted = false
var found = false
let ours = ev.pubkey == our_pubkey
var i = 0
var the_pk = ev.pubkey
if ours {
if let ref_pk = ev.referenced_pubkeys.first {
the_pk = ref_pk.ref_id
} else {
// self dm!?
print("TODO: handle self dm?")
}
}
for (pk, _) in dms.dms {
if pk == the_pk {
found = true
inserted = insert_uniq_sorted_event(events: &(dms.dms[i].1.events), new_ev: ev) {
$0.created_at < $1.created_at
}
break
}
i += 1
}
if !found {
let model = DirectMessageModel(events: [ev], our_pubkey: our_pubkey)
dms.dms.append((the_pk, model))
inserted = true
}
var new_bits: NewEventsBits? = nil
if inserted {
new_bits = handle_last_events(new_events: prev_events, ev: ev, timeline: .dms, shouldNotify: !ours)
}
return (inserted, new_bits)
}
@discardableResult
func handle_incoming_dms(prev_events: NewEventsBits, dms: DirectMessagesModel, our_pubkey: String, evs: [NostrEvent]) -> NewEventsBits? {
var inserted = false
var new_events: NewEventsBits? = nil
for ev in evs {
let res = handle_incoming_dm(ev: ev, our_pubkey: our_pubkey, dms: dms, prev_events: prev_events)
inserted = res.0 || inserted
if let new = res.1 {
new_events = new
}
}
if inserted {
dms.dms = dms.dms.filter({ $0.1.events.count > 0 }).sorted { a, b in
return a.1.events.last!.created_at > b.1.events.last!.created_at
}
}
return new_events
}
/// A helper to determine if we need to notify the user of new events
func handle_last_events(new_events: NewEventsBits, ev: NostrEvent, timeline: Timeline, shouldNotify: Bool = true) -> NewEventsBits? {
let last_ev = get_last_event(timeline)
if last_ev == nil || last_ev!.created_at < ev.created_at {
save_last_event(ev, timeline: timeline)
if shouldNotify {
return NewEventsBits(prev: new_events, setting: timeline)
}
}
return nil
}
/// Sometimes we get garbage in our notifications. Ensure we have our pubkey on this event
func event_has_our_pubkey(_ ev: NostrEvent, our_pubkey: String) -> Bool {
for tag in ev.tags {
if tag.count >= 2 && tag[0] == "p" && tag[1] == our_pubkey {
return true
}
}
return false
}
func should_show_event(contacts: Contacts, ev: NostrEvent) -> Bool {
if contacts.is_muted(ev.pubkey) {
return false
}
return ev.should_show_event
}

View File

@@ -1,41 +0,0 @@
//
// LibreTranslateServer.swift
// damus
//
// Created by Terry Yiu on 1/21/23.
//
import Foundation
enum LibreTranslateServer: String, CaseIterable, Identifiable {
var id: String { self.rawValue }
struct Model: Identifiable, Hashable {
var id: String { self.tag }
var tag: String
var displayName: String
var url: String?
}
case argosopentech
case terraprint
case vern
case custom
var model: Model {
switch self {
case .argosopentech:
return .init(tag: self.rawValue, displayName: "translate.argosopentech.com", url: "https://translate.argosopentech.com")
case .terraprint:
return .init(tag: self.rawValue, displayName: "translate.terraprint.co", url: "https://translate.terraprint.co")
case .vern:
return .init(tag: self.rawValue, displayName: "lt.vern.cc", url: "https://lt.vern.cc")
case .custom:
return .init(tag: self.rawValue, displayName: NSLocalizedString("Custom", comment: "Dropdown option for selecting a custom translation server."), url: nil)
}
}
static var allModels: [Model] {
return Self.allCases.map { $0.model }
}
}

View File

@@ -7,10 +7,6 @@
import Foundation
enum CountResult {
case already_counted
case success(Int)
}
class EventCounter {
var counts: [String: Int] = [:]
@@ -18,6 +14,11 @@ class EventCounter {
var our_events: [String: NostrEvent] = [:]
var our_pubkey: String
enum CountResult {
case already_counted
case success(Int)
}
init (our_pubkey: String) {
self.our_pubkey = our_pubkey
}

View File

@@ -32,30 +32,13 @@ struct IdBlock: Identifiable {
let block: Block
}
typealias Invoice = LightningInvoice<Amount>
typealias ZapInvoice = LightningInvoice<Int64>
enum InvoiceDescription {
case description(String)
case description_hash(Data)
}
struct LightningInvoice<T> {
let description: InvoiceDescription
let amount: T
struct Invoice {
let description: String
let amount: Amount
let string: String
let expiry: UInt64
let payment_hash: Data
let created_at: UInt64
var description_string: String {
switch description {
case .description(let string):
return string
case .description_hash:
return ""
}
}
}
enum Block {
@@ -206,78 +189,24 @@ enum Amount: Equatable {
case .any:
return NSLocalizedString("Any", comment: "Any amount of sats")
case .specific(let amt):
return format_msats(amt)
let numberFormatter = NumberFormatter()
numberFormatter.numberStyle = .decimal
numberFormatter.minimumFractionDigits = 0
numberFormatter.maximumFractionDigits = 3
numberFormatter.roundingMode = .down
let sats = NSNumber(value: (Double(amt) / 1000.0))
let formattedSats = numberFormatter.string(from: sats) ?? sats.stringValue
if formattedSats == numberFormatter.string(from: 1) {
return NSLocalizedString("\(formattedSats) sat", comment: "Amount of 1 sat.")
}
return NSLocalizedString("\(formattedSats) sats", comment: "Amount of sats.")
}
}
}
func format_actions_abbrev(_ actions: Int) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.positiveSuffix = "m"
formatter.positivePrefix = ""
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 3
formatter.roundingMode = .down
formatter.roundingIncrement = 0.1
formatter.multiplier = 1
if actions >= 1_000_000 {
formatter.positiveSuffix = "m"
formatter.multiplier = 0.000001
} else if actions >= 1000 {
formatter.positiveSuffix = "k"
formatter.multiplier = 0.001
} else {
return "\(actions)"
}
let actions = NSNumber(value: actions)
return formatter.string(from: actions) ?? "\(actions)"
}
func format_msats_abbrev(_ msats: Int64) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.positiveSuffix = "m"
formatter.positivePrefix = ""
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 3
formatter.roundingMode = .down
formatter.roundingIncrement = 0.1
formatter.multiplier = 1
let sats = NSNumber(value: (Double(msats) / 1000.0))
if msats >= 1_000_000*1000 {
formatter.positiveSuffix = "m"
formatter.multiplier = 0.000001
} else if msats >= 1000*1000 {
formatter.positiveSuffix = "k"
formatter.multiplier = 0.001
} else {
return sats.stringValue
}
return formatter.string(from: sats) ?? sats.stringValue
}
func format_msats(_ msat: Int64, locale: Locale = Locale.current) -> String {
let numberFormatter = NumberFormatter()
numberFormatter.numberStyle = .decimal
numberFormatter.minimumFractionDigits = 0
numberFormatter.maximumFractionDigits = 3
numberFormatter.roundingMode = .down
numberFormatter.locale = locale
let sats = NSNumber(value: (Double(msat) / 1000.0))
let formattedSats = numberFormatter.string(from: sats) ?? sats.stringValue
let bundle = bundleForLocale(locale: locale)
return String(format: bundle.localizedString(forKey: "sats_count", value: nil, table: nil), locale: locale, sats.decimalValue as NSDecimalNumber, formattedSats)
}
func convert_invoice_block(_ b: invoice_block) -> Block? {
guard let invstr = strblock_to_string(b.invstr) else {
return nil
@@ -287,8 +216,9 @@ func convert_invoice_block(_ b: invoice_block) -> Block? {
return nil
}
guard let description = convert_invoice_description(b11: b11) else {
return nil
var description = ""
if b11.description != nil {
description = String(cString: b11.description)
}
let amount: Amount = maybe_pointee(b11.msat).map { .specific(Int64($0.millisatoshis)) } ?? .any
@@ -299,18 +229,6 @@ func convert_invoice_block(_ b: invoice_block) -> Block? {
return .invoice(Invoice(description: description, amount: amount, string: invstr, expiry: b11.expiry, payment_hash: payment_hash, created_at: created_at))
}
func convert_invoice_description(b11: bolt11) -> InvoiceDescription? {
if let desc = b11.description {
return .description(String(cString: desc))
}
if var deschash = maybe_pointee(b11.description_hash) {
return .description_hash(Data(bytes: &deschash, count: 32))
}
return nil
}
func convert_mention_block(ind: Int32, tags: [[String]]) -> Block?
{
let ind = Int(ind)

View File

@@ -1,18 +0,0 @@
//
// ListModel.swift
// damus
//
// Created by William Casarin on 2023-01-25.
//
import Foundation
/*
class MutelistModel: ObservableObject {
let contacts: Contacts
@Published var users: [String]
}
*/

View File

@@ -1,32 +0,0 @@
//
// ReactionGroup.swift
// damus
//
// Created by William Casarin on 2023-02-21.
//
import Foundation
class EventGroup {
var events: [NostrEvent]
var last_event_at: Int64 {
guard let first = self.events.first else {
return 0
}
return first.created_at
}
init() {
self.events = []
}
init(events: [NostrEvent]) {
self.events = events
}
func insert(_ ev: NostrEvent) -> Bool {
return insert_uniq_sorted_event_created(events: &events, new_ev: ev)
}
}

View File

@@ -1,53 +0,0 @@
//
// ZapGroup.swift
// damus
//
// Created by William Casarin on 2023-02-21.
//
import Foundation
class ZapGroup {
var zaps: [Zap]
var msat_total: Int64
var zappers: Set<String>
var last_event_at: Int64 {
guard let first = zaps.first else {
return 0
}
return first.event.created_at
}
func zap_requests() -> [NostrEvent] {
zaps.map { z in z.request.ev }
}
init(zaps: [Zap]) {
self.zaps = zaps
self.msat_total = 0
self.zappers = Set()
}
init() {
self.zaps = []
self.msat_total = 0
self.zappers = Set()
}
func insert(_ zap: Zap) -> Bool {
if !insert_uniq_sorted_zap_by_created(zaps: &zaps, new_zap: zap) {
return false
}
msat_total += zap.invoice.amount
if !zappers.contains(zap.request.ev.pubkey) {
zappers.insert(zap.request.ev.pubkey)
}
return true
}
}

View File

@@ -1,294 +0,0 @@
//
// NotificationsModel.swift
// damus
//
// Created by William Casarin on 2023-02-21.
//
import Foundation
enum NotificationItem {
case repost(String, EventGroup)
case reaction(String, EventGroup)
case profile_zap(ZapGroup)
case event_zap(String, ZapGroup)
case reply(NostrEvent)
var id: String {
switch self {
case .repost(let evid, _):
return "repost_" + evid
case .reaction(let evid, _):
return "reaction_" + evid
case .profile_zap:
return "profile_zap"
case .event_zap(let evid, _):
return "event_zap_" + evid
case .reply(let ev):
return "reply_" + ev.id
}
}
var last_event_at: Int64 {
switch self {
case .reaction(_, let evgrp):
return evgrp.last_event_at
case .repost(_, let evgrp):
return evgrp.last_event_at
case .profile_zap(let zapgrp):
return zapgrp.last_event_at
case .event_zap(_, let zapgrp):
return zapgrp.last_event_at
case .reply(let reply):
return reply.created_at
}
}
}
class NotificationsModel: ObservableObject {
var incoming_zaps: [Zap]
var incoming_events: [NostrEvent]
var should_queue: Bool
// mappings from events to
var zaps: [String: ZapGroup]
var profile_zaps: ZapGroup
var reactions: [String: EventGroup]
var reposts: [String: EventGroup]
var replies: [NostrEvent]
var has_reply: Set<String>
@Published var notifications: [NotificationItem]
init() {
self.zaps = [:]
self.reactions = [:]
self.reposts = [:]
self.replies = []
self.has_reply = Set()
self.should_queue = true
self.incoming_zaps = []
self.incoming_events = []
self.profile_zaps = ZapGroup()
self.notifications = []
}
func uniq_pubkeys() -> [String] {
var pks = Set<String>()
for ev in incoming_events {
pks.insert(ev.pubkey)
}
for grp in reposts {
for ev in grp.value.events {
pks.insert(ev.pubkey)
}
}
for ev in replies {
pks.insert(ev.pubkey)
}
for zap in incoming_zaps {
pks.insert(zap.request.ev.pubkey)
}
return Array(pks)
}
func build_notifications() -> [NotificationItem] {
var notifs: [NotificationItem] = []
for el in zaps {
let evid = el.key
let zapgrp = el.value
let notif: NotificationItem = .event_zap(evid, zapgrp)
notifs.append(notif)
}
if !profile_zaps.zaps.isEmpty {
notifs.append(.profile_zap(profile_zaps))
}
for el in reposts {
let evid = el.key
let evgrp = el.value
notifs.append(.repost(evid, evgrp))
}
for el in reactions {
let evid = el.key
let evgrp = el.value
notifs.append(.reaction(evid, evgrp))
}
for reply in replies {
notifs.append(.reply(reply))
}
notifs.sort { $0.last_event_at > $1.last_event_at }
return notifs
}
private func insert_repost(_ ev: NostrEvent) -> Bool {
guard let reposted_ev = ev.inner_event else {
return false
}
let id = reposted_ev.id
if let evgrp = self.reposts[id] {
return evgrp.insert(ev)
} else {
let evgrp = EventGroup()
self.reposts[id] = evgrp
return evgrp.insert(ev)
}
}
private func insert_text(_ ev: NostrEvent) -> Bool {
guard !has_reply.contains(ev.id) else {
return false
}
has_reply.insert(ev.id)
replies.append(ev)
return true
}
private func insert_reaction(_ ev: NostrEvent) -> Bool {
guard let ref_id = ev.referenced_ids.last else {
return false
}
let id = ref_id.id
if let evgrp = self.reactions[id] {
return evgrp.insert(ev)
} else {
let evgrp = EventGroup()
self.reactions[id] = evgrp
return evgrp.insert(ev)
}
}
private func insert_event_immediate(_ ev: NostrEvent) -> Bool {
if ev.known_kind == .boost {
return insert_repost(ev)
} else if ev.known_kind == .like {
return insert_reaction(ev)
} else if ev.known_kind == .text {
return insert_text(ev)
}
return false
}
private func insert_zap_immediate(_ zap: Zap) -> Bool {
switch zap.target {
case .note(let notezt):
let id = notezt.note_id
if let zapgrp = self.zaps[notezt.note_id] {
return zapgrp.insert(zap)
} else {
let zapgrp = ZapGroup()
self.zaps[id] = zapgrp
return zapgrp.insert(zap)
}
case .profile:
return profile_zaps.insert(zap)
}
}
func insert_event(_ ev: NostrEvent) -> Bool {
if should_queue {
return insert_uniq_sorted_event_created(events: &incoming_events, new_ev: ev)
}
if insert_event_immediate(ev) {
self.notifications = build_notifications()
return true
}
return false
}
func insert_zap(_ zap: Zap) -> Bool {
if should_queue {
return insert_uniq_sorted_zap_by_created(zaps: &incoming_zaps, new_zap: zap)
}
if insert_zap_immediate(zap) {
self.notifications = build_notifications()
return true
}
return false
}
func filter(_ isIncluded: (NostrEvent) -> Bool) {
var changed = false
var count = 0
count = incoming_events.count
incoming_events = incoming_events.filter(isIncluded)
changed = changed || incoming_events.count != count
count = profile_zaps.zaps.count
profile_zaps.zaps = profile_zaps.zaps.filter { zap in isIncluded(zap.request.ev) }
changed = changed || profile_zaps.zaps.count != count
for el in reactions {
count = el.value.events.count
el.value.events = el.value.events.filter(isIncluded)
changed = changed || el.value.events.count != count
}
for el in reposts {
count = el.value.events.count
el.value.events = el.value.events.filter(isIncluded)
changed = changed || el.value.events.count != count
}
for el in zaps {
count = el.value.zaps.count
el.value.zaps = el.value.zaps.filter {
isIncluded($0.request.ev)
}
changed = changed || el.value.zaps.count != count
}
count = replies.count
replies = replies.filter(isIncluded)
changed = changed || replies.count != count
if changed {
self.notifications = build_notifications()
}
}
func flush() -> Bool {
var inserted = false
for zap in incoming_zaps {
inserted = insert_zap_immediate(zap) || inserted
}
for event in incoming_events {
inserted = insert_event_immediate(event) || inserted
}
if inserted {
self.notifications = build_notifications()
}
return inserted
}
}

View File

@@ -8,7 +8,7 @@
import Foundation
class ProfileModel: ObservableObject, Equatable {
var events: EventHolder = EventHolder()
@Published var events: [NostrEvent] = []
@Published var contacts: NostrEvent? = nil
@Published var following: Int = 0
@Published var relays: [String: RelayInfo]? = nil
@@ -20,24 +20,6 @@ class ProfileModel: ObservableObject, Equatable {
var sub_id = UUID().description
var prof_subid = UUID().description
func follows(pubkey: String) -> Bool {
guard let contacts = self.contacts else {
return false
}
for tag in contacts.tags {
guard tag.count >= 2 && tag[0] == "p" else {
continue
}
if tag[1] == pubkey {
return true
}
}
return false
}
func get_follow_target() -> FollowTarget {
if let contacts = contacts {
return .contact(contacts)
@@ -88,7 +70,7 @@ class ProfileModel: ObservableObject, Equatable {
}
func handle_profile_contact_event(_ ev: NostrEvent) {
process_contact_event(state: damus, ev: ev)
process_contact_event(pool: damus.pool, contacts: damus.contacts, pubkey: damus.pubkey, ev: ev)
// only use new stuff
if let current_ev = self.contacts {
@@ -111,13 +93,11 @@ class ProfileModel: ObservableObject, Equatable {
return
}
if ev.is_textlike || ev.known_kind == .boost {
if self.events.insert(ev) {
self.objectWillChange.send()
}
let _ = insert_uniq_sorted_event(events: &self.events, new_ev: ev, cmp: { $0.created_at > $1.created_at})
} else if ev.known_kind == .contacts {
handle_profile_contact_event(ev)
} else if ev.known_kind == .metadata {
process_metadata_event(our_pubkey: damus.pubkey, profiles: damus.profiles, ev: ev)
process_metadata_event(profiles: damus.profiles, ev: ev)
}
seen_event.insert(ev.id)
}

View File

@@ -8,9 +8,71 @@
import Foundation
final class ReactionsModel: EventsModel {
class ReactionsModel: ObservableObject {
let state: DamusState
let target: String
let sub_id: String
let profiles_id: String
init(state: DamusState, target: String) {
super.init(state: state, target: target, kind: .like)
@Published var reactions: [NostrEvent]
init (state: DamusState, target: String) {
self.state = state
self.target = target
self.sub_id = UUID().description
self.profiles_id = UUID().description
self.reactions = []
}
func get_filter() -> NostrFilter {
var filter = NostrFilter.filter_kinds([7])
filter.referenced_ids = [target]
filter.limit = 500
return filter
}
func subscribe() {
let filter = get_filter()
let filters = [filter]
self.state.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_nostr_event)
}
func unsubscribe() {
self.state.pool.unsubscribe(sub_id: sub_id)
}
func handle_event(relay_id: String, ev: NostrEvent) {
guard ev.kind == 7 else {
return
}
guard let reacted_to = last_etag(tags: ev.tags) else {
return
}
guard reacted_to == self.target else {
return
}
if insert_uniq_sorted_event(events: &self.reactions, new_ev: ev, cmp: { a, b in a.created_at < b.created_at } ) {
objectWillChange.send()
}
}
func handle_nostr_event(relay_id: String, ev: NostrConnectionEvent) {
guard case .nostr_event(let nev) = ev else {
return
}
switch nev {
case .event(_, let ev):
handle_event(relay_id: relay_id, ev: ev)
case .notice(_):
break
case .eose(_):
load_profiles(profiles_subid: profiles_id, relay_id: relay_id, events: reactions, damus_state: state)
break
}
}
}

View File

@@ -1,59 +0,0 @@
//
// Report.swift
// damus
//
// Created by William Casarin on 2023-01-24.
//
import Foundation
enum ReportType: String {
case explicit
case illegal
case spam
case impersonation
}
struct ReportNoteTarget {
let pubkey: String
let note_id: String
}
enum ReportTarget {
case user(String)
case note(ReportNoteTarget)
}
struct Report {
let type: ReportType
let target: ReportTarget
let message: String
}
func create_report_tags(target: ReportTarget, type: ReportType) -> [[String]] {
var tags: [[String]]
switch target {
case .user(let pubkey):
tags = [["p", pubkey]]
case .note(let notet):
tags = [["e", notet.note_id], ["p", notet.pubkey]]
}
tags.append(["report", type.rawValue])
return tags
}
func create_report_event(privkey: String, report: Report) -> NostrEvent? {
guard let pubkey = privkey_to_pubkey(privkey: privkey) else {
return nil
}
let kind = 1984
let tags = create_report_tags(target: report.target, type: report.type)
let ev = NostrEvent(content: report.message, pubkey: pubkey, kind: kind, tags: tags)
ev.id = calculate_event_id(ev: ev)
ev.sig = sign_event(privkey: privkey, ev: ev)
return ev
}

View File

@@ -1,15 +0,0 @@
//
// RepostsModel.swift
// damus
//
// Created by Terry Yiu on 1/22/23.
//
import Foundation
final class RepostsModel: EventsModel {
init(state: DamusState, target: String) {
super.init(state: state, target: target, kind: .boost)
}
}

View File

@@ -10,7 +10,7 @@ import Foundation
/// The data model for the SearchHome view, typically something global-like
class SearchHomeModel: ObservableObject {
var events: EventHolder = EventHolder()
@Published var events: [NostrEvent] = []
@Published var loading: Bool = false
var seen_pubkey: Set<String> = Set()
@@ -30,15 +30,9 @@ class SearchHomeModel: ObservableObject {
return filter
}
func filter_muted() {
events.filter { should_show_event(contacts: damus_state.contacts, ev: $0) }
self.objectWillChange.send()
}
func subscribe() {
loading = true
let to_relays = determine_to_relays(pool: damus_state.pool, filters: damus_state.relay_filters)
damus_state.pool.subscribe(sub_id: base_subid, filters: [get_base_filter()], handler: handle_event, to: to_relays)
damus_state.pool.subscribe(sub_id: base_subid, filters: [get_base_filter()], handler: handle_event)
}
func unsubscribe(to: String? = nil) {
@@ -56,14 +50,14 @@ class SearchHomeModel: ObservableObject {
guard sub_id == self.base_subid || sub_id == self.profiles_subid else {
return
}
if ev.is_textlike && should_show_event(contacts: damus_state.contacts, ev: ev) && !ev.is_reply(nil) {
if ev.is_textlike && ev.should_show_event && !ev.is_reply(nil) {
if seen_pubkey.contains(ev.pubkey) {
return
}
seen_pubkey.insert(ev.pubkey)
if self.events.insert(ev) {
self.objectWillChange.send()
let _ = insert_uniq_sorted_event(events: &events, new_ev: ev) {
$0.created_at > $1.created_at
}
}
case .notice(let msg):
@@ -76,7 +70,7 @@ class SearchHomeModel: ObservableObject {
// global events are not realtime
unsubscribe(to: relay_id)
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events.all_events), damus_state: damus_state)
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, events: events, damus_state: damus_state)
}
@@ -98,31 +92,8 @@ func find_profiles_to_fetch_pk(profiles: Profiles, event_pubkeys: [String]) -> [
return Array(pubkeys)
}
func find_profiles_to_fetch(profiles: Profiles, load: PubkeysToLoad) -> [String] {
switch load {
case .from_events(let events):
return find_profiles_to_fetch_from_events(profiles: profiles, events: events)
case .from_keys(let pks):
return find_profiles_to_fetch_from_keys(profiles: profiles, pks: pks)
}
}
func find_profiles_to_fetch_from_keys(profiles: Profiles, pks: [String]) -> [String] {
var pubkeys = Set<String>()
for pk in pks {
if profiles.lookup(id: pk) != nil {
continue
}
pubkeys.insert(pk)
}
return Array(pubkeys)
}
func find_profiles_to_fetch_from_events(profiles: Profiles, events: [NostrEvent]) -> [String] {
func find_profiles_to_fetch(profiles: Profiles, events: [NostrEvent]) -> [String] {
var pubkeys = Set<String>()
for ev in events {
@@ -136,14 +107,9 @@ func find_profiles_to_fetch_from_events(profiles: Profiles, events: [NostrEvent]
return Array(pubkeys)
}
enum PubkeysToLoad {
case from_events([NostrEvent])
case from_keys([String])
}
func load_profiles(profiles_subid: String, relay_id: String, load: PubkeysToLoad, damus_state: DamusState) {
func load_profiles(profiles_subid: String, relay_id: String, events: [NostrEvent], damus_state: DamusState) {
var filter = NostrFilter.filter_profiles
let authors = find_profiles_to_fetch(profiles: damus_state.profiles, load: load)
let authors = find_profiles_to_fetch(profiles: damus_state.profiles, events: events)
filter.authors = authors
guard !authors.isEmpty else {
@@ -159,7 +125,7 @@ func load_profiles(profiles_subid: String, relay_id: String, load: PubkeysToLoad
}
if ev.known_kind == .metadata {
process_metadata_event(our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev)
process_metadata_event(profiles: damus_state.profiles, ev: ev)
}
}

View File

@@ -9,27 +9,20 @@ import Foundation
class SearchModel: ObservableObject {
var events: EventHolder = EventHolder()
@Published var events: [NostrEvent] = []
@Published var loading: Bool = false
@Published var channel_name: String? = nil
let pool: RelayPool
var search: NostrFilter
let contacts: Contacts
let sub_id = UUID().description
let limit: UInt32 = 500
init(contacts: Contacts, pool: RelayPool, search: NostrFilter) {
self.contacts = contacts
init(pool: RelayPool, search: NostrFilter) {
self.pool = pool
self.search = search
}
func filter_muted() {
self.events.filter { should_show_event(contacts: contacts, ev: $0) }
self.objectWillChange.send()
}
func subscribe() {
// since 1 month
search.limit = self.limit
@@ -54,11 +47,7 @@ class SearchModel: ObservableObject {
return
}
guard should_show_event(contacts: contacts, ev: ev) else {
return
}
if self.events.insert(ev) {
if insert_uniq_sorted_event(events: &self.events, new_ev: ev, cmp: { $0.created_at > $1.created_at } ) {
objectWillChange.send()
}
}
@@ -99,21 +88,6 @@ func event_matches_hashtag(_ ev: NostrEvent, hashtags: [String]) -> Bool {
return false
}
func tag_is_hashtag(_ tag: [String]) -> Bool {
// "hashtag" is deprecated, will remove in the future
return tag.count >= 2 && (tag[0] == "hashtag" || tag[0] == "t")
}
func has_hashtag(_ tags: [[String]], hashtag: String) -> Bool {
for tag in tags {
if tag_is_hashtag(tag) && tag[1] == hashtag {
return true
}
}
return false
}
func event_matches_filter(_ ev: NostrEvent, filter: NostrFilter) -> Bool {
if let hashtags = filter.hashtag {
return event_matches_hashtag(ev, hashtags: hashtags)

View File

@@ -190,7 +190,7 @@ class ThreadModel: ObservableObject {
}
if ev.known_kind == .metadata {
process_metadata_event(our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev)
process_metadata_event(profiles: damus_state.profiles, ev: ev)
} else if ev.is_textlike {
self.add_event(ev, privkey: self.damus_state.keypair.privkey)
} else if ev.known_kind == .channel_meta || ev.known_kind == .channel_create {
@@ -207,7 +207,7 @@ class ThreadModel: ObservableObject {
}
if sub_id == self.base_subid {
load_profiles(profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(events), damus_state: damus_state)
load_profiles(profiles_subid: self.profiles_subid, relay_id: relay_id, events: events, damus_state: damus_state)
}
}

View File

@@ -1,37 +0,0 @@
//
// TranslationService.swift
// damus
//
// Created by Terry Yiu on 2/3/23.
//
import Foundation
enum TranslationService: String, CaseIterable, Identifiable {
var id: String { self.rawValue }
struct Model: Identifiable, Hashable {
var id: String { self.tag }
var tag: String
var displayName: String
}
case none
case libretranslate
case deepl
var model: Model {
switch self {
case .none:
return .init(tag: self.rawValue, displayName: NSLocalizedString("None", comment: "Dropdown option for selecting no translation service."))
case .libretranslate:
return .init(tag: self.rawValue, displayName: NSLocalizedString("LibreTranslate (Open Source)", comment: "Dropdown option for selecting LibreTranslate as the translation service."))
case .deepl:
return .init(tag: self.rawValue, displayName: NSLocalizedString("DeepL (Proprietary, Higher Accuracy)", comment: "Dropdown option for selecting DeepL as the translation service."))
}
}
static var allModels: [Model] {
return Self.allCases.map { $0.model }
}
}

Some files were not shown because too many files have changed in this diff Show More