Compare commits

..

1 Commits

Author SHA1 Message Date
tyiu 69ea3ed385 Fix Zaps string pluralization bug 2023-02-18 14:17:24 -05:00
360 changed files with 33211 additions and 21759 deletions
-2
View File
@@ -1,2 +0,0 @@
translations/
*.lproj/
+6 -496
View File
@@ -1,488 +1,4 @@
## [1.4.3-10] - 2023-04-25
### Added
- Add paste button to login (Suhail Saqan)
- Add nokyctranslate translation option (symbsrcool)
- You can now change the default zap type (William Casarin)
- Add partial support for different repost variants (William Casarin)
### Changed
- Change 500 custom zap to 420 (William Casarin)
- New looks to the custom zaps view (ericholguin)
- Adjust attachment images placement when posting (Swift)
- Only show friends, not friend-of-friend in friend filter (William Casarin)
### Fixed
- Fix reposts on macos and ipad (William Casarin)
- Fix slow reconnection issues (Bryan Montz)
- Fix issue where uploaded images were from someone else (Swift)
- Fix crash with LibreTranslate server setting selection and remove delisted vern server (Terry Yiu)
- Fix buggy zap amounts and wallet selector settings (William Casarin)
[1.4.3-10]: https://github.com/damus-io/damus/releases/tag/v1.4.3-10
## [1.4.3-2] - 2023-04-17
### Added
- Add deep links for local notifications (Swift)
- Add thread muting (Terry Yiu)
- Preview media uploads when posting (Swift)
- Add QR Code in profiles (ericholguin)
### Changed
- Always check signatures of profile events (William Casarin)
- Ask permission before uploading media (Swift)
- Show DM message in local notification (William Casarin)
### Fixed
- Fixed repost turning green too early and not reposting sometimes (Swift)
- Fix shuffling when choosing users to reply to (Joshua Jiang)
- Do not translate own notes if logged in with private key (Terry Yiu)
- Load missing profiles from boosts on home view (Gísli Kristjánsson)
- Load missing profiles from boosts on profile view (Gísli Kristjánsson)
- Fix tap area when mentioning users (OlegAba)
- Fix invalid DM author notifications (William Casarin)
- Fix relay signal indicator, properly show how many relays you are connected to (William Casarin)
[1.4.3-2]: https://github.com/damus-io/damus/releases/tag/v1.4.3-2
## [1.4.2-2] - 2023-04-12
### Added
- Include #btc in custom #bitcoin hashtag (William Casarin)
- Make notification dots configurable (William Casarin)
### Changed
- Display follows in most recent to oldest (Luis Cabrera)
### Fixed
- Fix hitches caused by syncronous loading of cached images (William Casarin)
- Fix tabs sometimes not switching (William Casarin)
[1.4.2-2]: https://github.com/damus-io/damus/releases/tag/v1.4.2-2
## [1.4.1-8] - 2023-04-10
### Added
- Add support for nostr: bech32 urls in posts and DMs (NIP19) (Bartholomew Joyce)
### Fixed
- Don't leak mentions in DMs (William Casarin)
- Fix tap area when mentioning users (OlegAba)
[1.4.1-8]: https://github.com/damus-io/damus/releases/tag/v1.4.1-8
## [1.4.1-7] - 2023-04-07
### Added
- Add #zap and #zapathon custom hashtags (William Casarin)
- Add custom #plebchain icon (William Casarin)
### Changed
- Add validation to prevent whitespaces be inputted on NIP-05 input field (Terry Yiu)
- Change reply color from blue to purple. Blue is banned from Damus. (William Casarin)
### Fixed
- Fix padding in post view (OlegAba)
- Show most recently bookmarked notes at the top (Bryan Montz)
[1.4.1-7]: https://github.com/damus-io/damus/releases/tag/v1.4.1-7
## [1.4.1-6] - 2023-04-06
### Added
- Custom hashtags for #bitcoin, #nostr and #coffeechain (William Casarin)
### Changed
- Disable translations in DMs by default (William Casarin)
### Fixed
- Don't show Translating... if we're not actually translating (William Casarin)
[1.4.1-6]: https://github.com/damus-io/damus/releases/tag/v1.4.1-6
## [1.4.1-4] - 2023-04-06
### Added
- Cache translations (William Casarin)
### Fixed
- Fix translation text popping (William Casarin)
- Fix broken auto-translations (William Casarin)
- Fix extraneous padding on some image posts (William Casarin)
- Fix crash in relay list view (William Casarin)
[1.4.1-4]: https://github.com/damus-io/damus/releases/tag/v1.4.1-4
## [1.4.1-3] - 2023-04-05
### Added
- Added text truncation settings (William Casarin)
### Changed
- Rename block to mute (William Casarin)
### Fixed
- Reduce chopping of images (mainvolume)
- Fix some notification settings not saving (William Casarin)
- Fix broken camera uploads (again) (Joel Klabo)
[1.4.1-3]: https://github.com/damus-io/damus/releases/tag/v1.4.1-3
## [1.4.1-2] - 2023-04-04
### Added
- Reply counts (William Casarin)
- Add option to only show notification from people you follow (Swift)
- Added local notifications for other events (Swift)
- Show a custom view when tagged user isn't found (ericholguin)
- Show referenced notes in DMs (William Casarin)
### Changed
- Show full bleed images on selected events in threads (William Casarin)
- Improvement to square image displaying (mainvolume)
### Fixed
- Fix broken website links that have missing https:// prefixes (William Casarin)
- Get around CCP bootstrap relay banning by caching user's relays as their bootstrap relays (William Casarin)
[1.4.1-2]: https://github.com/damus-io/damus/releases/tag/v1.4.1-2
## [1.4.1] - 2023-04-03
### Added
- Profile Picture Upload (Joel Klabo)
- Enable offline posting (William Casarin)
- Add auto-translation caching to ruduce api usage (Terry Yiu)
- Added support for gif uploads (Swift)
- Add a Divider in the Follows List for Large Screens (Joel Klabo)
- Upload Photos and Videos from Camera (Joel Klabo)
- Added ability to lookup users by nip05 identifiers (William Casarin)
### Changed
- Only truncate timeline text if enabled in settings (William Casarin)
- Make mentions wide in notifications like in timeline (William Casarin)
- Broadcast events you are replying to (William Casarin)
- Broadcast now also broadcasts event user's profile (William Casarin)
- Improved look of reply view (ericholguin)
- Remove gradient in some places for visibility (ericholguin)
### Fixed
- Fix cropped images (mainvolume)
- Truncate long text in notification items (William Casarin)
- Restore missing reply description on selected events (William Casarin)
- Show sent DMs immediately (William Casarin)
- Fixed size of translated text (William Casarin)
- Fix crash when reposting (William Casarin)
- Fix unclickable image dismiss button (OlegAba)
[1.4.1]: https://github.com/damus-io/damus/releases/tag/v1.4.1
## [1.4.0] - 2023-03-27
### Added
- Local zap notifications (Swift)
- Add support for video uploads (Swift)
- Auto Translation (Terry Yiu)
- Portuguese (Brazil) translations (Andressa Munturo)
- Spanish (Spain) translations (Max Pleb)
- Vietnamese translations (ShiryoRyo)
### Fixed
- Fixed small notification hit boxes (Terry Yiu)
[1.4.0]: https://github.com/damus-io/damus/releases/tag/v1.4.0
## [1.3.0-7] - 2023-03-24
- New experimental timeline view
[1.3.0-7]: https://github.com/damus-io/damus/releases/tag/v1.3.0-7
## [1.3.0-6] - 2023-03-21
### Fixed
- Fix bug where nostr: links and QRs stopped working (William Casarin)
[1.3.0-6]: https://github.com/damus-io/damus/releases/tag/v1.3.0-6
## [1.3.0-5] - 2023-03-20
### Added
- Add Time Ago to DM View (Joel Klabo)
### Fixed
- Fixed internal links opening in other nostr clients (William Casarin)
- Remove authentication for copying npub (Swift)
[1.3.0-5]: https://github.com/damus-io/damus/releases/tag/v1.3.0-5
## [1.3.0-4] - 2023-03-17
### Changed
- It's much easier to tag users in replies and posts (William Casarin)
### Fixed
- Fix bug where small black text appears during image upload (William Casarin)
[1.3.0-4]: https://github.com/damus-io/damus/releases/tag/v1.3.0-4
## [1.3.0-3] - 2023-03-17
### Fixed
- Fix image upload url delay after progress bar disappears (William Casarin)
- Fix issue where damus stops trying to reconnect (William Casarin)
[1.3.0-3]: https://github.com/damus-io/damus/releases/tag/v1.3.0-3
## [1.3.0-2] - 2023-03-16
### Added
- Add image uploader (Swift)
- Add option to always show images (never blur) (William Casarin)
- Canadian French (Pierre - synoptic_okubo)
- Hungarian translations (Zoltan)
- Korean translations (sogoagain)
- Swedish translations (Pextar)
### Changed
- Fixed embedded note popping (William Casarin)
- Bump notification limit from 100 to 500 (William Casarin)
### Fixed
- Fix zap button preventing scrolling (William Casarin)
[1.3.0-2]: https://github.com/damus-io/damus/releases/tag/v1.3.0-2
## [1.3.0] - 2023-03-15
### Added
- Extend user tagging search to all local profiles (William Casarin)
- Vibrate when a zap is received (Swift)
- New and Improved Share sheet (ericholguin)
- Bulgarian translations (elsat)
- Persian translations (Mahdi Taghizadeh)
- Ukrainian translations (Valeriia Khudiakova, Tony B)
### Changed
- Reduce battery usage by using exp backoff on connections (Bryan Montz)
- Don't show both realname and username if they are the same (William Casarin)
- Show error on invalid lightning tip address (Swift)
- Make DM Content More Visible (Joel Klabo)
- Remove spaces from hashtag searches (gladiusKatana)
### Fixed
- Show @ mentions for users with display_names and no username (William Casarin)
- Make user search case insensitive (William Casarin)
- Fix repost button sometimes not working (OlegAba)
- Don't show follows you for your own profile (benthecarman)
- Fix json appearing in profile searches (gladiusKatana)
- Fix unexpected font size when posting (Bryan Montz)
- Fix keyboard sticking issues (OlegAba)
- Fixed tab bar background color on macOS (Joel Klabo)
- Fix some links getting interpreted as images (gladiusKatana)
[1.3.0]: https://github.com/damus-io/damus/releases/tag/v1.3.0
## [1.2.0-4] - 2023-03-05
### Added
- Add ellipsis button to notes (ericholguin)
### Changed
- Immediately search for events and profiles (William Casarin)
- Use long-press for custom zaps (William Casarin)
- Make shaka animation smoother (Swift)
### Fixed
- Fixed hit detection bugs on profile page (OlegAba)
- Fix disappearing text on Thread view (Bryan Montz)
- Render links in notification summaries (Joel Klabo)
- Don't show notifications from ourselves (William Casarin)
- Fix issue where navbar back button would show the wrong text (Jack Chakany)
- Fix case sensitivity when searching hashtags (randymcmillan)
- Fix issue where opening reposts shows json (William Casarin)
[1.2.0-4]: https://github.com/damus-io/damus/releases/tag/v1.2.0-4
## [1.2.0-3] - 2023-03-04
### Added
- Add additional info to recommended relay view (ericholguin)
- Add shaka animation (Swift)
- Add option to disable image animation (OlegAba)
- Add additional warning when deleting account (ericholguin)
- Threads now load instantly and are cached (William Casarin)
### Fixed
- Wrap long profile display names (OlegAba)
- Fixed weird scaling on profile pictures (OlegAba)
- Fixed width of copy pubkey on profile page (Joel Klabo)
- Make damus purple use more consistent in mentions (Joel Klabo)
[1.2.0-3]: https://github.com/damus-io/damus/releases/tag/v1.2.0-3
## [1.1.0-10] - 2023-03-01
### Added
- Truncate large posts and add a show more button (OlegAba)
- Private Zaps (William Casarin)
### Fixed
- Fix default zap amount setting not getting updated (William Casarin)
- Fix issue where keyboard covers custom zap comment (William Casarin)
[1.1.0-10]: https://github.com/damus-io/damus/releases/tag/v1.1.0-10
## [1.1.0-9] - 2023-02-26
### Added
- Customized zaps (William Casarin)
- Add new Notifications View (William Casarin)
- Bookmarking (Joel Klabo)
- Chinese, Traditional (Hong Kong) translations (rasputin)
- Chinese, Traditional (Taiwan) translations (rasputin)
### Changed
- No more inline npubs when tagging users (Swift)
### Fixed
- Fix alignment of side menu labels (Joel Klabo)
- Fix duplicated participants in reply-to view (Joel Klabo)
- Load missing profiles in Zaps view (William Casarin)
- Fix memory leak with inline videos (William Casarin)
- Eliminate popping when scrolling (William Casarin)
[1.1.0-9]: https://github.com/damus-io/damus/releases/tag/v1.1.0-9
## [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)
- Czech translations (Martin Gabrhel)
- Indonesian translations (johnybergzy)
- Russian translations (Tony B)
### 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 ## [1.1.0-2] - 2023-02-14
### Added ### Added
@@ -505,6 +21,7 @@
### Added ### Added
- Relay Filtering (William Casarin) - Relay Filtering (William Casarin)
- Japanese translations (Terry Yiu)
- Add password autofill on account login and creation (Terry Yiu) - Add password autofill on account login and creation (Terry Yiu)
- Show if relay is paid (William Casarin) - Show if relay is paid (William Casarin)
- Add "Follows You" indicator on profile (William Casarin) - Add "Follows You" indicator on profile (William Casarin)
@@ -517,10 +34,6 @@
- Copy invoice button (Joel Klabo) - Copy invoice button (Joel Klabo)
- Receive Lightning Zaps (William Casarin) - Receive Lightning Zaps (William Casarin)
- Allow text selection in bio (Suhail Saqan) - Allow text selection in bio (Suhail Saqan)
- Chinese, Simplified (China mainland) translations (haolong, rasputin)
- Dutch translations (Heimen Stoffels - Vistaus)
- Greek translations (milicode)
- Japanese translations (akiomik, foxytanuki, Guetsu Ren - Nighthaven, h3y6e, middlingphys)
### Changed ### Changed
@@ -555,7 +68,6 @@
- LibreTranslate note translations (Terry Yiu) - LibreTranslate note translations (Terry Yiu)
- Added support for account deletion (William Casarin) - Added support for account deletion (William Casarin)
- User tagging and autocompletion in posts (Swift) - User tagging and autocompletion in posts (Swift)
- Polish translations (pysiak)
### Changed ### Changed
@@ -578,8 +90,7 @@
### Added ### Added
- Arabic translations (Barodane) - Added Arabic and Portuguese translations (Barodane, Antonio Chagas)
- Portuguese translations (Antonio Chagas)
- Add QRCode view for sharing your pubkey (ericholguin) - Add QRCode view for sharing your pubkey (ericholguin)
- Added nostr: uri handling (William Casarin) - Added nostr: uri handling (William Casarin)
@@ -606,8 +117,7 @@
### Added ### Added
- Reposts view (Terry Yiu) - Reposts view (Terry Yiu)
- Italian translations (Nicolò Carcagnì) - Translations for it_IT, it_CH, fr_FR, de_DE, de_AT and lv_LV (Nicolò Carcagnì, Solobalbo, Gregor, Peter Gerstbach, SYX)
- Latvian translations (SYX)
- Added ability to block users (William Casarin) - Added ability to block users (William Casarin)
- Added a way to report content (William Casarin) - Added a way to report content (William Casarin)
- Stretchable profile cover header (Swift) - Stretchable profile cover header (Swift)
@@ -634,9 +144,7 @@
- Show website on profiles (William Casarin) - Show website on profiles (William Casarin)
- Add the ability to choose participants when replying (Joel Klabo) - Add the ability to choose participants when replying (Joel Klabo)
- German translations (Gregor, Peter Gerstbach) - Translations for de_AT, de_DE, tr_TR, fr_FR (Gregor, Peter Gerstbach, Taylan Benli, Solobalbo)
- Turkish translations (Taylan Benli)
- French (France) translations (Solobalbo)
- Add DM Message Requests (William Casarin) - Add DM Message Requests (William Casarin)
@@ -1068,3 +576,5 @@
[0.1.2]: https://github.com/damus-io/damus/releases/tag/v0.1.2 [0.1.2]: https://github.com/damus-io/damus/releases/tag/v0.1.2
+1
View File
@@ -1,3 +1,4 @@
dependencies: [ dependencies: [
.Package(url: "https://github.com/daltoniam/Starscream.git", majorVersion: 4),
.Package(url: "https://github.com/jb55/secp256k1.swift.git", branch: "main") .Package(url: "https://github.com/jb55/secp256k1.swift.git", branch: "main")
] ]
+19 -6
View File
@@ -92,7 +92,7 @@ damus implements the following [Nostr Implementation Possibilities][nips]
## Contributing ## Contributing
Contributors welcome! Start by examining known issues: https://github.com/damus-io/damus/issues. Contributors welcome!
### Code ### Code
@@ -100,11 +100,6 @@ Contributors welcome! Start by examining known issues: https://github.com/damus-
[git-send-email]: http://git-send-email.io [git-send-email]: http://git-send-email.io
### Privacy
Your internet protocol (IP) address is exposed to the relays you connect to, and third party media hosters (e.g. nostr.build, imgur.com, giphy.com, youtube.com etc.) that render on Damus. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online.
The relay also learns which public keys you are requesting, meaning your public key will be tied to your IP address.
### Translations ### Translations
Translators welcome! Join the [Transifex][transifex] project. Translators welcome! Join the [Transifex][transifex] project.
@@ -113,6 +108,24 @@ All user-facing strings must have a comment in order to provide context to trans
[transifex]: https://explore.transifex.com/damus/damus-ios/ [transifex]: https://explore.transifex.com/damus/damus-ios/
#### Export Source Translations
If user-facing strings have been added or changed, please export them for translation as part of your pull request or commit by running:
```zsh
./devtools/export-source-translation.sh
```
This command will export source translations to `translations/en-US.xcloc/Localized Contents/en-US.xliff`, which the Transifex integration will read from the `master` branch and allow translators to translate those strings.
#### Import Translations
Once 100% of strings have been translated for a given locale, Transifex will open up a pull request with the `translations/<locale>.xliff` file changed. Currently, it must be manually imported into the project before merging the pull request by running:
```zsh
./devtools/import-translation.sh <locale_code_in_snake_case>
```
### Awards ### Awards
There may be nostr badges awarded for contributors in the future... :) There may be nostr badges awarded for contributors in the future... :)
+3 -10
View File
@@ -91,12 +91,13 @@ int bech32_encode(char *output, const char *hrp, const uint8_t *data, size_t dat
return 1; return 1;
} }
bech32_encoding bech32_decode_len(char* hrp, uint8_t *data, size_t *data_len, const char *input, size_t input_len) { bech32_encoding bech32_decode(char* hrp, uint8_t *data, size_t *data_len, const char *input, size_t max_input_len) {
uint32_t chk = 1; uint32_t chk = 1;
size_t i; size_t i;
size_t input_len = strlen(input);
size_t hrp_len; size_t hrp_len;
int have_lower = 0, have_upper = 0; int have_lower = 0, have_upper = 0;
if (input_len < 8) { if (input_len < 8 || input_len > max_input_len) {
return BECH32_ENCODING_NONE; return BECH32_ENCODING_NONE;
} }
*data_len = 0; *data_len = 0;
@@ -153,14 +154,6 @@ bech32_encoding bech32_decode_len(char* hrp, uint8_t *data, size_t *data_len, co
} }
} }
bech32_encoding bech32_decode(char* hrp, uint8_t *data, size_t *data_len, const char *input, size_t max_input_len) {
size_t len = strlen(input);
if (len > max_input_len) {
return BECH32_ENCODING_NONE;
}
return bech32_decode_len(hrp, data, data_len, input, len);
}
int bech32_convert_bits(uint8_t* out, size_t* outlen, int outbits, const uint8_t* in, size_t inlen, int inbits, int pad) { int bech32_convert_bits(uint8_t* out, size_t* outlen, int outbits, const uint8_t* in, size_t inlen, int inbits, int pad) {
uint32_t val = 0; uint32_t val = 0;
int bits = 0; int bits = 0;
-8
View File
@@ -118,14 +118,6 @@ bech32_encoding bech32_decode(
size_t max_input_len size_t max_input_len
); );
bech32_encoding bech32_decode_len(
char *hrp,
uint8_t *data,
size_t *data_len,
const char *input,
size_t input_len
);
/* Helper from bech32: translates inbits-bit bytes to outbits-bit bytes. /* Helper from bech32: translates inbits-bit bytes to outbits-bit bytes.
* @outlen is incremented as bytes are added. * @outlen is incremented as bytes are added.
* @pad is true if we're to pad, otherwise truncate last byte if necessary * @pad is true if we're to pad, otherwise truncate last byte if necessary
-56
View File
@@ -1,56 +0,0 @@
//
// block.h
// damus
//
// Created by William Casarin on 2023-04-09.
//
#ifndef block_h
#define block_h
#include "nostr_bech32.h"
#include "str_block.h"
#define MAX_BLOCKS 1024
enum block_type {
BLOCK_HASHTAG = 1,
BLOCK_TEXT = 2,
BLOCK_MENTION_INDEX = 3,
BLOCK_MENTION_BECH32 = 4,
BLOCK_URL = 5,
BLOCK_INVOICE = 6,
};
typedef struct invoice_block {
struct str_block invstr;
union {
struct bolt11 *bolt11;
};
} invoice_block_t;
typedef struct mention_bech32_block {
struct str_block str;
struct nostr_bech32 bech32;
} mention_bech32_block_t;
typedef struct block {
enum block_type type;
union {
struct str_block str;
struct invoice_block invoice;
struct mention_bech32_block mention_bech32;
int mention_index;
} block;
} block_t;
typedef struct blocks {
int num_blocks;
struct block *blocks;
} blocks_t;
void blocks_init(struct blocks *blocks);
void blocks_free(struct blocks *blocks);
#endif /* block_h */
-171
View File
@@ -1,171 +0,0 @@
//
// cursor.h
// damus
//
// Created by William Casarin on 2023-04-09.
//
#ifndef cursor_h
#define cursor_h
#include <ctype.h>
#include <string.h>
typedef unsigned char u8;
struct cursor {
const u8 *p;
const u8 *start;
const u8 *end;
};
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 inline int is_alphanumeric(char c) {
return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9');
}
static inline void make_cursor(struct cursor *c, const u8 *content, size_t len)
{
c->start = content;
c->end = content + len;
c->p = content;
}
static inline 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 inline int consume_until_whitespace(struct cursor *cur, int or_end) {
char c;
int consumedAtLeastOne = 0;
while (cur->p < cur->end) {
c = *cur->p;
if (is_whitespace(c))
return consumedAtLeastOne;
cur->p++;
consumedAtLeastOne = 1;
}
return or_end;
}
static inline int consume_until_non_alphanumeric(struct cursor *cur, int or_end) {
char c;
int consumedAtLeastOne = 0;
while (cur->p < cur->end) {
c = *cur->p;
if (!is_alphanumeric(c))
return consumedAtLeastOne;
cur->p++;
consumedAtLeastOne = 1;
}
return or_end;
}
static inline int parse_char(struct cursor *cur, char c) {
if (cur->p >= cur->end)
return 0;
if (*cur->p == c) {
cur->p++;
return 1;
}
return 0;
}
static inline int peek_char(struct cursor *cur, int ind) {
if ((cur->p + ind < cur->start) || (cur->p + ind >= cur->end))
return -1;
return *(cur->p + ind);
}
static int parse_digit(struct cursor *cur, int *digit) {
int c;
if ((c = peek_char(cur, 0)) == -1)
return 0;
c -= '0';
if (c >= 0 && c <= 9) {
*digit = c;
cur->p++;
return 1;
}
return 0;
}
static inline int pull_byte(struct cursor *cur, u8 *byte) {
if (cur->p >= cur->end)
return 0;
*byte = *cur->p;
cur->p++;
return 1;
}
static inline int pull_bytes(struct cursor *cur, int count, const u8 **bytes) {
if (cur->p + count > cur->end)
return 0;
*bytes = cur->p;
cur->p += count;
return 1;
}
static inline int parse_str(struct cursor *cur, const char *str) {
int i;
char c, cs;
unsigned long len;
len = strlen(str);
if (cur->p + len >= cur->end)
return 0;
for (i = 0; i < len; i++) {
c = tolower(cur->p[i]);
cs = tolower(str[i]);
if (c != cs)
return 0;
}
cur->p += len;
return 1;
}
#endif /* cursor_h */
+123 -43
View File
@@ -6,13 +6,127 @@
// //
#include "damus.h" #include "damus.h"
#include "cursor.h"
#include "bolt11.h" #include "bolt11.h"
#include "bech32.h"
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
static int parse_mention_index(struct cursor *cur, struct block *block) { typedef unsigned char u8;
struct cursor {
const u8 *p;
const u8 *start;
const u8 *end;
};
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;
c->end = content + 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;
cur->p++;
consumedAtLeastOne = true;
}
return or_end;
}
static int parse_char(struct cursor *cur, char c) {
if (cur->p >= cur->end)
return 0;
if (*cur->p == c) {
cur->p++;
return 1;
}
return 0;
}
static inline int peek_char(struct cursor *cur, int ind) {
if ((cur->p + ind < cur->start) || (cur->p + ind >= cur->end))
return -1;
return *(cur->p + ind);
}
static int parse_digit(struct cursor *cur, int *digit) {
int c;
if ((c = peek_char(cur, 0)) == -1)
return 0;
c -= '0';
if (c >= 0 && c <= 9) {
*digit = c;
cur->p++;
return 1;
}
return 0;
}
static int parse_str(struct cursor *cur, const char *str) {
int i;
char c, cs;
unsigned long len;
len = strlen(str);
if (cur->p + len >= cur->end)
return 0;
for (i = 0; i < len; i++) {
c = tolower(cur->p[i]);
cs = tolower(str[i]);
if (c != cs)
return 0;
}
cur->p += len;
return 1;
}
static int parse_mention(struct cursor *cur, struct block *block) {
int d1, d2, d3, ind; int d1, d2, d3, ind;
const u8 *start = cur->p; const u8 *start = cur->p;
@@ -37,8 +151,8 @@ static int parse_mention_index(struct cursor *cur, struct block *block) {
return 0; return 0;
} }
block->type = BLOCK_MENTION_INDEX; block->type = BLOCK_MENTION;
block->block.mention_index = ind; block->block.mention = ind;
return 1; return 1;
} }
@@ -160,27 +274,6 @@ static int parse_invoice(struct cursor *cur, struct block *block) {
return 1; return 1;
} }
static int parse_mention_bech32(struct cursor *cur, struct block *block) {
const u8 *start = cur->p;
if (!parse_str(cur, "nostr:"))
return 0;
block->block.str.start = (const char *)cur->p;
if (!parse_nostr_bech32(cur, &block->block.mention_bech32.bech32)) {
cur->p = start;
return 0;
}
block->block.str.end = (const char *)cur->p;
block->type = BLOCK_MENTION_BECH32;
return 1;
}
static int add_text_then_block(struct cursor *cur, struct blocks *blocks, struct block block, const u8 **start, const u8 *pre_mention) static int add_text_then_block(struct cursor *cur, struct blocks *blocks, struct block block, const u8 **start, const u8 *pre_mention)
{ {
if (!add_text_block(blocks, *start, pre_mention)) if (!add_text_block(blocks, *start, pre_mention))
@@ -210,7 +303,7 @@ int damus_parse_content(struct blocks *blocks, const char *content) {
pre_mention = cur.p; pre_mention = cur.p;
if (cp == -1 || is_whitespace(cp)) { if (cp == -1 || is_whitespace(cp)) {
if (c == '#' && (parse_mention_index(&cur, &block) || parse_hashtag(&cur, &block))) { if (c == '#' && (parse_mention(&cur, &block) || parse_hashtag(&cur, &block))) {
if (!add_text_then_block(&cur, blocks, block, &start, pre_mention)) if (!add_text_then_block(&cur, blocks, block, &start, pre_mention))
return 0; return 0;
continue; continue;
@@ -222,10 +315,6 @@ int damus_parse_content(struct blocks *blocks, const char *content) {
if (!add_text_then_block(&cur, blocks, block, &start, pre_mention)) if (!add_text_then_block(&cur, blocks, block, &start, pre_mention))
return 0; return 0;
continue; continue;
} else if (c == 'n' && parse_mention_bech32(&cur, &block)) {
if (!add_text_then_block(&cur, blocks, block, &start, pre_mention))
return 0;
continue;
} }
} }
@@ -246,17 +335,8 @@ void blocks_init(struct blocks *blocks) {
} }
void blocks_free(struct blocks *blocks) { void blocks_free(struct blocks *blocks) {
if (!blocks->blocks) { if (blocks->blocks) {
return; free(blocks->blocks);
blocks->num_blocks = 0;
} }
for (int i = 0; i < blocks->num_blocks; ++i) {
if (blocks->blocks[i].type == BLOCK_MENTION_BECH32) {
free(blocks->blocks[i].block.mention_bech32.bech32.buffer);
blocks->blocks[i].block.mention_bech32.bech32.buffer = NULL;
}
}
free(blocks->blocks);
blocks->num_blocks = 0;
} }
+38 -3
View File
@@ -9,10 +9,45 @@
#define damus_h #define damus_h
#include <stdio.h> #include <stdio.h>
#include "nostr_bech32.h"
#include "block.h"
typedef unsigned char u8;
#define MAX_BLOCKS 1024
enum block_type {
BLOCK_HASHTAG = 1,
BLOCK_TEXT = 2,
BLOCK_MENTION = 3,
BLOCK_URL = 4,
BLOCK_INVOICE = 5,
};
typedef struct str_block {
const char *start;
const char *end;
} str_block_t;
typedef struct invoice_block {
struct str_block invstr;
union {
struct bolt11 *bolt11;
};
} invoice_block_t;
typedef struct block {
enum block_type type;
union {
struct str_block str;
struct invoice_block invoice;
int mention;
} block;
} block_t;
typedef struct blocks {
int num_blocks;
struct block *blocks;
} blocks_t;
void blocks_init(struct blocks *blocks);
void blocks_free(struct blocks *blocks);
int damus_parse_content(struct blocks *blocks, const char *content); int damus_parse_content(struct blocks *blocks, const char *content);
#endif /* damus_h */ #endif /* damus_h */
-295
View File
@@ -1,295 +0,0 @@
//
// nostr_bech32.c
// damus
//
// Created by William Casarin on 2023-04-09.
//
#include "nostr_bech32.h"
#include <stdlib.h>
#include "cursor.h"
#include "bech32.h"
#define MAX_TLVS 16
#define TLV_SPECIAL 0
#define TLV_RELAY 1
#define TLV_AUTHOR 2
#define TLV_KIND 3
#define TLV_KNOWN_TLVS 4
struct nostr_tlv {
u8 type;
u8 len;
const u8 *value;
};
struct nostr_tlvs {
struct nostr_tlv tlvs[MAX_TLVS];
int num_tlvs;
};
static int parse_nostr_tlv(struct cursor *cur, struct nostr_tlv *tlv) {
// get the tlv tag
if (!pull_byte(cur, &tlv->type))
return 0;
// unknown, fail!
if (tlv->type >= TLV_KNOWN_TLVS)
return 0;
// get the length
if (!pull_byte(cur, &tlv->len))
return 0;
// is the reported length greater then our buffer? if so fail
if (cur->p + tlv->len > cur->end)
return 0;
tlv->value = cur->p;
cur->p += tlv->len;
return 1;
}
static int parse_nostr_tlvs(struct cursor *cur, struct nostr_tlvs *tlvs) {
int i;
tlvs->num_tlvs = 0;
for (i = 0; i < MAX_TLVS; i++) {
if (parse_nostr_tlv(cur, &tlvs->tlvs[i])) {
tlvs->num_tlvs++;
} else {
break;
}
}
if (tlvs->num_tlvs == 0)
return 0;
return 1;
}
static int find_tlv(struct nostr_tlvs *tlvs, u8 type, struct nostr_tlv **tlv) {
*tlv = NULL;
for (int i = 0; i < tlvs->num_tlvs; i++) {
if (tlvs->tlvs[i].type == type) {
*tlv = &tlvs->tlvs[i];
return 1;
}
}
return 0;
}
static int parse_nostr_bech32_type(const char *prefix, enum nostr_bech32_type *type) {
// Parse type
if (strcmp(prefix, "note") == 0) {
*type = NOSTR_BECH32_NOTE;
return 1;
} else if (strcmp(prefix, "npub") == 0) {
*type = NOSTR_BECH32_NPUB;
return 1;
} else if (strcmp(prefix, "nprofile") == 0) {
*type = NOSTR_BECH32_NPROFILE;
return 1;
} else if (strcmp(prefix, "nevent") == 0) {
*type = NOSTR_BECH32_NEVENT;
return 1;
} else if (strcmp(prefix, "nrelay") == 0) {
*type = NOSTR_BECH32_NRELAY;
return 1;
} else if (strcmp(prefix, "naddr") == 0) {
*type = NOSTR_BECH32_NADDR;
return 1;
}
return 0;
}
static int parse_nostr_bech32_note(struct cursor *cur, struct bech32_note *note) {
return pull_bytes(cur, 32, &note->event_id);
}
static int parse_nostr_bech32_npub(struct cursor *cur, struct bech32_npub *npub) {
return pull_bytes(cur, 32, &npub->pubkey);
}
static int tlvs_to_relays(struct nostr_tlvs *tlvs, struct relays *relays) {
struct nostr_tlv *tlv;
struct str_block *str;
relays->num_relays = 0;
for (int i = 0; i < tlvs->num_tlvs; i++) {
tlv = &tlvs->tlvs[i];
if (tlv->type != TLV_RELAY)
continue;
if (relays->num_relays + 1 > MAX_RELAYS)
break;
str = &relays->relays[relays->num_relays++];
str->start = (const char*)tlv->value;
str->end = (const char*)(tlv->value + tlv->len);
}
return 1;
}
static int parse_nostr_bech32_nevent(struct cursor *cur, struct bech32_nevent *nevent) {
struct nostr_tlvs tlvs;
struct nostr_tlv *tlv;
if (!parse_nostr_tlvs(cur, &tlvs))
return 0;
if (!find_tlv(&tlvs, TLV_SPECIAL, &tlv))
return 0;
if (tlv->len != 32)
return 0;
nevent->event_id = tlv->value;
if (find_tlv(&tlvs, TLV_AUTHOR, &tlv)) {
nevent->pubkey = tlv->value;
} else {
nevent->pubkey = NULL;
}
return tlvs_to_relays(&tlvs, &nevent->relays);
}
static int parse_nostr_bech32_naddr(struct cursor *cur, struct bech32_naddr *naddr) {
struct nostr_tlvs tlvs;
struct nostr_tlv *tlv;
if (!parse_nostr_tlvs(cur, &tlvs))
return 0;
if (!find_tlv(&tlvs, TLV_SPECIAL, &tlv))
return 0;
naddr->identifier.start = (const char*)tlv->value;
naddr->identifier.end = (const char*)tlv->value + tlv->len;
if (!find_tlv(&tlvs, TLV_AUTHOR, &tlv))
return 0;
naddr->pubkey = tlv->value;
return tlvs_to_relays(&tlvs, &naddr->relays);
}
static int parse_nostr_bech32_nprofile(struct cursor *cur, struct bech32_nprofile *nprofile) {
struct nostr_tlvs tlvs;
struct nostr_tlv *tlv;
if (!parse_nostr_tlvs(cur, &tlvs))
return 0;
if (!find_tlv(&tlvs, TLV_SPECIAL, &tlv))
return 0;
if (tlv->len != 32)
return 0;
nprofile->pubkey = tlv->value;
return tlvs_to_relays(&tlvs, &nprofile->relays);
}
static int parse_nostr_bech32_nrelay(struct cursor *cur, struct bech32_nrelay *nrelay) {
struct nostr_tlvs tlvs;
struct nostr_tlv *tlv;
if (!parse_nostr_tlvs(cur, &tlvs))
return 0;
if (!find_tlv(&tlvs, TLV_SPECIAL, &tlv))
return 0;
nrelay->relay.start = (const char*)tlv->value;
nrelay->relay.end = (const char*)tlv->value + tlv->len;
return 1;
}
int parse_nostr_bech32(struct cursor *cur, struct nostr_bech32 *obj) {
const u8 *start, *end;
start = cur->p;
if (!consume_until_non_alphanumeric(cur, 1)) {
cur->p = start;
return 0;
}
end = cur->p;
size_t data_len;
size_t input_len = end - start;
if (input_len < 10 || input_len > 10000) {
return 0;
}
obj->buffer = malloc(input_len * 2);
if (!obj->buffer)
return 0;
u8 data[input_len];
char prefix[input_len];
if (bech32_decode_len(prefix, data, &data_len, (const char*)start, input_len) == BECH32_ENCODING_NONE) {
cur->p = start;
return 0;
}
obj->buflen = 0;
if (!bech32_convert_bits(obj->buffer, &obj->buflen, 8, data, data_len, 5, 0)) {
goto fail;
}
if (!parse_nostr_bech32_type(prefix, &obj->type)) {
goto fail;
}
struct cursor bcur;
make_cursor(&bcur, obj->buffer, obj->buflen);
switch (obj->type) {
case NOSTR_BECH32_NOTE:
if (!parse_nostr_bech32_note(&bcur, &obj->data.note))
goto fail;
break;
case NOSTR_BECH32_NPUB:
if (!parse_nostr_bech32_npub(&bcur, &obj->data.npub))
goto fail;
break;
case NOSTR_BECH32_NEVENT:
if (!parse_nostr_bech32_nevent(&bcur, &obj->data.nevent))
goto fail;
break;
case NOSTR_BECH32_NADDR:
if (!parse_nostr_bech32_naddr(&bcur, &obj->data.naddr))
goto fail;
break;
case NOSTR_BECH32_NPROFILE:
if (!parse_nostr_bech32_nprofile(&bcur, &obj->data.nprofile))
goto fail;
break;
case NOSTR_BECH32_NRELAY:
if (!parse_nostr_bech32_nrelay(&bcur, &obj->data.nrelay))
goto fail;
break;
}
return 1;
fail:
free(obj->buffer);
cur->p = start;
return 0;
}
-78
View File
@@ -1,78 +0,0 @@
//
// nostr_bech32.h
// damus
//
// Created by William Casarin on 2023-04-09.
//
#ifndef nostr_bech32_h
#define nostr_bech32_h
#include <stdio.h>
#include "str_block.h"
#include "cursor.h"
typedef unsigned char u8;
#define MAX_RELAYS 10
struct relays {
struct str_block relays[MAX_RELAYS];
int num_relays;
};
enum nostr_bech32_type {
NOSTR_BECH32_NOTE = 1,
NOSTR_BECH32_NPUB = 2,
NOSTR_BECH32_NPROFILE = 3,
NOSTR_BECH32_NEVENT = 4,
NOSTR_BECH32_NRELAY = 5,
NOSTR_BECH32_NADDR = 6,
};
struct bech32_note {
const u8 *event_id;
};
struct bech32_npub {
const u8 *pubkey;
};
struct bech32_nevent {
struct relays relays;
const u8 *event_id;
const u8 *pubkey; // optional
};
struct bech32_nprofile {
struct relays relays;
const u8 *pubkey;
};
struct bech32_naddr {
struct relays relays;
struct str_block identifier;
const u8 *pubkey;
};
struct bech32_nrelay {
struct str_block relay;
};
typedef struct nostr_bech32 {
enum nostr_bech32_type type;
u8 *buffer; // holds strings and tlv stuff
size_t buflen;
union {
struct bech32_note note;
struct bech32_npub npub;
struct bech32_nevent nevent;
struct bech32_nprofile nprofile;
struct bech32_naddr naddr;
struct bech32_nrelay nrelay;
} data;
} nostr_bech32_t;
int parse_nostr_bech32(struct cursor *cur, struct nostr_bech32 *obj);
#endif /* nostr_bech32_h */
-16
View File
@@ -1,16 +0,0 @@
//
// str_block.h
// damus
//
// Created by William Casarin on 2023-04-09.
//
#ifndef str_block_h
#define str_block_h
typedef struct str_block {
const char *start;
const char *end;
} str_block_t;
#endif /* str_block_h */
File diff suppressed because it is too large Load Diff
@@ -17,6 +17,15 @@
"revision" : "40b4b38b3b1c83f7088c76189a742870e0ca06a9" "revision" : "40b4b38b3b1c83f7088c76189a742870e0ca06a9"
} }
}, },
{
"identity" : "starscream",
"kind" : "remoteSourceControl",
"location" : "https://github.com/daltoniam/Starscream",
"state" : {
"revision" : "df8d82047f6654d8e4b655d1b1525c64e1059d21",
"version" : "4.0.4"
}
},
{ {
"identity" : "vault", "identity" : "vault",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -1,38 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xF4",
"green" : "0xEE",
"red" : "0xEE"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x1E",
"green" : "0x1C",
"red" : "0x1C"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"red" : "0xBE",
"green" : "0x5F",
"blue" : "0x00"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xED",
"green" : "0x26",
"red" : "0xBF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x05",
"green" : "0xDF",
"red" : "0xFA"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "bitcoin-hashtag.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "bitcoin-hashtag.svg",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "bitcoin-hashtag.svg",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,43 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="12.843903"
height="17"
viewBox="0 0 12.843902 16.999999"
version="1.1"
id="svg2"
sodipodi:docname="bitcoin-hashtag.svg"
inkscape:version="1.3-dev (77bc73e, 2022-05-18)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2" />
<sodipodi:namedview
id="namedview2"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="31.12"
inkscape:cx="4.2577121"
inkscape:cy="7.535347"
inkscape:window-width="1526"
inkscape:window-height="957"
inkscape:window-x="1637"
inkscape:window-y="10"
inkscape:window-maximized="0"
inkscape:current-layer="svg2" />
<g
id="surface1"
transform="matrix(0.94507527,0,0,0.94507527,-4.5943665,-3.2875042)">
<path
style="fill:#f59119;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.40637"
d="M 18.388175,10.742602 C 18.668352,8.874761 17.240002,7.8694225 15.295251,7.193703 L 15.927019,4.6611305 14.383304,4.2765743 13.768015,6.7432244 C 13.361486,6.644338 12.943967,6.545453 12.531944,6.4520613 L 13.152726,3.9689298 11.609011,3.584375 10.977241,6.1169476 C 10.642129,6.0400371 10.312509,5.9686201 9.988384,5.886215 L 9.993834,5.880715 7.8623408,5.3478364 7.4558114,6.9959316 c 0,0 1.1426793,0.2636953 1.1207046,0.2801765 0.6207823,0.1538223 0.7361485,0.5658464 0.714174,0.8954659 l -0.7196673,2.889659 c 0.043949,0.01099 0.098885,0.02747 0.1648089,0.04944 L 8.5710227,11.072223 7.5601911,15.11555 c -0.076912,0.192277 -0.26919,0.477948 -0.7031873,0.368073 0.010984,0.02198 -1.1261994,-0.280174 -1.1261994,-0.280174 l -0.7636164,1.76346 2.0106754,0.505417 c 0.3735682,0.0934 0.7361485,0.186784 1.0987301,0.280176 l -0.6427568,2.565534 1.5437155,0.384556 0.6317701,-2.538067 c 0.4230107,0.115364 0.8295407,0.219746 1.2305777,0.318632 l -0.63177,2.527079 1.543716,0.384557 0.637263,-2.560042 c 2.631459,0.499922 4.614667,0.296656 5.444208,-2.082094 0.670226,-1.917284 -0.03296,-3.021507 -1.417363,-3.741176 1.010832,-0.236227 1.768957,-0.895465 1.972221,-2.268877 z m -3.526922,4.944286 c -0.477949,1.917283 -3.702721,0.884477 -4.752008,0.620782 l 0.851514,-3.395075 c 1.043795,0.2582 4.394922,0.774604 3.900494,2.774293 z M 15.3392,10.715134 c -0.433999,1.74698 -3.120394,0.857008 -3.993884,0.642757 l 0.769111,-3.081939 c 0.87349,0.219746 3.675252,0.620784 3.224773,2.439182 z m 0,0"
id="path2" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

@@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "coffee.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "coffee.svg",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "coffee.svg",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,55 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Svg Vector Icons : http://www.onlinewebfonts.com/icon -->
<svg
version="1.1"
x="0px"
y="0px"
viewBox="0 0 13.999999 18"
enable-background="new 0 0 1000 1000"
xml:space="preserve"
id="svg4"
sodipodi:docname="coffee.svg"
width="14"
height="18"
inkscape:version="1.3-dev (77bc73e, 2022-05-18)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs4" /><sodipodi:namedview
id="namedview4"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="10.680141"
inkscape:cx="-17.181421"
inkscape:cy="4.07298"
inkscape:window-width="1368"
inkscape:window-height="947"
inkscape:window-x="1764"
inkscape:window-y="58"
inkscape:window-maximized="0"
inkscape:current-layer="svg4" />
<metadata
id="metadata1"> Svg Vector Icons : http://www.onlinewebfonts.com/icon </metadata>
<g
id="g4"
transform="matrix(0.01779387,0,0,0.01779387,-1.8340539,0.04465199)"><g
transform="matrix(0.1,0,0,-0.1,0,511)"
id="g3"><path
d="m 4302.6,4870.6 c 149.5,-177.8 240.5,-319.3 347.6,-545.6 119.2,-254.6 169.7,-448.6 183.9,-699.2 22.2,-462.7 -137.4,-778 -539.5,-1060.9 -474.9,-335.4 -685,-739.6 -687,-1315.5 0,-260.7 38.4,-501.1 115.2,-739.6 50.5,-149.5 56.6,-159.6 60.6,-97 18.2,234.4 56.6,476.9 101,626.4 121.2,422.3 305.1,622.4 885.1,959.8 424.3,248.5 575.9,487 575.9,905.3 -2,501.1 -359.7,1295.3 -798.2,1768.1 -82.8,88.9 -198,202.1 -256.6,250.6 l -105.1,86.9 z"
id="path1"
style="fill:#be5f00;fill-opacity:1" /><path
d="m 5981.8,3577.3 c 272.8,-369.8 309.2,-846.7 90.9,-1192.2 -147.5,-232.4 -373.8,-406.2 -822.4,-638.5 -592,-303.1 -854.7,-683 -854.7,-1232.6 0,-276.8 14.2,-343.5 72.7,-343.5 38.4,0 48.5,16.2 68.7,111.1 34.4,167.7 135.4,349.6 262.7,476.9 147.5,145.5 349.6,244.5 838.6,412.2 503.2,171.8 725.4,280.9 846.7,416.3 210.1,232.4 276.8,535.5 202.1,903.2 -76.8,373.8 -216.2,618.3 -537.5,943.7 -155.7,155.6 -214.3,206.1 -167.8,143.4 z"
id="path2"
style="fill:#be5f00;fill-opacity:1" /><path
d="M 2748.7,592.8 C 2158.7,507.9 1732.3,352.3 1542.4,156.3 1415.1,25 1409,-11.4 1427.2,-445.8 c 34.3,-832.5 181.9,-1729.7 462.7,-2829 153.6,-600.1 309.2,-1113.4 351.6,-1159.9 56.6,-62.6 272.8,-157.6 476.9,-210.1 668.8,-173.8 2172.2,-196 2960.3,-42.4 357.7,68.7 604.2,163.7 731.5,278.9 68.7,60.6 84.9,92.9 107.1,198 64.7,335.4 56.6,319.3 131.3,319.3 107.1,0 438.5,92.9 602.2,167.7 220.3,103.1 363.7,202.1 567.8,398.1 470.8,452.6 759.8,1149.8 761.8,1840.8 0,398.1 -72.7,614.3 -274.8,818.4 -220.3,222.3 -466.8,309.2 -937.6,325.4 l -309.2,12.1 14.2,218.2 14.2,218.2 -56.6,52.5 C 6866.9,314 6274.9,499.9 5696.9,582.7 L 5614,594.8 5533.2,457.4 5450.4,322 5565.6,305.8 c 588,-84.9 868.9,-159.6 978,-256.6 56.6,-50.5 60.6,-62.6 44.5,-113.2 -80.8,-226.3 -1000.2,-371.8 -2329.8,-371.8 -1220.5,0 -2097.5,123.3 -2295.5,321.3 -52.5,54.5 -56.6,66.7 -36.4,111.1 48.5,107.1 341.5,206.1 822.4,276.8 143.5,20.2 301.1,38.4 349.6,38.4 46.5,0 84.9,4 84.9,8.1 0,8.1 -175.8,295 -185.9,305.1 -4.2,2.1 -115.3,-12 -248.7,-32.2 z m 4797.1,-1614.5 c 153.6,-26.3 272.8,-82.8 317.2,-153.6 56.6,-84.9 60.6,-400.1 8.1,-652.7 -111.1,-545.6 -404.1,-996.2 -788.1,-1220.5 -139.4,-80.8 -333.4,-151.5 -355.6,-129.3 -8.1,8.1 18.2,216.2 58.6,462.7 78.8,482.9 159.6,1073 202.1,1450.8 l 24.2,232.4 70.7,12.1 c 115.3,20.3 325.4,20.3 462.8,-1.9 z"
id="path3"
style="fill:#be5f00;fill-opacity:1" /></g></g>
</svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

@@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "nostr-hashtag.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "nostr-hashtag.svg",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "nostr-hashtag.svg",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,51 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.0"
width="18pt"
height="18.199053pt"
viewBox="0 0 18 18.199053"
preserveAspectRatio="xMidYMid"
id="svg1"
sodipodi:docname="nostr-hashtag.svg"
inkscape:version="1.3-dev (77bc73e, 2022-05-18)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="pt"
showgrid="false"
inkscape:zoom="5.4017383"
inkscape:cx="50.354161"
inkscape:cy="-13.514168"
inkscape:window-width="1497"
inkscape:window-height="866"
inkscape:window-x="1747"
inkscape:window-y="96"
inkscape:window-maximized="0"
inkscape:current-layer="svg1" />
<metadata
id="metadata1">
Created by potrace 1.15, written by Peter Selinger 2001-2017
</metadata>
<g
transform="matrix(0.00138509,0,0,-0.00138509,0.3,17.927982)"
fill="#000000"
stroke="none"
id="g1">
<path
d="m 11315,12756 c -49,-23 -135,-71 -190,-106 -128,-81 -170,-100 -222,-100 -37,0 -43,-3 -43,-22 0,-11 12,-36 26,-55 l 26,-34 -20,-37 c -128,-248 -171,-359 -212,-547 -56,-262 -43,-645 36,-1045 48,-244 104,-451 234,-865 158,-503 280,-942 320,-1145 27,-140 60,-448 60,-559 0,-164 -39,-366 -92,-478 -57,-123 -176,-222 -286,-239 -49,-8 -50,-23 -5,-38 18,-6 34,-14 38,-18 13,-13 -25,-66 -97,-134 -121,-115 -267,-174 -432,-174 -200,0 -327,87 -732,507 -233,240 -541,529 -604,567 -31,18 -32,18 -27,0 3,-11 9,-36 13,-56 8,-49 -5,-49 -65,1 -75,62 -168,118 -250,150 -134,51 -214,63 -481,70 l -245,6 -65,41 c -100,63 -125,68 -295,66 -186,-3 -262,7 -484,64 -279,71 -341,67 -780,-61 -334,-97 -442,-118 -761,-150 -200,-19 -365,-19 -496,1 -58,9 -107,14 -111,11 -3,-4 6,-22 21,-41 l 27,-35 -23,-5 c -13,-3 -61,-8 -106,-11 -113,-9 -157,-26 -219,-87 -50,-49 -54,-51 -140,-64 -60,-10 -108,-25 -153,-48 -304,-157 -331,-166 -471,-166 -48,0 -120,7 -160,15 -40,9 -74,13 -78,10 -3,-3 0,-20 7,-37 6,-18 10,-34 7,-36 -2,-3 -24,0 -47,7 -24,6 -103,16 -176,23 -163,15 -350,1 -521,-40 l -114,-26 51,-23 c 53,-23 76,-50 66,-77 -3,-8 -51,-38 -105,-66 -251,-129 -515,-384 -727,-703 -110,-166 -208,-325 -217,-355 -6,-21 -5,-22 34,-16 36,6 41,4 41,-12 0,-11 -15,-45 -34,-76 -46,-75 -76,-161 -127,-359 -78,-307 -137,-444 -243,-569 -38,-44 -51,-67 -43,-72 7,-4 40,-8 75,-8 37,0 62,-4 62,-11 0,-6 -12,-26 -27,-45 -32,-43 -24,-53 48,-60 108,-11 103,-6 95,-82 -4,-37 -11,-128 -15,-202 -5,-74 -16,-164 -26,-200 -22,-85 -75,-190 -136,-269 -27,-35 -49,-67 -49,-72 0,-4 17,-11 38,-14 20,-3 49,-8 64,-10 64,-10 285,31 408,76 101,37 249,117 348,188 54,38 110,75 126,80 15,6 70,11 123,11 267,0 582,98 766,239 34,26 72,53 83,59 25,13 31,8 58,-46 21,-40 38,-32 57,26 30,93 97,200 141,223 25,14 38,3 98,-86 27,-38 73,-95 104,-125 31,-30 56,-63 56,-75 0,-11 -20,-60 -45,-110 -42,-84 -81,-202 -70,-213 3,-3 27,16 54,41 50,47 81,59 81,34 0,-8 -10,-49 -21,-93 -26,-97 -32,-275 -10,-313 l 14,-25 36,41 c 42,48 118,88 251,131 102,33 185,47 345,57 55,4 159,13 230,21 292,34 285,35 368,-56 66,-72 122,-115 170,-130 20,-6 37,-15 37,-19 0,-12 -22,-35 -75,-77 -27,-21 -151,-133 -275,-248 -424,-394 -462,-415 -864,-486 -65,-12 -145,-29 -177,-39 -191,-60 -348,-213 -554,-541 -103,-165 -162,-241 -292,-383 -176,-190 -332,-333 -778,-711 -497,-422 -750,-737 -895,-1113 -64,-165 -104,-203 -216,-203 -38,0 -94,7 -124,15 -74,20 -244,20 -301,0 -27,-10 -61,-33 -87,-62 -41,-45 -43,-46 -89,-39 -25,3 -67,18 -92,33 C 457,781 369,812 175,813 49,814 0,786 0,713 0,669 31,628 122,557 204,491 254,434 469,153 527,77 549,57 597,33 652,7 662,5 780,6 c 69,0 150,6 180,14 30,7 132,32 225,56 281,72 308,76 511,84 181,6 195,8 247,34 76,37 137,107 231,267 230,392 287,481 394,626 219,293 469,581 756,869 284,284 368,342 641,439 164,59 295,123 410,199 101,68 257,219 295,286 14,24 56,76 93,116 100,106 177,142 437,203 165,40 229,62 390,133 191,85 220,94 460,143 118,24 239,49 267,56 29,7 56,10 59,6 4,-3 15,-50 26,-104 14,-73 21,-159 25,-331 6,-265 -3,-385 -47,-605 -50,-247 -105,-393 -222,-592 -59,-98 -74,-167 -58,-256 23,-126 61,-177 174,-232 95,-47 169,-52 357,-28 125,16 244,22 505,26 667,12 1011,-35 1799,-241 277,-73 308,-86 376,-161 72,-79 113,-170 175,-388 19,-66 42,-130 50,-143 42,-63 155,-112 261,-112 109,0 124,57 54,206 -48,102 -53,149 -17,165 31,15 73,-5 102,-48 12,-18 45,-87 75,-153 57,-132 106,-197 173,-233 35,-18 56,-21 136,-20 73,2 110,-3 159,-19 89,-29 333,-32 403,-5 114,44 140,145 60,231 -23,24 -112,72 -272,143 -71,32 -199,124 -250,179 -71,78 -111,140 -224,343 -132,236 -172,297 -262,391 -58,61 -94,89 -148,115 -113,56 -85,53 -866,85 -223,9 -616,36 -656,45 -11,2 -86,12 -165,20 -179,20 -595,95 -712,130 -145,42 -319,139 -371,208 -32,42 -53,102 -67,187 -22,144 48,316 261,640 125,190 193,317 230,428 31,95 60,282 60,392 0,76 4,102 16,114 20,20 25,18 192,-102 73,-52 168,-114 210,-137 83,-47 132,-82 132,-95 0,-4 -14,-17 -32,-28 l -32,-21 29,-11 c 108,-41 305,-52 707,-39 186,6 345,9 353,5 8,-3 15,-15 15,-27 0,-20 3,-21 54,-15 110,13 266,81 426,187 89,58 99,63 170,69 161,14 229,78 523,495 88,124 143,185 169,185 2,0 2,-27 0,-60 -2,-33 0,-60 5,-60 23,0 140,145 212,263 117,192 165,317 271,712 106,394 111,410 135,410 15,0 22,-10 29,-40 13,-53 12,-52 42,-24 40,36 103,133 154,234 25,50 50,90 55,90 6,0 11,-17 13,-37 2,-21 7,-38 12,-38 19,0 93,101 139,190 29,56 87,206 140,363 50,147 114,318 142,380 132,288 199,526 246,867 25,190 25,756 0,975 -23,195 -53,379 -78,485 -46,194 -152,515 -233,706 -130,306 -218,564 -260,764 -48,228 -48,572 1,770 31,128 82,204 166,246 64,33 118,83 187,174 120,158 184,199 503,321 250,96 347,153 347,204 0,41 -42,57 -174,68 -112,9 -385,70 -439,99 -10,5 -27,29 -37,54 -23,53 -99,137 -159,176 -56,35 -72,36 -40,1 24,-26 23,-26 -13,3 -21,16 -42,41 -47,56 -9,26 -14,28 -60,28 -50,0 -101,16 -101,32 0,4 -42,8 -92,7 -88,0 -98,-3 -183,-43 z m 550,-126 c 10,-11 16,-20 13,-20 -3,0 -13,9 -23,20 -10,11 -16,20 -13,20 3,0 13,-9 23,-20 z"
id="path1"
style="fill:#cc43c5;fill-opacity:1" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.2 KiB

@@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "plebchain.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "plebchain.svg",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "plebchain.svg",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,51 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 21.315096 18"
width="21.315096"
height="18"
version="1.1"
id="svg21"
sodipodi:docname="plebchain.svg"
inkscape:version="1.3-dev (77bc73e, 2022-05-18)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs21" />
<sodipodi:namedview
id="namedview21"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="19.666667"
inkscape:cx="12.762712"
inkscape:cy="12.991525"
inkscape:window-width="1418"
inkscape:window-height="883"
inkscape:window-x="1745"
inkscape:window-y="10"
inkscape:window-maximized="0"
inkscape:current-layer="svg21" />
<path
d="M 18.625339,11.886754 C 17.668676,9.87076 15.553749,8.748431 12.298294,7.531368 12.258627,7.5164347 11.514296,7.2747022 10.649566,6.9942364 9.6532356,6.6713042 8.5682391,6.7520372 7.6316422,7.220569 L 5.7999816,8.136166 13.616623,17.9338 h 4.316652 l 0.703264,-1.662727 c 0.593598,-1.401862 0.641198,-3.009057 -0.0112,-4.384319 z"
id="path5"
style="fill:#bf26ed;fill-opacity:1;stroke-width:0.466665" />
<path
d="M 16.627546,5.1089093 C 16.710146,4.5484447 16.797412,3.95578 16.838479,3.6790475 17.083011,2.0219197 15.937348,0.48005806 14.28022,0.23552546 12.623092,-0.00900704 11.081231,1.1366559 10.836698,2.7937838 c -0.0406,0.2762657 -0.128333,0.8689305 -0.210932,1.4298619 -0.282799,0.058333 -0.513332,0.2851324 -0.558132,0.5870648 -0.0658,0.441465 0.084,1.0145298 0.370999,1.2072625 0.09707,1.4303286 1.118596,2.778524 2.547992,2.989457 1.429395,0.210933 2.796257,-0.785398 3.302589,-2.1265932 0.330399,-0.1021996 0.639331,-0.6071313 0.703731,-1.0490632 0.0434,-0.302399 -0.111533,-0.5856647 -0.365399,-0.7228643 z"
id="path10"
style="fill:#bf26ed;fill-opacity:1;stroke-width:0.466665" />
<path
d="m 21.193864,14.872477 c -0.0392,-0.501665 -0.310799,-0.956663 -0.684131,-1.294529 l -1.900727,-1.723394 c 0,0 0.172666,0.346732 0.332732,1.276796 L 15.53415,11.550288 c -0.878731,-0.405999 -1.835861,-0.616465 -2.804191,-0.616465 h -0.807331 c -0.87733,0 -1.681394,-0.553464 -1.9338602,-1.393928 C 9.8926348,9.220696 9.819835,8.832897 9.7904351,8.363432 V 8.362965 C 10.503966,7.7651672 10.944031,6.8285703 10.968765,5.8700401 11.247364,5.6651742 11.372897,5.0865094 11.28843,4.648311 11.230564,4.3482453 10.990698,4.1317127 10.706032,4.0850462 10.599632,3.5283147 10.487633,2.93985 10.435366,2.6654509 10.121301,1.0199896 8.5327726,-0.05940684 6.8873113,0.25419216 5.2418501,0.56779106 4.1624536,2.1567859 4.4760526,3.8022471 4.5283191,4.0766462 4.6407854,4.665111 4.7467184,5.2218423 4.4989192,5.3697751 4.355653,5.6595742 4.4125861,5.9596399 4.4956525,6.3983051 4.8255848,6.8901701 5.159717,6.9779032 5.2950499,7.2952355 5.4798493,7.5892345 5.7015152,7.8510336 L 5.7999816,8.136166 5.2171168,11.699621 3.2925898,12.11822 C 3.041524,12.177953 2.8109914,12.278752 2.6061254,12.411752 1.8342613,12.912484 1.5458622,13.865414 1.7731282,14.725478 1.8309946,14.943877 2.8833245,17.9338 2.8833245,17.9338 H 15.133284 l 0.408332,-1.691661 3.480855,0.605265 c 1.184397,0.211399 2.267993,-0.748064 2.171393,-1.974927 z"
id="path16"
style="fill:#f59119;fill-opacity:1;stroke-width:0.466665" />
<path
d="m 12.589026,15.06801 -2.251659,-0.776531 c 0,0 0.96693,-0.1106 0.95853,-0.625798 -4.67e-4,-0.03687 -0.01213,-0.240332 -0.307999,-0.210932 -0.0812,0.0079 -0.734064,0.07327 -1.1927962,0.118999 -0.3028657,0.03033 -0.6015314,0.09007 -0.8927304,0.178733 l -0.1591328,0.04853 -3.5256551,-2.101393 c 0,0 0,0 0.3495322,-0.763464 C 6.1779803,9.600561 5.8004482,8.136166 5.8004482,8.136166 L 1.324663,10.373825 C 0.63539857,10.718224 0.2,11.422889 0.2,12.193819 c 0,0.939397 0.64306455,1.756528 1.5558615,1.977727 l 6.4357789,1.371062 2.5834586,0.955731 c 0.167999,0.0532 0.329932,0.0602 0.517065,0.0033 0.187599,-0.05693 0.246399,-0.09847 0.337398,-0.165199 0.2464,-0.180133 0.706065,-0.536665 1.037864,-0.795664 0.170799,-0.133933 0.125533,-0.402266 -0.0784,-0.472732 z"
id="path21"
style="fill:#bf26ed;fill-opacity:1;stroke-width:0.466665" />
</svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

@@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "zapathon.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "zapathon.svg",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "zapathon.svg",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,60 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="11.106925"
height="18"
viewBox="0 0 2.9387073 4.7624999"
version="1.1"
id="svg1"
inkscape:version="1.3-dev (77bc73e, 2022-05-18)"
sodipodi:docname="zapathon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="16"
inkscape:cx="16.96875"
inkscape:cy="9.96875"
inkscape:window-width="1406"
inkscape:window-height="767"
inkscape:window-x="1741"
inkscape:window-y="214"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-10.993855,-8.058313)">
<g
transform="matrix(0.01604881,0,0,-0.01604881,10.573102,13.422443)"
id="g10"
inkscape:label="Bolt"
style="display:inline">
<path
id="path14"
style="fill:#c98f19;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
d="m 94.833712,155.13322 -8.129016,18.00903 -55.167069,-3.59001 18.868958,-24.19503 44.825648,2.77303 z m 83.691438,80.98476 -8.89814,-11.93085 -11.755,-16.519 -1.505,-2.114 -43.191,-60.691 L 63.081111,74.14633 79.705774,38.853232 C 121.34411,95.870625 162.2814,153.39008 203.27601,210.87013 Z m -22.57414,11.14115 12.32197,61.15287 -21.22997,22.33399 -17.432,-69.53486 -7.0256,-30.70796 27.52164,-9.9823 5.84396,26.73826"
sodipodi:nodetypes="cccccccccccccccccccccc" />
<path
id="path16"
style="fill:#fadf05;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
d="m 178.78514,236.43412 -5.679,-0.377 -8.065,-0.533 -5.9,-0.389 -33.394,-2.212 6.162,29.388 6.223,29.667 8.95029,38.92409 -54.426393,-75.90909 -61.5969,-86.55 44.1332,2.921 8.9137,0.589 -6.1723,-29.396 -0.9867,-4.71 -1.5106,-7.209 -11.7273,-55.917704 16.3074,22.9175 37.169603,52.234204 43.189,60.693 1.505,2.113 16.907,23.756 h -0.002"
sodipodi:nodetypes="cccccccccccccccccccccc" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

@@ -1,21 +0,0 @@
{
"images" : [
{
"filename" : "bitcoin-logo.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="20px" height="20px" viewBox="0 0 20 20" version="1.1">
<g id="surface1">
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(96.862745%,57.647059%,10.196078%);fill-opacity:1;" d="M 19.699219 12.417969 C 18.363281 17.777344 12.9375 21.035156 7.582031 19.699219 C 2.226562 18.363281 -1.035156 12.9375 0.300781 7.582031 C 1.636719 2.222656 7.0625 -1.035156 12.417969 0.300781 C 17.773438 1.632812 21.035156 7.0625 19.699219 12.417969 Z M 19.699219 12.417969 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 14.410156 8.574219 C 14.609375 7.246094 13.59375 6.53125 12.210938 6.050781 L 12.660156 4.25 L 11.5625 3.976562 L 11.125 5.730469 C 10.835938 5.660156 10.539062 5.589844 10.246094 5.523438 L 10.6875 3.757812 L 9.589844 3.484375 L 9.140625 5.285156 C 8.902344 5.230469 8.667969 5.179688 8.4375 5.121094 L 8.441406 5.117188 L 6.925781 4.738281 L 6.636719 5.910156 C 6.636719 5.910156 7.449219 6.097656 7.433594 6.109375 C 7.875 6.21875 7.957031 6.511719 7.941406 6.746094 L 7.429688 8.800781 C 7.460938 8.808594 7.5 8.820312 7.546875 8.835938 L 7.429688 8.808594 L 6.710938 11.683594 C 6.65625 11.820312 6.519531 12.023438 6.210938 11.945312 C 6.21875 11.960938 5.410156 11.746094 5.410156 11.746094 L 4.867188 13 L 6.296875 13.359375 C 6.5625 13.425781 6.820312 13.492188 7.078125 13.558594 L 6.621094 15.382812 L 7.71875 15.65625 L 8.167969 13.851562 C 8.46875 13.933594 8.757812 14.007812 9.042969 14.078125 L 8.59375 15.875 L 9.691406 16.148438 L 10.144531 14.328125 C 12.015625 14.683594 13.425781 14.539062 14.015625 12.847656 C 14.492188 11.484375 13.992188 10.699219 13.007812 10.1875 C 13.726562 10.019531 14.265625 9.550781 14.410156 8.574219 Z M 11.902344 12.089844 C 11.5625 13.453125 9.269531 12.71875 8.523438 12.53125 L 9.128906 10.117188 C 9.871094 10.300781 12.253906 10.667969 11.902344 12.089844 Z M 12.242188 8.554688 C 11.933594 9.796875 10.023438 9.164062 9.402344 9.011719 L 9.949219 6.820312 C 10.570312 6.976562 12.5625 7.261719 12.242188 8.554688 Z M 12.242188 8.554688 "/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

+3 -4
View File
@@ -8,8 +8,8 @@
import SwiftUI import SwiftUI
let RECTANGLE_GRADIENT = LinearGradient(gradient: Gradient(colors: [ let RECTANGLE_GRADIENT = LinearGradient(gradient: Gradient(colors: [
DamusColors.purple, Color("DamusPurple"),
DamusColors.blue Color("DamusBlue")
]), startPoint: .leading, endPoint: .trailing) ]), startPoint: .leading, endPoint: .trailing)
struct CustomPicker<SelectionValue: Hashable, Content: View>: View { struct CustomPicker<SelectionValue: Hashable, Content: View>: View {
@@ -52,10 +52,9 @@ struct CustomPicker<SelectionValue: Hashable, Content: View>: View {
.accentColor(tag == selection ? textColor() : .gray) .accentColor(tag == selection ? textColor() : .gray)
} }
} }
.background(Color(UIColor.systemBackground))
} }
func textColor() -> Color { func textColor() -> Color {
colorScheme == .light ? DamusColors.black : DamusColors.white colorScheme == .light ? Color("DamusBlack") : Color("DamusWhite")
} }
} }
-25
View File
@@ -1,25 +0,0 @@
//
// DamusColors.swift
// damus
//
// Created by William Casarin on 2023-03-27.
//
import Foundation
import SwiftUI
class DamusColors {
static let adaptableGrey = Color("DamusAdaptableGrey")
static let white = Color("DamusWhite")
static let black = Color("DamusBlack")
static let brown = Color("DamusBrown")
static let yellow = Color("DamusYellow")
static let lightGrey = Color("DamusLightGrey")
static let mediumGrey = Color("DamusMediumGrey")
static let darkGrey = Color("DamusDarkGrey")
static let green = Color("DamusGreen")
static let purple = Color("DamusPurple")
static let deepPurple = Color("DamusDeepPurple")
static let blue = Color("DamusBlue")
}
-44
View File
@@ -1,44 +0,0 @@
//
// IconLabel.swift
// damus
//
// Created by William Casarin on 2023-04-05.
//
import SwiftUI
import UIKit
struct IconLabel: View {
let text: String
let img_name: String
let img_color: Color
init(_ text: String, img_name: String, color: Color) {
self.text = text
self.img_name = img_name
self.img_color = color
}
var body: some View {
HStack(spacing: 0) {
Image(systemName: img_name)
.foregroundColor(img_color)
.frame(width: 20)
.padding([.trailing], 20)
Text(text)
}
}}
struct IconLabel_Previews: PreviewProvider {
static var previews: some View {
Form {
Section {
IconLabel(NSLocalizedString("Keys", comment: "Settings section for managing keys"), img_name: "key.fill", color: .orange)
IconLabel(NSLocalizedString("Local Notifications", comment: "Section header for damus local notifications user configuration"), img_name: "bell.fill", color: .blue)
IconLabel(NSLocalizedString("Appearance", comment: "Section header for text and appearance settings"), img_name: "textformat", color: .red)
}
}
}
}
+167 -146
View File
@@ -31,90 +31,166 @@ struct ShareSheet: UIViewControllerRepresentable {
} }
} }
struct ImageContextMenuModifier: ViewModifier {
enum ImageShape { let url: URL?
case square let image: UIImage?
case landscape @Binding var showShareSheet: Bool
case portrait
case unknown
static func determine_image_shape(_ size: CGSize) -> ImageShape { func body(content: Content) -> some View {
guard size.height > 0 else { return content.contextMenu {
return .unknown Button {
} UIPasteboard.general.url = url
let imageRatio = size.width / size.height } label: {
switch imageRatio { Label(NSLocalizedString("Copy Image URL", comment: "Context menu option to copy the URL of an image into clipboard."), systemImage: "doc.on.doc")
case 1.0: return .square }
case ..<1.0: return .portrait if let someImage = image {
case 1.0...: return .landscape Button {
default: return .unknown UIPasteboard.general.image = someImage
} label: {
Label(NSLocalizedString("Copy Image", comment: "Context menu option to copy an image into clipboard."), systemImage: "photo.on.rectangle")
}
Button {
UIImageWriteToSavedPhotosAlbum(someImage, nil, nil, nil)
} label: {
Label(NSLocalizedString("Save Image", comment: "Context menu option to save an image."), systemImage: "square.and.arrow.down")
}
}
Button {
showShareSheet = true
} label: {
Label(NSLocalizedString("Share", comment: "Button to share an image."), systemImage: "square.and.arrow.up")
}
} }
} }
} }
// Try either calculated imagefill from the real image or from metadata hints in tags private struct ImageContainerView: View {
func lookup_imgmeta_size_hint(events: EventCache, url: URL?) -> CGSize? {
guard let url, let url: URL?
let meta = events.lookup_img_metadata(url: url),
let img_size = meta.meta.dim?.size else { @State private var image: UIImage?
return nil @State private var showShareSheet = false
private struct ImageHandler: ImageModifier {
@Binding var handler: UIImage?
func modify(_ image: UIImage) -> UIImage {
handler = image
return image
}
} }
return img_size 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 {
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()
}
.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)
}
}
.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)
)
}
}
} }
struct ImageCarousel: View { struct ImageCarousel: View {
var urls: [URL] var urls: [URL]
let evid: String @State var open_sheet: Bool = false
@State var current_url: URL? = nil
let state: DamusState
@State private var open_sheet: Bool = false
@State private var current_url: URL? = nil
@State private var image_fill: ImageFill? = nil
let fillHeight: CGFloat = 350
let maxHeight: CGFloat = UIScreen.main.bounds.height * 1.2
init(state: DamusState, evid: String, urls: [URL]) {
_open_sheet = State(initialValue: false)
_current_url = State(initialValue: nil)
_image_fill = State(initialValue: state.previews.lookup_image_meta(evid))
self.urls = urls
self.evid = evid
self.state = state
}
var filling: Bool {
image_fill?.filling == true
}
var height: CGFloat {
image_fill?.height ?? fillHeight
}
func Placeholder(url: URL, geo_size: CGSize) -> some View {
Group {
if let meta = state.events.lookup_img_metadata(url: url),
case .processed(let blurhash) = meta.state {
Image(uiImage: blurhash)
.resizable()
.frame(width: geo_size.width * UIScreen.main.scale, height: self.height * UIScreen.main.scale)
} else {
EmptyView()
}
}
.onAppear {
if self.image_fill == nil,
let meta = state.events.lookup_img_metadata(url: url),
let size = meta.meta.dim?.size
{
let fill = ImageFill.calculate_image_fill(geo_size: geo_size, img_size: size, maxHeight: maxHeight, fillHeight: fillHeight)
self.image_fill = fill
}
}
}
var body: some View { var body: some View {
TabView { TabView {
@@ -122,36 +198,30 @@ struct ImageCarousel: View {
Rectangle() Rectangle()
.foregroundColor(Color.clear) .foregroundColor(Color.clear)
.overlay { .overlay {
GeometryReader { geo in KFAnimatedImage(url)
KFAnimatedImage(url) .imageContext(.note)
.callbackQueue(.dispatch(.global(qos:.background))) .cancelOnDisappear(true)
.backgroundDecode(true) .configure { view in
.imageContext(.note, disable_animation: state.settings.disable_animation) view.framePreloadCount = 3
.image_fade(duration: 0.25) }
.cancelOnDisappear(true) .aspectRatio(contentMode: .fit)
.configure { view in .tabItem {
view.framePreloadCount = 3 Text(url.absoluteString)
}
.id(url.absoluteString)
.contextMenu {
Button(NSLocalizedString("Copy Image", comment: "Context menu option to copy an image to clipboard.")) {
UIPasteboard.general.string = url.absoluteString
} }
.imageFill(for: geo.size, max: maxHeight, fill: fillHeight) { fill in }
state.previews.cache_image_meta(evid: evid, image_fill: fill)
image_fill = fill
}
.background {
Placeholder(url: url, geo_size: geo.size)
}
.aspectRatio(contentMode: filling ? .fill : .fit)
.tabItem {
Text(url.absoluteString)
}
.id(url.absoluteString)
}
} }
} }
} }
.cornerRadius(10)
.fullScreenCover(isPresented: $open_sheet) { .fullScreenCover(isPresented: $open_sheet) {
ImageView(urls: urls, disable_animation: state.settings.disable_animation) ImageView(urls: urls)
} }
.frame(height: self.height) .frame(height: 200)
.onTapGesture { .onTapGesture {
open_sheet = true open_sheet = true
} }
@@ -159,57 +229,8 @@ struct ImageCarousel: View {
} }
} }
// MARK: - Image Modifier
extension KFOptionSetter {
/// Sets a block to get image size
///
/// - Parameter block: The block which is used to read the image object.
/// - Returns: `Self` value after read size
public func imageFill(for size: CGSize, max: CGFloat, fill: CGFloat, block: @escaping (ImageFill) throws -> Void) -> Self {
let modifier = AnyImageModifier { image -> KFCrossPlatformImage in
let img_size = image.size
let geo_size = size
let fill = ImageFill.calculate_image_fill(geo_size: geo_size, img_size: img_size, maxHeight: max, fillHeight: fill)
DispatchQueue.main.async { [block, fill] in
try? block(fill)
}
return image
}
options.imageModifier = modifier
return self
}
}
public struct ImageFill {
let filling: Bool?
let height: CGFloat
static func calculate_image_fill(geo_size: CGSize, img_size: CGSize, maxHeight: CGFloat, fillHeight: CGFloat) -> ImageFill {
let shape = ImageShape.determine_image_shape(img_size)
let xfactor = geo_size.width / img_size.width
let scaled = img_size.height * xfactor
//print("calc_img_fill \(img_size.width)x\(img_size.height) xfactor:\(xfactor) scaled:\(scaled)")
// calculate scaled image height
// set scale factor and constrain images to minimum 150
// and animations to scaled factor for dynamic size adjustment
switch shape {
case .portrait, .landscape:
let filling = scaled > maxHeight
let height = filling ? fillHeight : scaled
return ImageFill(filling: filling, height: height)
case .square, .unknown:
return ImageFill(filling: nil, height: scaled)
}
}
}
struct ImageCarousel_Previews: PreviewProvider { struct ImageCarousel_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
ImageCarousel(state: test_damus_state(), evid: "evid", 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")!,URL(string: "https://jb55.com/red-me.jpg")!])
} }
} }
+5 -7
View File
@@ -14,7 +14,6 @@ struct InvoiceView: View {
let invoice: Invoice let invoice: Invoice
@State var showing_select_wallet: Bool = false @State var showing_select_wallet: Bool = false
@State var copied = false @State var copied = false
let settings: UserSettingsStore
var CopyButton: some View { var CopyButton: some View {
Button { Button {
@@ -30,17 +29,17 @@ struct InvoiceView: View {
.foregroundColor(.gray) .foregroundColor(.gray)
} else { } else {
Image(systemName: "checkmark.circle") Image(systemName: "checkmark.circle")
.foregroundColor(DamusColors.green) .foregroundColor(Color("DamusGreen"))
} }
} }
} }
var PayButton: some View { var PayButton: some View {
Button { Button {
if settings.show_wallet_selector { if should_show_wallet_selector(our_pubkey) {
showing_select_wallet = true showing_select_wallet = true
} else { } else {
open_with_wallet(wallet: settings.default_wallet.model, invoice: invoice.string) open_with_wallet(wallet: get_default_wallet(our_pubkey).model, invoice: invoice.string)
} }
} label: { } label: {
RoundedRectangle(cornerRadius: 20, style: .circular) RoundedRectangle(cornerRadius: 20, style: .circular)
@@ -81,7 +80,7 @@ struct InvoiceView: View {
.padding(30) .padding(30)
} }
.sheet(isPresented: $showing_select_wallet, onDismiss: {showing_select_wallet = false}) { .sheet(isPresented: $showing_select_wallet, onDismiss: {showing_select_wallet = false}) {
SelectWalletView(default_wallet: settings.default_wallet, showingSelectWallet: $showing_select_wallet, our_pubkey: our_pubkey, invoice: invoice.string) SelectWalletView(showingSelectWallet: $showing_select_wallet, our_pubkey: our_pubkey, invoice: invoice.string)
} }
} }
} }
@@ -112,8 +111,7 @@ let test_invoice = Invoice(description: .description("this is a description"), a
struct InvoiceView_Previews: PreviewProvider { struct InvoiceView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
InvoiceView(our_pubkey: "", invoice: test_invoice, settings: test_damus_state().settings) InvoiceView(our_pubkey: "", invoice: test_invoice)
.frame(width: 300, height: 200) .frame(width: 300, height: 200)
} }
} }
+2 -3
View File
@@ -10,7 +10,6 @@ import SwiftUI
struct InvoicesView: View { struct InvoicesView: View {
let our_pubkey: String let our_pubkey: String
var invoices: [Invoice] var invoices: [Invoice]
let settings: UserSettingsStore
@State var open_sheet: Bool = false @State var open_sheet: Bool = false
@State var current_invoice: Invoice? = nil @State var current_invoice: Invoice? = nil
@@ -18,7 +17,7 @@ struct InvoicesView: View {
var body: some View { var body: some View {
TabView { TabView {
ForEach(invoices, id: \.string) { invoice in ForEach(invoices, id: \.string) { invoice in
InvoiceView(our_pubkey: our_pubkey, invoice: invoice, settings: settings) InvoiceView(our_pubkey: our_pubkey, invoice: invoice)
.tabItem { .tabItem {
Text(invoice.string) Text(invoice.string)
} }
@@ -32,7 +31,7 @@ struct InvoicesView: View {
struct InvoicesView_Previews: PreviewProvider { struct InvoicesView_Previews: PreviewProvider {
static var previews: some View { 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)], settings: test_damus_state().settings) InvoicesView(our_pubkey: "", invoices: [Invoice.init(description: .description("description"), amount: .specific(10000), string: "invstr", expiry: 100000, payment_hash: Data(), created_at: 1000000)])
.frame(width: 300) .frame(width: 300)
} }
} }
+9 -34
View File
@@ -24,33 +24,19 @@ struct NIP05Badge: View {
self.clickable = clickable self.clickable = clickable
} }
var nip05_color: Bool { var nip05_color: Color {
return use_nip05_color(pubkey: pubkey, contacts: contacts) return get_nip05_color(pubkey: pubkey, contacts: contacts)
}
var Seal: some View {
Group {
if nip05_color {
LINEAR_GRADIENT
.mask(Image(systemName: "checkmark.seal.fill")
.resizable()
).frame(width: 14, height: 14)
} else if show_domain {
Image(systemName: "checkmark.seal.fill")
.font(.footnote)
.nip05_colorized(gradient: nip05_color)
}
}
} }
var body: some View { var body: some View {
HStack(spacing: 2) { HStack(spacing: 2) {
Seal Image(systemName: "checkmark.seal.fill")
.font(.footnote)
.foregroundColor(nip05_color)
if show_domain { if show_domain {
if clickable { if clickable {
Text(nip05.host) Text(nip05.host)
.nip05_colorized(gradient: nip05_color) .foregroundColor(nip05_color)
.onTapGesture { .onTapGesture {
if let nip5url = nip05.siteUrl { if let nip5url = nip05.siteUrl {
openURL(nip5url) openURL(nip5url)
@@ -58,7 +44,7 @@ struct NIP05Badge: View {
} }
} else { } else {
Text(nip05.host) Text(nip05.host)
.foregroundColor(.gray) .foregroundColor(nip05_color)
} }
} }
} }
@@ -66,19 +52,8 @@ struct NIP05Badge: View {
} }
} }
extension View { func get_nip05_color(pubkey: String, contacts: Contacts) -> Color {
func nip05_colorized(gradient: Bool) -> some View { return contacts.is_friend_or_self(pubkey) ? .accentColor : .gray
if gradient {
return AnyView(self.foregroundStyle(LINEAR_GRADIENT))
} else {
return AnyView(self.foregroundColor(.gray))
}
}
}
func use_nip05_color(pubkey: String, contacts: Contacts) -> Bool {
return contacts.is_friend_or_self(pubkey) ? true : false
} }
struct NIP05Badge_Previews: PreviewProvider { struct NIP05Badge_Previews: PreviewProvider {
+2
View File
@@ -15,10 +15,12 @@ struct Reposted: View {
var body: some View { var body: some View {
HStack(alignment: .center) { HStack(alignment: .center) {
Image(systemName: "arrow.2.squarepath") Image(systemName: "arrow.2.squarepath")
.font(.footnote)
.foregroundColor(Color.gray) .foregroundColor(Color.gray)
ProfileName(pubkey: pubkey, profile: profile, damus: damus, show_friend_confirmed: true, show_nip5_domain: false) ProfileName(pubkey: pubkey, profile: profile, damus: damus, show_friend_confirmed: true, show_nip5_domain: false)
.foregroundColor(Color.gray) .foregroundColor(Color.gray)
Text("Reposted", comment: "Text indicating that the post was reposted (i.e. re-shared).") Text("Reposted", comment: "Text indicating that the post was reposted (i.e. re-shared).")
.font(.footnote)
.foregroundColor(Color.gray) .foregroundColor(Color.gray)
} }
} }
+1 -7
View File
@@ -15,18 +15,15 @@ struct SelectableText: View {
@State private var selectedTextHeight: CGFloat = .zero @State private var selectedTextHeight: CGFloat = .zero
@State private var selectedTextWidth: CGFloat = .zero @State private var selectedTextWidth: CGFloat = .zero
let size: EventViewKind
var body: some View { var body: some View {
GeometryReader { geo in GeometryReader { geo in
TextViewRepresentable( TextViewRepresentable(
attributedString: attributedString, attributedString: attributedString,
textColor: UIColor.label, textColor: UIColor.label,
font: eventviewsize_to_uifont(size), font: UIFont.preferredFont(forTextStyle: .title2),
fixedWidth: selectedTextWidth, fixedWidth: selectedTextWidth,
height: $selectedTextHeight height: $selectedTextHeight
) )
.padding([.leading, .trailing], -1.0)
.onAppear { .onAppear {
self.selectedTextWidth = geo.size.width self.selectedTextWidth = geo.size.width
} }
@@ -52,11 +49,8 @@ struct SelectableText: View {
view.isEditable = false view.isEditable = false
view.dataDetectorTypes = .all view.dataDetectorTypes = .all
view.isSelectable = true view.isSelectable = true
view.backgroundColor = .clear
view.textContainer.lineFragmentPadding = 0 view.textContainer.lineFragmentPadding = 0
view.textContainerInset = .zero view.textContainerInset = .zero
view.textContainerInset.left = 1.0
view.textContainerInset.right = 1.0
return view return view
} }
-22
View File
@@ -1,22 +0,0 @@
//
// ThiccDivider.swift
// damus
//
// Created by William Casarin on 2023-04-03.
//
import SwiftUI
struct ThiccDivider: View {
var body: some View {
Rectangle()
.frame(height: 4)
.foregroundColor(DamusColors.adaptableGrey)
}
}
struct ThiccDivider_Previews: PreviewProvider {
static var previews: some View {
ThiccDivider()
}
}
+99 -145
View File
@@ -8,184 +8,138 @@
import SwiftUI import SwiftUI
import NaturalLanguage import NaturalLanguage
struct Translated: Equatable {
let artifacts: NoteArtifacts
let language: String
}
enum TranslateStatus: Equatable {
case havent_tried
case trying
case translating
case translated(Translated)
case not_needed
}
struct TranslateView: View { struct TranslateView: View {
let damus_state: DamusState let damus_state: DamusState
let event: NostrEvent let event: NostrEvent
let size: EventViewKind
let currentLanguage: String
@State var translated: TranslateStatus @State var checkingTranslationStatus: Bool = false
@State var currentLanguage: String = "en"
init(damus_state: DamusState, event: NostrEvent, size: EventViewKind) { @State var noteLanguage: String? = nil
self.damus_state = damus_state @State var translated_note: String? = nil
self.event = event @State var show_translated_note: Bool = false
self.size = size @State var translated_artifacts: NoteArtifacts? = nil
if #available(iOS 16, *) {
self.currentLanguage = Locale.current.language.languageCode?.identifier ?? "en"
} else {
self.currentLanguage = Locale.current.languageCode ?? "en"
}
if damus_state.pubkey == event.pubkey && damus_state.is_privkey_user {
// Do not translate self-authored notes if logged in with a private key
// as we can assume the user can understand their own notes.
// The detected language prediction could be incorrect and not in the list of preferred languages.
// Offering a translation in this case is definitely incorrect so let's avoid it altogether.
self._translated = State(initialValue: .not_needed)
} else if let cached = damus_state.events.lookup_translated_artifacts(evid: event.id) {
self._translated = State(initialValue: cached)
} else {
let initval: TranslateStatus = self.damus_state.settings.auto_translate ? .trying : .havent_tried
self._translated = State(initialValue: initval)
}
}
let preferredLanguages = Set(Locale.preferredLanguages.map { localeToLanguage($0) })
var TranslateButton: some View { var TranslateButton: some View {
Button(NSLocalizedString("Translate Note", comment: "Button to translate note from different language.")) { Button(NSLocalizedString("Translate Note", comment: "Button to translate note from different language.")) {
self.translated = .trying show_translated_note = true
} }
.translate_button_style() .translate_button_style()
} }
func TranslatedView(lang: String?, artifacts: NoteArtifacts) -> some View { func Translated(lang: String, artifacts: NoteArtifacts) -> some View {
return VStack(alignment: .leading) { return Group {
Text(String(format: NSLocalizedString("Translated from %@", comment: "Button to indicate that the note has been translated from a different language."), lang ?? "ja")) Button(NSLocalizedString("Translated from \(lang)", comment: "Button to indicate that the note has been translated from a different language.")) {
.foregroundColor(.gray) show_translated_note = false
.font(.footnote)
.padding([.top, .bottom], 10)
if self.size == .selected {
SelectableText(attributedString: artifacts.content.attributed, size: self.size)
} else {
artifacts.content.text
.font(eventviewsize_to_font(self.size))
} }
.translate_button_style()
SelectableText(attributedString: artifacts.content)
} }
} }
func failed_attempt() { func CheckingStatus(lang: String) -> some View {
DispatchQueue.main.async { return Button(NSLocalizedString("Translating from \(lang)...", comment: "Button to indicate that the note is in the process of being translated from a different language.")) {
self.translated = .not_needed show_translated_note = false
damus_state.events.store_translation_artifacts(evid: event.id, translated: .not_needed)
} }
.translate_button_style()
} }
func attempt_translation() async { func MainContent(note_lang: String) -> some View {
guard case .trying = translated else { return Group {
return let languageName = Locale.current.localizedString(forLanguageCode: note_lang)
} if let lang = languageName, show_translated_note {
if checkingTranslationStatus {
guard damus_state.settings.can_translate(damus_state.pubkey) else { CheckingStatus(lang: lang)
return } else if let artifacts = translated_artifacts {
} Translated(lang: lang, artifacts: artifacts)
}
let note_lang = event.note_language(damus_state.keypair.privkey) ?? currentLanguage } else {
TranslateButton
// Don't translate if its in our preferred languages }
guard !preferredLanguages.contains(note_lang) else {
failed_attempt()
return
}
DispatchQueue.main.async {
self.translated = .translating
}
// If the note language is different from our preferred languages, send a translation request.
let translator = Translator(damus_state.settings)
let originalContent = event.get_content(damus_state.keypair.privkey)
let translated_note = try? await translator.translate(originalContent, from: note_lang, to: currentLanguage)
guard let translated_note else {
// if its the same, give up and don't retry
failed_attempt()
return
}
guard originalContent != translated_note else {
// if its the same, give up and don't retry
failed_attempt()
return
}
// Render translated note
let translated_blocks = event.get_blocks(content: translated_note)
let artifacts = render_blocks(blocks: translated_blocks, profiles: damus_state.profiles, privkey: damus_state.keypair.privkey)
// and cache it
DispatchQueue.main.async {
self.translated = .translated(Translated(artifacts: artifacts, language: note_lang))
damus_state.events.store_translation_artifacts(evid: event.id, translated: self.translated)
} }
} }
var body: some View { var body: some View {
Group { Group {
switch translated { if let note_lang = noteLanguage, noteLanguage != currentLanguage {
case .havent_tried: MainContent(note_lang: note_lang)
if damus_state.settings.auto_translate { } else {
Text("")
} else {
TranslateButton
}
case .trying:
Text("") Text("")
case .translating:
Text("Translating...", comment: "Text to display when waiting for the translation of a note to finish processing before showing it.")
.foregroundColor(.gray)
.font(.footnote)
.padding([.top, .bottom], 10)
case .translated(let translated):
let languageName = Locale.current.localizedString(forLanguageCode: translated.language)
TranslatedView(lang: languageName, artifacts: translated.artifacts)
case .not_needed:
Text("")
}
}
.onChange(of: translated) { val in
guard case .trying = translated else {
return
}
Task {
await attempt_translation()
} }
} }
.task { .task {
await attempt_translation() guard noteLanguage == nil && !checkingTranslationStatus && damus_state.settings.can_translate(damus_state.pubkey) else {
} return
} }
}
checkingTranslationStatus = true
extension View { if #available(iOS 16, *) {
func translate_button_style() -> some View { currentLanguage = Locale.current.language.languageCode?.identifier ?? "en"
return self } else {
.font(.footnote) currentLanguage = Locale.current.languageCode ?? "en"
.contentShape(Rectangle()) }
.padding([.top, .bottom], 10)
// 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 { struct TranslateView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let ds = test_damus_state() let ds = test_damus_state()
TranslateView(damus_state: ds, event: test_event, size: .normal) TranslateView(damus_state: ds, event: test_event)
} }
} }
-53
View File
@@ -1,53 +0,0 @@
//
// TruncatedText.swift
// damus
//
// Created by William Casarin on 2023-04-06.
//
import SwiftUI
struct TruncatedText: View {
let text: CompatibleText
let maxChars: Int = 280
var body: some View {
let truncatedAttributedString: AttributedString? = getTruncatedString()
if let truncatedAttributedString {
Text(truncatedAttributedString)
.fixedSize(horizontal: false, vertical: true)
} else {
text.text
.fixedSize(horizontal: false, vertical: true)
}
if truncatedAttributedString != nil {
Spacer()
Button(NSLocalizedString("Show more", comment: "Button to show entire note.")) { }
.allowsHitTesting(false)
}
}
func getTruncatedString() -> AttributedString? {
let nsAttributedString = NSAttributedString(text.attributed)
if nsAttributedString.length < maxChars { return nil }
let range = NSRange(location: 0, length: maxChars)
let truncatedAttributedString = nsAttributedString.attributedSubstring(from: range)
return AttributedString(truncatedAttributedString) + "..."
}
}
struct TruncatedText_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: 100) {
TruncatedText(text: CompatibleText(stringLiteral: "hello\nthere\none\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven"))
.frame(width: 200, height: 200)
TruncatedText(text: CompatibleText(stringLiteral: "hello\nthere\none\ntwo\nthree\nfour"))
.frame(width: 200, height: 200)
}
}
}
+16 -36
View File
@@ -7,51 +7,31 @@
import SwiftUI import SwiftUI
struct UserViewRow: View {
let damus_state: DamusState
let pubkey: String
@State var navigating: Bool = false
var body: some View {
let dest = ProfileView(damus_state: damus_state, pubkey: pubkey)
UserView(damus_state: damus_state, pubkey: pubkey)
.contentShape(Rectangle())
.background(
NavigationLink(destination: dest, isActive: $navigating) {
EmptyView()
}
)
.onTapGesture {
navigating = true
}
}
}
struct UserView: View { struct UserView: View {
let damus_state: DamusState let damus_state: DamusState
let pubkey: String let pubkey: String
var body: some View { var body: some View {
let pmodel = ProfileModel(pubkey: pubkey, damus: damus_state)
let followers = FollowersModel(damus_state: damus_state, target: pubkey)
let pv = ProfileView(damus_state: damus_state, profile: pmodel, followers: followers)
VStack { NavigationLink(destination: pv) {
HStack { ProfilePicView(pubkey: pubkey, size: PFP_SIZE, highlight: .none, profiles: damus_state.profiles)
ProfilePicView(pubkey: pubkey, size: PFP_SIZE, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
VStack(alignment: .leading) {
VStack(alignment: .leading) { let profile = damus_state.profiles.lookup(id: pubkey)
let profile = damus_state.profiles.lookup(id: pubkey) ProfileName(pubkey: pubkey, profile: profile, damus: damus_state, show_friend_confirmed: false, show_nip5_domain: false)
ProfileName(pubkey: pubkey, profile: profile, damus: damus_state, show_friend_confirmed: false, show_nip5_domain: false) if let about = profile?.about {
if let about = profile?.about { Text(about)
Text(about) .lineLimit(3)
.lineLimit(3) .font(.footnote)
.font(.footnote)
}
} }
Spacer()
} }
Spacer()
} }
.buttonStyle(PlainButtonStyle())
} }
} }
-1
View File
@@ -22,7 +22,6 @@ struct WebsiteLink: View {
}, label: { }, label: {
Text(link_text) Text(link_text)
.font(.footnote) .font(.footnote)
.foregroundColor(.accentColor)
}) })
} }
} }
+67 -132
View File
@@ -7,22 +7,6 @@
import SwiftUI 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 { struct ZapButton: View {
let damus_state: DamusState let damus_state: DamusState
let event: NostrEvent let event: NostrEvent
@@ -35,8 +19,61 @@ struct ZapButton: View {
@State var slider_value: Double = 0.0 @State var slider_value: Double = 0.0
@State var slider_visible: Bool = false @State var slider_visible: Bool = false
@State var showing_select_wallet: Bool = false @State var showing_select_wallet: Bool = false
@State var showing_zap_customizer: Bool = false
@State var is_charging: Bool = false func send_zap() {
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)
// TODO: gather comment?
let content = ""
let zapreq = make_zap_request_event(pubkey: damus_state.pubkey, privkey: privkey, content: content, relays: relays, target: target)
zapping = true
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 {
zapping = false
}
return
}
DispatchQueue.main.async {
damus_state.lnurls.endpoints[target.pubkey] = payreq
}
let zap_amount = get_default_zap_amount(pubkey: damus_state.pubkey) ?? 1000
guard let inv = await fetch_zap_invoice(payreq, zapreq: zapreq, sats: zap_amount) else {
DispatchQueue.main.async {
zapping = false
}
return
}
DispatchQueue.main.async {
zapping = false
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)
}
}
}
//damus_state.pool.send(.event(zapreq))
}
var zap_img: String { var zap_img: String {
if bar.zapped { if bar.zapped {
@@ -55,10 +92,6 @@ struct ZapButton: View {
return Color.orange return Color.orange
} }
if is_charging {
return Color.yellow
}
if !zapping { if !zapping {
return nil return nil
} }
@@ -68,66 +101,21 @@ struct ZapButton: View {
var body: some View { var body: some View {
HStack(spacing: 4) { HStack(spacing: 4) {
Button(action: { EventActionButton(img: zap_img, col: zap_color) {
}, label: { if bar.zapped {
Image(systemName: zap_img) //notify(.delete, bar.our_tip)
.foregroundColor(zap_color == nil ? Color.gray : zap_color!) } else if !zapping {
.font(.footnote.weight(.medium)) send_zap()
})
.simultaneousGesture(LongPressGesture().onEnded {_ in
guard !zapping else {
return
} }
self.showing_zap_customizer = true
})
.highPriorityGesture(TapGesture().onEnded {_ in
guard !zapping else {
return
}
send_zap(damus_state: damus_state, event: event, lnurl: lnurl, is_custom: false, comment: nil, amount_sats: nil, zap_type: damus_state.settings.default_zap_type)
self.zapping = 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)
} }
} .accessibilityLabel(NSLocalizedString("Zap", comment: "Accessibility label for zap button"))
.sheet(isPresented: $showing_zap_customizer) {
CustomizeZapView(state: damus_state, event: event, lnurl: lnurl) Text(String("\(bar.zap_total > 0 ? "\(format_msats_abbrev(bar.zap_total))" : "")"))
.font(.footnote)
.foregroundColor(bar.zapped ? Color.orange : Color.gray)
} }
.sheet(isPresented: $showing_select_wallet, onDismiss: {showing_select_wallet = false}) { .sheet(isPresented: $showing_select_wallet, onDismiss: {showing_select_wallet = false}) {
SelectWalletView(default_wallet: damus_state.settings.default_wallet, showingSelectWallet: $showing_select_wallet, our_pubkey: damus_state.pubkey, invoice: invoice) 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 damus_state.settings.show_wallet_selector {
self.invoice = inv
self.showing_select_wallet = true
} else {
let wallet = damus_state.settings.default_wallet.model
open_with_wallet(wallet: wallet, invoice: inv)
}
}
self.zapping = false
} }
} }
} }
@@ -135,61 +123,8 @@ struct ZapButton: View {
struct ZapButton_Previews: PreviewProvider { struct ZapButton_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let bar = ActionBarModel(likes: 0, boosts: 0, zaps: 10, zap_total: 15623414, replies: 2, our_like: nil, our_boost: nil, our_zap: nil, our_reply: nil) 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) 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 keypair = damus_state.keypair.to_full() 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(keypair: keypair, content: content, relays: relays, target: target, zap_type: zap_type)
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 ?? damus_state.settings.default_zap_amount
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
}
+195 -282
View File
@@ -6,23 +6,35 @@
// //
import SwiftUI import SwiftUI
import Starscream
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://brb.io",
]
struct TimestampedProfile { struct TimestampedProfile {
let profile: Profile let profile: Profile
let timestamp: Int64 let timestamp: Int64
let event: NostrEvent
} }
enum Sheets: Identifiable { enum Sheets: Identifiable {
case post(PostAction) case post
case report(ReportTarget) case report(ReportTarget)
case reply(NostrEvent)
case event(NostrEvent) case event(NostrEvent)
case filter case filter
var id: String { var id: String {
switch self { switch self {
case .report: return "report" case .report: return "report"
case .post(let action): return "post-" + (action.ev?.id ?? "") case .post: return "post"
case .reply(let ev): return "reply-" + ev.id
case .event(let ev): return "event-" + ev.id case .event(let ev): return "event-" + ev.id
case .filter: return "filter" case .filter: return "filter"
} }
@@ -63,42 +75,35 @@ struct ContentView: View {
@State var active_sheet: Sheets? = nil @State var active_sheet: Sheets? = nil
@State var damus_state: DamusState? = nil @State var damus_state: DamusState? = nil
@State var selected_timeline: Timeline? = .home @State var selected_timeline: Timeline? = .home
@State var is_thread_open: Bool = false
@State var is_deleted_account: Bool = false @State var is_deleted_account: Bool = false
@State var is_profile_open: Bool = false @State var is_profile_open: Bool = false
@State var event: NostrEvent? = nil @State var event: NostrEvent? = nil
@State var active_profile: String? = nil @State var active_profile: String? = nil
@State var active_search: NostrFilter? = nil @State var active_search: NostrFilter? = nil
@State var active_event: NostrEvent? = nil @State var active_event_id: String? = nil
@State var profile_open: Bool = false @State var profile_open: Bool = false
@State var thread_open: Bool = false @State var thread_open: Bool = false
@State var search_open: Bool = false @State var search_open: Bool = false
@State var muting: String? = nil @State var blocking: String? = nil
@State var confirm_mute: Bool = false @State var confirm_block: Bool = false
@State var user_muted_confirm: Bool = false @State var user_blocked_confirm: Bool = false
@State var confirm_overwrite_mutelist: Bool = false @State var confirm_overwrite_mutelist: Bool = false
@State var filter_state : FilterState = .posts_and_replies @State var filter_state : FilterState = .posts_and_replies
@State private var isSideBarOpened = false @State private var isSideBarOpened = false
@StateObject var home: HomeModel = HomeModel() @StateObject var home: HomeModel = HomeModel()
// connect retry timer // connect retry timer
let timer = Timer.publish(every: 4, on: .main, in: .common).autoconnect() let timer = Timer.publish(every: 4, on: .main, in: .common).autoconnect()
let sub_id = UUID().description let sub_id = UUID().description
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
var mystery: some View {
Text("Are you lost?", comment: "Text asking the user if they are lost in the app.")
.id("what")
}
var PostingTimelineView: some View { var PostingTimelineView: some View {
VStack { VStack {
ZStack { ZStack {
TabView(selection: $filter_state) { TabView(selection: $filter_state) {
// This is needed or else there is a bug when switching from the 3rd or 2nd tab to first. no idea why.
mystery
contentTimelineView(filter: FilterState.posts.filter) contentTimelineView(filter: FilterState.posts.filter)
.tag(FilterState.posts) .tag(FilterState.posts)
.id(FilterState.posts) .id(FilterState.posts)
@@ -110,7 +115,7 @@ struct ContentView: View {
if privkey != nil { if privkey != nil {
PostButtonContainer(is_left_handed: damus_state?.settings.left_handed ?? false) { PostButtonContainer(is_left_handed: damus_state?.settings.left_handed ?? false) {
self.active_sheet = .post(.posting) self.active_sheet = .post
} }
} }
} }
@@ -131,7 +136,7 @@ struct ContentView: View {
func contentTimelineView(filter: (@escaping (NostrEvent) -> Bool)) -> some View { func contentTimelineView(filter: (@escaping (NostrEvent) -> Bool)) -> some View {
ZStack { ZStack {
if let damus = self.damus_state { 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)
} }
} }
} }
@@ -142,10 +147,28 @@ struct ContentView: View {
search_open = false search_open = false
isSideBarOpened = false isSideBarOpened = false
} }
var timelineNavItem: Text { var timelineNavItem: some View {
return Text(timeline_name(selected_timeline)) VStack {
.bold() 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("Global", comment: "Toolbar label for Global view where posts from all connected relay servers appear.")
.bold()
case .none:
Text("", comment: "Toolbar label for unknown views. This label would be displayed only if a new timeline view is added but a toolbar label was not explicitly assigned to it yet.")
}
}
} }
func MainContent(damus: DamusState) -> some View { func MainContent(damus: DamusState) -> some View {
@@ -153,57 +176,41 @@ struct ContentView: View {
NavigationLink(destination: MaybeProfileView, isActive: $profile_open) { NavigationLink(destination: MaybeProfileView, isActive: $profile_open) {
EmptyView() EmptyView()
} }
if let active_event { NavigationLink(destination: MaybeThreadView, isActive: $thread_open) {
let thread = ThreadModel(event: active_event, damus_state: damus_state!) EmptyView()
NavigationLink(destination: ThreadView(state: damus_state!, thread: thread), isActive: $thread_open) {
EmptyView()
}
} }
NavigationLink(destination: MaybeSearchView, isActive: $search_open) { NavigationLink(destination: MaybeSearchView, isActive: $search_open) {
EmptyView() EmptyView()
} }
switch selected_timeline { switch selected_timeline {
case .search: case .search:
if #available(iOS 16.0, *) { SearchHomeView(damus_state: damus_state!, model: SearchHomeModel(damus_state: damus_state!))
SearchHomeView(damus_state: damus_state!, model: SearchHomeModel(damus_state: damus_state!))
.scrollDismissesKeyboard(.immediately)
} else {
// Fallback on earlier versions
SearchHomeView(damus_state: damus_state!, model: SearchHomeModel(damus_state: damus_state!))
}
case .home: case .home:
PostingTimelineView PostingTimelineView
case .notifications: case .notifications:
NotificationsView(state: damus, notifications: home.notifications) VStack(spacing: 0) {
Divider()
TimelineView(events: $home.notifications, loading: $home.loading, damus: damus, show_friend_icon: true, filter: { _ in true })
}
case .dms: case .dms:
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings) DirectMessagesView(damus_state: damus_state!)
.environmentObject(home.dms)
case .none: case .none:
EmptyView() EmptyView()
} }
} }
.navigationBarTitle(timeline_name(selected_timeline), 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 { .toolbar {
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
VStack { timelineNavItem
if selected_timeline == .home { .opacity(isSideBarOpened ? 0 : 1)
Image("damus-home") .animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
.resizable()
.frame(width:30,height:30)
.shadow(color: DamusColors.purple, radius: 2)
.opacity(isSideBarOpened ? 0 : 1)
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
} else {
timelineNavItem
.opacity(isSideBarOpened ? 0 : 1)
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
}
}
} }
} }
.ignoresSafeArea(.keyboard)
} }
var MaybeSearchView: some View { var MaybeSearchView: some View {
@@ -216,6 +223,16 @@ struct ContentView: View {
} }
} }
var MaybeThreadView: some View {
Group {
if let evid = self.active_event_id {
BuildThreadV2View(damus: damus_state!, event_id: evid)
} else {
EmptyView()
}
}
}
var MaybeProfileView: some View { var MaybeProfileView: some View {
Group { Group {
if let pk = self.active_profile { if let pk = self.active_profile {
@@ -230,9 +247,9 @@ struct ContentView: View {
func MaybeReportView(target: ReportTarget) -> some View { func MaybeReportView(target: ReportTarget) -> some View {
Group { Group {
if let damus_state { if let ds = damus_state {
if let sec = damus_state.keypair.privkey { if let sec = ds.keypair.privkey {
ReportView(postbox: damus_state.postbox, target: target, privkey: sec) ReportView(pool: ds.pool, target: target, privkey: sec)
} else { } else {
EmptyView() EmptyView()
} }
@@ -242,62 +259,63 @@ struct ContentView: View {
} }
} }
func open_event(ev: NostrEvent) {
self.active_event = ev
self.thread_open = true
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
if let damus = self.damus_state { if let damus = self.damus_state {
NavigationView { NavigationView {
TabView { // Prevents navbar appearance change on scroll ZStack {
MainContent(damus: damus) TabView { // Prevents navbar appearance change on scroll
.toolbar() { MainContent(damus: damus)
ToolbarItem(placement: .navigationBarLeading) { .toolbar() {
Button { ToolbarItem(placement: .navigationBarLeading) {
isSideBarOpened.toggle() Button {
} label: { isSideBarOpened.toggle()
ProfilePicView(pubkey: damus_state!.pubkey, size: 32, highlight: .none, profiles: damus_state!.profiles, disable_animation: damus_state!.settings.disable_animation) } label: {
.opacity(isSideBarOpened ? 0 : 1) ProfilePicView(pubkey: damus_state!.pubkey, size: 32, highlight: .none, profiles: damus_state!.profiles)
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened) .opacity(isSideBarOpened ? 0 : 1)
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
}
.disabled(isSideBarOpened)
} }
.disabled(isSideBarOpened)
} ToolbarItem(placement: .navigationBarTrailing) {
HStack(alignment: .center) {
ToolbarItem(placement: .navigationBarTrailing) { if home.signal.signal != home.signal.max_signal {
HStack(alignment: .center) { NavigationLink(destination: RelayConfigView(state: damus_state!)) {
SignalView(state: damus_state!, signal: home.signal) Text("\(home.signal.signal)/\(home.signal.max_signal)", comment: "Fraction of how many of the user's relay servers that are operational.")
.font(.callout)
// maybe expand this to other timelines in the future .foregroundColor(.gray)
if selected_timeline == .search { }
Button(action: { }
//isFilterVisible.toggle()
self.active_sheet = .filter // maybe expand this to other timelines in the future
}) { if selected_timeline == .search {
// checklist, checklist.checked, lisdt.bullet, list.bullet.circle, line.3.horizontal.decrease..., line.3.horizontail.decrease Button(action: {
Label(NSLocalizedString("Filter", comment: "Button label text for filtering relay servers."), systemImage: "line.3.horizontal.decrease") //isFilterVisible.toggle()
.foregroundColor(.gray) self.active_sheet = .filter
//.contentShape(Rectangle()) }) {
// checklist, checklist.checked, lisdt.bullet, list.bullet.circle, line.3.horizontal.decrease..., line.3.horizontail.decrease
Label("Filter", systemImage: "line.3.horizontal.decrease")
.foregroundColor(.gray)
//.contentShape(Rectangle())
}
} }
} }
} }
} }
} }
.tabViewStyle(.page(indexDisplayMode: .never))
} }
.tabViewStyle(.page(indexDisplayMode: .never))
.overlay( .overlay(
SideMenuView(damus_state: damus, isSidebarVisible: $isSideBarOpened.animation()) SideMenuView(damus_state: damus, isSidebarVisible: $isSideBarOpened.animation())
) )
} }
.navigationViewStyle(.stack) .navigationViewStyle(.stack)
TabBar(new_events: $home.new_events, selected: $selected_timeline, settings: damus.settings, action: switch_timeline) TabBar(new_events: $home.new_events, selected: $selected_timeline, isSidebarVisible: $isSideBarOpened, action: switch_timeline)
.padding([.bottom], 8) .padding([.bottom], 8)
.background(Color(uiColor: .systemBackground).ignoresSafeArea())
} }
} }
.ignoresSafeArea(.keyboard)
.onAppear() { .onAppear() {
self.connect() self.connect()
setup_notifications() setup_notifications()
@@ -306,8 +324,10 @@ struct ContentView: View {
switch item { switch item {
case .report(let target): case .report(let target):
MaybeReportView(target: target) MaybeReportView(target: target)
case .post(let action): case .post:
PostView(action: action, damus_state: damus_state!) PostView(replying_to: nil, references: [], damus_state: damus_state!)
case .reply(let event):
ReplyView(replying_to: event, damus: damus_state!)
case .event: case .event:
EventDetailView() EventDetailView()
case .filter: case .filter:
@@ -332,11 +352,8 @@ struct ContentView: View {
active_profile = ref.ref_id active_profile = ref.ref_id
profile_open = true profile_open = true
} else if ref.key == "e" { } else if ref.key == "e" {
find_event(state: damus_state!, evid: ref.ref_id, search_type: .event, find_from: nil) { ev in active_event_id = ref.ref_id
if let ev { thread_open = true
open_event(ev: ev)
}
}
} }
case .filter(let filt): case .filter(let filt):
active_search = filt active_search = filt
@@ -346,9 +363,24 @@ struct ContentView: View {
} }
} }
.onReceive(handle_notify(.compose)) { notif in .onReceive(handle_notify(.boost)) { notif in
let action = notif.object as! PostAction guard let privkey = self.privkey else {
self.active_sheet = .post(action) return
}
let ev = notif.object as! NostrEvent
let boost = make_boost_event(pubkey: pubkey, privkey: privkey, boosted: ev)
self.damus_state?.pool.send(.event(boost))
}
.onReceive(handle_notify(.open_thread)) { obj in
//let ev = obj.object as! NostrEvent
//thread.set_active_event(ev)
//is_thread_open = true
}
.onReceive(handle_notify(.reply)) { notif in
let ev = notif.object as! NostrEvent
self.active_sheet = .reply(ev)
}
.onReceive(handle_notify(.like)) { like in
} }
.onReceive(handle_notify(.deleted_account)) { notif in .onReceive(handle_notify(.deleted_account)) { notif in
self.is_deleted_account = true self.is_deleted_account = true
@@ -357,20 +389,14 @@ struct ContentView: View {
let target = notif.object as! ReportTarget let target = notif.object as! ReportTarget
self.active_sheet = .report(target) self.active_sheet = .report(target)
} }
.onReceive(handle_notify(.mute)) { notif in .onReceive(handle_notify(.block)) { notif in
let pubkey = notif.object as! String let pubkey = notif.object as! String
self.muting = pubkey self.blocking = pubkey
self.confirm_mute = true self.confirm_block = true
} }
.onReceive(handle_notify(.broadcast_event)) { obj in .onReceive(handle_notify(.broadcast_event)) { obj in
let ev = obj.object as! NostrEvent let ev = obj.object as! NostrEvent
guard let ds = self.damus_state else { self.damus_state?.pool.send(.event(ev))
return
}
ds.postbox.send(ev)
if let profile = ds.profiles.profiles[ev.pubkey] {
ds.postbox.send(profile.event)
}
} }
.onReceive(handle_notify(.unfollow)) { notif in .onReceive(handle_notify(.unfollow)) { notif in
guard let privkey = self.privkey else { guard let privkey = self.privkey else {
@@ -384,7 +410,7 @@ struct ContentView: View {
let target = notif.object as! FollowTarget let target = notif.object as! FollowTarget
let pk = target.pubkey let pk = target.pubkey
if let ev = unfollow_user(postbox: damus.postbox, if let ev = unfollow_user(pool: damus.pool,
our_contacts: damus.contacts.event, our_contacts: damus.contacts.event,
pubkey: damus.pubkey, pubkey: damus.pubkey,
privkey: privkey, privkey: privkey,
@@ -435,16 +461,7 @@ struct ContentView: View {
//let to_relays = tup.1 //let to_relays = tup.1
print("post \(post.content)") print("post \(post.content)")
let new_ev = post_to_event(post: post, privkey: privkey, pubkey: pubkey) let new_ev = post_to_event(post: post, privkey: privkey, pubkey: pubkey)
guard let ds = self.damus_state else { self.damus_state?.pool.send(.event(new_ev))
return
}
ds.postbox.send(new_ev)
for eref in new_ev.referenced_ids.prefix(3) {
// also broadcast at most 3 referenced events
if let ev = ds.events.lookup(eref.ref_id) {
ds.postbox.send(ev)
}
}
case .cancel: case .cancel:
active_sheet = nil active_sheet = nil
print("post cancelled") print("post cancelled")
@@ -454,56 +471,7 @@ struct ContentView: View {
self.damus_state?.pool.connect_to_disconnected() self.damus_state?.pool.connect_to_disconnected()
} }
.onReceive(handle_notify(.new_mutes)) { notif in .onReceive(handle_notify(.new_mutes)) { notif in
home.filter_events() home.filter_muted()
}
.onReceive(handle_notify(.mute_thread)) { notif in
home.filter_events()
}
.onReceive(handle_notify(.unmute_thread)) { notif in
home.filter_events()
}
.onReceive(handle_notify(.local_notification)) { notif in
guard let local = notif.object as? LossyLocalNotification,
let damus_state else {
return
}
guard let target = damus_state.events.lookup(local.event_id) else {
return
}
switch local.type {
case .dm:
selected_timeline = .dms
damus_state.dms.open_dm_by_pk(target.pubkey)
case .like: fallthrough
case .zap: fallthrough
case .mention: fallthrough
case .repost:
open_event(ev: target)
}
}
.onReceive(handle_notify(.onlyzaps_mode)) { notif in
let hide = notif.object as! Bool
home.filter_events()
guard let damus_state else {
return
}
guard let profile = damus_state.profiles.lookup(id: damus_state.pubkey) else {
return
}
profile.reactions = !hide
guard let profile_ev = make_metadata_event(keypair: damus_state.keypair, metadata: profile) else {
return
}
damus_state.postbox.send(profile_ev)
} }
.alert(NSLocalizedString("Deleted Account", comment: "Alert message to indicate this is a deleted account"), isPresented: $is_deleted_account) { .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.")) { Button(NSLocalizedString("Logout", comment: "Button to close the alert that informs that the current account has been deleted.")) {
@@ -511,23 +479,23 @@ struct ContentView: View {
notify(.logout, ()) notify(.logout, ())
} }
} }
.alert(NSLocalizedString("User muted", comment: "Alert message to indicate the user has been muted"), isPresented: $user_muted_confirm, actions: { .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 muted a user was successful.")) { Button(NSLocalizedString("Thanks!", comment: "Button to close out of alert that informs that the action to block a user was successful.")) {
user_muted_confirm = false user_blocked_confirm = false
} }
}, message: { }, message: {
if let pubkey = self.muting { if let pubkey = self.blocking {
let profile = damus_state!.profiles.lookup(id: pubkey) let profile = damus_state!.profiles.lookup(id: pubkey)
let name = Profile.displayName(profile: profile, pubkey: pubkey).username let name = Profile.displayName(profile: profile, pubkey: pubkey)
Text("\(name) has been muted", comment: "Alert message that informs a user was muted.") Text("\(name) has been blocked", comment: "Alert message that informs a user was blocked.")
} else { } else {
Text("User has been muted", comment: "Alert message that informs a user was d.") 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: { .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.")) { Button(NSLocalizedString("Cancel", comment: "Button to cancel out of alert that creates a new mutelist.")) {
confirm_overwrite_mutelist = false confirm_overwrite_mutelist = false
confirm_mute = false confirm_block = false
} }
Button(NSLocalizedString("Yes, Overwrite", comment: "Text of button that confirms to overwrite the existing mutelist.")) { Button(NSLocalizedString("Yes, Overwrite", comment: "Text of button that confirms to overwrite the existing mutelist.")) {
@@ -539,7 +507,7 @@ struct ContentView: View {
return return
} }
guard let pubkey = muting else { guard let pubkey = blocking else {
return return
} }
@@ -548,20 +516,20 @@ struct ContentView: View {
} }
damus_state?.contacts.set_mutelist(mutelist) damus_state?.contacts.set_mutelist(mutelist)
ds.postbox.send(mutelist) ds.pool.send(.event(mutelist))
confirm_overwrite_mutelist = false confirm_overwrite_mutelist = false
confirm_mute = false confirm_block = false
user_muted_confirm = true user_blocked_confirm = true
} }
}, message: { }, message: {
Text("No mute list found, create a new one? This will overwrite any previous mute lists.", comment: "Alert message prompt that asks if the user wants to create a new mute list, overwriting previous mute lists.") 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("Mute User", comment: "Title of alert for muting a user."), isPresented: $confirm_mute, actions: { .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 muting a user."), role: .cancel) { Button(NSLocalizedString("Cancel", comment: "Alert button to cancel out of alert for blocking a user."), role: .cancel) {
confirm_mute = false confirm_block = false
} }
Button(NSLocalizedString("Mute", comment: "Alert button to mute a user."), role: .destructive) { Button(NSLocalizedString("Block", comment: "Alert button to block a user."), role: .destructive) {
guard let ds = damus_state else { guard let ds = damus_state else {
return return
} }
@@ -572,7 +540,7 @@ struct ContentView: View {
guard let keypair = ds.keypair.to_full() else { guard let keypair = ds.keypair.to_full() else {
return return
} }
guard let pubkey = muting else { guard let pubkey = blocking else {
return return
} }
@@ -580,23 +548,21 @@ struct ContentView: View {
return return
} }
damus_state?.contacts.set_mutelist(ev) damus_state?.contacts.set_mutelist(ev)
ds.postbox.send(ev) ds.pool.send(.event(ev))
} }
} }
}, message: { }, message: {
if let pubkey = muting { if let pubkey = blocking {
let profile = damus_state?.profiles.lookup(id: pubkey) let profile = damus_state?.profiles.lookup(id: pubkey)
let name = Profile.displayName(profile: profile, pubkey: pubkey).username let name = Profile.displayName(profile: profile, pubkey: pubkey)
Text("Mute \(name)?", comment: "Alert message prompt to ask if a user should be muted.") Text("Block \(name)?", comment: "Alert message prompt to ask if a user should be blocked.")
} else { } else {
Text("Could not find user to mute...", comment: "Alert message to indicate that the muted user could not be found.") 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) { func switch_timeline(_ timeline: Timeline) {
self.isSideBarOpened = false
self.popToRoot() self.popToRoot()
NotificationCenter.default.post(name: .switched_timeline, object: timeline) NotificationCenter.default.post(name: .switched_timeline, object: timeline)
@@ -625,22 +591,16 @@ struct ContentView: View {
let pool = RelayPool() let pool = RelayPool()
let metadatas = RelayMetadatas() let metadatas = RelayMetadatas()
let relay_filters = RelayFilters(our_pubkey: pubkey) let relay_filters = RelayFilters(our_pubkey: pubkey)
let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey)
let new_relay_filters = load_relay_filters(pubkey) == nil let new_relay_filters = load_relay_filters(pubkey) == nil
for relay in bootstrap_relays { for relay in BOOTSTRAP_RELAYS {
if let url = RelayURL(relay) { 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_new_relay(relay_filters: relay_filters, metadatas: metadatas, pool: pool, url: url, info: .rw, new_relay_filters: new_relay_filters)
} }
} }
pool.register_handler(sub_id: sub_id, handler: home.handle_event) pool.register_handler(sub_id: sub_id, handler: home.handle_event)
// dumb stuff needed for property wrappers
UserSettingsStore.pubkey = pubkey
let settings = UserSettingsStore()
UserSettingsStore.shared = settings
self.damus_state = DamusState(pool: pool, self.damus_state = DamusState(pool: pool,
keypair: keypair, keypair: keypair,
likes: EventCounter(our_pubkey: pubkey), likes: EventCounter(our_pubkey: pubkey),
@@ -652,16 +612,10 @@ struct ContentView: View {
previews: PreviewCache(), previews: PreviewCache(),
zaps: Zaps(our_pubkey: pubkey), zaps: Zaps(our_pubkey: pubkey),
lnurls: LNUrls(), lnurls: LNUrls(),
settings: settings, settings: UserSettingsStore(),
relay_filters: relay_filters, relay_filters: relay_filters,
relay_metadata: metadatas, relay_metadata: metadatas,
drafts: Drafts(), drafts: Drafts()
events: EventCache(),
bookmarks: BookmarksManager(pubkey: pubkey),
postbox: PostBox(pool: pool),
bootstrap_relays: bootstrap_relays,
replies: ReplyCounter(our_pubkey: pubkey),
muted_threads: MutedThreadsManager(keypair: keypair)
) )
home.damus_state = self.damus_state! home.damus_state = self.damus_state!
@@ -685,6 +639,31 @@ func get_since_time(last_event: NostrEvent?) -> Int64? {
return nil return nil
} }
func ws_nostr_event(relay: String, ev: WebSocketEvent) -> NostrEvent? {
switch ev {
case .binary(let dat):
return NostrEvent(content: "binary data? \(dat.count) bytes", pubkey: relay)
case .cancelled:
return NostrEvent(content: "cancelled", pubkey: relay)
case .connected:
return NostrEvent(content: "connected", pubkey: relay)
case .disconnected:
return NostrEvent(content: "disconnected", pubkey: relay)
case .error(let err):
return NostrEvent(content: "error \(err.debugDescription)", pubkey: relay)
case .text(let txt):
return NostrEvent(content: "text \(txt)", pubkey: relay)
case .pong:
return NostrEvent(content: "pong", pubkey: relay)
case .ping:
return NostrEvent(content: "ping", pubkey: relay)
case .viabilityChanged(let b):
return NostrEvent(content: "viabilityChanged \(b)", pubkey: relay)
case .reconnectSuggested(let b):
return NostrEvent(content: "reconnectSuggested \(b)", pubkey: relay)
}
}
func is_notification(ev: NostrEvent, pubkey: String) -> Bool { func is_notification(ev: NostrEvent, pubkey: String) -> Bool {
if ev.pubkey == pubkey { if ev.pubkey == pubkey {
return false return false
@@ -785,69 +764,3 @@ func setup_notifications() {
} }
} }
func find_event(state: DamusState, evid: String, search_type: SearchType, find_from: [String]?, callback: @escaping (NostrEvent?) -> ()) {
if let ev = state.events.lookup(evid) {
callback(ev)
return
}
let subid = UUID().description
var has_event = false
var filter = search_type == .event ? NostrFilter.filter_ids([ evid ]) : NostrFilter.filter_authors([ evid ])
if search_type == .profile {
filter.kinds = [NostrKind.metadata.rawValue]
}
filter.limit = 1
var attempts = 0
state.pool.subscribe_to(sub_id: subid, filters: [filter], to: find_from) { relay_id, res in
guard case .nostr_event(let ev) = res else {
return
}
guard ev.subid == subid else {
return
}
switch ev {
case .ok:
break
case .event(_, let ev):
has_event = true
callback(ev)
state.pool.unsubscribe(sub_id: subid)
case .eose:
if !has_event {
attempts += 1
if attempts == state.pool.descriptors.count / 2 {
callback(nil)
}
state.pool.unsubscribe(sub_id: subid, to: [relay_id])
}
case .notice(_):
break
}
}
}
func timeline_name(_ timeline: Timeline?) -> String {
guard let timeline else {
return ""
}
switch timeline {
case .home:
return NSLocalizedString("Home", comment: "Navigation bar title for Home view where posts and replies appear from those who the user is following.")
case .notifications:
return NSLocalizedString("Notifications", comment: "Toolbar label for Notifications view.")
case .search:
return NSLocalizedString("Universe 🛸", comment: "Toolbar label for the universal view where posts from all connected relay servers appear.")
case .dms:
return NSLocalizedString("DMs", comment: "Toolbar label for DMs view, where DM is the English abbreviation for Direct Message.")
}
}
-14
View File
@@ -14,16 +14,6 @@
<string>nostr</string> <string>nostr</string>
</array> </array>
</dict> </dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleURLName</key>
<string>io.damus</string>
<key>CFBundleURLSchemes</key>
<array>
<string>damus</string>
</array>
</dict>
</array> </array>
<key>LSApplicationQueriesSchemes</key> <key>LSApplicationQueriesSchemes</key>
<array> <array>
@@ -46,9 +36,5 @@
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>
<true/> <true/>
</dict> </dict>
<key>NSCameraUsageDescription</key>
<string>Damus needs access to your camera if you want to upload photos from it</string>
<key>NSMicrophoneUsageDescription</key>
<string>Damus needs access to your microphone if you want to upload recorded videos from it</string>
</dict> </dict>
</plist> </plist>
+2 -24
View File
@@ -11,52 +11,34 @@ import Foundation
class ActionBarModel: ObservableObject { class ActionBarModel: ObservableObject {
@Published var our_like: NostrEvent? @Published var our_like: NostrEvent?
@Published var our_boost: NostrEvent? @Published var our_boost: NostrEvent?
@Published var our_reply: NostrEvent?
@Published var our_zap: Zap? @Published var our_zap: Zap?
@Published var likes: Int @Published var likes: Int
@Published var boosts: Int @Published var boosts: Int
@Published var zaps: Int @Published var zaps: Int
@Published var zap_total: Int64 @Published var zap_total: Int64
@Published var replies: Int
static func empty() -> ActionBarModel { static func empty() -> ActionBarModel {
return ActionBarModel(likes: 0, boosts: 0, zaps: 0, zap_total: 0, replies: 0, our_like: nil, our_boost: nil, our_zap: nil, our_reply: nil) return ActionBarModel(likes: 0, boosts: 0, zaps: 0, zap_total: 0, our_like: nil, our_boost: nil, our_zap: nil)
} }
init() { init(likes: Int, boosts: Int, zaps: Int, zap_total: Int64, our_like: NostrEvent?, our_boost: NostrEvent?, our_zap: Zap?) {
self.our_like = nil
self.our_boost = nil
self.our_reply = nil
self.our_zap = nil
self.likes = 0
self.boosts = 0
self.zaps = 0
self.zap_total = 0
self.replies = 0
}
init(likes: Int, boosts: Int, zaps: Int, zap_total: Int64, replies: Int, our_like: NostrEvent?, our_boost: NostrEvent?, our_zap: Zap?, our_reply: NostrEvent?) {
self.likes = likes self.likes = likes
self.boosts = boosts self.boosts = boosts
self.zaps = zaps self.zaps = zaps
self.replies = replies
self.zap_total = zap_total self.zap_total = zap_total
self.our_like = our_like self.our_like = our_like
self.our_boost = our_boost self.our_boost = our_boost
self.our_zap = our_zap self.our_zap = our_zap
self.our_reply = our_reply
} }
func update(damus: DamusState, evid: String) { func update(damus: DamusState, evid: String) {
self.likes = damus.likes.counts[evid] ?? 0 self.likes = damus.likes.counts[evid] ?? 0
self.boosts = damus.boosts.counts[evid] ?? 0 self.boosts = damus.boosts.counts[evid] ?? 0
self.zaps = damus.zaps.event_counts[evid] ?? 0 self.zaps = damus.zaps.event_counts[evid] ?? 0
self.replies = damus.replies.get_replies(evid)
self.zap_total = damus.zaps.event_totals[evid] ?? 0 self.zap_total = damus.zaps.event_totals[evid] ?? 0
self.our_like = damus.likes.our_events[evid] self.our_like = damus.likes.our_events[evid]
self.our_boost = damus.boosts.our_events[evid] self.our_boost = damus.boosts.our_events[evid]
self.our_zap = damus.zaps.our_zaps[evid]?.first self.our_zap = damus.zaps.our_zaps[evid]?.first
self.our_reply = damus.replies.our_reply(evid)
self.objectWillChange.send() self.objectWillChange.send()
} }
@@ -72,10 +54,6 @@ class ActionBarModel: ObservableObject {
return our_like != nil return our_like != nil
} }
var replied: Bool {
return our_reply != nil
}
var boosted: Bool { var boosted: Bool {
return our_boost != nil return our_boost != nil
} }
-71
View File
@@ -1,71 +0,0 @@
//
// BookmarksManager.swift
// damus
//
// Created by Joel Klabo on 2/18/23.
//
import Foundation
fileprivate func get_bookmarks_key(pubkey: String) -> String {
pk_setting_key(pubkey, key: "bookmarks")
}
func load_bookmarks(pubkey: String) -> [NostrEvent] {
let key = get_bookmarks_key(pubkey: pubkey)
return (UserDefaults.standard.stringArray(forKey: key) ?? []).compactMap {
event_from_json(dat: $0)
}
}
func save_bookmarks(pubkey: String, current_value: [NostrEvent], value: [NostrEvent]) -> Bool {
let uniq_bookmarks = uniq(value)
if uniq_bookmarks != current_value {
let encoded = uniq_bookmarks.map(event_to_json)
UserDefaults.standard.set(encoded, forKey: get_bookmarks_key(pubkey: pubkey))
return true
}
return false
}
class BookmarksManager: ObservableObject {
private let userDefaults = UserDefaults.standard
private let pubkey: String
private var _bookmarks: [NostrEvent]
var bookmarks: [NostrEvent] {
get {
return _bookmarks
}
set {
if save_bookmarks(pubkey: pubkey, current_value: _bookmarks, value: newValue) {
self._bookmarks = newValue
self.objectWillChange.send()
}
}
}
init(pubkey: String) {
self._bookmarks = load_bookmarks(pubkey: pubkey)
self.pubkey = pubkey
}
func isBookmarked(_ ev: NostrEvent) -> Bool {
return bookmarks.contains(ev)
}
func updateBookmark(_ ev: NostrEvent) {
if isBookmarked(ev) {
bookmarks = bookmarks.filter { $0 != ev }
} else {
bookmarks.insert(ev, at: 0)
}
}
func clearAll() {
bookmarks = []
}
}
+3 -3
View File
@@ -140,7 +140,7 @@ func follow_user(pool: RelayPool, our_contacts: NostrEvent?, pubkey: String, pri
return ev return ev
} }
func unfollow_user(postbox: PostBox, our_contacts: NostrEvent?, pubkey: String, privkey: String, unfollow: String) -> NostrEvent? { func unfollow_user(pool: RelayPool, our_contacts: NostrEvent?, pubkey: String, privkey: String, unfollow: String) -> NostrEvent? {
guard let cs = our_contacts else { guard let cs = our_contacts else {
return nil return nil
} }
@@ -149,7 +149,7 @@ func unfollow_user(postbox: PostBox, our_contacts: NostrEvent?, pubkey: String,
ev.calculate_id() ev.calculate_id()
ev.sign(privkey: privkey) ev.sign(privkey: privkey)
postbox.send(ev) pool.send(.event(ev))
return ev return ev
} }
@@ -242,7 +242,7 @@ func follow_with_existing_contacts(our_pubkey: String, our_contacts: NostrEvent,
func make_contact_relays(_ relays: [RelayDescriptor]) -> [String: RelayInfo] { func make_contact_relays(_ relays: [RelayDescriptor]) -> [String: RelayInfo] {
return relays.reduce(into: [:]) { acc, relay in return relays.reduce(into: [:]) { acc, relay in
acc[relay.url.url.absoluteString] = relay.info acc[relay.url.absoluteString] = relay.info
} }
} }
-1
View File
@@ -14,7 +14,6 @@ class CreateAccountModel: ObservableObject {
@Published var about: String = "" @Published var about: String = ""
@Published var pubkey: String = "" @Published var pubkey: String = ""
@Published var privkey: String = "" @Published var privkey: String = ""
@Published var profile_image: String? = nil
var pubkey_bech32: String { var pubkey_bech32: String {
return bech32_pubkey(self.pubkey) ?? "" return bech32_pubkey(self.pubkey) ?? ""
+3 -17
View File
@@ -24,20 +24,6 @@ struct DamusState {
let relay_filters: RelayFilters let relay_filters: RelayFilters
let relay_metadata: RelayMetadatas let relay_metadata: RelayMetadatas
let drafts: Drafts let drafts: Drafts
let events: EventCache
let bookmarks: BookmarksManager
let postbox: PostBox
let bootstrap_relays: [String]
let replies: ReplyCounter
let muted_threads: MutedThreadsManager
@discardableResult
func add_zap(zap: Zap) -> Bool {
// store generic zap mapping
self.zaps.add_zap(zap: zap)
// associate with events as well
return self.events.store_zap(zap: zap)
}
var pubkey: String { var pubkey: String {
return keypair.pubkey return keypair.pubkey
@@ -46,9 +32,9 @@ struct DamusState {
var is_privkey_user: Bool { var is_privkey_user: Bool {
keypair.privkey != nil keypair.privkey != nil
} }
static var settings_pubkey: String? = nil
static var empty: DamusState { 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(), bookmarks: BookmarksManager(pubkey: ""), postbox: PostBox(pool: RelayPool()), bootstrap_relays: [], replies: ReplyCounter(our_pubkey: ""), muted_threads: MutedThreadsManager(keypair: Keypair(pubkey: "", privkey: nil))) } 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())
}
} }
+1 -13
View File
@@ -7,19 +7,7 @@
import Foundation import Foundation
enum DeepLPlan: String, CaseIterable, Identifiable, StringCodable { enum DeepLPlan: String, CaseIterable, Identifiable {
init?(from string: String) {
guard let dl = DeepLPlan(rawValue: string) else {
return nil
}
self = dl
}
func to_string() -> String {
return self.rawValue
}
var id: String { self.rawValue } var id: String { self.rawValue }
struct Model: Identifiable, Hashable { struct Model: Identifiable, Hashable {
+2 -6
View File
@@ -16,8 +16,6 @@ class DirectMessageModel: ObservableObject {
@Published var draft: String @Published var draft: String
let pubkey: String
var is_request: Bool var is_request: Bool
var our_pubkey: String var our_pubkey: String
@@ -31,19 +29,17 @@ class DirectMessageModel: ObservableObject {
return true return true
} }
init(events: [NostrEvent], our_pubkey: String, pubkey: String) { init(events: [NostrEvent], our_pubkey: String) {
self.events = events self.events = events
self.is_request = false self.is_request = false
self.our_pubkey = our_pubkey self.our_pubkey = our_pubkey
self.draft = "" self.draft = ""
self.pubkey = pubkey
} }
init(our_pubkey: String, pubkey: String) { init(our_pubkey: String) {
self.events = [] self.events = []
self.is_request = false self.is_request = false
self.our_pubkey = our_pubkey self.our_pubkey = our_pubkey
self.draft = "" self.draft = ""
self.pubkey = pubkey
} }
} }
+9 -32
View File
@@ -8,43 +8,20 @@
import Foundation import Foundation
class DirectMessagesModel: ObservableObject { class DirectMessagesModel: ObservableObject {
@Published var dms: [DirectMessageModel] = [] @Published var dms: [(String, DirectMessageModel)] = []
@Published var loading: Bool = false @Published var loading: Bool = false
@Published var open_dm: Bool = false
@Published private(set) var active_model: DirectMessageModel = DirectMessageModel(our_pubkey: "", pubkey: "")
let our_pubkey: String let our_pubkey: String
init(our_pubkey: String) { init(our_pubkey: String) {
self.our_pubkey = our_pubkey self.our_pubkey = our_pubkey
} }
var message_requests: [DirectMessageModel] { var message_requests: [(String, DirectMessageModel)] {
return dms.filter { dm in dm.is_request } return dms.filter { dm in dm.1.is_request }
} }
var friend_dms: [DirectMessageModel] { var friend_dms: [(String, DirectMessageModel)] {
return dms.filter { dm in !dm.is_request } return dms.filter { dm in !dm.1.is_request }
}
func set_active_dm_model(_ model: DirectMessageModel) {
self.active_model = model
}
func open_dm_by_pk(_ pubkey: String) {
self.set_active_dm(pubkey)
self.open_dm = true
}
func open_dm_by_model(_ model: DirectMessageModel) {
self.set_active_dm_model(model)
self.open_dm = true
}
func set_active_dm(_ pubkey: String) {
for model in self.dms where model.pubkey == pubkey {
self.set_active_dm_model(model)
break
}
} }
func lookup_or_create(_ pubkey: String) -> DirectMessageModel { func lookup_or_create(_ pubkey: String) -> DirectMessageModel {
@@ -52,15 +29,15 @@ class DirectMessagesModel: ObservableObject {
return dm return dm
} }
let new = DirectMessageModel(our_pubkey: our_pubkey, pubkey: pubkey) let new = DirectMessageModel(our_pubkey: our_pubkey)
dms.append(new) dms.append((pubkey, new))
return new return new
} }
func lookup(_ pubkey: String) -> DirectMessageModel? { func lookup(_ pubkey: String) -> DirectMessageModel? {
for dm in dms { for dm in dms {
if pubkey == dm.pubkey { if pubkey == dm.0 {
return dm return dm.1
} }
} }
+2 -18
View File
@@ -7,23 +7,7 @@
import Foundation import Foundation
class DraftArtifacts {
var content: NSMutableAttributedString
var media: [UploadedMedia]
init() {
self.content = NSMutableAttributedString(string: "")
self.media = []
}
init(content: NSMutableAttributedString, media: [UploadedMedia]) {
self.content = content
self.media = media
}
}
class Drafts: ObservableObject { class Drafts: ObservableObject {
@Published var post: DraftArtifacts? = nil @Published var post: String = ""
@Published var replies: [NostrEvent: DraftArtifacts] = [:] @Published var replies: [NostrEvent: String] = [:]
@Published var quotes: [NostrEvent: DraftArtifacts] = [:]
} }
+1 -5
View File
@@ -74,12 +74,8 @@ func build_mention_indices(_ blocks: [Block], type: MentionType) -> Set<Int> {
switch block { switch block {
case .mention(let m): case .mention(let m):
if m.type == type { if m.type == type {
if let idx = m.index { acc.insert(m.index)
acc.insert(idx)
}
} }
case .relay:
return
case .text: case .text:
return return
case .hashtag: case .hashtag:
+2 -58
View File
@@ -9,65 +9,9 @@ import Foundation
class EventsModel: ObservableObject { class EventsModel: ObservableObject {
let state: DamusState var has_event: Set<String> = Set()
let target: String
let kind: NostrKind
let sub_id = UUID().uuidString
let profiles_id = UUID().uuidString
@Published var events: [NostrEvent] = [] @Published var events: [NostrEvent] = []
init(state: DamusState, target: String, kind: NostrKind) { init() {
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 .ok:
break
case .eose(_):
load_profiles(profiles_subid: profiles_id, relay_id: relay_id, load: .from_events(events), damus_state: state)
}
} }
} }
+1 -4
View File
@@ -82,7 +82,7 @@ class FollowersModel: ObservableObject {
if ev.known_kind == .contacts { if ev.known_kind == .contacts {
handle_contact_event(ev) handle_contact_event(ev)
} else if ev.known_kind == .metadata { } else if ev.known_kind == .metadata {
process_metadata_event(events: damus_state.events, our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev) process_metadata_event(our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev)
} }
case .notice(let msg): case .notice(let msg):
@@ -94,9 +94,6 @@ class FollowersModel: ObservableObject {
} else if sub_id == self.profiles_id { } else if sub_id == self.profiles_id {
damus_state.pool.unsubscribe(sub_id: profiles_id, to: [relay_id]) damus_state.pool.unsubscribe(sub_id: profiles_id, to: [relay_id])
} }
case .ok:
break
} }
} }
} }
+2 -4
View File
@@ -22,7 +22,7 @@ class FollowingModel {
} }
func get_filter() -> NostrFilter { func get_filter() -> NostrFilter {
var f = NostrFilter.filter_kinds([NostrKind.metadata.rawValue]) var f = NostrFilter.filter_kinds([0])
f.authors = self.contacts.reduce(into: Array<String>()) { acc, pk in f.authors = self.contacts.reduce(into: Array<String>()) { acc, pk in
// don't fetch profiles we already have // don't fetch profiles we already have
if damus_state.profiles.lookup(id: pk) != nil { if damus_state.profiles.lookup(id: pk) != nil {
@@ -58,11 +58,9 @@ class FollowingModel {
break break
case .nostr_event(let nev): case .nostr_event(let nev):
switch nev { switch nev {
case .ok:
break
case .event(_, let ev): case .event(_, let ev):
if ev.kind == 0 { if ev.kind == 0 {
process_metadata_event(events: damus_state.events, our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev) process_metadata_event(our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev)
} }
case .notice(let msg): case .notice(let msg):
print("followingmodel notice: \(msg)") print("followingmodel notice: \(msg)")
+132 -397
View File
@@ -8,19 +8,26 @@
import Foundation import Foundation
import UIKit import UIKit
struct NewEventsBits: OptionSet { struct NewEventsBits {
let rawValue: Int let bits: Int
static let home = NewEventsBits(rawValue: 1 << 0) init() {
static let zaps = NewEventsBits(rawValue: 1 << 1) bits = 0
static let mentions = NewEventsBits(rawValue: 1 << 2) }
static let reposts = NewEventsBits(rawValue: 1 << 3)
static let likes = NewEventsBits(rawValue: 1 << 4) init (prev: NewEventsBits, setting: Timeline) {
static let search = NewEventsBits(rawValue: 1 << 5) self.bits = prev.bits | timeline_bit(setting)
static let dms = NewEventsBits(rawValue: 1 << 6) }
static let all = NewEventsBits(rawValue: 0xFFFFFFFF) init (prev: NewEventsBits, unsetting: Timeline) {
static let notifications: NewEventsBits = [.zaps, .likes, .reposts, .mentions] self.bits = prev.bits & ~timeline_bit(unsetting)
}
func is_set(_ timeline: Timeline) -> Bool {
let notification_bit = timeline_bit(timeline)
return (bits & notification_bit) == notification_bit
}
} }
class HomeModel: ObservableObject { class HomeModel: ObservableObject {
@@ -41,27 +48,35 @@ class HomeModel: ObservableObject {
let dms_subid = UUID().description let dms_subid = UUID().description
let init_subid = UUID().description let init_subid = UUID().description
let profiles_subid = UUID().description let profiles_subid = UUID().description
var loading: Bool = false
var signal = SignalModel()
@Published var new_events: NewEventsBits = NewEventsBits() @Published var new_events: NewEventsBits = NewEventsBits()
@Published var notifications = NotificationsModel() @Published var notifications: [NostrEvent] = []
@Published var events = EventHolder() @Published var dms: DirectMessagesModel
@Published var events: [NostrEvent] = []
@Published var loading: Bool = false
@Published var signal: SignalModel = SignalModel()
init() { init() {
self.damus_state = DamusState.empty self.damus_state = DamusState.empty
filter_events() self.dms = DirectMessagesModel(our_pubkey: damus_state.pubkey)
self.setup_debouncer() self.setup_debouncer()
} }
init(damus_state: DamusState) {
self.damus_state = damus_state
self.dms = DirectMessagesModel(our_pubkey: damus_state.pubkey)
self.setup_debouncer()
}
var pool: RelayPool { var pool: RelayPool {
return damus_state.pool return damus_state.pool
} }
var dms: DirectMessagesModel { func setup_debouncer() {
return damus_state.dms // 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 { func has_sub_id_event(sub_id: String, ev_id: String) -> Bool {
@@ -72,13 +87,6 @@ class HomeModel: ObservableObject {
return has_event[sub_id]!.contains(ev_id) return has_event[sub_id]!.contains(ev_id)
} }
func setup_debouncer() {
// turn off debouncer after initial load
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
self.should_debounce_dms = false
}
}
func process_event(sub_id: String, relay_id: String, ev: NostrEvent) { func process_event(sub_id: String, relay_id: String, ev: NostrEvent) {
if has_sub_id_event(sub_id: sub_id, ev_id: ev.id) { if has_sub_id_event(sub_id: sub_id, ev_id: ev.id) {
@@ -118,49 +126,36 @@ class HomeModel: ObservableObject {
handle_channel_meta(ev) handle_channel_meta(ev)
case .zap: case .zap:
handle_zap_event(ev) handle_zap_event(ev)
case .zap_request:
break
} }
} }
func handle_zap_event_with_zapper(profiles: Profiles, ev: NostrEvent, our_keypair: Keypair, zapper: String) { 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, our_privkey: our_keypair.privkey) else { guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper) else {
return return
} }
damus_state.add_zap(zap: zap) damus_state.zaps.add_zap(zap: zap)
guard zap.target.pubkey == our_keypair.pubkey else { guard zap.target.pubkey == our_pubkey else {
return return
} }
if !notifications.insert_zap(zap, damus_state: damus_state) { if !insert_uniq_sorted_event(events: &notifications, new_ev: ev, cmp: { $0.created_at > $1.created_at }) {
return return
} }
if handle_last_event(ev: ev, timeline: .notifications) { handle_last_event(ev: ev, timeline: .notifications)
if damus_state.settings.zap_vibration {
// Generate zap vibration
zap_vibrate(zap_amount: zap.invoice.amount)
}
if damus_state.settings.zap_notification {
// Create in-app local notification for zap received.
create_in_app_zap_notification(profiles: profiles, zap: zap, evId: ev.referenced_ids.first?.id ?? "")
}
}
return return
} }
func handle_zap_event(_ ev: NostrEvent) { func handle_zap_event(_ ev: NostrEvent) {
// These are zap notifications // These are zap notifications
guard let ptag = event_tag(ev, name: "p") else { guard let ptag = event_tag(ev, name: "p") else {
return return
} }
let our_keypair = damus_state.keypair
if let local_zapper = damus_state.profiles.lookup_zapper(pubkey: ptag) { if let local_zapper = damus_state.profiles.lookup_zapper(pubkey: ptag) {
handle_zap_event_with_zapper(profiles: self.damus_state.profiles, ev: ev, our_keypair: our_keypair, zapper: local_zapper) handle_zap_event_with_zapper(ev, our_pubkey: damus_state.pubkey, zapper: local_zapper)
return return
} }
@@ -179,38 +174,34 @@ class HomeModel: ObservableObject {
DispatchQueue.main.async { DispatchQueue.main.async {
self.damus_state.profiles.zappers[ptag] = zapper self.damus_state.profiles.zappers[ptag] = zapper
self.handle_zap_event_with_zapper(profiles: self.damus_state.profiles, ev: ev, our_keypair: our_keypair, zapper: zapper) self.handle_zap_event_with_zapper(ev, our_pubkey: self.damus_state.pubkey, zapper: zapper)
} }
} }
} }
func handle_channel_create(_ ev: NostrEvent) { func handle_channel_create(_ ev: NostrEvent) {
guard ev.is_valid else {
return
}
self.channels[ev.id] = ev self.channels[ev.id] = ev
} }
func handle_channel_meta(_ ev: NostrEvent) { func handle_channel_meta(_ ev: NostrEvent) {
} }
func filter_events() { func filter_muted() {
events.filter { ev in self.events = events.filter { !damus_state.contacts.is_muted($0.pubkey) }
!damus_state.contacts.is_muted(ev.pubkey) self.dms.dms = dms.dms.filter { !damus_state.contacts.is_muted($0.0) }
} self.notifications = notifications.filter { !damus_state.contacts.is_muted($0.pubkey) }
self.dms.dms = dms.dms.filter { ev in
!damus_state.contacts.is_muted(ev.pubkey)
}
notifications.filter { ev in
if damus_state.settings.onlyzaps_mode && ev.known_kind == NostrKind.like {
return false
}
return !damus_state.contacts.is_muted(ev.pubkey) && !damus_state.muted_threads.isMutedThread(ev, privkey: damus_state.keypair.privkey)
}
} }
func handle_delete_event(_ ev: NostrEvent) { func handle_delete_event(_ ev: NostrEvent) {
guard ev.is_valid else {
return
}
self.deleted_events.insert(ev.id) self.deleted_events.insert(ev.id)
} }
@@ -229,13 +220,13 @@ class HomeModel: ObservableObject {
func handle_boost_event(sub_id: String, _ ev: NostrEvent) { func handle_boost_event(sub_id: String, _ ev: NostrEvent) {
var boost_ev_id = ev.last_refid()?.ref_id var boost_ev_id = ev.last_refid()?.ref_id
if let inner_ev = ev.get_inner_event(cache: damus_state.events) { if let inner_ev = ev.inner_event {
boost_ev_id = inner_ev.id boost_ev_id = inner_ev.id
guard validate_event(ev: inner_ev) == .ok else { guard inner_ev.is_valid else {
return return
} }
if inner_ev.is_textlike { if inner_ev.is_textlike {
handle_text_event(sub_id: sub_id, ev) handle_text_event(sub_id: sub_id, ev)
} }
@@ -261,15 +252,12 @@ class HomeModel: ObservableObject {
return return
} }
if damus_state.settings.onlyzaps_mode { // CHECK SIGS ON THESE
return
}
switch damus_state.likes.add_event(ev, target: e.ref_id) { switch damus_state.likes.add_event(ev, target: e.ref_id) {
case .already_counted: case .already_counted:
break break
case .success(let n): case .success(let n):
handle_notification(ev: ev)
let liked = Counted(event: ev, id: e.ref_id, total: n) let liked = Counted(event: ev, id: e.ref_id, total: n)
notify(.liked, liked) notify(.liked, liked)
notify(.update_stats, e.ref_id) notify(.update_stats, e.ref_id)
@@ -290,28 +278,34 @@ class HomeModel: ObservableObject {
send_home_filters(relay_id: relay_id) send_home_filters(relay_id: relay_id)
} }
case .error(let merr): case .error(let merr):
let desc = String(describing: merr) let desc = merr.debugDescription
if desc.contains("Software caused connection abort") { if desc.contains("Software caused connection abort") {
pool.reconnect(to: [relay_id]) pool.reconnect(to: [relay_id])
} }
case .disconnected: case .disconnected: fallthrough
case .cancelled:
pool.reconnect(to: [relay_id]) pool.reconnect(to: [relay_id])
case .reconnectSuggested(let t):
if t {
pool.reconnect(to: [relay_id])
}
default: default:
break break
} }
update_signal_from_pool(signal: self.signal, pool: damus_state.pool) update_signal_from_pool(signal: signal, pool: damus_state.pool)
print("ws_event \(ev)")
case .nostr_event(let ev): case .nostr_event(let ev):
switch ev { switch ev {
case .event(let sub_id, let ev): case .event(let sub_id, let ev):
// globally handle likes // 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 == .boost || ev.known_kind == .zap || ev.known_kind == .contacts || ev.known_kind == .metadata
if !always_process { if !always_process {
// TODO: other views like threads might have their own sub ids, so ignore those events... or should we? // TODO: other views like threads might have their own sub ids, so ignore those events... or should we?
return return
} }
*/
self.process_event(sub_id: sub_id, relay_id: relay_id, ev: ev) self.process_event(sub_id: sub_id, relay_id: relay_id, ev: ev)
case .notice(let msg): case .notice(let msg):
@@ -321,22 +315,16 @@ class HomeModel: ObservableObject {
case .eose(let sub_id): case .eose(let sub_id):
if sub_id == dms_subid { if sub_id == dms_subid {
var dms = dms.dms.flatMap { $0.events } var dms = dms.dms.flatMap { $0.1.events }
dms.append(contentsOf: incoming_dms) dms.append(contentsOf: incoming_dms)
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(dms), damus_state: damus_state) load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, events: dms, damus_state: damus_state)
} else if sub_id == notifications_subid { } 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)
} else if sub_id == home_subid {
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events.events), damus_state: damus_state)
} }
self.loading = false self.loading = false
break break
case .ok:
break
} }
} }
} }
@@ -357,13 +345,13 @@ class HomeModel: ObservableObject {
var friends = damus_state.contacts.get_friend_list() var friends = damus_state.contacts.get_friend_list()
friends.append(damus_state.pubkey) friends.append(damus_state.pubkey)
var contacts_filter = NostrFilter.filter_kinds([NostrKind.metadata.rawValue]) var contacts_filter = NostrFilter.filter_kinds([0])
contacts_filter.authors = friends contacts_filter.authors = friends
var our_contacts_filter = NostrFilter.filter_kinds([NostrKind.contacts.rawValue, NostrKind.metadata.rawValue]) var our_contacts_filter = NostrFilter.filter_kinds([3, 0])
our_contacts_filter.authors = [damus_state.pubkey] our_contacts_filter.authors = [damus_state.pubkey]
var our_blocklist_filter = NostrFilter.filter_kinds([NostrKind.list.rawValue]) var our_blocklist_filter = NostrFilter.filter_kinds([30000])
our_blocklist_filter.parameter = ["mute"] our_blocklist_filter.parameter = ["mute"]
our_blocklist_filter.authors = [damus_state.pubkey] our_blocklist_filter.authors = [damus_state.pubkey]
@@ -382,29 +370,25 @@ class HomeModel: ObservableObject {
our_dms_filter.authors = [ damus_state.pubkey ] our_dms_filter.authors = [ damus_state.pubkey ]
// TODO: separate likes? // TODO: separate likes?
var home_filter_kinds = [ var home_filter = NostrFilter.filter_kinds([
NostrKind.text.rawValue, NostrKind.text.rawValue,
NostrKind.boost.rawValue NostrKind.chat.rawValue,
] NostrKind.like.rawValue,
if !damus_state.settings.onlyzaps_mode { NostrKind.boost.rawValue,
home_filter_kinds.append(NostrKind.like.rawValue) ])
}
var home_filter = NostrFilter.filter_kinds(home_filter_kinds)
// include our pubkey as well even if we're not technically a friend // include our pubkey as well even if we're not technically a friend
home_filter.authors = friends home_filter.authors = friends
home_filter.limit = 500 home_filter.limit = 500
var notifications_filter_kinds = [ var notifications_filter = NostrFilter.filter_kinds([
NostrKind.text.rawValue, NostrKind.text.rawValue,
NostrKind.chat.rawValue,
NostrKind.like.rawValue,
NostrKind.boost.rawValue, NostrKind.boost.rawValue,
NostrKind.zap.rawValue, NostrKind.zap.rawValue,
] ])
if !damus_state.settings.onlyzaps_mode {
notifications_filter_kinds.append(NostrKind.like.rawValue)
}
var notifications_filter = NostrFilter.filter_kinds(notifications_filter_kinds)
notifications_filter.pubkeys = [damus_state.pubkey] notifications_filter.pubkeys = [damus_state.pubkey]
notifications_filter.limit = 500 notifications_filter.limit = 100
var home_filters = [home_filter] var home_filters = [home_filter]
var notifications_filters = [notifications_filter] var notifications_filters = [notifications_filter]
@@ -457,7 +441,7 @@ class HomeModel: ObservableObject {
} }
func handle_metadata_event(_ ev: NostrEvent) { func handle_metadata_event(_ ev: NostrEvent) {
process_metadata_event(events: damus_state.events, our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev) process_metadata_event(our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev)
} }
func get_last_event_of_kind(relay_id: String, kind: Int) -> NostrEvent? { func get_last_event_of_kind(relay_id: String, kind: Int) -> NostrEvent? {
@@ -468,63 +452,36 @@ class HomeModel: ObservableObject {
return m[kind] return m[kind]
} }
func handle_notification(ev: NostrEvent) { func handle_notification(ev: NostrEvent) {
// don't show notifications from ourselves
guard ev.pubkey != damus_state.pubkey else {
return
}
guard event_has_our_pubkey(ev, our_pubkey: self.damus_state.pubkey) else { guard event_has_our_pubkey(ev, our_pubkey: self.damus_state.pubkey) else {
return return
} }
guard should_show_event(contacts: damus_state.contacts, ev: ev) && !damus_state.muted_threads.isMutedThread(ev, privkey: damus_state.keypair.privkey) else { if !insert_uniq_sorted_event(events: &notifications, new_ev: ev, cmp: { $0.created_at > $1.created_at }) {
return return
} }
damus_state.events.insert(ev)
if let inner_ev = ev.get_inner_event(cache: damus_state.events) {
damus_state.events.insert(inner_ev)
}
if !notifications.insert_event(ev, damus_state: damus_state) {
return
}
if handle_last_event(ev: ev, timeline: .notifications) {
process_local_notification(damus_state: damus_state, event: ev)
}
}
@discardableResult handle_last_event(ev: ev, timeline: .notifications)
func handle_last_event(ev: NostrEvent, timeline: Timeline, shouldNotify: Bool = true) -> Bool { }
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) { if let new_bits = handle_last_events(new_events: self.new_events, ev: ev, timeline: timeline, shouldNotify: shouldNotify) {
new_events = new_bits new_events = new_bits
return true
} else {
return false
} }
} }
func insert_home_event(_ ev: NostrEvent) { func insert_home_event(_ ev: NostrEvent) {
if events.insert(ev) { 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) handle_last_event(ev: ev, timeline: .home)
} }
} }
func handle_text_event(sub_id: String, _ ev: NostrEvent) { func handle_text_event(sub_id: String, _ ev: NostrEvent) {
guard should_show_event(contacts: damus_state.contacts, ev: ev) else { guard should_show_event(contacts: damus_state.contacts, ev: ev) else {
return return
} }
// TODO: will we need to process this in other places like zap request contents, etc?
process_image_metadata(cache: damus_state.events, ev: ev)
damus_state.replies.count_replies(ev)
damus_state.events.insert(ev)
if sub_id == home_subid { if sub_id == home_subid {
insert_home_event(ev) insert_home_event(ev)
@@ -533,26 +490,15 @@ class HomeModel: ObservableObject {
} }
} }
func got_new_dm(notifs: NewEventsBits, ev: NostrEvent) {
self.new_events = notifs
if damus_state.settings.dm_notification {
let convo = ev.decrypted(privkey: self.damus_state.keypair.privkey) ?? NSLocalizedString("New encrypted direct message", comment: "Notification that the user has received a new direct message")
let notify = LocalNotification(type: .dm, event: ev, target: ev, content: convo)
create_local_notification(profiles: damus_state.profiles, notify: notify)
}
}
func handle_dm(_ ev: NostrEvent) { func handle_dm(_ ev: NostrEvent) {
guard should_show_event(contacts: damus_state.contacts, ev: ev) else { guard should_show_event(contacts: damus_state.contacts, ev: ev) else {
return return
} }
damus_state.events.insert(ev)
if !should_debounce_dms { if !should_debounce_dms {
self.incoming_dms.append(ev) 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) { if let notifs = handle_incoming_dms(prev_events: self.new_events, dms: self.dms, our_pubkey: self.damus_state.pubkey, evs: self.incoming_dms) {
got_new_dm(notifs: notifs, ev: ev) self.new_events = notifs
} }
self.incoming_dms = [] self.incoming_dms = []
return return
@@ -560,9 +506,9 @@ class HomeModel: ObservableObject {
incoming_dms.append(ev) incoming_dms.append(ev)
dm_debouncer.debounce { [self] in 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) { if let notifs = handle_incoming_dms(prev_events: self.new_events, dms: self.dms, our_pubkey: self.damus_state.pubkey, evs: self.incoming_dms) {
got_new_dm(notifs: notifs, ev: ev) self.new_events = notifs
} }
self.incoming_dms = [] self.incoming_dms = []
} }
@@ -575,8 +521,8 @@ func update_signal_from_pool(signal: SignalModel, pool: RelayPool) {
signal.max_signal = pool.relays.count signal.max_signal = pool.relays.count
} }
if signal.signal != pool.num_connected { if signal.signal != pool.num_connecting {
signal.signal = pool.num_connected signal.signal = signal.max_signal - pool.num_connecting
} }
} }
@@ -670,9 +616,15 @@ func print_filters(relay_id: String?, filters groups: [[NostrFilter]]) {
print("-----") print("-----")
} }
func process_metadata_profile(our_pubkey: String, profiles: Profiles, profile: Profile, ev: NostrEvent) { func process_metadata_event(our_pubkey: String, profiles: Profiles, ev: NostrEvent) {
guard let profile: Profile = decode_data(Data(ev.content.utf8)) else {
return
}
if our_pubkey == ev.pubkey && (profile.deleted ?? false) { if our_pubkey == ev.pubkey && (profile.deleted ?? false) {
notify(.deleted_account, ()) DispatchQueue.main.async {
notify(.deleted_account, ())
}
return return
} }
@@ -685,7 +637,7 @@ func process_metadata_profile(our_pubkey: String, profiles: Profiles, profile: P
} }
} }
let tprof = TimestampedProfile(profile: profile, timestamp: ev.created_at, event: ev) let tprof = TimestampedProfile(profile: profile, timestamp: ev.created_at)
profiles.add(id: ev.pubkey, profile: tprof) profiles.add(id: ev.pubkey, profile: tprof)
if let nip05 = profile.nip05, old_nip05 != profile.nip05 { if let nip05 = profile.nip05, old_nip05 != profile.nip05 {
@@ -697,7 +649,6 @@ func process_metadata_profile(our_pubkey: String, profiles: Profiles, profile: P
DispatchQueue.main.async { DispatchQueue.main.async {
profiles.validated[ev.pubkey] = validated profiles.validated[ev.pubkey] = validated
profiles.nip05_pubkey[nip05] = ev.pubkey
notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile)) notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile))
} }
} }
@@ -706,57 +657,21 @@ func process_metadata_profile(our_pubkey: String, profiles: Profiles, profile: P
// load pfps asap // load pfps asap
let picture = tprof.profile.picture ?? robohash(ev.pubkey) let picture = tprof.profile.picture ?? robohash(ev.pubkey)
if URL(string: picture) != nil { if URL(string: picture) != nil {
notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile)) DispatchQueue.main.async {
notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile))
}
} }
let banner = tprof.profile.banner ?? "" let banner = tprof.profile.banner ?? ""
if URL(string: banner) != nil { if URL(string: banner) != nil {
notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile)) DispatchQueue.main.async {
notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile))
}
} }
notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile)) notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile))
} }
func guard_valid_event(events: EventCache, ev: NostrEvent, callback: @escaping () -> Void) {
let validated = events.is_event_valid(ev.id)
switch validated {
case .unknown:
Task {
let result = validate_event(ev: ev)
DispatchQueue.main.async {
events.store_event_validation(evid: ev.id, validated: result)
guard result == .ok else {
return
}
callback()
}
}
case .ok:
callback()
case .bad_id: fallthrough
case .bad_sig:
break
}
}
func process_metadata_event(events: EventCache, our_pubkey: String, profiles: Profiles, ev: NostrEvent) {
guard_valid_event(events: events, ev: ev) {
DispatchQueue.global(qos: .background).async {
guard let profile: Profile = decode_data(Data(ev.content.utf8)) else {
return
}
DispatchQueue.main.async {
process_metadata_profile(our_pubkey: our_pubkey, profiles: profiles, profile: profile, ev: ev)
}
}
}
}
func robohash(_ pk: String) -> String { func robohash(_ pk: String) -> String {
return "https://robohash.org/" + pk return "https://robohash.org/" + pk
} }
@@ -787,7 +702,7 @@ func process_contact_event(state: DamusState, ev: NostrEvent) {
func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) { func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
let bootstrap_dict: [String: RelayInfo] = [:] let bootstrap_dict: [String: RelayInfo] = [:]
let old_decoded = m_old_ev.flatMap { decode_json_relays($0.content) } ?? state.bootstrap_relays.reduce(into: bootstrap_dict) { (d, r) in let old_decoded = m_old_ev.flatMap { decode_json_relays($0.content) } ?? BOOTSTRAP_RELAYS.reduce(into: bootstrap_dict) { (d, r) in
d[r] = .rw d[r] = .rw
} }
@@ -813,7 +728,7 @@ func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
for d in diff { for d in diff {
changed = true changed = true
if new.contains(d) { if new.contains(d) {
if let url = RelayURL(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) 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)
} }
} else { } else {
@@ -822,15 +737,14 @@ func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
} }
if changed { if changed {
save_bootstrap_relays(pubkey: state.pubkey, relays: Array(new))
notify(.relays_changed, ()) notify(.relays_changed, ())
} }
} }
func add_new_relay(relay_filters: RelayFilters, metadatas: RelayMetadatas, pool: RelayPool, url: RelayURL, info: RelayInfo, new_relay_filters: Bool) { 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) try? pool.add_relay(url, info: info)
let relay_id = url.id let relay_id = url.absoluteString
guard metadatas.lookup(relay_id: relay_id) == nil else { guard metadatas.lookup(relay_id: relay_id) == nil else {
return return
} }
@@ -895,10 +809,10 @@ func handle_incoming_dm(ev: NostrEvent, our_pubkey: String, dms: DirectMessagesM
} }
} }
for model in dms.dms { for (pk, _) in dms.dms {
if model.pubkey == the_pk { if pk == the_pk {
found = true found = true
inserted = insert_uniq_sorted_event(events: &(dms.dms[i].events), new_ev: ev) { inserted = insert_uniq_sorted_event(events: &(dms.dms[i].1.events), new_ev: ev) {
$0.created_at < $1.created_at $0.created_at < $1.created_at
} }
@@ -908,8 +822,8 @@ func handle_incoming_dm(ev: NostrEvent, our_pubkey: String, dms: DirectMessagesM
} }
if !found { if !found {
let model = DirectMessageModel(events: [ev], our_pubkey: our_pubkey, pubkey: the_pk) let model = DirectMessageModel(events: [ev], our_pubkey: our_pubkey)
dms.dms.append(model) dms.dms.append((the_pk, model))
inserted = true inserted = true
} }
@@ -936,53 +850,14 @@ func handle_incoming_dms(prev_events: NewEventsBits, dms: DirectMessagesModel, o
} }
if inserted { if inserted {
dms.dms = dms.dms.filter({ $0.events.count > 0 }).sorted { a, b in dms.dms = dms.dms.filter({ $0.1.events.count > 0 }).sorted { a, b in
return a.events.last!.created_at > b.events.last!.created_at return a.1.events.last!.created_at > b.1.events.last!.created_at
} }
} }
return new_events return new_events
} }
func determine_event_notifications(_ ev: NostrEvent) -> NewEventsBits {
guard let kind = ev.known_kind else {
return []
}
if kind == .zap {
return [.zaps]
}
if kind == .boost {
return [.reposts]
}
if kind == .text {
return [.mentions]
}
if kind == .like {
return [.likes]
}
return []
}
func timeline_to_notification_bits(_ timeline: Timeline, ev: NostrEvent?) -> NewEventsBits {
switch timeline {
case .home:
return [.home]
case .notifications:
if let ev {
return determine_event_notifications(ev)
}
return [.notifications]
case .search:
return [.search]
case .dms:
return [.dms]
}
}
/// A helper to determine if we need to notify the user of 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? { func handle_last_events(new_events: NewEventsBits, ev: NostrEvent, timeline: Timeline, shouldNotify: Bool = true) -> NewEventsBits? {
@@ -991,7 +866,7 @@ func handle_last_events(new_events: NewEventsBits, ev: NostrEvent, timeline: Tim
if last_ev == nil || last_ev!.created_at < ev.created_at { if last_ev == nil || last_ev!.created_at < ev.created_at {
save_last_event(ev, timeline: timeline) save_last_event(ev, timeline: timeline)
if shouldNotify { if shouldNotify {
return new_events.union(timeline_to_notification_bits(timeline, ev: ev)) return NewEventsBits(prev: new_events, setting: timeline)
} }
} }
@@ -1018,143 +893,3 @@ func should_show_event(contacts: Contacts, ev: NostrEvent) -> Bool {
return ev.should_show_event return ev.should_show_event
} }
func zap_vibrate(zap_amount: Int64) {
let sats = zap_amount / 1000
var vibration_generator: UIImpactFeedbackGenerator
if sats >= 10000 {
vibration_generator = UIImpactFeedbackGenerator(style: .heavy)
} else if sats >= 1000 {
vibration_generator = UIImpactFeedbackGenerator(style: .medium)
} else {
vibration_generator = UIImpactFeedbackGenerator(style: .light)
}
vibration_generator.impactOccurred()
}
func zap_notification_title(_ zap: Zap) -> String {
if zap.private_request != nil {
return NSLocalizedString("Private Zap", comment: "Title of notification when a private zap is received.")
} else {
return NSLocalizedString("Zap", comment: "Title of notification when a non-private zap is received.")
}
}
func zap_notification_body(profiles: Profiles, zap: Zap, locale: Locale = Locale.current) -> String {
let src = zap.private_request ?? zap.request.ev
let anon = event_is_anonymous(ev: src)
let pk = anon ? "anon" : src.pubkey
let profile = profiles.lookup(id: pk)
let sats = NSNumber(value: (Double(zap.invoice.amount) / 1000.0))
let formattedSats = format_msats_abbrev(zap.invoice.amount)
let name = Profile.displayName(profile: profile, pubkey: pk).display_name
if src.content.isEmpty {
let format = localizedStringFormat(key: "zap_notification_no_message", locale: locale)
return String(format: format, locale: locale, sats.decimalValue as NSDecimalNumber, formattedSats, name)
} else {
let format = localizedStringFormat(key: "zap_notification_with_message", locale: locale)
return String(format: format, locale: locale, sats.decimalValue as NSDecimalNumber, formattedSats, name, src.content)
}
}
func create_in_app_zap_notification(profiles: Profiles, zap: Zap, locale: Locale = Locale.current, evId: String) {
let content = UNMutableNotificationContent()
content.title = zap_notification_title(zap)
content.body = zap_notification_body(profiles: profiles, zap: zap, locale: locale)
content.sound = UNNotificationSound.default
content.userInfo = LossyLocalNotification(type: .zap, event_id: evId).to_user_info()
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let request = UNNotificationRequest(identifier: "myZapNotification", content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("Error: \(error)")
} else {
print("Local notification scheduled")
}
}
}
func process_local_notification(damus_state: DamusState, event ev: NostrEvent) {
guard let type = ev.known_kind else {
return
}
if damus_state.settings.notification_only_from_following,
damus_state.contacts.follow_state(ev.pubkey) != .follows
{
return
}
// Don't show notifications from muted threads.
if damus_state.muted_threads.isMutedThread(ev, privkey: damus_state.keypair.privkey) {
return
}
if type == .text && damus_state.settings.mention_notification {
let blocks = ev.blocks(damus_state.keypair.privkey)
for case .mention(let mention) in blocks where mention.ref.ref_id == damus_state.keypair.pubkey {
let content = NSAttributedString(render_note_content(ev: ev, profiles: damus_state.profiles, privkey: damus_state.keypair.privkey).content.attributed).string
let notify = LocalNotification(type: .mention, event: ev, target: ev, content: content)
create_local_notification(profiles: damus_state.profiles, notify: notify )
}
} else if type == .boost && damus_state.settings.repost_notification, let inner_ev = ev.get_inner_event(cache: damus_state.events) {
let notify = LocalNotification(type: .repost, event: ev, target: inner_ev, content: inner_ev.content)
create_local_notification(profiles: damus_state.profiles, notify: notify)
} else if type == .like && damus_state.settings.like_notification,
let evid = ev.referenced_ids.last?.ref_id,
let liked_event = damus_state.events.lookup(evid)
{
let notify = LocalNotification(type: .like, event: ev, target: liked_event, content: liked_event.content)
create_local_notification(profiles: damus_state.profiles, notify: notify)
}
}
func create_local_notification(profiles: Profiles, notify: LocalNotification) {
let content = UNMutableNotificationContent()
var title = ""
var identifier = ""
let displayName = event_author_name(profiles: profiles, pubkey: notify.event.pubkey)
switch notify.type {
case .mention:
title = String(format: NSLocalizedString("Mentioned by %@", comment: "Mentioned by heading in local notification"), displayName)
identifier = "myMentionNotification"
case .repost:
title = String(format: NSLocalizedString("Reposted by %@", comment: "Reposted by heading in local notification"), displayName)
identifier = "myBoostNotification"
case .like:
title = String(format: NSLocalizedString("Liked by %@", comment: "Liked by heading in local notification"), displayName)
identifier = "myLikeNotification"
case .dm:
title = String(format: NSLocalizedString("%@", comment: "DM by heading in local notification"), displayName)
identifier = "myDMNotification"
case .zap:
// not handled here
break
}
content.title = title
content.body = notify.content
content.sound = UNNotificationSound.default
content.userInfo = notify.to_lossy().to_user_info()
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("Error: \(error)")
} else {
print("Local notification scheduled")
}
}
}
-63
View File
@@ -1,63 +0,0 @@
//
// ImageUploadModel.swift
// damus
//
// Created by William Casarin on 2023-03-16.
//
import Foundation
import UIKit
enum MediaUpload {
case image(URL)
case video(URL)
var genericFileName: String {
"damus_generic_filename.\(file_extension)"
}
var file_extension: String {
switch self {
case .image(let url):
return url.pathExtension
case .video(let url):
return url.pathExtension
}
}
var localURL: URL {
switch self {
case .image(let url):
return url
case .video(let url):
return url
}
}
var is_image: Bool {
if case .image = self {
return true
}
return false
}
}
class ImageUploadModel: NSObject, URLSessionTaskDelegate, ObservableObject {
@Published var progress: Double? = nil
func start(media: MediaUpload, uploader: MediaUploader) async -> ImageUploadResult {
let res = await create_upload_request(mediaToUpload: media, mediaUploader: uploader, progress: self)
DispatchQueue.main.async {
self.progress = nil
}
return res
}
func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
DispatchQueue.main.async {
self.progress = Double(totalBytesSent) / Double(totalBytesExpectedToSend)
}
}
}
+4 -12
View File
@@ -7,7 +7,7 @@
import Foundation import Foundation
enum LibreTranslateServer: String, CaseIterable, Identifiable, StringCodable { enum LibreTranslateServer: String, CaseIterable, Identifiable {
var id: String { self.rawValue } var id: String { self.rawValue }
struct Model: Identifiable, Hashable { struct Model: Identifiable, Hashable {
@@ -17,19 +17,9 @@ enum LibreTranslateServer: String, CaseIterable, Identifiable, StringCodable {
var url: String? var url: String?
} }
func to_string() -> String {
return rawValue
}
init?(from string: String) {
guard let libreTranslateServer = LibreTranslateServer(rawValue: string) else {
return nil
}
self = libreTranslateServer
}
case argosopentech case argosopentech
case terraprint case terraprint
case vern
case custom case custom
var model: Model { var model: Model {
@@ -38,6 +28,8 @@ enum LibreTranslateServer: String, CaseIterable, Identifiable, StringCodable {
return .init(tag: self.rawValue, displayName: "translate.argosopentech.com", url: "https://translate.argosopentech.com") return .init(tag: self.rawValue, displayName: "translate.argosopentech.com", url: "https://translate.argosopentech.com")
case .terraprint: case .terraprint:
return .init(tag: self.rawValue, displayName: "translate.terraprint.co", url: "https://translate.terraprint.co") 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: case .custom:
return .init(tag: self.rawValue, displayName: NSLocalizedString("Custom", comment: "Dropdown option for selecting a custom translation server."), url: nil) return .init(tag: self.rawValue, displayName: NSLocalizedString("Custom", comment: "Dropdown option for selecting a custom translation server."), url: nil)
} }
+14
View File
@@ -0,0 +1,14 @@
//
// LocalUserConfig.swift
// damus
//
// Created by William Casarin on 2022-06-15.
//
import Foundation
struct LocalUserConfig: Codable {
let relays: [RelayDescriptor]
}
+13 -115
View File
@@ -21,8 +21,8 @@ enum MentionType {
} }
} }
struct Mention: Equatable { struct Mention {
let index: Int? let index: Int
let type: MentionType let type: MentionType
let ref: ReferencedId let ref: ReferencedId
} }
@@ -58,30 +58,12 @@ struct LightningInvoice<T> {
} }
} }
enum Block: Equatable { enum Block {
static func == (lhs: Block, rhs: Block) -> Bool {
switch (lhs, rhs) {
case (.text(let a), .text(let b)):
return a == b
case (.mention(let a), .mention(let b)):
return a == b
case (.hashtag(let a), .hashtag(let b)):
return a == b
case (.url(let a), .url(let b)):
return a == b
case (.invoice(let a), .invoice(let b)):
return a.string == b.string
case (_, _):
return false
}
}
case text(String) case text(String)
case mention(Mention) case mention(Mention)
case hashtag(String) case hashtag(String)
case url(URL) case url(URL)
case invoice(Invoice) case invoice(Invoice)
case relay(String)
var is_invoice: Invoice? { var is_invoice: Invoice? {
if case .invoice(let invoice) = self { if case .invoice(let invoice) = self {
@@ -112,14 +94,6 @@ enum Block: Equatable {
return nil return nil
} }
var is_note_mention: Bool {
guard case .mention(let mention) = self else {
return false
}
return mention.type == .event
}
var is_mention: Bool { var is_mention: Bool {
if case .mention = self { if case .mention = self {
return true return true
@@ -132,17 +106,7 @@ func render_blocks(blocks: [Block]) -> String {
return blocks.reduce("") { str, block in return blocks.reduce("") { str, block in
switch block { switch block {
case .mention(let m): case .mention(let m):
if let idx = m.index { return str + "#[\(m.index)]"
return str + "#[\(idx)]"
} else if m.type == .pubkey, let pk = bech32_pubkey(m.ref.ref_id) {
return str + "nostr:\(pk)"
} else if let note_id = bech32_note_id(m.ref.ref_id) {
return str + "nostr:\(note_id)"
} else {
return str + m.ref.ref_id
}
case .relay(let relay):
return str + relay
case .text(let txt): case .text(let txt):
return str + txt return str + txt
case .hashtag(let htag): case .hashtag(let htag):
@@ -205,16 +169,14 @@ func convert_block(_ b: block_t, tags: [[String]]) -> Block? {
return nil return nil
} }
return .text(str) return .text(str)
} else if b.type == BLOCK_MENTION_INDEX { } else if b.type == BLOCK_MENTION {
return convert_mention_index_block(ind: b.block.mention_index, tags: tags) return convert_mention_block(ind: b.block.mention, tags: tags)
} else if b.type == BLOCK_URL { } else if b.type == BLOCK_URL {
return convert_url_block(b.block.str) return convert_url_block(b.block.str)
} else if b.type == BLOCK_INVOICE { } else if b.type == BLOCK_INVOICE {
return convert_invoice_block(b.block.invoice) return convert_invoice_block(b.block.invoice)
} else if b.type == BLOCK_MENTION_BECH32 {
return convert_mention_bech32_block(b.block.mention_bech32)
} }
return nil return nil
} }
@@ -301,19 +263,17 @@ func format_msats_abbrev(_ msats: Int64) -> String {
return formatter.string(from: sats) ?? sats.stringValue return formatter.string(from: sats) ?? sats.stringValue
} }
func format_msats(_ msat: Int64, locale: Locale = Locale.current) -> String { func format_msats(_ msat: Int64) -> String {
let numberFormatter = NumberFormatter() let numberFormatter = NumberFormatter()
numberFormatter.numberStyle = .decimal numberFormatter.numberStyle = .decimal
numberFormatter.minimumFractionDigits = 0 numberFormatter.minimumFractionDigits = 0
numberFormatter.maximumFractionDigits = 3 numberFormatter.maximumFractionDigits = 3
numberFormatter.roundingMode = .down numberFormatter.roundingMode = .down
numberFormatter.locale = locale
let sats = NSNumber(value: (Double(msat) / 1000.0)) let sats = NSNumber(value: (Double(msat) / 1000.0))
let formattedSats = numberFormatter.string(from: sats) ?? sats.stringValue let formattedSats = numberFormatter.string(from: sats) ?? sats.stringValue
let format = localizedStringFormat(key: "sats_count", locale: locale) return String(format: NSLocalizedString("sats_count", comment: "Amount of sats."), sats.decimalValue as NSDecimalNumber, formattedSats)
return String(format: format, locale: locale, sats.decimalValue as NSDecimalNumber, formattedSats)
} }
func convert_invoice_block(_ b: invoice_block) -> Block? { func convert_invoice_block(_ b: invoice_block) -> Block? {
@@ -337,60 +297,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)) return .invoice(Invoice(description: description, amount: amount, string: invstr, expiry: b11.expiry, payment_hash: payment_hash, created_at: created_at))
} }
func convert_mention_bech32_block(_ b: mention_bech32_block) -> Block?
{
switch b.bech32.type {
case NOSTR_BECH32_NOTE:
let note = b.bech32.data.note;
let event_id = hex_encode(Data(bytes: note.event_id, count: 32))
let event_id_ref = ReferencedId(ref_id: event_id, relay_id: nil, key: "e")
return .mention(Mention(index: nil, type: .event, ref: event_id_ref))
case NOSTR_BECH32_NEVENT:
let nevent = b.bech32.data.nevent;
let event_id = hex_encode(Data(bytes: nevent.event_id, count: 32))
var relay_id: String? = nil
if nevent.relays.num_relays > 0 {
relay_id = strblock_to_string(nevent.relays.relays.0)
}
let event_id_ref = ReferencedId(ref_id: event_id, relay_id: relay_id, key: "e")
return .mention(Mention(index: nil, type: .event, ref: event_id_ref))
case NOSTR_BECH32_NPUB:
let npub = b.bech32.data.npub
let pubkey = hex_encode(Data(bytes: npub.pubkey, count: 32))
let pubkey_ref = ReferencedId(ref_id: pubkey, relay_id: nil, key: "p")
return .mention(Mention(index: nil, type: .pubkey, ref: pubkey_ref))
case NOSTR_BECH32_NPROFILE:
let nprofile = b.bech32.data.nprofile
let pubkey = hex_encode(Data(bytes: nprofile.pubkey, count: 32))
var relay_id: String? = nil
if nprofile.relays.num_relays > 0 {
relay_id = strblock_to_string(nprofile.relays.relays.0)
}
let pubkey_ref = ReferencedId(ref_id: pubkey, relay_id: relay_id, key: "p")
return .mention(Mention(index: nil, type: .pubkey, ref: pubkey_ref))
case NOSTR_BECH32_NRELAY:
let nrelay = b.bech32.data.nrelay
guard let relay_str = strblock_to_string(nrelay.relay) else {
return nil
}
return .relay(relay_str)
case NOSTR_BECH32_NADDR:
// TODO: wtf do I do with this
guard let naddr = strblock_to_string(b.str) else {
return nil
}
return .text("nostr:" + naddr)
default:
return nil
}
}
func convert_invoice_description(b11: bolt11) -> InvoiceDescription? { func convert_invoice_description(b11: bolt11) -> InvoiceDescription? {
if let desc = b11.description { if let desc = b11.description {
return .description(String(cString: desc)) return .description(String(cString: desc))
@@ -403,7 +309,7 @@ func convert_invoice_description(b11: bolt11) -> InvoiceDescription? {
return nil return nil
} }
func convert_mention_index_block(ind: Int32, tags: [[String]]) -> Block? func convert_mention_block(ind: Int32, tags: [[String]]) -> Block?
{ {
let ind = Int(ind) let ind = Int(ind)
@@ -641,7 +547,7 @@ func parse_mention_type(_ c: String) -> MentionType? {
} }
/// Convert /// Convert
func make_post_tags(post_blocks: [PostBlock], tags: [[String]], silent_mentions: Bool) -> PostTags { func make_post_tags(post_blocks: [PostBlock], tags: [[String]]) -> PostTags {
var new_tags = tags var new_tags = tags
var blocks: [Block] = [] var blocks: [Block] = []
@@ -651,14 +557,6 @@ func make_post_tags(post_blocks: [PostBlock], tags: [[String]], silent_mentions:
guard let mention_type = parse_mention_type(ref.key) else { guard let mention_type = parse_mention_type(ref.key) else {
continue continue
} }
if silent_mentions || mention_type == .event {
let mention = Mention(index: nil, type: mention_type, ref: ref)
let block = Block.mention(mention)
blocks.append(block)
continue
}
if let ind = find_tag_ref(type: ref.key, id: ref.ref_id, tags: tags) { if let ind = find_tag_ref(type: ref.key, id: ref.ref_id, tags: tags) {
let mention = Mention(index: ind, type: mention_type, ref: ref) let mention = Mention(index: ind, type: mention_type, ref: ref)
let block = Block.mention(mention) let block = Block.mention(mention)
@@ -682,9 +580,9 @@ func make_post_tags(post_blocks: [PostBlock], tags: [[String]], silent_mentions:
} }
func post_to_event(post: NostrPost, privkey: String, pubkey: String) -> NostrEvent { func post_to_event(post: NostrPost, privkey: String, pubkey: String) -> NostrEvent {
let tags = post.references.map(refid_to_tag) + post.tags let tags = post.references.map(refid_to_tag)
let post_blocks = parse_post_blocks(content: post.content) let post_blocks = parse_post_blocks(content: post.content)
let post_tags = make_post_tags(post_blocks: post_blocks, tags: tags, silent_mentions: false) let post_tags = make_post_tags(post_blocks: post_blocks, tags: tags)
let content = render_blocks(blocks: post_tags.blocks) let content = render_blocks(blocks: post_tags.blocks)
let new_ev = NostrEvent(content: content, pubkey: pubkey, kind: post.kind.rawValue, tags: post_tags.tags) let new_ev = NostrEvent(content: content, pubkey: pubkey, kind: post.kind.rawValue, tags: post_tags.tags)
new_ev.calculate_id() new_ev.calculate_id()
-76
View File
@@ -1,76 +0,0 @@
//
// MutedThreadsManager.swift
// damus
//
// Created by Terry Yiu on 4/6/23.
//
import Foundation
fileprivate func getMutedThreadsKey(pubkey: String) -> String {
pk_setting_key(pubkey, key: "muted_threads")
}
func loadMutedThreads(pubkey: String) -> [String] {
let key = getMutedThreadsKey(pubkey: pubkey)
return UserDefaults.standard.stringArray(forKey: key) ?? []
}
func saveMutedThreads(pubkey: String, currentValue: [String], value: [String]) -> Bool {
let uniqueMutedThreads = Array(Set(value))
if uniqueMutedThreads != currentValue {
UserDefaults.standard.set(uniqueMutedThreads, forKey: getMutedThreadsKey(pubkey: pubkey))
return true
}
return false
}
class MutedThreadsManager: ObservableObject {
private let userDefaults = UserDefaults.standard
private let keypair: Keypair
private var _mutedThreadsSet: Set<String>
private var _mutedThreads: [String]
var mutedThreads: [String] {
get {
return _mutedThreads
}
set {
if saveMutedThreads(pubkey: keypair.pubkey, currentValue: _mutedThreads, value: newValue) {
self._mutedThreads = newValue
self.objectWillChange.send()
}
}
}
init(keypair: Keypair) {
self._mutedThreads = loadMutedThreads(pubkey: keypair.pubkey)
self._mutedThreadsSet = Set(_mutedThreads)
self.keypair = keypair
}
func isMutedThread(_ ev: NostrEvent, privkey: String?) -> Bool {
return _mutedThreadsSet.contains(ev.thread_id(privkey: privkey))
}
func updateMutedThread(_ ev: NostrEvent) {
let threadId = ev.thread_id(privkey: nil)
if isMutedThread(ev, privkey: keypair.privkey) {
mutedThreads = mutedThreads.filter { $0 != threadId }
_mutedThreadsSet.remove(threadId)
notify(.unmute_thread, ev)
} else {
mutedThreads.append(threadId)
_mutedThreadsSet.insert(threadId)
notify(.mute_thread, ev)
}
}
func clearAll() {
mutedThreads = []
_mutedThreadsSet.removeAll()
}
}
@@ -1,50 +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)
}
func would_filter(_ isIncluded: (NostrEvent) -> Bool) -> Bool {
for ev in events {
if !isIncluded(ev) {
return true
}
}
return false
}
func filter(_ isIncluded: (NostrEvent) -> Bool) -> EventGroup? {
let new_evs = events.filter(isIncluded)
guard new_evs.count > 0 else {
return nil
}
return EventGroup(events: new_evs)
}
}
-76
View File
@@ -1,76 +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
if let priv = z.private_request {
return priv
} else {
return z.request.ev
}
}
}
func would_filter(_ isIncluded: (NostrEvent) -> Bool) -> Bool {
for zap in zaps {
if !isIncluded(zap.request_ev) {
return true
}
}
return false
}
func filter(_ isIncluded: (NostrEvent) -> Bool) -> ZapGroup? {
let new_zaps = zaps.filter { isIncluded($0.request_ev) }
guard new_zaps.count > 0 else {
return nil
}
let grp = ZapGroup()
for zap in new_zaps {
grp.insert(zap)
}
return grp
}
init() {
self.zaps = []
self.msat_total = 0
self.zappers = Set()
}
@discardableResult
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
}
}
-351
View File
@@ -1,351 +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 is_reply: NostrEvent? {
if case .reply(let ev) = self {
return ev
}
return nil
}
var is_zap: ZapGroup? {
switch self {
case .profile_zap(let zapgrp):
return zapgrp
case .event_zap(_, let zapgrp):
return zapgrp
case .reaction:
return nil
case .reply:
return nil
case .repost:
return nil
}
}
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
}
}
func would_filter(_ isIncluded: (NostrEvent) -> Bool) -> Bool {
switch self {
case .repost(_, let evgrp):
return evgrp.would_filter(isIncluded)
case .reaction(_, let evgrp):
return evgrp.would_filter(isIncluded)
case .profile_zap(let zapgrp):
return zapgrp.would_filter(isIncluded)
case .event_zap(_, let zapgrp):
return zapgrp.would_filter(isIncluded)
case .reply(let ev):
return !isIncluded(ev)
}
}
func filter(_ isIncluded: (NostrEvent) -> Bool) -> NotificationItem? {
switch self {
case .repost(let evid, let evgrp):
return evgrp.filter(isIncluded).map { .repost(evid, $0) }
case .reaction(let evid, let evgrp):
return evgrp.filter(isIncluded).map { .reaction(evid, $0) }
case .profile_zap(let zapgrp):
return zapgrp.filter(isIncluded).map { .profile_zap($0) }
case .event_zap(let evid, let zapgrp):
return zapgrp.filter(isIncluded).map { .event_zap(evid, $0) }
case .reply(let ev):
if isIncluded(ev) { return .reply(ev) }
return nil
}
}
}
class NotificationsModel: ObservableObject, ScrollQueue {
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 set_should_queue(_ val: Bool) {
self.should_queue = val
}
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, cache: EventCache) -> Bool {
guard let reposted_ev = ev.get_inner_event(cache: cache) 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, cache: EventCache) -> Bool {
if ev.known_kind == .boost {
return insert_repost(ev, cache: cache)
} 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, damus_state: DamusState) -> Bool {
if should_queue {
return insert_uniq_sorted_event_created(events: &incoming_events, new_ev: ev)
}
if insert_event_immediate(ev, cache: damus_state.events) {
self.notifications = build_notifications()
return true
}
return false
}
func insert_zap(_ zap: Zap, damus_state: DamusState) -> 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(_ damus_state: DamusState) -> 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, cache: damus_state.events) || inserted
}
if inserted {
self.notifications = build_notifications()
}
return inserted
}
}
+7 -7
View File
@@ -11,17 +11,17 @@ struct NostrPost {
let kind: NostrKind let kind: NostrKind
let content: String let content: String
let references: [ReferencedId] let references: [ReferencedId]
let tags: [[String]]
init (content: String, references: [ReferencedId], kind: NostrKind = .text, tags: [[String]] = []) { init (content: String, references: [ReferencedId]) {
self.content = content
self.references = references
self.kind = .text
}
init (content: String, references: [ReferencedId], kind: NostrKind) {
self.content = content self.content = content
self.references = references self.references = references
self.kind = kind self.kind = kind
self.tags = tags
}
func to_event(keypair: FullKeypair) -> NostrEvent {
return post_to_event(post: self, privkey: keypair.privkey, pubkey: keypair.pubkey)
} }
} }
+7 -17
View File
@@ -8,16 +8,14 @@
import Foundation import Foundation
class ProfileModel: ObservableObject, Equatable { class ProfileModel: ObservableObject, Equatable {
var events: EventHolder = EventHolder() @Published var events: [NostrEvent] = []
@Published var contacts: NostrEvent? = nil @Published var contacts: NostrEvent? = nil
@Published var following: Int = 0 @Published var following: Int = 0
@Published var relays: [String: RelayInfo]? = nil @Published var relays: [String: RelayInfo]? = nil
@Published var progress: Int = 0
let pubkey: String let pubkey: String
let damus: DamusState let damus: DamusState
var seen_event: Set<String> = Set() var seen_event: Set<String> = Set()
var sub_id = UUID().description var sub_id = UUID().description
var prof_subid = UUID().description var prof_subid = UUID().description
@@ -113,13 +111,11 @@ class ProfileModel: ObservableObject, Equatable {
return return
} }
if ev.is_textlike || ev.known_kind == .boost { if ev.is_textlike || ev.known_kind == .boost {
if self.events.insert(ev) { insert_uniq_sorted_event(events: &self.events, new_ev: ev, cmp: { $0.created_at > $1.created_at})
self.objectWillChange.send()
}
} else if ev.known_kind == .contacts { } else if ev.known_kind == .contacts {
handle_profile_contact_event(ev) handle_profile_contact_event(ev)
} else if ev.known_kind == .metadata { } else if ev.known_kind == .metadata {
process_metadata_event(events: damus.events, our_pubkey: damus.pubkey, profiles: damus.profiles, ev: ev) process_metadata_event(our_pubkey: damus.pubkey, profiles: damus.profiles, ev: ev)
} }
seen_event.insert(ev.id) seen_event.insert(ev.id)
} }
@@ -129,21 +125,15 @@ class ProfileModel: ObservableObject, Equatable {
case .ws_event: case .ws_event:
return return
case .nostr_event(let resp): case .nostr_event(let resp):
guard resp.subid == self.sub_id || resp.subid == self.prof_subid else {
return
}
switch resp { switch resp {
case .ok: case .event(let sid, let ev):
break if sid != self.sub_id && sid != self.prof_subid {
case .event(_, let ev): return
}
add_event(ev) add_event(ev)
case .notice(let notice): case .notice(let notice):
notify(.notice, notice) notify(.notice, notice)
case .eose: case .eose:
if resp.subid == sub_id {
load_profiles(profiles_subid: prof_subid, relay_id: relay_id, load: .from_events(events.events), damus_state: damus)
}
progress += 1
break break
} }
} }
+65 -3
View File
@@ -8,9 +8,71 @@
import Foundation 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) { @Published var reactions: [NostrEvent]
super.init(state: state, target: target, kind: .like)
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
}
} }
} }
+4 -17
View File
@@ -8,25 +8,12 @@
import Foundation import Foundation
class ReplyMap { class ReplyMap {
var replies: [String: Set<String>] = [:] var replies: [String: String] = [:]
func lookup(_ id: String) -> Set<String>? { func lookup(_ id: String) -> String? {
return replies[id] return replies[id]
} }
func add(id: String, reply_id: String) {
private func ensure_set(id: String) { replies[id] = reply_id
if replies[id] == nil {
replies[id] = Set()
}
}
@discardableResult
func add(id: String, reply_id: String) -> Bool {
ensure_set(id: id)
if (replies[id]!).contains(reply_id) {
return false
}
replies[id]!.insert(reply_id)
return true
} }
} }
+66 -4
View File
@@ -7,9 +7,71 @@
import Foundation import Foundation
final class RepostsModel: EventsModel { class RepostsModel: ObservableObject {
let state: DamusState
init(state: DamusState, target: String) { let target: String
super.init(state: state, target: target, kind: .boost) let sub_id: String
let profiles_id: String
@Published var reposts: [NostrEvent]
init (state: DamusState, target: String) {
self.state = state
self.target = target
self.sub_id = UUID().description
self.profiles_id = UUID().description
self.reposts = []
}
func get_filter() -> NostrFilter {
var filter = NostrFilter.filter_kinds([NostrKind.boost.rawValue])
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 == NostrKind.boost.rawValue else {
return
}
guard let reposted_event = last_etag(tags: ev.tags) else {
return
}
guard reposted_event == self.target else {
return
}
if insert_uniq_sorted_event(events: &self.reposts, 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: reposts, damus_state: state)
break
}
} }
} }
@@ -1,12 +0,0 @@
//
// SearchResultsModel.swift
// damus
//
// Created by William Casarin on 2023-03-03.
//
import Foundation
class SearchResultsModel: ObservableObject {
}
+13 -47
View File
@@ -10,7 +10,7 @@ import Foundation
/// The data model for the SearchHome view, typically something global-like /// The data model for the SearchHome view, typically something global-like
class SearchHomeModel: ObservableObject { class SearchHomeModel: ObservableObject {
var events: EventHolder = EventHolder() @Published var events: [NostrEvent] = []
@Published var loading: Bool = false @Published var loading: Bool = false
var seen_pubkey: Set<String> = Set() var seen_pubkey: Set<String> = Set()
@@ -24,15 +24,14 @@ class SearchHomeModel: ObservableObject {
} }
func get_base_filter() -> NostrFilter { func get_base_filter() -> NostrFilter {
var filter = NostrFilter.filter_kinds([NostrKind.text.rawValue, NostrKind.chat.rawValue]) var filter = NostrFilter.filter_kinds([1, 42])
filter.limit = self.limit filter.limit = self.limit
filter.until = Int64(Date.now.timeIntervalSince1970) filter.until = Int64(Date.now.timeIntervalSince1970)
return filter return filter
} }
func filter_muted() { func filter_muted() {
events.filter { should_show_event(contacts: damus_state.contacts, ev: $0) } events = events.filter { should_show_event(contacts: damus_state.contacts, ev: $0) }
self.objectWillChange.send()
} }
func subscribe() { func subscribe() {
@@ -62,14 +61,12 @@ class SearchHomeModel: ObservableObject {
} }
seen_pubkey.insert(ev.pubkey) seen_pubkey.insert(ev.pubkey)
if self.events.insert(ev) { insert_uniq_sorted_event(events: &events, new_ev: ev) {
self.objectWillChange.send() $0.created_at > $1.created_at
} }
} }
case .notice(let msg): case .notice(let msg):
print("search home notice: \(msg)") print("search home notice: \(msg)")
case .ok:
break
case .eose(let sub_id): case .eose(let sub_id):
loading = false loading = false
@@ -78,7 +75,7 @@ class SearchHomeModel: ObservableObject {
// global events are not realtime // global events are not realtime
unsubscribe(to: relay_id) 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)
} }
@@ -100,55 +97,24 @@ func find_profiles_to_fetch_pk(profiles: Profiles, event_pubkeys: [String]) -> [
return Array(pubkeys) return Array(pubkeys)
} }
func find_profiles_to_fetch(profiles: Profiles, load: PubkeysToLoad, cache: EventCache) -> [String] {
switch load {
case .from_events(let events):
return find_profiles_to_fetch_from_events(profiles: profiles, events: events, cache: cache)
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 { func find_profiles_to_fetch(profiles: Profiles, events: [NostrEvent]) -> [String] {
if profiles.lookup(id: pk) != nil {
continue
}
pubkeys.insert(pk)
}
return Array(pubkeys)
}
func find_profiles_to_fetch_from_events(profiles: Profiles, events: [NostrEvent], cache: EventCache) -> [String] {
var pubkeys = Set<String>() var pubkeys = Set<String>()
for ev in events { for ev in events {
// lookup profiles from boosted events if profiles.lookup(id: ev.pubkey) != nil {
if ev.known_kind == .boost, let bev = ev.get_inner_event(cache: cache), profiles.lookup(id: bev.pubkey) == nil { continue
pubkeys.insert(bev.pubkey)
} }
if profiles.lookup(id: ev.pubkey) == nil { pubkeys.insert(ev.pubkey)
pubkeys.insert(ev.pubkey)
}
} }
return Array(pubkeys) return Array(pubkeys)
} }
enum PubkeysToLoad { func load_profiles(profiles_subid: String, relay_id: String, events: [NostrEvent], damus_state: DamusState) {
case from_events([NostrEvent])
case from_keys([String])
}
func load_profiles(profiles_subid: String, relay_id: String, load: PubkeysToLoad, damus_state: DamusState) {
var filter = NostrFilter.filter_profiles var filter = NostrFilter.filter_profiles
let authors = find_profiles_to_fetch(profiles: damus_state.profiles, load: load, cache: damus_state.events) let authors = find_profiles_to_fetch(profiles: damus_state.profiles, events: events)
filter.authors = authors filter.authors = authors
guard !authors.isEmpty else { guard !authors.isEmpty else {
@@ -164,7 +130,7 @@ func load_profiles(profiles_subid: String, relay_id: String, load: PubkeysToLoad
} }
if ev.known_kind == .metadata { if ev.known_kind == .metadata {
process_metadata_event(events: damus_state.events, our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev) process_metadata_event(our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev)
} }
} }
+4 -8
View File
@@ -9,7 +9,7 @@ import Foundation
class SearchModel: ObservableObject { class SearchModel: ObservableObject {
var events: EventHolder = EventHolder() @Published var events: [NostrEvent] = []
@Published var loading: Bool = false @Published var loading: Bool = false
@Published var channel_name: String? = nil @Published var channel_name: String? = nil
@@ -26,14 +26,13 @@ class SearchModel: ObservableObject {
} }
func filter_muted() { func filter_muted() {
self.events.filter { should_show_event(contacts: contacts, ev: $0) } self.events = self.events.filter { should_show_event(contacts: contacts, ev: $0) }
self.objectWillChange.send()
} }
func subscribe() { func subscribe() {
// since 1 month // since 1 month
search.limit = self.limit search.limit = self.limit
search.kinds = [NostrKind.text.rawValue, NostrKind.like.rawValue] search.kinds = [1,5,7]
//likes_filter.ids = ref_events.referenced_ids! //likes_filter.ids = ref_events.referenced_ids!
@@ -58,7 +57,7 @@ class SearchModel: ObservableObject {
return 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() objectWillChange.send()
} }
} }
@@ -131,9 +130,6 @@ func handle_subid_event(pool: RelayPool, relay_id: String, ev: NostrConnectionEv
case .event(let ev_subid, let ev): case .event(let ev_subid, let ev):
handle(ev_subid, ev) handle(ev_subid, ev)
return (ev_subid, false) return (ev_subid, false)
case .ok:
return (nil, false)
case .notice(let note): case .notice(let note):
if note.contains("Too many subscription filters") { if note.contains("Too many subscription filters") {
+124 -66
View File
@@ -30,126 +30,184 @@ enum InitialEvent {
/// manages the lifetime of a thread /// manages the lifetime of a thread
class ThreadModel: ObservableObject { class ThreadModel: ObservableObject {
@Published var event: NostrEvent @Published var initial_event: InitialEvent
var event_map: Set<NostrEvent> @Published var events: [NostrEvent] = []
@Published var event_map: [String: Int] = [:]
@Published var loading: Bool = false @Published var loading: Bool = false
var replies: ReplyMap = ReplyMap() var replies: ReplyMap = ReplyMap()
init(event: NostrEvent, damus_state: DamusState) { var event: NostrEvent? {
self.damus_state = damus_state switch initial_event {
self.event_map = Set() case .event(let ev):
self.event = event return ev
add_event(event, privkey: nil) case .event_id(let evid):
for event in events {
if event.id == evid {
return event
}
}
return nil
}
} }
let damus_state: DamusState let damus_state: DamusState
let profiles_subid = UUID().description let profiles_subid = UUID().description
let base_subid = UUID().description var base_subid = UUID().description
let meta_subid = UUID().description
init(evid: String, damus_state: DamusState) {
self.damus_state = damus_state
self.initial_event = .event_id(evid)
}
var subids: [String] { init(event: NostrEvent, damus_state: DamusState) {
return [profiles_subid, base_subid, meta_subid] self.damus_state = damus_state
self.initial_event = .event(event)
} }
func unsubscribe() { func unsubscribe() {
self.damus_state.pool.remove_handler(sub_id: base_subid)
self.damus_state.pool.remove_handler(sub_id: meta_subid)
self.damus_state.pool.remove_handler(sub_id: profiles_subid)
self.damus_state.pool.unsubscribe(sub_id: base_subid) self.damus_state.pool.unsubscribe(sub_id: base_subid)
self.damus_state.pool.unsubscribe(sub_id: meta_subid) print("unsubscribing from thread \(initial_event.id) with sub_id \(base_subid)")
self.damus_state.pool.unsubscribe(sub_id: profiles_subid)
print("unsubscribing from thread \(event.id) with sub_id \(base_subid)")
} }
@discardableResult func reset_events() {
func set_active_event(_ ev: NostrEvent, privkey: String?) -> Bool { self.events.removeAll()
self.event = ev self.event_map.removeAll()
add_event(ev, privkey: privkey) self.replies.replies.removeAll()
}
func should_resubscribe(_ ev_b: NostrEvent) -> Bool {
if self.events.count == 0 {
return true
}
//self.objectWillChange.send() if ev_b.is_root_event() {
return false return false
}
// rough heuristic to save us from resubscribing all the time
//return ev_b.count_ids() != self.event.count_ids()
return true
}
func set_active_event(_ ev: NostrEvent, privkey: String?) {
if should_resubscribe(ev) {
unsubscribe()
self.initial_event = .event(ev)
subscribe()
} else {
self.initial_event = .event(ev)
if events.count == 0 {
add_event(ev, privkey: privkey)
}
}
} }
func subscribe() { func subscribe() {
var meta_events = NostrFilter()
var event_filter = NostrFilter()
var ref_events = NostrFilter() var ref_events = NostrFilter()
var events_filter = NostrFilter()
//var likes_filter = NostrFilter.filter_kinds(7])
let thread_id = event.thread_id(privkey: nil) // TODO: add referenced relays
switch self.initial_event {
ref_events.referenced_ids = [thread_id, event.id] case .event(let ev):
ref_events.kinds = [NostrKind.text.rawValue] ref_events.referenced_ids = ev.referenced_ids.map { $0.ref_id }
ref_events.limit = 1000 ref_events.referenced_ids?.append(ev.id)
ref_events.limit = 50
event_filter.ids = [thread_id, event.id] events_filter.ids = ref_events.referenced_ids ?? []
events_filter.limit = 100
meta_events.referenced_ids = [event.id] events_filter.ids?.append(ev.id)
case .event_id(let evid):
var kinds = [NostrKind.zap.rawValue, NostrKind.text.rawValue, NostrKind.boost.rawValue] ref_events.referenced_ids = [evid]
if !damus_state.settings.onlyzaps_mode { ref_events.limit = 50
kinds.append(NostrKind.like.rawValue) events_filter.ids = [evid]
events_filter.limit = 100
} }
meta_events.kinds = kinds
meta_events.limit = 1000 //likes_filter.ids = ref_events.referenced_ids!
/*
if let last_ev = self.events.last {
if last_ev.created_at <= Int64(Date().timeIntervalSince1970) {
ref_events.since = last_ev.created_at
}
}
*/
let base_filters = [event_filter, ref_events]
let meta_filters = [meta_events]
print("subscribing to thread \(event.id) with sub_id \(base_subid)") print("subscribing to thread \(initial_event.id) with sub_id \(base_subid)")
damus_state.pool.register_handler(sub_id: base_subid, handler: handle_event)
loading = true loading = true
damus_state.pool.subscribe(sub_id: base_subid, filters: base_filters, handler: handle_event) damus_state.pool.send(.subscribe(.init(filters: [ref_events, events_filter], sub_id: base_subid)))
damus_state.pool.subscribe(sub_id: meta_subid, filters: meta_filters, handler: handle_event) }
func lookup(_ event_id: String) -> NostrEvent? {
if let i = event_map[event_id] {
return events[i]
}
return nil
} }
func add_event(_ ev: NostrEvent, privkey: String?) { func add_event(_ ev: NostrEvent, privkey: String?) {
if event_map.contains(ev) { guard ev.should_show_event else {
return return
} }
let the_ev = damus_state.events.upsert(ev) if event_map[ev.id] != nil {
damus_state.replies.count_replies(the_ev) return
damus_state.events.add_replies(ev: the_ev) }
event_map.insert(ev) for reply in ev.direct_replies(privkey) {
objectWillChange.send() self.replies.add(id: ev.id, reply_id: reply.ref_id)
}
if insert_uniq_sorted_event(events: &self.events, new_ev: ev, cmp: { $0.created_at < $1.created_at }) {
objectWillChange.send()
}
//self.events.append(ev)
//self.events = self.events.sorted { $0.created_at < $1.created_at }
var i: Int = 0
for ev in events {
self.event_map[ev.id] = i
i += 1
}
if let evid = self.initial_event.is_event_id {
if ev.id == evid {
// this should trigger a resubscribe...
set_active_event(ev, privkey: privkey)
}
}
}
func handle_channel_meta(_ ev: NostrEvent) {
guard let meta: ChatroomMetadata = decode_json(ev.content) else {
return
}
notify(.chatroom_meta, meta)
} }
func handle_event(relay_id: String, ev: NostrConnectionEvent) { func handle_event(relay_id: String, ev: NostrConnectionEvent) {
let (sub_id, done) = handle_subid_event(pool: damus_state.pool, relay_id: relay_id, ev: ev) { sid, ev in let (sub_id, done) = handle_subid_event(pool: damus_state.pool, relay_id: relay_id, ev: ev) { sid, ev in
guard subids.contains(sid) else { guard sid == base_subid || sid == profiles_subid else {
return return
} }
if ev.known_kind == .metadata { if ev.known_kind == .metadata {
process_metadata_event(events: damus_state.events, our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev) process_metadata_event(our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev)
} else if ev.is_textlike { } else if ev.is_textlike {
self.add_event(ev, privkey: self.damus_state.keypair.privkey) self.add_event(ev, privkey: self.damus_state.keypair.privkey)
} else if ev.known_kind == .channel_meta || ev.known_kind == .channel_create {
handle_channel_meta(ev)
} }
} }
guard done, let sub_id, subids.contains(sub_id) else { guard done && (sub_id == base_subid || sub_id == profiles_subid) else {
return return
} }
if event_map.contains(event) { if (events.contains { ev in ev.id == initial_event.id }) {
loading = false loading = false
} }
if sub_id == self.base_subid { if sub_id == self.base_subid {
load_profiles(profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(Array(event_map)), damus_state: damus_state) load_profiles(profiles_subid: self.profiles_subid, relay_id: relay_id, events: events, damus_state: damus_state)
} }
} }
+2 -17
View File
@@ -7,19 +7,7 @@
import Foundation import Foundation
enum TranslationService: String, CaseIterable, Identifiable, StringCodable { enum TranslationService: String, CaseIterable, Identifiable {
init?(from string: String) {
guard let ts = TranslationService(rawValue: string) else {
return nil
}
self = ts
}
func to_string() -> String {
return self.rawValue
}
var id: String { self.rawValue } var id: String { self.rawValue }
struct Model: Identifiable, Hashable { struct Model: Identifiable, Hashable {
@@ -31,18 +19,15 @@ enum TranslationService: String, CaseIterable, Identifiable, StringCodable {
case none case none
case libretranslate case libretranslate
case deepl case deepl
case nokyctranslate
var model: Model { var model: Model {
switch self { switch self {
case .none: case .none:
return .init(tag: self.rawValue, displayName: NSLocalizedString("none_translation_service", value: "None", comment: "Dropdown option for selecting no translation service.")) return .init(tag: self.rawValue, displayName: NSLocalizedString("None", comment: "Dropdown option for selecting no translation service."))
case .libretranslate: case .libretranslate:
return .init(tag: self.rawValue, displayName: NSLocalizedString("LibreTranslate (Open Source)", comment: "Dropdown option for selecting LibreTranslate as the translation service.")) return .init(tag: self.rawValue, displayName: NSLocalizedString("LibreTranslate (Open Source)", comment: "Dropdown option for selecting LibreTranslate as the translation service."))
case .deepl: case .deepl:
return .init(tag: self.rawValue, displayName: NSLocalizedString("DeepL (Proprietary, Higher Accuracy)", comment: "Dropdown option for selecting DeepL as the translation service.")) return .init(tag: self.rawValue, displayName: NSLocalizedString("DeepL (Proprietary, Higher Accuracy)", comment: "Dropdown option for selecting DeepL as the translation service."))
case .nokyctranslate:
return .init(tag: self.rawValue, displayName: NSLocalizedString("NoKYCTranslate.com (Prepay with BTC)", comment: "Dropdown option for selecting NoKYCTranslate.com as the translation service."))
} }
} }
+149 -187
View File
@@ -7,162 +7,108 @@
import Foundation import Foundation
import Vault import Vault
import UIKit
let fallback_zap_amount = 1000 func should_show_wallet_selector(_ pubkey: String) -> Bool {
return UserDefaults.standard.object(forKey: "show_wallet_selector") as? Bool ?? true
}
@propertyWrapper struct Setting<T: Equatable> { func pk_setting_key(_ pubkey: String, key: String) -> String {
private let key: String return "\(pubkey)_\(key)"
private var value: T }
init(key: String, default_value: T) { func default_zap_setting_key(pubkey: String) -> String {
self.key = pk_setting_key(UserSettingsStore.pubkey ?? "", key: key) return pk_setting_key(pubkey, key: "default_zap_amount")
if let loaded = UserDefaults.standard.object(forKey: self.key) as? T { }
self.value = loaded
} else if let loaded = UserDefaults.standard.object(forKey: key) as? T { func set_default_zap_amount(pubkey: String, amount: Int) {
// try to load from deprecated non-pubkey-keyed setting let key = default_zap_setting_key(pubkey: pubkey)
self.value = loaded UserDefaults.standard.setValue(amount, forKey: key)
} else { }
self.value = default_value
} func get_default_zap_amount(pubkey: String) -> Int? {
let key = default_zap_setting_key(pubkey: pubkey)
let amt = UserDefaults.standard.integer(forKey: key)
if amt == 0 {
return nil
} }
return amt
var wrappedValue: T { }
get { return value }
set {
guard self.value != newValue else { func get_default_wallet(_ pubkey: String) -> Wallet {
return if let defaultWalletName = UserDefaults.standard.string(forKey: "default_wallet"),
} let default_wallet = Wallet(rawValue: defaultWalletName)
self.value = newValue {
UserDefaults.standard.set(newValue, forKey: key) return default_wallet
UserSettingsStore.shared!.objectWillChange.send() } else {
} return .system_default_wallet
} }
} }
@propertyWrapper class StringSetting<T: StringCodable & Equatable> { private func get_translation_service(_ pubkey: String) -> TranslationService? {
private let key: String guard let translation_service = UserDefaults.standard.string(forKey: "translation_service") else {
private var value: T return nil
}
init(key: String, default_value: T) {
self.key = pk_setting_key(UserSettingsStore.pubkey ?? "", key: key) return TranslationService(rawValue: translation_service)
if let loaded = UserDefaults.standard.string(forKey: self.key), let val = T.init(from: loaded) { }
self.value = val
} else if let loaded = UserDefaults.standard.string(forKey: key), let val = T.init(from: loaded) { private func get_deepl_plan(_ pubkey: String) -> DeepLPlan? {
// try to load from deprecated non-pubkey-keyed setting guard let server_name = UserDefaults.standard.string(forKey: "deepl_plan") else {
self.value = val return nil
} else { }
self.value = default_value
} return DeepLPlan(rawValue: server_name)
}
private func get_libretranslate_server(_ pubkey: String) -> LibreTranslateServer? {
guard let server_name = UserDefaults.standard.string(forKey: "libretranslate_server") else {
return nil
} }
var wrappedValue: T { return LibreTranslateServer(rawValue: server_name)
get { return value } }
set {
guard self.value != newValue else { private func get_libretranslate_url(_ pubkey: String, server: LibreTranslateServer) -> String? {
return if let url = server.model.url {
} return url
self.value = newValue
UserDefaults.standard.set(newValue.to_string(), forKey: key)
UserSettingsStore.shared!.objectWillChange.send()
}
} }
return UserDefaults.standard.object(forKey: "libretranslate_url") as? String
} }
class UserSettingsStore: ObservableObject { class UserSettingsStore: ObservableObject {
static var pubkey: String? = nil @Published var default_wallet: Wallet {
static var shared: UserSettingsStore? = nil didSet {
UserDefaults.standard.set(default_wallet.rawValue, forKey: "default_wallet")
@StringSetting(key: "default_wallet", default_value: .system_default_wallet)
var default_wallet: Wallet
@StringSetting(key: "default_media_uploader", default_value: .nostrBuild)
var default_media_uploader: MediaUploader
@Setting(key: "show_wallet_selector", default_value: true)
var show_wallet_selector: Bool
@Setting(key: "left_handed", default_value: false)
var left_handed: Bool
@Setting(key: "always_show_images", default_value: false)
var always_show_images: Bool
@Setting(key: "zap_vibration", default_value: true)
var zap_vibration: Bool
@Setting(key: "zap_notification", default_value: true)
var zap_notification: Bool
@Setting(key: "default_zap_amount", default_value: fallback_zap_amount)
var default_zap_amount: Int
@Setting(key: "mention_notification", default_value: true)
var mention_notification: Bool
@StringSetting(key: "zap_type", default_value: ZapType.pub)
var default_zap_type: ZapType
@Setting(key: "repost_notification", default_value: true)
var repost_notification: Bool
@Setting(key: "dm_notification", default_value: true)
var dm_notification: Bool
@Setting(key: "like_notification", default_value: true)
var like_notification: Bool
@Setting(key: "notification_only_from_following", default_value: false)
var notification_only_from_following: Bool
@Setting(key: "translate_dms", default_value: false)
var translate_dms: Bool
@Setting(key: "truncate_timeline_text", default_value: false)
var truncate_timeline_text: Bool
@Setting(key: "truncate_mention_text", default_value: true)
var truncate_mention_text: Bool
@Setting(key: "notification_indicators", default_value: NewEventsBits.all.rawValue)
var notification_indicators: Int
@Setting(key: "auto_translate", default_value: true)
var auto_translate: Bool
@Setting(key: "show_only_preferred_languages", default_value: false)
var show_only_preferred_languages: Bool
@Setting(key: "onlyzaps_mode", default_value: false)
var onlyzaps_mode: Bool
@Setting(key: "disable_animation", default_value: UIAccessibility.isReduceMotionEnabled)
var disable_animation: Bool
// Helper for inverse of disable_animation.
// disable_animation was introduced as a setting first, but it's more natural for the settings UI to show the inverse.
var enable_animation: Bool {
get {
!disable_animation
}
set {
disable_animation = !newValue
} }
} }
@StringSetting(key: "friend_filter", default_value: .all) @Published var show_wallet_selector: Bool {
var friend_filter: FriendFilter didSet {
UserDefaults.standard.set(show_wallet_selector, forKey: "show_wallet_selector")
@StringSetting(key: "notification_state", default_value: .all) }
var notification_state: NotificationFilterState }
@StringSetting(key: "translation_service", default_value: .none) @Published var left_handed: Bool {
var translation_service: TranslationService didSet {
UserDefaults.standard.set(left_handed, forKey: "left_handed")
}
}
@StringSetting(key: "deepl_plan", default_value: .free) @Published var translation_service: TranslationService {
var deepl_plan: DeepLPlan didSet {
UserDefaults.standard.set(translation_service.rawValue, forKey: "translation_service")
var deepl_api_key: String { }
}
@Published var deepl_plan: DeepLPlan {
didSet {
UserDefaults.standard.set(deepl_plan.rawValue, forKey: "deepl_plan")
}
}
@Published var deepl_api_key: String {
didSet { didSet {
do { do {
if deepl_api_key == "" { if deepl_api_key == "" {
@@ -176,14 +122,31 @@ class UserSettingsStore: ObservableObject {
} }
} }
@StringSetting(key: "libretranslate_server", default_value: .terraprint) @Published var libretranslate_server: LibreTranslateServer {
var libretranslate_server: LibreTranslateServer didSet {
if oldValue == libretranslate_server {
@Setting(key: "libretranslate_url", default_value: "") return
var libretranslate_url: String }
@Setting(key: "libretranslate_api_key", default_value: "") UserDefaults.standard.set(libretranslate_server.rawValue, forKey: "libretranslate_server")
var libretranslate_api_key: String {
libretranslate_api_key = ""
if libretranslate_server == .custom {
libretranslate_url = ""
} else {
libretranslate_url = libretranslate_server.model.url!
}
}
}
@Published var libretranslate_url: String {
didSet {
UserDefaults.standard.set(libretranslate_url, forKey: "libretranslate_url")
}
}
@Published var libretranslate_api_key: String {
didSet { didSet {
do { do {
if libretranslate_api_key == "" { if libretranslate_api_key == "" {
@@ -196,34 +159,53 @@ class UserSettingsStore: ObservableObject {
} }
} }
} }
@Published var nokyctranslate_api_key: String {
didSet {
do {
if nokyctranslate_api_key == "" {
try clearNoKYCTranslateApiKey()
} else {
try saveNoKYCTranslateApiKey(nokyctranslate_api_key)
}
} catch {
// No-op.
}
}
}
init() { init() {
// TODO: pubkey-scoped settings
let pubkey = ""
self.default_wallet = get_default_wallet(pubkey)
show_wallet_selector = should_show_wallet_selector(pubkey)
left_handed = UserDefaults.standard.object(forKey: "left_handed") as? Bool ?? false
// Note from @tyiu:
// Default translation service is disabled by default for now until we gain some confidence that it is working well in production.
// Instead of throwing all Damus users onto feature immediately, allow for discovery of feature organically.
// Also, we are connecting to servers listed as mirrors on the official LibreTranslate GitHub README that do not require API keys.
// However, we have not asked them for permission to use, so we're trying to be good neighbors for now.
// Opportunity: spin up dedicated trusted LibreTranslate server that requires an API key for any access (or higher rate limit access).
if let translation_service = get_translation_service(pubkey) {
self.translation_service = translation_service
} else {
self.translation_service = .none
}
if let libretranslate_server = get_libretranslate_server(pubkey) {
self.libretranslate_server = libretranslate_server
self.libretranslate_url = get_libretranslate_url(pubkey, server: libretranslate_server) ?? ""
} else {
// Choose a random server to distribute load.
libretranslate_server = .allCases.filter { $0 != .custom }.randomElement()!
libretranslate_url = ""
}
do {
libretranslate_api_key = try Vault.getPrivateKey(keychainConfiguration: DamusLibreTranslateKeychainConfiguration())
} catch {
libretranslate_api_key = ""
}
if let deepl_plan = get_deepl_plan(pubkey) {
self.deepl_plan = deepl_plan
} else {
self.deepl_plan = .free
}
do { do {
deepl_api_key = try Vault.getPrivateKey(keychainConfiguration: DamusDeepLKeychainConfiguration()) deepl_api_key = try Vault.getPrivateKey(keychainConfiguration: DamusDeepLKeychainConfiguration())
} catch { } catch {
deepl_api_key = "" deepl_api_key = ""
} }
do {
nokyctranslate_api_key = try Vault.getPrivateKey(keychainConfiguration: DamusNoKYCTranslateKeychainConfiguration())
} catch {
nokyctranslate_api_key = ""
}
} }
private func saveLibreTranslateApiKey(_ apiKey: String) throws { private func saveLibreTranslateApiKey(_ apiKey: String) throws {
@@ -234,14 +216,6 @@ class UserSettingsStore: ObservableObject {
try Vault.deletePrivateKey(keychainConfiguration: DamusLibreTranslateKeychainConfiguration()) try Vault.deletePrivateKey(keychainConfiguration: DamusLibreTranslateKeychainConfiguration())
} }
private func saveNoKYCTranslateApiKey(_ apiKey: String) throws {
try Vault.savePrivateKey(apiKey, keychainConfiguration: DamusNoKYCTranslateKeychainConfiguration())
}
private func clearNoKYCTranslateApiKey() throws {
try Vault.deletePrivateKey(keychainConfiguration: DamusNoKYCTranslateKeychainConfiguration())
}
private func saveDeepLApiKey(_ apiKey: String) throws { private func saveDeepLApiKey(_ apiKey: String) throws {
try Vault.savePrivateKey(apiKey, keychainConfiguration: DamusDeepLKeychainConfiguration()) try Vault.savePrivateKey(apiKey, keychainConfiguration: DamusDeepLKeychainConfiguration())
} }
@@ -258,8 +232,6 @@ class UserSettingsStore: ObservableObject {
return URLComponents(string: libretranslate_url) != nil return URLComponents(string: libretranslate_url) != nil
case .deepl: case .deepl:
return deepl_api_key != "" return deepl_api_key != ""
case .nokyctranslate:
return nokyctranslate_api_key != ""
} }
} }
} }
@@ -275,13 +247,3 @@ struct DamusDeepLKeychainConfiguration: KeychainConfiguration {
var accessGroup: String? = nil var accessGroup: String? = nil
var accountName = "deepl_apikey" var accountName = "deepl_apikey"
} }
struct DamusNoKYCTranslateKeychainConfiguration: KeychainConfiguration {
var serviceName = "damus"
var accessGroup: String? = nil
var accountName = "nokyctranslate_apikey"
}
func pk_setting_key(_ pubkey: String, key: String) -> String {
return "\(pubkey)_\(key)"
}
+14 -25
View File
@@ -7,7 +7,7 @@
import Foundation import Foundation
enum Wallet: String, CaseIterable, Identifiable, StringCodable { enum Wallet: String, CaseIterable, Identifiable {
var id: String { self.rawValue } var id: String { self.rawValue }
struct Model: Identifiable, Hashable { struct Model: Identifiable, Hashable {
@@ -20,17 +20,6 @@ enum Wallet: String, CaseIterable, Identifiable, StringCodable {
var image: String var image: String
} }
func to_string() -> String {
return rawValue
}
init?(from string: String) {
guard let w = Wallet(rawValue: string) else {
return nil
}
self = w
}
// New url prefixes needed to be added to LSApplicationQueriesSchemes // New url prefixes needed to be added to LSApplicationQueriesSchemes
case system_default_wallet case system_default_wallet
case strike case strike
@@ -53,42 +42,42 @@ enum Wallet: String, CaseIterable, Identifiable, StringCodable {
return .init(index: -1, tag: "systemdefaultwallet", displayName: NSLocalizedString("Local default", comment: "Dropdown option label for system default for Lightning wallet."), return .init(index: -1, tag: "systemdefaultwallet", displayName: NSLocalizedString("Local default", comment: "Dropdown option label for system default for Lightning wallet."),
link: "lightning:", appStoreLink: "lightning:", image: "") link: "lightning:", appStoreLink: "lightning:", image: "")
case .strike: case .strike:
return .init(index: 0, tag: "strike", displayName: "Strike", link: "strike:", return .init(index: 0, tag: "strike", displayName: NSLocalizedString("Strike", comment: "Dropdown option label for Lightning wallet, Strike."), link: "strike:",
appStoreLink: "https://apps.apple.com/us/app/strike-bitcoin-payments/id1488724463", image: "strike") appStoreLink: "https://apps.apple.com/us/app/strike-bitcoin-payments/id1488724463", image: "strike")
case .cashapp: case .cashapp:
return .init(index: 1, tag: "cashapp", displayName: "Cash App", link: "https://cash.app/launch/lightning/", return .init(index: 1, tag: "cashapp", displayName: NSLocalizedString("Cash App", comment: "Dropdown option label for Lightning wallet, Cash App."), link: "https://cash.app/launch/lightning/",
appStoreLink: "https://apps.apple.com/us/app/cash-app/id711923939", image: "cashapp") appStoreLink: "https://apps.apple.com/us/app/cash-app/id711923939", image: "cashapp")
case .muun: case .muun:
return .init(index: 2, tag: "muun", displayName: "Muun", link: "muun:", appStoreLink: "https://apps.apple.com/us/app/muun-wallet/id1482037683", image: "muun") return .init(index: 2, tag: "muun", displayName: NSLocalizedString("Muun", comment: "Dropdown option label for Lightning wallet, Muun."), link: "muun:", appStoreLink: "https://apps.apple.com/us/app/muun-wallet/id1482037683", image: "muun")
case .bluewallet: case .bluewallet:
return .init(index: 3, tag: "bluewallet", displayName: "Blue Wallet", link: "bluewallet:lightning:", return .init(index: 3, tag: "bluewallet", displayName: NSLocalizedString("Blue Wallet", comment: "Dropdown option label for Lightning wallet, Blue Wallet."), link: "bluewallet:lightning:",
appStoreLink: "https://apps.apple.com/us/app/bluewallet-bitcoin-wallet/id1376878040", image: "bluewallet") appStoreLink: "https://apps.apple.com/us/app/bluewallet-bitcoin-wallet/id1376878040", image: "bluewallet")
case .walletofsatoshi: case .walletofsatoshi:
return .init(index: 4, tag: "walletofsatoshi", displayName: "Wallet of Satoshi", link: "walletofsatoshi:lightning:", return .init(index: 4, tag: "walletofsatoshi", displayName: NSLocalizedString("Wallet of Satoshi", comment: "Dropdown option label for Lightning wallet, Wallet of Satoshi."), link: "walletofsatoshi:lightning:",
appStoreLink: "https://apps.apple.com/us/app/wallet-of-satoshi/id1438599608", image: "walletofsatoshi") appStoreLink: "https://apps.apple.com/us/app/wallet-of-satoshi/id1438599608", image: "walletofsatoshi")
case .zebedee: case .zebedee:
return .init(index: 5, tag: "zebedee", displayName: "Zebedee", link: "zebedee:lightning:", return .init(index: 5, tag: "zebedee", displayName: NSLocalizedString("Zebedee", comment: "Dropdown option label for Lightning wallet, Zebedee."), link: "zebedee:lightning:",
appStoreLink: "https://apps.apple.com/us/app/zebedee-wallet/id1484394401", image: "zebedee") appStoreLink: "https://apps.apple.com/us/app/zebedee-wallet/id1484394401", image: "zebedee")
case .zeusln: case .zeusln:
return .init(index: 6, tag: "zeusln", displayName: "Zeus LN", link: "zeusln:lightning:", return .init(index: 6, tag: "zeusln", displayName: NSLocalizedString("Zeus LN", comment: "Dropdown option label for Lightning wallet, Zeus LN."), link: "zeusln:lightning:",
appStoreLink: "https://apps.apple.com/us/app/zeus-ln/id1456038895", image: "zeusln") appStoreLink: "https://apps.apple.com/us/app/zeus-ln/id1456038895", image: "zeusln")
case .lnlink: case .lnlink:
return .init(index: 7, tag: "lnlink", displayName: "LNLink", link: "lnlink:lightning:", return .init(index: 7, tag: "lnlink", displayName: NSLocalizedString("LNLink", comment: "Dropdown option label for Lightning wallet, LNLink."), link: "lnlink:lightning:",
appStoreLink: "https://testflight.apple.com/join/aNY4yuuZ", image: "lnlink") appStoreLink: "https://testflight.apple.com/join/aNY4yuuZ", image: "lnlink")
case .phoenix: case .phoenix:
return .init(index: 8, tag: "phoenix", displayName: "Phoenix", link: "phoenix://", return .init(index: 8, tag: "phoenix", displayName: NSLocalizedString("Phoenix", comment: "Dropdown option label for Lightning wallet, Phoenix."), link: "phoenix://",
appStoreLink: "https://apps.apple.com/us/app/phoenix-wallet/id1544097028", image: "phoenix") appStoreLink: "https://apps.apple.com/us/app/phoenix-wallet/id1544097028", image: "phoenix")
case .breez: case .breez:
return .init(index: 9, tag: "breez", displayName: "Breez", link: "breez:", return .init(index: 9, tag: "breez", displayName: NSLocalizedString("Breez", comment: "Dropdown option label for Lightning wallet, Breez."), link: "breez:",
appStoreLink: "https://apps.apple.com/us/app/breez-lightning-client-pos/id1463604142", image: "breez") appStoreLink: "https://apps.apple.com/us/app/breez-lightning-client-pos/id1463604142", image: "breez")
case .bitcoinbeach: case .bitcoinbeach:
return .init(index: 10, tag: "bitcoinbeach", displayName: "Bitcoin Beach", link: "bitcoinbeach://", return .init(index: 10, tag: "bitcoinbeach", displayName: NSLocalizedString("Bitcoin Beach", comment: "Dropdown option label for Lightning wallet, Bitcoin Beach."), link: "bitcoinbeach://",
appStoreLink: "https://apps.apple.com/sv/app/bitcoin-beach-wallet/id1531383905", image: "bbw") appStoreLink: "https://apps.apple.com/sv/app/bitcoin-beach-wallet/id1531383905", image: "bbw")
case .blixtwallet: case .blixtwallet:
return .init(index: 11, tag: "blixtwallet", displayName: "Blixt Wallet", link: "blixtwallet:lightning:", return .init(index: 11, tag: "blixtwallet", displayName: NSLocalizedString("Blixt Wallet", comment: "Dropdown option label for Lightning wallet, Blixt Wallet"), link: "blixtwallet:lightning:",
appStoreLink: "https://testflight.apple.com/join/EXvGhRzS", image: "blixt-wallet") appStoreLink: "https://testflight.apple.com/join/EXvGhRzS", image: "blixt-wallet")
case .river: case .river:
return .init(index: 12, tag: "river", displayName: "River", link: "river://", return .init(index: 12, tag: "river", displayName: NSLocalizedString("River", comment: "Dropdown option label for Lightning wallet, River"), link: "river://",
appStoreLink: "https://apps.apple.com/us/app/river-buy-mine-bitcoin/id1536176542", image: "river") appStoreLink: "https://apps.apple.com/us/app/river-buy-mine-bitcoin/id1536176542", image: "river")
} }
+25 -33
View File
@@ -10,21 +10,18 @@ import Foundation
class ZapsModel: ObservableObject { class ZapsModel: ObservableObject {
let state: DamusState let state: DamusState
let target: ZapTarget let target: ZapTarget
var zaps: [Zap]
let zaps_subid = UUID().description let zaps_subid = UUID().description
let profiles_subid = UUID().description
init(state: DamusState, target: ZapTarget) { init(state: DamusState, target: ZapTarget) {
self.state = state self.state = state
self.target = target self.target = target
} self.zaps = []
var zaps: [Zap] {
return state.events.lookup_zaps(target: target)
} }
func subscribe() { func subscribe() {
var filter = NostrFilter.filter_kinds([NostrKind.zap.rawValue]) var filter = NostrFilter.filter_kinds([9735])
switch target { switch target {
case .profile(let profile_id): case .profile(let profile_id):
filter.pubkeys = [profile_id] filter.pubkeys = [profile_id]
@@ -47,39 +44,34 @@ class ZapsModel: ObservableObject {
return return
} }
switch resp { guard case .event(_, let ev) = resp else {
case .ok: return
break }
case .notice:
break guard ev.kind == 9735 else {
case .eose: return
let events = state.events.lookup_zaps(target: target).map { $0.request_ev } }
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events), damus_state: state)
case .event(_, let ev): if let zap = state.zaps.zaps[ev.id] {
guard ev.kind == 9735 else { if insert_uniq_sorted_zap(zaps: &zaps, new_zap: zap) {
objectWillChange.send()
}
} else {
guard let zapper = state.profiles.lookup_zapper(pubkey: target.pubkey) else {
return return
} }
if let zap = state.zaps.zaps[ev.id] { guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper) else {
if state.events.store_zap(zap: zap) { return
objectWillChange.send() }
}
} else { state.zaps.add_zap(zap: zap)
guard let zapper = state.profiles.lookup_zapper(pubkey: target.pubkey) else {
return if insert_uniq_sorted_zap(zaps: &zaps, new_zap: zap) {
} objectWillChange.send()
guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper, our_privkey: state.keypair.privkey) else {
return
}
if self.state.add_zap(zap: zap) {
objectWillChange.send()
}
} }
} }
} }
} }
+9 -30
View File
@@ -7,7 +7,7 @@
import Foundation import Foundation
class Profile: Codable { struct Profile: Codable {
var value: [String: AnyCodable] var value: [String: AnyCodable]
init (name: String?, display_name: String?, about: String?, picture: String?, banner: String?, website: String?, lud06: String?, lud16: String?, nip05: String?) { init (name: String?, display_name: String?, about: String?, picture: String?, banner: String?, website: String?, lud06: String?, lud16: String?, nip05: String?) {
@@ -39,7 +39,7 @@ class Profile: Codable {
return s return s
} }
private func set_val<T>(_ key: String, _ val: T?) { private mutating func set_val<T>(_ key: String, _ val: T?) {
if val == nil { if val == nil {
self.value.removeValue(forKey: key) self.value.removeValue(forKey: key)
return return
@@ -48,15 +48,10 @@ class Profile: Codable {
self.value[key] = AnyCodable.init(val) self.value[key] = AnyCodable.init(val)
} }
private func set_str(_ key: String, _ val: String?) { private mutating func set_str(_ key: String, _ val: String?) {
set_val(key, val) set_val(key, val)
} }
var reactions: Bool? {
get { return get_val("reactions"); }
set(s) { set_val("reactions", s) }
}
var deleted: Bool? { var deleted: Bool? {
get { return get_val("deleted"); } get { return get_val("deleted"); }
set(s) { set_val("deleted", s) } set(s) { set_val("deleted", s) }
@@ -103,33 +98,16 @@ class Profile: Codable {
} }
var website_url: URL? { var website_url: URL? {
if self.website?.trimmingCharacters(in: .whitespacesAndNewlines) == "" { return self.website.flatMap { URL(string: $0) }
return nil
}
return self.website.flatMap { url in
let trim = url.trimmingCharacters(in: .whitespacesAndNewlines)
if !(trim.hasPrefix("http://") || trim.hasPrefix("https://")) {
return URL(string: "https://" + trim)
}
return URL(string: trim)
}
} }
private var _lnurl: String? = nil
var lnurl: String? { var lnurl: String? {
if let _lnurl {
return _lnurl
}
guard let addr = lud16 ?? lud06 else { guard let addr = lud16 ?? lud06 else {
return nil; return nil;
} }
if addr.contains("@") { if addr.contains("@") {
// this is a heavy op and is used a lot in views, cache it! return lnaddress_to_lnurl(addr);
let addr = lnaddress_to_lnurl(addr);
self._lnurl = addr
return addr
} }
if !addr.lowercased().hasPrefix("lnurl") { if !addr.lowercased().hasPrefix("lnurl") {
@@ -152,7 +130,7 @@ class Profile: Codable {
self.value = [:] self.value = [:]
} }
required init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer() let container = try decoder.singleValueContainer()
self.value = try container.decode([String: AnyCodable].self) self.value = try container.decode([String: AnyCodable].self)
} }
@@ -162,8 +140,9 @@ class Profile: Codable {
try container.encode(value) try container.encode(value)
} }
static func displayName(profile: Profile?, pubkey: String) -> DisplayName { static func displayName(profile: Profile?, pubkey: String) -> String {
return parse_display_name(profile: profile, pubkey: pubkey) let pk = bech32_nopre_pubkey(pubkey) ?? pubkey
return profile?.name ?? abbrev_pubkey(pk)
} }
} }
+47 -252
View File
@@ -10,18 +10,13 @@ import CommonCrypto
import secp256k1 import secp256k1
import secp256k1_implementation import secp256k1_implementation
import CryptoKit import CryptoKit
import NaturalLanguage
enum ValidationResult: Decodable { enum ValidationResult: Decodable {
case unknown
case ok case ok
case bad_id case bad_id
case bad_sig case bad_sig
var is_bad: Bool {
return self == .bad_id || self == .bad_sig
}
} }
struct OtherEvent { struct OtherEvent {
@@ -42,18 +37,6 @@ struct ReferencedId: Identifiable, Hashable, Equatable {
var id: String { var id: String {
return ref_id return ref_id
} }
static func q(_ id: String, relay_id: String? = nil) -> ReferencedId {
return ReferencedId(ref_id: id, relay_id: relay_id, key: "q")
}
static func e(_ id: String, relay_id: String? = nil) -> ReferencedId {
return ReferencedId(ref_id: id, relay_id: relay_id, key: "e")
}
static func p(_ id: String, relay_id: String? = nil) -> ReferencedId {
return ReferencedId(ref_id: id, relay_id: relay_id, key: "p")
}
} }
struct EventId: Identifiable, CustomStringConvertible { struct EventId: Identifiable, CustomStringConvertible {
@@ -98,7 +81,7 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has
} }
var too_big: Bool { var too_big: Bool {
return self.content.utf8.count > 16000 return self.content.count > 16000
} }
var should_show_event: Bool { var should_show_event: Bool {
@@ -109,6 +92,14 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has
return calculate_event_id(ev: self) == self.id return calculate_event_id(ev: self) == self.id
} }
var is_valid: Bool {
return validity == .ok
}
lazy var validity: ValidationResult = {
return .ok //validate_event(ev: self)
}()
private var _blocks: [Block]? = nil private var _blocks: [Block]? = nil
func blocks(_ privkey: String?) -> [Block] { func blocks(_ privkey: String?) -> [Block] {
if let bs = _blocks { if let bs = _blocks {
@@ -123,22 +114,14 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has
return parse_mentions(content: content, tags: self.tags) return parse_mentions(content: content, tags: self.tags)
} }
private lazy var inner_event: NostrEvent? = { lazy var inner_event: NostrEvent? = {
return event_from_json(dat: self.content) // don't try to deserialize an inner event if we know there won't be one
if self.known_kind == .boost {
return event_from_json(dat: self.content)
}
return nil
}() }()
func get_inner_event(cache: EventCache) -> NostrEvent? {
guard self.known_kind == .boost else {
return nil
}
if self.content == "", let ref = self.referenced_ids.first {
return cache.lookup(ref.ref_id)
}
return self.inner_event
}
private var _event_refs: [EventRef]? = nil private var _event_refs: [EventRef]? = nil
func event_refs(_ privkey: String?) -> [EventRef] { func event_refs(_ privkey: String?) -> [EventRef] {
if let rs = _event_refs { if let rs = _event_refs {
@@ -174,7 +157,7 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has
pubkey = refkey.ref_id pubkey = refkey.ref_id
} }
let dec = decrypt_dm(key, pubkey: pubkey, content: self.content, encoding: .base64) let dec = decrypt_dm(key, pubkey: pubkey, content: self.content)
self.decrypted_content = dec self.decrypted_content = dec
return dec return dec
@@ -185,9 +168,6 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has
return decrypted(privkey: privkey) ?? "*failed to decrypt content*" return decrypted(privkey: privkey) ?? "*failed to decrypt content*"
} }
return content
/*
switch validity { switch validity {
case .ok: case .ok:
return content return content
@@ -196,7 +176,6 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has
case .bad_sig: case .bad_sig:
return content + "\n\n*WARNING: invalid signature, could be forged!*" return content + "\n\n*WARNING: invalid signature, could be forged!*"
} }
*/
} }
var description: String { var description: String {
@@ -232,16 +211,6 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has
} }
} }
} }
public func thread_id(privkey: String?) -> String {
for ref in event_refs(privkey) {
if let thread_id = ref.is_thread_id {
return thread_id.ref_id
}
}
return self.id
}
public func last_refid() -> ReferencedId? { public func last_refid() -> ReferencedId? {
var mlast: Int? = nil var mlast: Int? = nil
@@ -276,25 +245,6 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has
return event_is_reply(self, privkey: privkey) return event_is_reply(self, privkey: privkey)
} }
func note_language(_ privkey: String?) -> String? {
// 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 = blocks(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)
guard let locale = languageRecognizer.languageHypotheses(withMaximum: 1).first(where: { $0.value >= 0.5 })?.key.rawValue else {
return nil
}
// Remove the variant component and just take the language part as translation services typically only supports the variant-less language.
// Moreover, speakers of one variant can generally understand other variants.
return localeToLanguage(locale)
}
public var referenced_ids: [ReferencedId] { public var referenced_ids: [ReferencedId] {
return get_referenced_ids(key: "e") return get_referenced_ids(key: "e")
} }
@@ -328,8 +278,8 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has
return (self.flags & 1) != 0 return (self.flags & 1) != 0
} }
init(id: String = "", content: String, pubkey: String, kind: Int = 1, tags: [[String]] = [], createdAt: Int64 = Int64(Date().timeIntervalSince1970)) { init(content: String, pubkey: String, kind: Int = 1, tags: [[String]] = [], createdAt: Int64 = Int64(Date().timeIntervalSince1970)) {
self.id = id self.id = ""
self.sig = "" self.sig = ""
self.content = content self.content = content
@@ -338,6 +288,20 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has
self.tags = tags self.tags = tags
self.created_at = createdAt self.created_at = createdAt
} }
/// Intiialization statement used to specificy ID
///
/// This is mainly used for contant and testing data
init(id: String, content: String, pubkey: String, kind: Int = 1, tags: [[String]] = []) {
self.id = id
self.sig = ""
self.content = content
self.pubkey = pubkey
self.kind = kind
self.tags = tags
self.created_at = Int64(Date().timeIntervalSince1970)
}
init(from: NostrEvent, content: String? = nil) { init(from: NostrEvent, content: String? = nil) {
self.id = from.id self.id = from.id
@@ -554,21 +518,16 @@ func make_first_contact_event(keypair: Keypair) -> NostrEvent? {
guard let privkey = keypair.privkey else { guard let privkey = keypair.privkey else {
return nil return nil
} }
let bootstrap_relays = load_bootstrap_relays(pubkey: keypair.pubkey)
let rw_relay_info = RelayInfo(read: true, write: true) let rw_relay_info = RelayInfo(read: true, write: true)
var relays: [String: RelayInfo] = [:] var relays: [String: RelayInfo] = [:]
for relay in BOOTSTRAP_RELAYS {
for relay in bootstrap_relays {
relays[relay] = rw_relay_info relays[relay] = rw_relay_info
} }
let relay_json = encode_json(relays)! let relay_json = encode_json(relays)!
let damus_pubkey = "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681" let damus_pubkey = "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681"
let jb55_pubkey = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" // lol
let tags = [ let tags = [
["p", damus_pubkey], ["p", damus_pubkey],
["p", jb55_pubkey],
["p", keypair.pubkey] // you're a friend of yourself! ["p", keypair.pubkey] // you're a friend of yourself!
] ]
let ev = NostrEvent(content: relay_json, let ev = NostrEvent(content: relay_json,
@@ -580,7 +539,7 @@ func make_first_contact_event(keypair: Keypair) -> NostrEvent? {
return ev return ev
} }
func make_metadata_event(keypair: Keypair, metadata: Profile) -> NostrEvent? { func make_metadata_event(keypair: Keypair, metadata: NostrMetadata) -> NostrEvent? {
guard let privkey = keypair.privkey else { guard let privkey = keypair.privkey else {
return nil return nil
} }
@@ -628,146 +587,22 @@ func zap_target_to_tags(_ target: ZapTarget) -> [[String]] {
} }
} }
func make_private_zap_request_event(identity: FullKeypair, enc_key: FullKeypair, target: ZapTarget, message: String) -> String? { func make_zap_request_event(pubkey: String, privkey: String, content: String, relays: [RelayDescriptor], target: ZapTarget) -> NostrEvent {
// target tags must be the same as zap request target tags
let tags = zap_target_to_tags(target)
let note = NostrEvent(content: message, pubkey: identity.pubkey, kind: 9733, tags: tags)
note.id = calculate_event_id(ev: note)
note.sig = sign_event(privkey: identity.privkey, ev: note)
guard let note_json = encode_json(note) else {
return nil
}
return encrypt_message(message: note_json, privkey: enc_key.privkey, to_pk: target.pubkey, encoding: .bech32)
}
func decrypt_private_zap(our_privkey: String, zapreq: NostrEvent, target: ZapTarget) -> NostrEvent? {
guard let anon_tag = zapreq.tags.first(where: { t in t.count >= 2 && t[0] == "anon" }) else {
return nil
}
let enc_note = anon_tag[1]
var note = decrypt_note(our_privkey: our_privkey, their_pubkey: zapreq.pubkey, enc_note: enc_note, encoding: .bech32)
// check to see if the private note was from us
if note == nil {
guard let our_private_keypair = generate_private_keypair(our_privkey: our_privkey, id: target.id, created_at: zapreq.created_at) else{
return nil
}
// use our private keypair and their pubkey to get the shared secret
note = decrypt_note(our_privkey: our_private_keypair.privkey, their_pubkey: target.pubkey, enc_note: enc_note, encoding: .bech32)
}
guard let note else {
return nil
}
guard note.kind == 9733 else {
return nil
}
let zr_etag = zapreq.referenced_ids.first
let note_etag = note.referenced_ids.first
guard zr_etag == note_etag else {
return nil
}
let zr_ptag = zapreq.referenced_pubkeys.first
let note_ptag = note.referenced_pubkeys.first
guard let zr_ptag, let note_ptag, zr_ptag == note_ptag else {
return nil
}
guard validate_event(ev: note) == .ok else {
return nil
}
return note
}
func generate_private_keypair(our_privkey: String, id: String, created_at: Int64) -> FullKeypair? {
let to_hash = our_privkey + id + String(created_at)
guard let dat = to_hash.data(using: .utf8) else {
return nil
}
let privkey_bytes = sha256(dat)
let privkey = hex_encode(privkey_bytes)
guard let pubkey = privkey_to_pubkey(privkey: privkey) else {
return nil
}
return FullKeypair(pubkey: pubkey, privkey: privkey)
}
func make_zap_request_event(keypair: FullKeypair, content: String, relays: [RelayDescriptor], target: ZapTarget, zap_type: ZapType) -> NostrEvent? {
var tags = zap_target_to_tags(target) var tags = zap_target_to_tags(target)
var relay_tag = ["relays"] var relay_tag = ["relays"]
relay_tag.append(contentsOf: relays.map { $0.url.id }) relay_tag.append(contentsOf: relays.map { $0.url.absoluteString })
tags.append(relay_tag) tags.append(relay_tag)
let ev = NostrEvent(content: content, pubkey: pubkey, kind: 9734, tags: tags)
var kp = keypair
let now = Int64(Date().timeIntervalSince1970)
var message = content
switch zap_type {
case .pub:
break
case .non_zap:
break
case .anon:
tags.append(["anon"])
kp = generate_new_keypair().to_full()!
case .priv:
guard let priv_kp = generate_private_keypair(our_privkey: keypair.privkey, id: target.id, created_at: now) else {
return nil
}
kp = priv_kp
guard let privreq = make_private_zap_request_event(identity: keypair, enc_key: kp, target: target, message: message) else {
return nil
}
tags.append(["anon", privreq])
message = ""
}
let ev = NostrEvent(content: message, pubkey: kp.pubkey, kind: 9734, tags: tags, createdAt: now)
ev.id = calculate_event_id(ev: ev) ev.id = calculate_event_id(ev: ev)
ev.sig = sign_event(privkey: kp.privkey, ev: ev) ev.sig = sign_event(privkey: privkey, ev: ev)
return ev return ev
} }
func uniq<T: Hashable>(_ xs: [T]) -> [T] {
var s = Set<T>()
var ys: [T] = []
for x in xs {
if s.contains(x) {
continue
}
s.insert(x)
ys.append(x)
}
return ys
}
func gather_reply_ids(our_pubkey: String, from: NostrEvent) -> [ReferencedId] { func gather_reply_ids(our_pubkey: String, from: NostrEvent) -> [ReferencedId] {
var ids = get_referenced_ids(tags: from.tags, key: "e").first.map { [$0] } ?? [] var ids = get_referenced_ids(tags: from.tags, key: "e").first.map { [$0] } ?? []
ids.append(.e(from.id)) ids.append(ReferencedId(ref_id: from.id, relay_id: nil, key: "e"))
ids.append(contentsOf: uniq(from.referenced_pubkeys.filter { $0.ref_id != our_pubkey })) ids.append(contentsOf: from.referenced_pubkeys.filter { $0.ref_id != our_pubkey })
if from.pubkey != our_pubkey {
ids.append(ReferencedId(ref_id: from.pubkey, relay_id: nil, key: "p"))
}
return ids
}
func gather_quote_ids(our_pubkey: String, from: NostrEvent) -> [ReferencedId] {
var ids: [ReferencedId] = [.q(from.id)]
if from.pubkey != our_pubkey { if from.pubkey != our_pubkey {
ids.append(ReferencedId(ref_id: from.pubkey, relay_id: nil, key: "p")) ids.append(ReferencedId(ref_id: from.pubkey, relay_id: nil, key: "p"))
} }
@@ -789,14 +624,14 @@ func event_to_json(ev: NostrEvent) -> String {
return str return str
} }
func decrypt_dm(_ privkey: String?, pubkey: String, content: String, encoding: EncEncoding) -> String? { func decrypt_dm(_ privkey: String?, pubkey: String, content: String) -> String? {
guard let privkey = privkey else { guard let privkey = privkey else {
return nil return nil
} }
guard let shared_sec = get_shared_secret(privkey: privkey, pubkey: pubkey) else { guard let shared_sec = get_shared_secret(privkey: privkey, pubkey: pubkey) else {
return nil return nil
} }
guard let dat = (encoding == .base64 ? decode_dm_base64(content) : decode_dm_bech32(content)) else { guard let dat = decode_dm_base64(content) else {
return nil return nil
} }
guard let dat = aes_decrypt(data: dat.content, iv: dat.iv, shared_sec: shared_sec) else { guard let dat = aes_decrypt(data: dat.content, iv: dat.iv, shared_sec: shared_sec) else {
@@ -805,13 +640,6 @@ func decrypt_dm(_ privkey: String?, pubkey: String, content: String, encoding: E
return String(data: dat, encoding: .utf8) return String(data: dat, encoding: .utf8)
} }
func decrypt_note(our_privkey: String, their_pubkey: String, enc_note: String, encoding: EncEncoding) -> NostrEvent? {
guard let dec = decrypt_dm(our_privkey, pubkey: their_pubkey, content: enc_note, encoding: encoding) else {
return nil
}
return decode_nostr_event_json(json: dec)
}
func get_shared_secret(privkey: String, pubkey: String) -> [UInt8]? { func get_shared_secret(privkey: String, pubkey: String) -> [UInt8]? {
guard let privkey_bytes = try? privkey.bytes else { guard let privkey_bytes = try? privkey.bytes else {
@@ -857,39 +685,6 @@ struct DirectMessageBase64 {
let iv: [UInt8] let iv: [UInt8]
} }
func encode_dm_bech32(content: [UInt8], iv: [UInt8]) -> String {
let content_bech32 = bech32_encode(hrp: "pzap", content)
let iv_bech32 = bech32_encode(hrp: "iv", iv)
return content_bech32 + "_" + iv_bech32
}
func decode_dm_bech32(_ all: String) -> DirectMessageBase64? {
let parts = all.split(separator: "_")
guard parts.count == 2 else {
return nil
}
let content_bech32 = String(parts[0])
let iv_bech32 = String(parts[1])
guard let content_tup = try? bech32_decode(content_bech32) else {
return nil
}
guard let iv_tup = try? bech32_decode(iv_bech32) else {
return nil
}
guard content_tup.hrp == "pzap" else {
return nil
}
guard iv_tup.hrp == "iv" else {
return nil
}
return DirectMessageBase64(content: content_tup.data.bytes, iv: iv_tup.data.bytes)
}
func encode_dm_base64(content: [UInt8], iv: [UInt8]) -> String { func encode_dm_base64(content: [UInt8], iv: [UInt8]) -> String {
let content_b64 = base64_encode(content) let content_b64 = base64_encode(content)
let iv_b64 = base64_encode(iv) let iv_b64 = base64_encode(iv)
@@ -1018,8 +813,8 @@ func last_etag(tags: [[String]]) -> String? {
return e return e
} }
func inner_event_or_self(ev: NostrEvent, cache: EventCache) -> NostrEvent { func inner_event_or_self(ev: NostrEvent) -> NostrEvent {
guard let inner_ev = ev.get_inner_event(cache: cache) else { guard let inner_ev = ev.inner_event else {
return ev return ev
} }
@@ -1054,7 +849,7 @@ func first_eref_mention(ev: NostrEvent, privkey: String?) -> Mention? {
extension [ReferencedId] { extension [ReferencedId] {
var pRefs: [ReferencedId] { var pRefs: [ReferencedId] {
get { get {
Set(self).filter { ref in self.filter { ref in
ref.key == "p" ref.key == "p"
} }
} }
+5 -9
View File
@@ -37,23 +37,19 @@ struct NostrFilter: Codable, Equatable {
} }
public static func filter_hashtag(_ htags: [String]) -> NostrFilter { public static func filter_hashtag(_ htags: [String]) -> NostrFilter {
return NostrFilter(ids: nil, kinds: nil, referenced_ids: nil, pubkeys: nil, since: nil, until: nil, authors: nil, hashtag: htags.map { $0.lowercased() }) return NostrFilter(ids: nil, kinds: nil, referenced_ids: nil, pubkeys: nil, since: nil, until: nil, authors: nil, hashtag: htags)
} }
public static var filter_text: NostrFilter { public static var filter_text: NostrFilter {
return filter_kinds([NostrKind.text.rawValue]) return filter_kinds([1])
} }
public static func filter_ids(_ ids: [String]) -> NostrFilter {
return NostrFilter(ids: ids, kinds: nil, referenced_ids: nil, pubkeys: nil, since: nil, until: nil, authors: nil, hashtag: nil)
}
public static var filter_profiles: NostrFilter { public static var filter_profiles: NostrFilter {
return filter_kinds([NostrKind.metadata.rawValue]) return filter_kinds([0])
} }
public static var filter_contacts: NostrFilter { public static var filter_contacts: NostrFilter {
return filter_kinds([NostrKind.contacts.rawValue]) return filter_kinds([3])
} }
public static func filter_authors(_ authors: [String]) -> NostrFilter { public static func filter_authors(_ authors: [String]) -> NostrFilter {
-1
View File
@@ -21,5 +21,4 @@ enum NostrKind: Int {
case chat = 42 case chat = 42
case list = 30000 case list = 30000
case zap = 9735 case zap = 9735
case zap_request = 9734
} }
+1 -4
View File
@@ -127,9 +127,6 @@ func decode_nostr_uri(_ s: String) -> NostrLink? {
var uri = s.replacingOccurrences(of: "nostr://", with: "") var uri = s.replacingOccurrences(of: "nostr://", with: "")
uri = uri.replacingOccurrences(of: "nostr:", with: "") uri = uri.replacingOccurrences(of: "nostr:", with: "")
uri = uri.replacingOccurrences(of: "damus://", with: "")
uri = uri.replacingOccurrences(of: "damus:", with: "")
let parts = uri.split(separator: ":") let parts = uri.split(separator: ":")
.reduce(into: Array<String>()) { acc, str in .reduce(into: Array<String>()) { acc, str in
guard let decoded = str.removingPercentEncoding else { guard let decoded = str.removingPercentEncoding else {
@@ -140,7 +137,7 @@ func decode_nostr_uri(_ s: String) -> NostrLink? {
} }
if tag_is_hashtag(parts) { if tag_is_hashtag(parts) {
return .filter(NostrFilter.filter_hashtag([parts[1]])) return .filter(NostrFilter.filter_hashtag([parts[1].lowercased()]))
} }
if let rid = tag_to_refid(parts) { if let rid = tag_to_refid(parts) {
+25
View File
@@ -0,0 +1,25 @@
//
// NostrMetadata.swift
// damus
//
// Created by William Casarin on 2022-05-21.
//
import Foundation
struct NostrMetadata: Codable {
let display_name: String?
let name: String?
let about: String?
let website: String?
let nip05: String?
let picture: String?
let banner: String?
let lud06: String?
let lud16: String?
}
func create_account_to_metadata(_ model: CreateAccountModel) -> NostrMetadata {
return NostrMetadata(display_name: model.real_name, name: model.nick_name, about: model.about, website: nil, nip05: nil, picture: nil, banner: nil, lud06: nil, lud16: nil)
}
+1 -24
View File
@@ -7,22 +7,13 @@
import Foundation import Foundation
struct CommandResult {
let event_id: String
let ok: Bool
let msg: String
}
enum NostrResponse: Decodable { enum NostrResponse: Decodable {
case event(String, NostrEvent) case event(String, NostrEvent)
case notice(String) case notice(String)
case eose(String) case eose(String)
case ok(CommandResult)
var subid: String? { var subid: String? {
switch self { switch self {
case .ok(_):
return nil
case .event(let sub_id, _): case .event(let sub_id, _):
return sub_id return sub_id
case .eose(let sub_id): case .eose(let sub_id):
@@ -57,23 +48,9 @@ enum NostrResponse: Decodable {
let sub_id = try container.decode(String.self) let sub_id = try container.decode(String.self)
self = .eose(sub_id) self = .eose(sub_id)
return return
} else if typ == "OK" {
var cr: CommandResult
do {
let event_id = try container.decode(String.self)
let ok = try container.decode(Bool.self)
let msg = try container.decode(String.self)
cr = CommandResult(event_id: event_id, ok: ok, msg: msg)
} catch {
print(error)
throw error
}
self = .ok(cr)
return
//ev.pow = count_hash_leading_zero_bits(ev.id)
} }
throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "expected EVENT, NOTICE or OK, got \(typ)")) throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "expected EVENT or NOTICE, got \(typ)"))
} }
} }
-1
View File
@@ -12,7 +12,6 @@ import UIKit
class Profiles { class Profiles {
var profiles: [String: TimestampedProfile] = [:] var profiles: [String: TimestampedProfile] = [:]
var validated: [String: NIP05] = [:] var validated: [String: NIP05] = [:]
var nip05_pubkey: [String: String] = [:]
var zappers: [String: String] = [:] var zappers: [String: String] = [:]
func is_validated(_ pk: String) -> NIP05? { func is_validated(_ pk: String) -> NIP05? {
+6 -4
View File
@@ -14,8 +14,8 @@ public struct RelayInfo: Codable {
static let rw = RelayInfo(read: true, write: true) static let rw = RelayInfo(read: true, write: true)
} }
public struct RelayDescriptor { public struct RelayDescriptor: Codable {
public let url: RelayURL public let url: URL
public let info: RelayInfo public let info: RelayInfo
} }
@@ -52,12 +52,14 @@ class Relay: Identifiable {
let descriptor: RelayDescriptor let descriptor: RelayDescriptor
let connection: RelayConnection let connection: RelayConnection
var last_pong: UInt32
var flags: Int var flags: Int
init(descriptor: RelayDescriptor, connection: RelayConnection) { init(descriptor: RelayDescriptor, connection: RelayConnection) {
self.flags = 0 self.flags = 0
self.descriptor = descriptor self.descriptor = descriptor
self.connection = connection self.connection = connection
self.last_pong = 0
} }
func mark_broken() { func mark_broken() {
@@ -79,6 +81,6 @@ enum RelayError: Error {
case RelayNotFound case RelayNotFound
} }
func get_relay_id(_ url: RelayURL) -> String { func get_relay_id(_ url: URL) -> String {
return url.url.absoluteString return url.absoluteString
} }
+68 -96
View File
@@ -5,82 +5,57 @@
// Created by William Casarin on 2022-04-02. // Created by William Casarin on 2022-04-02.
// //
import Combine
import Foundation import Foundation
import Starscream
enum NostrConnectionEvent { enum NostrConnectionEvent {
case ws_event(WebSocketEvent) case ws_event(WebSocketEvent)
case nostr_event(NostrResponse) case nostr_event(NostrResponse)
} }
public struct RelayURL: Hashable { class RelayConnection: WebSocketDelegate {
private(set) var url: URL var isConnected: Bool = false
var isConnecting: Bool = false
var id: String { var isReconnecting: Bool = false
return url.absoluteString var last_connection_attempt: Double = 0
} var socket: WebSocket
var handleEvent: (NostrConnectionEvent) -> ()
init?(_ str: String) { let url: URL
guard let url = URL(string: str) else {
return nil
}
guard let scheme = url.scheme else {
return nil
}
guard scheme == "ws" || scheme == "wss" else {
return nil
}
self.url = url
}
}
final class RelayConnection { init(url: URL, handleEvent: @escaping (NostrConnectionEvent) -> ()) {
private(set) var isConnected = false
private(set) var isConnecting = false
private(set) var last_connection_attempt: TimeInterval = 0
private lazy var socket = WebSocket(url.url)
private var subscriptionToken: AnyCancellable?
private var handleEvent: (NostrConnectionEvent) -> ()
private let url: RelayURL
init(url: RelayURL, handleEvent: @escaping (NostrConnectionEvent) -> ()) {
self.url = url self.url = url
self.handleEvent = handleEvent self.handleEvent = handleEvent
// just init, we don't actually use this one
self.socket = make_websocket(url: url)
} }
func connect(force: Bool = false) { func reconnect() {
if !force && (isConnected || isConnecting) { if self.isConnected {
self.isReconnecting = true
self.disconnect()
} else {
// we're already disconnected, so just connect
self.connect(force: true)
}
}
func connect(force: Bool = false){
if !force && (self.isConnected || self.isConnecting) {
return return
} }
var req = URLRequest(url: self.url)
req.timeoutInterval = 5
socket = make_websocket(url: url)
socket.delegate = self
isConnecting = true isConnecting = true
last_connection_attempt = Date().timeIntervalSince1970 last_connection_attempt = Date().timeIntervalSince1970
subscriptionToken = socket.subject
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
switch completion {
case .failure(let error):
self?.receive(event: .error(error))
case .finished:
self?.receive(event: .disconnected(.normalClosure, nil))
}
} receiveValue: { [weak self] event in
self?.receive(event: event)
}
socket.connect() socket.connect()
} }
func disconnect() { func disconnect() {
socket.disconnect() socket.disconnect()
subscriptionToken = nil
isConnected = false isConnected = false
isConnecting = false isConnecting = false
} }
@@ -90,46 +65,33 @@ final class RelayConnection {
print("failed to encode nostr req: \(req)") print("failed to encode nostr req: \(req)")
return return
} }
socket.send(.string(req))
socket.write(string: req)
} }
private func receive(event: WebSocketEvent) { func didReceive(event: WebSocketEvent, client: WebSocket) {
switch event { switch event {
case .connected: case .connected:
self.isConnected = true self.isConnected = true
self.isConnecting = false self.isConnecting = false
case .message(let message):
self.receive(message: message) case .disconnected:
case .disconnected(let closeCode, let reason): self.isConnecting = false
if closeCode != .normalClosure { self.isConnected = false
print("⚠️ Warning: RelayConnection (\(self.url)) closed with code \(closeCode), reason: \(String(describing: reason))") if self.isReconnecting {
self.isReconnecting = false
self.connect()
} }
isConnected = false
isConnecting = false case .cancelled: fallthrough
reconnect() case .error:
case .error(let error): self.isConnecting = false
print("⚠️ Warning: RelayConnection (\(self.url)) error: \(error)") self.isConnected = false
isConnected = false
isConnecting = false case .text(let txt):
reconnect() if txt.count > 2000 {
}
self.handleEvent(.ws_event(event))
}
func reconnect() {
guard !isConnecting else {
return // we're already trying to connect
}
disconnect()
connect()
}
private func receive(message: URLSessionWebSocketTask.Message) {
switch message {
case .string(let messageString):
if messageString.utf8.count > 2000 {
DispatchQueue.global(qos: .default).async { DispatchQueue.global(qos: .default).async {
if let ev = decode_nostr_event(txt: messageString) { if let ev = decode_nostr_event(txt: txt) {
DispatchQueue.main.async { DispatchQueue.main.async {
self.handleEvent(.nostr_event(ev)) self.handleEvent(.nostr_event(ev))
} }
@@ -137,19 +99,22 @@ final class RelayConnection {
} }
} }
} else { } else {
if let ev = decode_nostr_event(txt: messageString) { if let ev = decode_nostr_event(txt: txt) {
handleEvent(.nostr_event(ev)) handleEvent(.nostr_event(ev))
return return
} }
} }
case .data(let messageData):
if let messageString = String(data: messageData, encoding: .utf8) { print("decode failed for \(txt)")
receive(message: .string(messageString)) // TODO: trigger event error
}
@unknown default: default:
print("An unexpected URLSessionWebSocketTask.Message was received.") break
} }
handleEvent(.ws_event(event))
} }
} }
func make_nostr_req(_ req: NostrRequest) -> String? { func make_nostr_req(_ req: NostrRequest) -> String? {
@@ -173,7 +138,7 @@ func make_nostr_push_event(ev: NostrEvent) -> String? {
} }
func make_nostr_unsubscribe_req(_ sub_id: String) -> String? { func make_nostr_unsubscribe_req(_ sub_id: String) -> String? {
"[\"CLOSE\",\"\(sub_id)\"]" return "[\"CLOSE\",\"\(sub_id)\"]"
} }
func make_nostr_subscription_req(_ filters: [NostrFilter], sub_id: String) -> String? { func make_nostr_subscription_req(_ filters: [NostrFilter], sub_id: String) -> String? {
@@ -190,3 +155,10 @@ func make_nostr_subscription_req(_ filters: [NostrFilter], sub_id: String) -> St
req += "]" req += "]"
return req return req
} }
func make_websocket(url: URL) -> WebSocket {
let req = URLRequest(url: url)
//req.setValue("chat,superchat", forHTTPHeaderField: "Sec-WebSocket-Protocol")
return WebSocket(request: req, compressionHandler: .none)
}
+21 -30
View File
@@ -6,7 +6,6 @@
// //
import Foundation import Foundation
import Network
struct SubscriptionId: Identifiable, CustomStringConvertible { struct SubscriptionId: Identifiable, CustomStringConvertible {
let id: String let id: String
@@ -45,24 +44,7 @@ class RelayPool {
var request_queue: [QueuedRequest] = [] var request_queue: [QueuedRequest] = []
var seen: Set<String> = Set() var seen: Set<String> = Set()
var counts: [String: UInt64] = [:] var counts: [String: UInt64] = [:]
private let network_monitor = NWPathMonitor()
private let network_monitor_queue = DispatchQueue(label: "io.damus.network_monitor")
private var last_network_status: NWPath.Status = .unsatisfied
init() {
network_monitor.pathUpdateHandler = { [weak self] path in
if (path.status == .satisfied || path.status == .requiresConnection) && self?.last_network_status != path.status {
DispatchQueue.main.async {
self?.connect_to_disconnected()
}
}
self?.last_network_status = path.status
}
network_monitor.start(queue: network_monitor_queue)
}
var descriptors: [RelayDescriptor] { var descriptors: [RelayDescriptor] {
relays.map { $0.descriptor } relays.map { $0.descriptor }
} }
@@ -70,10 +52,6 @@ class RelayPool {
var num_connecting: Int { var num_connecting: Int {
return relays.reduce(0) { n, r in n + (r.connection.isConnecting ? 1 : 0) } return relays.reduce(0) { n, r in n + (r.connection.isConnecting ? 1 : 0) }
} }
var num_connected: Int {
return relays.reduce(0) { n, r in n + (r.connection.isConnected ? 1 : 0) }
}
func remove_handler(sub_id: String) { func remove_handler(sub_id: String) {
self.handlers = handlers.filter { $0.sub_id != sub_id } self.handlers = handlers.filter { $0.sub_id != sub_id }
@@ -106,7 +84,7 @@ class RelayPool {
} }
} }
func add_relay(_ url: RelayURL, info: RelayInfo) throws { func add_relay(_ url: URL, info: RelayInfo) throws {
let relay_id = get_relay_id(url) let relay_id = get_relay_id(url)
if get_relay(relay_id) != nil { if get_relay(relay_id) != nil {
throw RelayError.RelayAlreadyExists throw RelayError.RelayAlreadyExists
@@ -124,11 +102,11 @@ class RelayPool {
for relay in relays { for relay in relays {
let c = relay.connection let c = relay.connection
let is_connecting = c.isConnecting let is_connecting = c.isReconnecting || c.isConnecting
if is_connecting && (Date.now.timeIntervalSince1970 - c.last_connection_attempt) > 5 { if is_connecting && (Date.now.timeIntervalSince1970 - c.last_connection_attempt) > 5 {
print("stale connection detected (\(relay.descriptor.url.url.absoluteString)). retrying...") print("stale connection detected (\(relay.descriptor.url.absoluteString)). retrying...")
relay.connection.reconnect() relay.connection.connect(force: true)
} else if relay.is_broken || is_connecting || c.isConnected { } else if relay.is_broken || is_connecting || c.isConnected {
continue continue
} else { } else {
@@ -226,6 +204,19 @@ class RelayPool {
relays.first(where: { $0.id == id }) relays.first(where: { $0.id == id })
} }
func record_last_pong(relay_id: String, event: NostrConnectionEvent) {
if case .ws_event(let ws_event) = event {
if case .pong = ws_event {
for relay in relays {
if relay.id == relay_id {
relay.last_pong = UInt32(Date.now.timeIntervalSince1970)
return
}
}
}
}
}
func run_queue(_ relay_id: String) { func run_queue(_ relay_id: String) {
self.request_queue = request_queue.reduce(into: Array<QueuedRequest>()) { (q, req) in self.request_queue = request_queue.reduce(into: Array<QueuedRequest>()) { (q, req) in
guard req.relay == relay_id else { guard req.relay == relay_id else {
@@ -255,6 +246,7 @@ class RelayPool {
} }
func handle_event(relay_id: String, event: NostrConnectionEvent) { func handle_event(relay_id: String, event: NostrConnectionEvent) {
record_last_pong(relay_id: relay_id, event: event)
record_seen(relay_id: relay_id, event: event) record_seen(relay_id: relay_id, event: event)
// run req queue when we reconnect // run req queue when we reconnect
@@ -264,6 +256,7 @@ class RelayPool {
} }
} }
// handle reconnect logic, etc?
for handler in handlers { for handler in handlers {
handler.callback(relay_id, event) handler.callback(relay_id, event)
} }
@@ -271,10 +264,8 @@ class RelayPool {
} }
func add_rw_relay(_ pool: RelayPool, _ url: String) { func add_rw_relay(_ pool: RelayPool, _ url: String) {
guard let url = RelayURL(url) else { let url_ = URL(string: url)!
return try? pool.add_relay(url_, info: RelayInfo.rw)
}
try? pool.add_relay(url, info: RelayInfo.rw)
} }
-87
View File
@@ -1,87 +0,0 @@
//
// WebSocket.swift
// damus
//
// Created by Bryan Montz on 4/13/23.
//
import Combine
import Foundation
enum WebSocketEvent {
case connected
case message(URLSessionWebSocketTask.Message)
case disconnected(URLSessionWebSocketTask.CloseCode, String?)
case error(Error)
}
final class WebSocket: NSObject, URLSessionWebSocketDelegate {
private let url: URL
private let session: URLSession
private lazy var webSocketTask: URLSessionWebSocketTask = {
let task = session.webSocketTask(with: url)
task.delegate = self
return task
}()
let subject = PassthroughSubject<WebSocketEvent, Never>()
init(_ url: URL, session: URLSession = .shared) {
self.url = url
self.session = session
}
func connect() {
resume()
}
func disconnect(closeCode: URLSessionWebSocketTask.CloseCode = .normalClosure, reason: Data? = nil) {
webSocketTask.cancel(with: closeCode, reason: reason)
// reset after disconnecting to be ready for reconnecting
let task = session.webSocketTask(with: url)
task.delegate = self
webSocketTask = task
let reason_str: String?
if let reason {
reason_str = String(data: reason, encoding: .utf8)
} else {
reason_str = nil
}
subject.send(.disconnected(closeCode, reason_str))
}
func send(_ message: URLSessionWebSocketTask.Message) {
webSocketTask.send(message) { [weak self] error in
if let error {
self?.subject.send(.error(error))
}
}
}
private func resume() {
webSocketTask.receive { [weak self] result in
switch result {
case .success(let message):
self?.subject.send(.message(message))
self?.resume()
case .failure(let error):
self?.subject.send(.error(error))
}
}
webSocketTask.resume()
}
// MARK: - URLSessionWebSocketDelegate
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol theProtocol: String?) {
subject.send(.connected)
}
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
disconnect(closeCode: closeCode, reason: reason)
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ import Foundation
func created_deleted_account_profile(keypair: FullKeypair) -> NostrEvent { func created_deleted_account_profile(keypair: FullKeypair) -> NostrEvent {
let profile = Profile() var profile = Profile()
profile.deleted = true profile.deleted = true
profile.about = "account deleted" profile.about = "account deleted"
profile.name = "nobody" profile.name = "nobody"
-146
View File
@@ -1,146 +0,0 @@
import UIKit
extension UIImage {
public convenience init?(blurHash: String, size: CGSize, punch: Float = 1) {
guard blurHash.count >= 6 else { return nil }
let sizeFlag = String(blurHash[0]).decode83()
let numY = (sizeFlag / 9) + 1
let numX = (sizeFlag % 9) + 1
let quantisedMaximumValue = String(blurHash[1]).decode83()
let maximumValue = Float(quantisedMaximumValue + 1) / 166
guard blurHash.count == 4 + 2 * numX * numY else { return nil }
let colours: [(Float, Float, Float)] = (0 ..< numX * numY).map { i in
if i == 0 {
let value = String(blurHash[2 ..< 6]).decode83()
return decodeDC(value)
} else {
let value = String(blurHash[4 + i * 2 ..< 4 + i * 2 + 2]).decode83()
return decodeAC(value, maximumValue: maximumValue * punch)
}
}
let width = Int(size.width)
let height = Int(size.height)
let bytesPerRow = width * 3
guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else { return nil }
CFDataSetLength(data, bytesPerRow * height)
guard let pixels = CFDataGetMutableBytePtr(data) else { return nil }
for y in 0 ..< height {
for x in 0 ..< width {
var r: Float = 0
var g: Float = 0
var b: Float = 0
for j in 0 ..< numY {
for i in 0 ..< numX {
let basis = cos(Float.pi * Float(x) * Float(i) / Float(width)) * cos(Float.pi * Float(y) * Float(j) / Float(height))
let colour = colours[i + j * numX]
r += colour.0 * basis
g += colour.1 * basis
b += colour.2 * basis
}
}
let intR = UInt8(linearTosRGB(r))
let intG = UInt8(linearTosRGB(g))
let intB = UInt8(linearTosRGB(b))
pixels[3 * x + 0 + y * bytesPerRow] = intR
pixels[3 * x + 1 + y * bytesPerRow] = intG
pixels[3 * x + 2 + y * bytesPerRow] = intB
}
}
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)
guard let provider = CGDataProvider(data: data) else { return nil }
guard let cgImage = CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 24, bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: bitmapInfo, provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent) else { return nil }
self.init(cgImage: cgImage)
}
}
private func decodeDC(_ value: Int) -> (Float, Float, Float) {
let intR = value >> 16
let intG = (value >> 8) & 255
let intB = value & 255
return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB))
}
private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) {
let quantR = value / (19 * 19)
let quantG = (value / 19) % 19
let quantB = value % 19
let rgb = (
signPow((Float(quantR) - 9) / 9, 2) * maximumValue,
signPow((Float(quantG) - 9) / 9, 2) * maximumValue,
signPow((Float(quantB) - 9) / 9, 2) * maximumValue
)
return rgb
}
private func signPow(_ value: Float, _ exp: Float) -> Float {
return copysign(pow(abs(value), exp), value)
}
private func linearTosRGB(_ value: Float) -> Int {
let v = max(0, min(1, value))
if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) }
else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) }
}
private func sRGBToLinear<Type: BinaryInteger>(_ value: Type) -> Float {
let v = Float(Int64(value)) / 255
if v <= 0.04045 { return v / 12.92 }
else { return pow((v + 0.055) / 1.055, 2.4) }
}
private let encodeCharacters: [String] = {
return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) }
}()
private let decodeCharacters: [String: Int] = {
var dict: [String: Int] = [:]
for (index, character) in encodeCharacters.enumerated() {
dict[character] = index
}
return dict
}()
extension String {
func decode83() -> Int {
var value: Int = 0
for character in self {
if let digit = decodeCharacters[String(character)] {
value = value * 83 + digit
}
}
return value
}
}
private extension String {
subscript (offset: Int) -> Character {
return self[index(startIndex, offsetBy: offset)]
}
subscript (bounds: CountableClosedRange<Int>) -> Substring {
let start = index(startIndex, offsetBy: bounds.lowerBound)
let end = index(startIndex, offsetBy: bounds.upperBound)
return self[start...end]
}
subscript (bounds: CountableRange<Int>) -> Substring {
let start = index(startIndex, offsetBy: bounds.lowerBound)
let end = index(startIndex, offsetBy: bounds.upperBound)
return self[start..<end]
}
}
-145
View File
@@ -1,145 +0,0 @@
import UIKit
extension UIImage {
public func blurHash(numberOfComponents components: (Int, Int)) -> String? {
let pixelWidth = Int(round(size.width * scale))
let pixelHeight = Int(round(size.height * scale))
let context = CGContext(
data: nil,
width: pixelWidth,
height: pixelHeight,
bitsPerComponent: 8,
bytesPerRow: pixelWidth * 4,
space: CGColorSpace(name: CGColorSpace.sRGB)!,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)!
context.scaleBy(x: scale, y: -scale)
context.translateBy(x: 0, y: -size.height)
UIGraphicsPushContext(context)
draw(at: .zero)
UIGraphicsPopContext()
guard let cgImage = context.makeImage(),
let dataProvider = cgImage.dataProvider,
let data = dataProvider.data,
let pixels = CFDataGetBytePtr(data) else {
assertionFailure("Unexpected error!")
return nil
}
let width = cgImage.width
let height = cgImage.height
let bytesPerRow = cgImage.bytesPerRow
var factors: [(Float, Float, Float)] = []
for y in 0 ..< components.1 {
for x in 0 ..< components.0 {
let normalisation: Float = (x == 0 && y == 0) ? 1 : 2
let factor = multiplyBasisFunction(pixels: pixels, width: width, height: height, bytesPerRow: bytesPerRow, bytesPerPixel: cgImage.bitsPerPixel / 8, pixelOffset: 0) {
normalisation * cos(Float.pi * Float(x) * $0 / Float(width)) as Float * cos(Float.pi * Float(y) * $1 / Float(height)) as Float
}
factors.append(factor)
}
}
let dc = factors.first!
let ac = factors.dropFirst()
var hash = ""
let sizeFlag = (components.0 - 1) + (components.1 - 1) * 9
hash += sizeFlag.encode83(length: 1)
let maximumValue: Float
if ac.count > 0 {
let actualMaximumValue = ac.map({ max(abs($0.0), abs($0.1), abs($0.2)) }).max()!
let quantisedMaximumValue = Int(max(0, min(82, floor(actualMaximumValue * 166 - 0.5))))
maximumValue = Float(quantisedMaximumValue + 1) / 166
hash += quantisedMaximumValue.encode83(length: 1)
} else {
maximumValue = 1
hash += 0.encode83(length: 1)
}
hash += encodeDC(dc).encode83(length: 4)
for factor in ac {
hash += encodeAC(factor, maximumValue: maximumValue).encode83(length: 2)
}
return hash
}
private func multiplyBasisFunction(pixels: UnsafePointer<UInt8>, width: Int, height: Int, bytesPerRow: Int, bytesPerPixel: Int, pixelOffset: Int, basisFunction: (Float, Float) -> Float) -> (Float, Float, Float) {
var r: Float = 0
var g: Float = 0
var b: Float = 0
let buffer = UnsafeBufferPointer(start: pixels, count: height * bytesPerRow)
for x in 0 ..< width {
for y in 0 ..< height {
let basis = basisFunction(Float(x), Float(y))
r += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 0 + y * bytesPerRow])
g += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 1 + y * bytesPerRow])
b += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 2 + y * bytesPerRow])
}
}
let scale = 1 / Float(width * height)
return (r * scale, g * scale, b * scale)
}
}
private func encodeDC(_ value: (Float, Float, Float)) -> Int {
let roundedR = linearTosRGB(value.0)
let roundedG = linearTosRGB(value.1)
let roundedB = linearTosRGB(value.2)
return (roundedR << 16) + (roundedG << 8) + roundedB
}
private func encodeAC(_ value: (Float, Float, Float), maximumValue: Float) -> Int {
let quantR = Int(max(0, min(18, floor(signPow(value.0 / maximumValue, 0.5) * 9 + 9.5))))
let quantG = Int(max(0, min(18, floor(signPow(value.1 / maximumValue, 0.5) * 9 + 9.5))))
let quantB = Int(max(0, min(18, floor(signPow(value.2 / maximumValue, 0.5) * 9 + 9.5))))
return quantR * 19 * 19 + quantG * 19 + quantB
}
private func signPow(_ value: Float, _ exp: Float) -> Float {
return copysign(pow(abs(value), exp), value)
}
private func linearTosRGB(_ value: Float) -> Int {
let v = max(0, min(1, value))
if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) }
else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) }
}
private func sRGBToLinear<Type: BinaryInteger>(_ value: Type) -> Float {
let v = Float(Int64(value)) / 255
if v <= 0.04045 { return v / 12.92 }
else { return pow((v + 0.055) / 1.055, 2.4) }
}
private let encodeCharacters: [String] = {
return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) }
}()
extension BinaryInteger {
func encode83(length: Int) -> String {
var result = ""
for i in 1 ... length {
let digit = (Int(self) / pow(83, length - i)) % 83
result += encodeCharacters[Int(digit)]
}
return result
}
}
private func pow(_ base: Int, _ exponent: Int) -> Int {
return (0 ..< exponent).reduce(1) { value, _ in value * base }
}
-19
View File
@@ -1,19 +0,0 @@
Copyright (c) 2018 Wolt Enterprises
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

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