Compare commits

...

481 Commits

Author SHA1 Message Date
1767a677bb Fix profile view toolbar alignment bug in iOS 18
Changelog-Fixed: Fix profile view toolbar alignment bug in iOS 18
2024-07-29 10:21:06 -04:00
Daniel D’Aquino
dba1799df0 Merge pull request #2338 from ericholguin/move-posting-timeline
refactor: move posting timeline
2024-07-24 10:53:00 -07:00
Daniel D’Aquino
2db3d7310f Add changelog for v1.8 and 1.9 (14) App Store releases
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-07-19 13:12:52 -07:00
Daniel D’Aquino
10b1cf64ae Merge pull request #2330 from ericholguin/highlight-fixes
highlight: fixes and improvements
2024-07-19 10:50:30 -07:00
ericholguin
afdd3f1d43 refactor: move posting timeline
This patch simply moves the PostingTimelineView into its own file outside of
ContentView.

Signed-off-by: ericholguin <ericholguin@apache.org>
2024-07-15 20:10:13 -06:00
ericholguin
1b8e3fe184 highlight: fixes and improvements
This patch allows highlights to be included in posts as well as removes context
when creating a highlight. Highlights now route as the root and selecting the
highlight in root routes to the highlighted event.

Signed-off-by: ericholguin <ericholguin@apache.org>
2024-07-15 19:58:59 -06:00
William Casarin
8ab1c6a899 restore localization for custom tabs
Signed-off-by: William Casarin <jb55@jb55.com>
2024-07-15 12:27:04 -07:00
Daniel D’Aquino
e8fae19b97 Version bump to 1.11 2024-07-15 12:19:56 -07:00
William Casarin
63e70605fc Revert "Update README.md"
This reverts commit fa70c376b1.
2024-07-14 21:30:05 -07:00
Daniel D’Aquino
35df9f7ab7 Add support for OnlyZaps mode on the new chat thread
With this commit, long-presses on chat bubbles will now reveal a zap
sheet if they are on OnlyZaps mode and have zaps unlocked.

Users without OnlyZaps or with Zaps blocked will continue to see the
emoji reaction sheet

Closes: https://github.com/damus-io/damus/issues/2327
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-07-14 21:26:03 -07:00
Daniel D’Aquino
605d88add1 Fix issue where very long names would appear in two lines on the chat event view
Closes: https://github.com/damus-io/damus/issues/2329
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-07-14 21:26:03 -07:00
Daniel D’Aquino
2b0a7d126d Add missing mention view from chat event bubble view
This commit adds event mentions to the chat bubbles.

Testing
-------

PASS

Damus: This commit
Device: iPhone 15 simulator
iOS: 17.5
Coverage:
- Tested referencing an event on a thread reply. Thread reply shows up as expected
- Checked appearance on light and dark mode
- Tapping on the mentioned event takes the user to that event

Closes: https://github.com/damus-io/damus/issues/2309
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-07-14 21:26:03 -07:00
Daniel D’Aquino
6e2c133faa Truncate long text messages on chat event view
Very large messages on the chat event view cause issues with swipe and
long-press interactions, and they might be a nuisance during scrolling.

This commit adds text truncation to the chat event view. The "show
more" button causes the user to navigate to the message, which is
reasonable to avoid overloading too many interactions on the same view
and having a huge text bubble that is difficult to interact with.

Closes: https://github.com/damus-io/damus/issues/2326
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-07-14 21:26:03 -07:00
Daniel D’Aquino
9885ff1912 Reduce minimum width for chat event view
Some users have reported that there is unwanted horizontal padding on
small messages. This was due to the minimum chat event view width. To
address this feedback, the minimum width has been reduced to a very
small amount, so that small messages with no other content can more
tightly hug the inner content.

Closes: https://github.com/damus-io/damus/issues/2312
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-07-14 21:26:03 -07:00
William Casarin
abb818bbd4 Fix crash in profile related to profile updates
Changelog-Fixed: Fix crash on profile page when there are profile updates
Signed-off-by: William Casarin <jb55@jb55.com>
2024-07-14 21:26:03 -07:00
William Casarin
f1dc023e18 fix crash when adding duplicate mute items
Changelog-Fixed: Fix crash when adding duplicate mute items
Signed-off-by: William Casarin <jb55@jb55.com>
2024-07-14 21:26:03 -07:00
William Casarin
4a332c7ffa simplify CustomPicker and fix ios18 runtime error
This fixes a reflection runtime error for our custom picker

Fixes: https://github.com/damus-io/damus/issues/2332
Signed-off-by: William Casarin <jb55@jb55.com>
2024-07-14 21:26:03 -07:00
William Casarin
616f730ae5 flatbuffers: don't crash if there are flatbuffer errors
Some japanese user profiles are breaking the flatbuffer profile
builder for some reason

Changelog-Fixed: Fix pretty bad crash when building flatbuffer profiles
Signed-off-by: William Casarin <jb55@jb55.com>
2024-07-14 21:26:03 -07:00
Daniel D’Aquino
164cea96f3 Merge pull request #2325 from tyiu/reactions-view-fix
Fix reactions view to not show reactions from replies on parent note
2024-07-13 11:23:05 -07:00
alltheseas
fa70c376b1 Update README.md
Added NIP-70 to NIP list
2024-07-10 01:02:46 +03:00
847f31f5a6 Fix reactions view to not show reactions from replies on parent note
Changelog-Fixed: Fix reactions view to not show reactions from replies on parent note
2024-07-06 19:07:33 -04:00
Daniel D’Aquino
fd130b78e7 Merge pull request #2308 from ericholguin/simplify-onboarding
ux: Simplify Onboarding
2024-07-05 12:04:59 -07:00
Daniel D’Aquino
0be0273121 Update push notification device token address
This commit sets up the correct server address to send device token
notifications to.

Testing
-------

PASS

Device: iPhone 13 Mini
iOS: 17.5
Damus: This commit
strfry-push-notify: 6c52129ab52f37f6686b1a3d1d0d8b478de9e60f
Setup:
- strfry-push-notify and notification device token server setup on the real damus server
- APNS environment setup to development on the server (temporarily)
- Developer settings turned on
- Experimental push notifications support turned ON
- "Send device tokens to localhost" setting turned OFF
- Notification mode in notification settings set to PUSH notifications
Steps:
1. Get a simulator up and running and connected to the Damus relay
2. Send a DM to the main device under test.
3. Check if push notification arrives even with Damus closed. PASS

Closes: https://github.com/damus-io/damus/issues/1733
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
2024-07-03 11:08:13 -07:00
Daniel D’Aquino
b349de22b7 Merge changes from 'release_1.9' 2024-07-01 11:20:30 -07:00
Daniel D’Aquino
cc2d196705 Merge pull request #2310 from tyiu/mute-user-bug
Fix missing Mute button in profile view menu
2024-07-01 11:16:33 -07:00
Daniel D’Aquino
53be29efc2 Fix build error on the test target
This commit is a trivial fix for a build error on the test target

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-06-24 15:25:35 -07:00
Daniel D’Aquino
529ee63f29 Merge pull request #2295 from tyiu/change-emoji-component
Revamp emoji picker to be less error-prone and add search, frequently used, and multiple skin tone support capabilities
2024-06-24 15:23:27 -07:00
Daniel D’Aquino
490e8ec1fb Fix build error on the test target
This commit is a trivial fix for a build error on the test target

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-06-24 14:38:56 -07:00
Daniel D’Aquino
df267ffd04 Version bump to 1.10 (1) 2024-06-24 11:51:17 -07:00
Daniel D’Aquino
b771e8f49a Merge pull request #2295 from tyiu/change-emoji-component
Revamp emoji picker to be less error-prone and add search, frequently used, and multiple skin tone support capabilities
2024-06-24 11:38:31 -07:00
Daniel D’Aquino
a88e80a346 Merge pull request #2307 from damus-io/review_highlights_2024-06-19_rebased
Highlights (rebased to solve merge conflicts + minor tweaks)
2024-06-24 11:30:59 -07:00
8ac9863765 Fix missing Mute button in profile view menu
Changelog-Fixed: Fix missing Mute button in profile view menu
2024-06-23 22:40:33 -04:00
ericholguin
4a851501a1 ux: Simplify Onboarding
This patch simplifies the onboarding flow based on Jeroen's suggestions.

Setup view:
  - Removes extra nostr information
  - Only shows two buttons, create account and sign in.

Create Account view:
  - When a user uploads a photo it is now displayed
  - Name is now required
  - Public key is now hidden
  - Create account model has been updated to match metadata

Save Keys view:
  - Removes the requirement to copy the nsec
  - Simplified explanation
  - Only shows two buttons, save and not now

Testing
——
iPhone 15 Pro Max (17.0) Light Mode:
https://v.nostr.build/3P75x.mp4

iPhone SE (3rd generation) (16.4) Dark Mode:
https://v.nostr.build/wGBQL.mp4

——

Changelog-Fixed: Create Account model now uses correct metadata
Changelog-Changed: Onboarding design
2024-06-22 11:46:53 -06:00
Daniel D’Aquino
4ccfe81558 Allow highlighting to be disabled on SelectableText
Changed the interface of SelectableText to allow highlighting to be
disabled in places where it is not applicable (For example, on the
AboutView).

This prevents the need for adding dummy events in places where
highlighting is not applicable, preventing the user from making bad
highlights.

Testing
-------

PASS

Device: iPhone 13 mini
iOS: 17.5
Damus: This version
Steps:
1. Go to a user profile and select some text in their bio. The "highlight" option should not be present.
2. Go to a note and select some text. The "highlight" option should be available
2024-06-21 14:18:36 -07:00
Daniel D’Aquino
e7ed9dfe86 Small tweaks for better code safety 2024-06-21 14:11:45 -07:00
ericholguin
0dce7aea45 ux: Create Highlights
This patch allows users to create a highlight in Damus.
This is done by modifying the menu options when text is selected, including a custom highlight option.
This option presents a sheet to the user of what they are highlighting with a cancel or post button.
If they press Post the sheet will dismiss and their highlight will be posted.

Testing
——
iPhone 15 Pro Max (17.3.1) Dark Mode:
https://v.nostr.build/wGDnx.mp4

iPhone SE (3rd generation) (16.4) Light Mode:
https://v.nostr.build/xEK0e.mp4

——

Changelog-Added: Ability to create highlights

Signed-off-by: ericholguin <ericholguin@apache.org>
2024-06-21 12:00:44 -07:00
ericholguin
6376c61bad Highlights
This patch adds highlights (NIP-84) to Damus.

Kind 9802 are handled by all the necessary models.
We show highlighted events, longform events, and url references.
Url references also leverage text fragments to take the user to the highlighted text.

Testing
——
iPhone 15 Pro Max (17.0) Dark Mode:
https://v.nostr.build/oM6DW.mp4

iPhone 15 Pro Max (17.0) Light Mode:
https://v.nostr.build/BRrmP.mp4

iPhone SE (3rd generation) (16.4) Light Mode:
https://v.nostr.build/6GzKa.mp4
——

Closes: https://github.com/damus-io/damus/issues/2172
Closes: https://github.com/damus-io/damus/issues/1772
Closes: https://github.com/damus-io/damus/issues/1773
Closes: https://github.com/damus-io/damus/issues/2173
Closes: https://github.com/damus-io/damus/issues/2175
Changelog-Added: Highlights (NIP-84)

PATCH CHANGELOG:
V1 -> V2: addressed review comments highlights are now truncated and highlight label shown in Thread view
V2 -> V3: handle case where highlight context is smaller than the highlight content

Signed-off-by: ericholguin <ericholguin@apache.org>
2024-06-21 12:00:44 -07:00
bdd1403a7d Merge remote-tracking branch 'upstream/master' into change-emoji-component 2024-06-19 18:33:58 -04:00
Daniel D’Aquino
23c3130a82 New chat thread view
This commit changes the thread view to a new UX concept where children views of the selected view are now presented as chat bubbles, and the entire tree of conversation is shown flattened. New interactions, layout, and design changes have been introduced to revamp the user experience.

Testing
-------

Device: A mix of iPhone physical devices and simulator
iOS: A mix of iOS 17 versions
Damus: A mix of versions leading up to this one.
Coverage:
1. Unit tests are passing
2. A select few users have been using prototypes versions of this as their daily driver
3. Layout tested with an eclectic mix of threads
4. Posting new notes to the thread works
5. Clicking on reply quote view takes user to the mentioned message with a momentary visible highlight
6. Swipe actions work
7. Long press on chat bubbles works and shows emoji selector. Adding emoji sends the reaction
8. Clicking on notes selects them with an easy to follow transition

Known issues:
1. The text on the reply quote view occasionally appears to be off-center (in about 10% of occurrences). The cause is still unknown
2. Long press will still show the emoji keyboard even if user is on "onlyzaps" mode
3. Quoted events are not rendered on chat bubbles. When user posts a quoted event with no text, that could lead to confusion

Closes: https://github.com/damus-io/damus/issues/1126
Changelog-Added: Completely new threads experience that is easier and more pleasant to use
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-06-19 12:33:15 -07:00
9172102f4d Revamp emoji picker to be less error-prone and add search, frequently used, and multiple skin tone support capabilities
Changelog-Added: Revamp emoji picker to be less error-prone and add search, frequently used, and multiple skin tone support capabilities
2024-06-17 23:53:19 -04:00
ericholguin
8bcd8317f1 Fix persistent wallet on log out
This patch simply disconects the wallet connection when a user logs out.

Changelog-Fixed: Fixed wallet not disconnecting when a user logs out

Signed-off-by: ericholguin <ericholguin@apache.org>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-06-10 05:34:30 -07:00
6cd9d7b1da Remove unused Trie and UserSearchCache as they have been superseded by NostrDB 2024-06-09 11:04:06 -04:00
Daniel D’Aquino
2c84184dbd Improve UX feedback around notification mode setting
Changing the notification mode setting requires successfully sending or
revoking the device token to the server. As this is an action that might
fail, it is important to have a clear UX feedback in case this fails.

Testing
--------

PASS

Device: iPhone 15 simulator
iOS: 17.4
Damus: This commit
strfry-push-notify: d6c2ff289c80e0a90874a7499ed6408394659fc9
Coverage:
1. Checked that push notification mode setting is invisible when experimental push notifications mode is disabled
2. Checked that push notification mode setting is visible when experimental push notifications mode is enabled
3. Checked that switching between push and local notifications sends requests to the server
4. Checked that switching to push notification mode will cause local notifications to be suppressed and push notifications will be sent to the APNS server
5. Checked that switching back to local notification mode will cause local notifications to be displayed, and push notifications will NOT be sent to APNS
6. Checked that if the API server is off, switching from local to push notification modes is not possible and shows an error to the user.
7. Checked that sending APNS payload to Apple's test APNS page will actually deliver the push notification successfully.

Closes: https://github.com/damus-io/damus/issues/1704
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
2024-05-24 16:49:09 -07:00
Daniel D’Aquino
901a6fc98f Revoke device token when user switches to local notification mode
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
2024-05-24 16:49:09 -07:00
Daniel D’Aquino
a0f6bdd8d9 Send device token when switching to push notifications mode
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
2024-05-24 16:49:09 -07:00
Daniel D’Aquino
8feb228ea0 Add NIP-98 authentication to push notification client
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
2024-05-24 16:49:09 -07:00
Daniel D’Aquino
b148fb735e Move device token sending logic to its own component
This commit moves the device token logic to a new
PushNotificationClient, to move complexity from this specific feature
away from damusApp.swift

This commit also slightly improves the handling of device tokens, by
caching it on the client struct even if the user is using local
notifications, so that the device token can be sent to the server immediately after
switching to push notifications mode.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
2024-05-24 16:49:09 -07:00
Daniel D’Aquino
0a9bcb6189 Add notification mode setting
This allows the user to switch between local and push notifications

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
2024-05-24 16:49:09 -07:00
Daniel D’Aquino
5a68cfa448 Fallback push notification suppression while we do not have entitlement
We do not have the ability to suppress push notifications yet (we are
waiting to receive the entitlement from Apple)

In the meantime, attempt to fallback gracefully where possible

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
2024-05-24 16:49:09 -07:00
alltheseas
c99aaea598 Add working email hyperlink
Closes: https://github.com/damus-io/damus/issues/2260
Closes: https://github.com/damus-io/damus/prs/2272
Changelog-Changed: Added first aid contact damus support email
Signed-off-by: elsat <npub1zafcms4xya5ap9zr7xxr0jlrtrattwlesytn2s42030lzu0dwlzqpd26k5>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-17 15:38:05 -07:00
Daniel D’Aquino
46185c55d1 Chunk home filters to avoid hitting max filter item limits
When a user is following several accounts, they may get a stale feed
caused by the subscription request being rejected by relays (due to max filter item limits).

This commit implements a fix that gets around the issue by
creating several chunked filters for the home feed event and contact
metadata subscriptions.

This is a short to medium-term practical fix, where we get around the
practical limitations imposed by most relays. In the future we should
work on longer-term solutions, which will likely require protocol improvements

Main Test
---------

Procedure:
1. Login with Elsat's npub (Or some account that follows about 2K people)
2. Check the home feed. There should be fresh notes.

REPRO:
Device: iPhone 15 simulator
iOS: 17.4
Damus: 1.9 (3) (0d9954290a)
Results:
- No fresh notes, most recent post is from several hours ago (Feed is stale)

FIX TEST:
Device: iPhone 15 simulator
iOS: 17.4
Damus: This commit
Results:
- Fresh notes appear, most recent post is from a few seconds ago.

Other testing:
--------------

- New automated test passing
- All other automated tests passing
- Tested scrolling down the feed on these conditions:
  - Device: iPhone 13 Mini
  - iOS: 17.4.1
  - Accounts:
    - One with about 160 contacts and 10 relays (Daniel D’Aquino)
    - One with about 1K+ contacts and 9 relays (Freedom Smuggler)
    - One with about 981 contacts and 6 relays (jb55)
    - Elsat's account (2K+ accounts and 8 relays)
  - Result: None of those were stale

Changelog-Fixed: Fix stale feed issue when follow list is too big
Closes: https://github.com/damus-io/damus/issues/2194
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
2024-05-13 10:56:15 -07:00
William Casarin
52aefc8d64 nip10: simplify and fix reply-to-root bugs
This removes EventRefs alltogether and uses the form we use in Damus
Android.

This simplifies our ThreadReply logic and fixes a reply-to-root bug

Reported-by: NotBiebs <justinbieber@stemstr.app>
Changelog-Fixed: Fix thread bug where a quote isn't picked up as a reply
Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-11 09:19:22 -07:00
William Casarin
8dbdff7ff0 1.9 (4)
Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-10 13:22:20 -07:00
Daniel D’Aquino
784fb20b4f Fix incorrect Privacy manifest plist key
Xcode entered the key description text instead of the actual key code on
our privacy manifest files.

This commit fixes that key.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Link: 20240509233547.69137-1-daniel@daquino.me
Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-10 09:22:03 -07:00
William Casarin
0d9954290a 1.9 (3)
Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-09 15:29:38 -07:00
William Casarin
13a7ee82d0 nip10: be nicer to deprecated nip10
to ease the transition, let's not add more than 2 etags or deprecated
nip10 gets confused

Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-09 15:28:16 -07:00
William Casarin
23138c5e03 v1.9 (2)
Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-09 15:03:58 -07:00
William Casarin
213a622dde version: damus versions should be inherited from the project
Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-09 15:02:24 -07:00
William Casarin
4ac3da7612 events: try nostrdb cache if we don't have one in-memory
Our nip10 logic looks for notes only in the in-memory cache. This means
sometimes threads don't get fully loaded if for whatever reason it's in
the nostrdb cache but not in-memory.

Ideally we would just remove our in-memory cache, but for now let's just
hack it so it falls back to nostrdb and makes an owned note.

Changelog-Fixed: Fixed threads not loading sometimes
Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-09 14:44:22 -07:00
William Casarin
bb1f912f78 nip10: consolidate event_ref logic into ThreadReply
These are overlapping concepts, lets slowly get rid of EventRef

Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-09 14:44:22 -07:00
William Casarin
a190a5e8fb nip10: handle invalid reply-with-no-root
I noticed a few clients do this even though its not valid. Let's handle it.

Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-09 13:49:06 -07:00
William Casarin
514a053dce nip10: marker replies
This should drastically increase compatibility for damus replies in
other clients.

Also filter non-pubkey references when replying so we don't run into the
q-tag bug.

Changelog-Added: Added nip10 marker replies
Changelog-Fixed: Fixed issue where some replies were including the q tag
Fixes: https://github.com/damus-io/damus/issues/2239
Fixes: https://github.com/damus-io/damus/issues/2233
Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-09 13:33:04 -07:00
William Casarin
0b199a18b4 Revert "perf: debounce scroll queue"
This perf change was experimental and probably minor anyways

This reverts commit d49cf5a505.

Fixes: https://github.com/damus-io/damus/issues/2235
Changelog-Fixed: Fixed issue where timeline was scrolling when it isn't supposed to
Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-09 11:11:29 -07:00
William Casarin
23a125ea0f test: add missing mute test file
Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-07 18:13:23 -07:00
Daniel D’Aquino
f406d27507 Add basic word muting automated test
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-07 18:06:08 -07:00
Daniel D’Aquino
ceb6eb03fb Implement cache on MutelistManager
To filter muted events on a timeline, we need to check if an event is
muted. However, we need to do so in a very efficient manner, to avoid performance degradation.

This commit improves performance by caching mute statuses for as long as
the mutelist stays the same.

Testing
--------

PASS

Device: iPhone 13 Mini
iOS: 17.4.1
Damus: This commit
Damus baseline: bb8dba6df6e2534dfb193402399b31a4fae8052d
Steps:
1. Downgrade to baseline version, Run SwiftUI profiler on Xcode instruments
2. Scroll down home feed. Try to notice hangs and microhangs on the UI
3. Upgrade to version under test. Start the same profiler test.
4. Check that hangs/microhangs frequency and severity are roughly the same.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-07 18:05:45 -07:00
Daniel D’Aquino
b917b4e9d6 Apply mute rules to Timeline views by default
Mute rules were not applied in all timeline views. This commit adds code
to filter out muted events on timelines. It also provides an opt-out
parameter if there is ever a need to a timeline without mute filters

Testing
--------

PASS

Device: iPhone 13 Mini
iOS: 17.4.1
Damus: This commit
Coverage:
1. Add a muted keyword
2. Go to home view, check that events with that keyword are now not showing up

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-07 18:05:45 -07:00
Daniel D’Aquino
e981ae247e Mute: Add user_keypair to MutelistManager
The user keypair is necessary to determine whether or not a given event
is muted or not. Previously, the user keypair had to be passed on each
function call as an optional parameter, and word filtering would not
work unless the caller remembered to add the keypair parameter.

All usages of MutelistManager functions indicate that the desired base
keypair is always the same as the DamusState's keypair that owns the
MutelistManager. Therefore, it is simpler and less error prone to simply
pass the keypair to MutelistManager during its initialization.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-07 18:05:45 -07:00
William Casarin
dcd7b5b111 nip10: fix mixed nip10 markers
Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-07 14:07:47 -07:00
William Casarin
a721256e9b nip10: add marker nip10 support when reading notes
We still need to add these when writing notes.

Changelog-Added: Add marker nip10 support when reading notes
Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-07 14:07:39 -07:00
William Casarin
007bcc8687 test: disable broken auth test
Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-07 14:07:14 -07:00
Daniel D’Aquino
ccb94e6d69 Merge pull request #2201 from tyiu/thai
Add Thai as a supported language
2024-05-06 13:06:55 -07:00
William Casarin
3c9fd36654 Merge commit 0a6e40798a ("docs: add NIP04 to readme for encrypted DM's") 2024-05-06 11:54:42 -07:00
Daniel D’Aquino
9a9b5d5f4f Merge Privacy report updates from release branch 'v1.8_relay_fix_and_video_player' 2024-05-06 11:25:52 -07:00
Daniel D’Aquino
d4f041aead Fill up missing Privacy report information for App submission
This commit adds missing privacy report information for both damus and
the notification extension.

It details the reason we use
- File timestamps
- UserDefaults

The reason codes were taken from Apple's documentation: https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api

Testing
--------

PASS

Damus: this commit
Steps:
1. Build app for archival
2. Access the local archive and perform a secondary click
3. Click on "Generate Privacy Report"
4. Open the privacy report PDF. It should show no errors. PASS

Closes: https://github.com/damus-io/damus/issues/2184
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
2024-05-06 11:21:35 -07:00
8133da82c1 Add Thai as a supported language 2024-05-03 21:02:47 -04:00
Daniel D’Aquino
fc8f211da2 Merge pull request #2199 from tyiu/tyiu/search-profile-sort
Refactor UserSearch profile sorting so that it can be used in SearchResultsView
2024-05-03 13:14:16 -07:00
Daniel D’Aquino
cea4922442 Merge release branch 'v1.8_relay_fix_and_video_player'
Manually fixed simple merge conflict in damus.xcodeproj/project.pbxproj
2024-05-03 12:36:58 -07:00
Daniel D’Aquino
669a313f92 Relays: Always respect user's local relay list when present
This commit fixes an issue where the Damus relay (Or other bootstrap relays) would be added to the user's relay list even though they explicitly removed it.

The root cause of the issue lies in the way we load bootstrap relays. The default bootstrap relays would be initially loaded even though the user already has a bootstrap list stored, just in case all the relays on the user list fails. This would cause the app to inadvertently connect to relays that the user did not select whenever there is a connectivity issue with all their listed relays.

The fix is to simply not add the default bootstrap list when the user already has a list stored. We do not need to use bootstrap relays in order to get our relay list, because that list is already stored in both UserDefaults as well as on NostrDB through the user's contact list event. (A contact list which is also locally loaded on startup since the fix related to https://github.com/damus-io/damus/issues/2057)

Issue reproduction + Testing
----------------------------

Procedure:
1. Disconnect from all relays, and disconnect from the Damus relay last.
2. Connect to a local relay (that you control). Connection should be successful.
3. Quit the app completely.
4. Stop the local relay.
5. Restart the app.
6. Go to the relay list view.
7. Check the relay list. It should list the one local relay selected by the user

Issue reproduction:
- Device: iPhone 15 simulator
- iOS: 17.4
- Damus: 1.8 (`97169f4fa276723bfab28ca304953ec206c904d2`)
- Result: ISSUE REPRODUCED
- Details: On step 7, the relay list only lists the Damus relay

Fix test:
- Device: iPhone 15 simulator
- iOS: 17.4
- Damus: This commit
- Result: PASS
- Details: On step 7, the local relay is listed even though connection is unsuccessful. No notes are loaded since no relays were able to connect successfully

Quick regression check
----------------------

PASS

Device: iPhone 15 simulator
iOS: 17.4
Damus: This commit
Steps:
1. Reinstall app from scratch
2. Create a new account, go through onboarding
3. Make sure that new account connects to bootstrap relays. PASS
4. Sign out
5. Sign in with previously existing account (The one from the previous test) (Notice no UserDefaults exists for this user at that point)
6. Make sure relay list is loaded to the latest relay list known to the bootstrap relays (i.e. connects only to the Damus relay) (It cannot recover the latest relay list pointing only to the local relay, since the bootstrap relays have no knowledge about that relay or the contact lists stored there.). PASS

Note: The behavior on step 6 is not a bug, it is an expected limitation. In fact, this behavior is privacy protecting, as the user may not want those public relays from knowing about its connection preference to the local relay (and its address)

Other information
------------------

Q: How is this test using local relays related or equivalent to Tor relay list described in #2186?
A: Those Tor relays need dedicated software (such as Orbot VPN) to be running successfully in order for Damus to make a successful connection to them. If at any moment that VPN stops working, it would trigger the same situation as described in the test above, where all relay connections fail at once.

Q: In #2186, the user reports that the Damus relay is added, but does not describe the Damus relay replacing existing relays. What is the difference?
A: I believe the difference is in the order in which relays are added or removed. We have to remember that the relay we just disconnected from will likely still have version N-1 of our contact list event, where it still includes itself on that list.

Changelog-Fixed: Fix issue where bootstrap relays would inadvertently be added to the user's list on connectivity issues
Closes: https://github.com/damus-io/damus/issues/2186
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
2024-05-03 12:16:59 -07:00
William Casarin
ac7d6c65c7 Merge remote-tracking branch 'github/translations' 2024-05-03 10:53:36 -05:00
transifex-integration[bot]
c15f0454de Translate Localizable.stringsdict in th
100% translated source file: 'Localizable.stringsdict'
on 'th'.
2024-05-03 09:40:22 +00:00
6bddee0354 Refactor UserSearch profile sorting so that it can be used in SearchResultsView 2024-05-02 18:09:55 -04:00
transifex-integration[bot]
43fc662bf6 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2024-05-02 13:59:49 +00:00
transifex-integration[bot]
d2b7878d03 Translate InfoPlist.strings in th
100% translated source file: 'InfoPlist.strings'
on 'th'.
2024-05-02 02:13:52 +00:00
Daniel D’Aquino
c6d9e0b3c9 Fix GIF uploads
This commit fixes GIF uploads and improves GIF support:
- MediaPicker will now skip location data removal processing, as it is not needed on GIF images and causes them to be converted to JPEG images
- The uploader now sets more accurate MIME types on the upload request

Issue Repro
-----------

Device: iPhone 13 Mini
iOS: 17.4.1
Damus: `ada99418f6fcdb1354bc5c1c3f3cc3b4db994ce6`
Steps:
1. Download a GIF from GIPHY to the iOS photo gallery
2. Upload that and attach into a post in Damus
3. Check if GIF is animated.
Results: GIF is not animated. Issue is reproduced.

Testing
-------

PASS

Device: iPhone 13 Mini
iOS: 17.4.1
Damus: this commit
Steps:
1. Create a new post
2. Upload the same GIF as the repro and post
3. Make sure GIF is animated. PASS
4. Create a new post
5. Upload a new GIF image (that has never been uploaded by the user on the app) and post
6. Make sure the GIF is animated on the post. PASS
7. Make sure that JPEGs can still be successfully uploaded. PASS
8. Make sure that MP4s can be uploaded.
9. Make a new post that contains 1 JPEG, 1 MP4 file, and 2 GIF files. Make sure they are all uploaded correctly and all GIF files are animated. PASS

Closes: https://github.com/damus-io/damus/issues/2157
Changelog-Fixed: Fix broken GIF uploads
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
2024-05-01 10:27:05 -07:00
Daniel D’Aquino
97169f4fa2 Fix GIF uploads
This commit fixes GIF uploads and improves GIF support:
- MediaPicker will now skip location data removal processing, as it is not needed on GIF images and causes them to be converted to JPEG images
- The uploader now sets more accurate MIME types on the upload request

Issue Repro
-----------

Device: iPhone 13 Mini
iOS: 17.4.1
Damus: `ada99418f6fcdb1354bc5c1c3f3cc3b4db994ce6`
Steps:
1. Download a GIF from GIPHY to the iOS photo gallery
2. Upload that and attach into a post in Damus
3. Check if GIF is animated.
Results: GIF is not animated. Issue is reproduced.

Testing
-------

PASS

Device: iPhone 13 Mini
iOS: 17.4.1
Damus: this commit
Steps:
1. Create a new post
2. Upload the same GIF as the repro and post
3. Make sure GIF is animated. PASS
4. Create a new post
5. Upload a new GIF image (that has never been uploaded by the user on the app) and post
6. Make sure the GIF is animated on the post. PASS
7. Make sure that JPEGs can still be successfully uploaded. PASS
8. Make sure that MP4s can be uploaded.
9. Make a new post that contains 1 JPEG, 1 MP4 file, and 2 GIF files. Make sure they are all uploaded correctly and all GIF files are animated. PASS

Closes: https://github.com/damus-io/damus/issues/2157
Changelog-Fixed: Fix broken GIF uploads
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
2024-05-01 10:25:19 -07:00
Daniel D’Aquino
862101a3f7 Fix Ghost notifications from Damus Purple Impending expiration
This commit fixes the "ghost notifications" experienced by Purple users
whose membership has expired (or about to expire).

It does that by using a similar mechanism as other notifications to keep
track of the last event date seen on the notifications tab in a
persistent way.

Testing
--------

iOS: 17.4
Device: iPhone 15 simulator
damus-api: bfe6c4240a0b3729724162896f0024a963586f7c
Damus: This commit
Setup:
1. Local Purple server
2. Damus running on local testing mode for Purple
3. An existing but expired Purple account (on the local server)

Steps:
1. Reopen app after pointing to the new server and setting things up.
2. Check that the bell icon shows there is a new notification. PASS
3. Check that purple expiration notifications are visible. PASS
4. Restart app.
5. Check the bell icon. This time there should be no new notifications. PASS
6. Using another account, engage with the primary test account to cause a new notification to appear.
7. After a second or two, the bell icon should indicate there is a new notification from the other user. PASS
8. Switch out and into the app. Check that the bell icon does not indicate any new notifications. PASS
9. Restart the app again. The bell icon should once again NOT indicate any new notifications. PASS

Changelog-Fixed: Fix ghost notifications caused by Purple impending expiration notifications
Closes: https://github.com/damus-io/damus/issues/2158
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
2024-05-01 10:08:54 -07:00
Daniel D’Aquino
a9a2a52881 ui: add First Aid view to settings
also create the contact list reset First Aid action

Automatically detecting whether or not to create a blank contact list
when we could not find any is very tricky. It could mean that no contact
list exists, but it could also mean that a temporary network or relay
outage occurred.

Since resetting the contact list when one already exists is a
destructive action, we should make no assumptions. Instead, we should
provide users the tool to fix it based on their own judgement.

For that reason, the first aid view was created. It detects if no
contact list was found, and in those cases, it gives them an option to
reset (with appropriate warning messages).

Testing 1: Contact list creation robustness
-----------------------------

Setup:
1. Network Link Conditioner installed and configured to this profile:
  - DNS delay: 400 ms
  - Downlink bandwidth: 100 kbps
  - Uplink bandwidth: 50 kbps
  - Packets dropped: 50% (On both uplink and downlink)
  - Delay: 1000 ms (Both uplink and downlink)

Procedure:
1. Turn Network Link conditioner ON
2. Go through the account creation steps
3. At the moment the onboarding follow suggestions screen shows up, quit the app
3. Turn Network Link conditioner OFF
4. Start the app again
5. Verify the home screen. It should present notes from the Damus account (the default follow)
6. Follow someone and wait for 5 seconds
7. Restart app
8. Look at the home feed. Notes from user from step 6 should appear, and that user should appear as being followed by you.

- Repro details:
  - Damus version: ada99418f6
  - Device: iPhone 15 simulator
  - iOS: 17.4
  - Number of runs: 3 times
  - Result: FAILS (issue is reproduced) 3 out of 3 times
- Test details:
  - Damus version: This commit
  - Device: iPhone 15 simulator
  - iOS: 17.4
  - Number of runs: 3 times
  - Result: PASSES all criteria 3 out of 3 times

Testing 2: Contact list First Aid
------------------------------

Setup:
1. Reproduce the issue with the old version as outlined in "Testing 1" above
2. Upgrade to the version in this commit

Steps:
1. Go to Settings > First Aid
2. A button to reset the contact list (and some text for context) should appear. PASS
3. Click on the button. A warning message should appear. PASS
4. Click "cancel". The action should be cancelled and nothing should have changed. PASS
5. Click on the reset button again.
6. Click "Continue" on the warning prompt. The reset button will now show "Contact list has been reset" with a green checkmark. PASS
5. Go back to the home tab. Notes from the Damus account should immediately appear. PASS
6. Try to follow someone and restart the app. Follows should now stick persistently. PASS
7. Go to the First Aid screen again. The reset option should no longer be present. PASS

Changelog-Added: Add First Aid solution for users who do not have a contact list created for their account
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Link: 20240422230912.65056-4-daniel@daquino.me
Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-01 10:01:12 -07:00
Daniel D’Aquino
c8aba00f85 contacts: save first list to storage during onboarding
This commit adds a mechanism to add the contact list to storage as soon
as it is generated, and thus it reduces the risk of poor network
conditions causing issues.

Changelog-Fixed: Improve reliability of contact list creation during onboarding
Closes: https://github.com/damus-io/damus/issues/2057
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Link: 20240422230912.65056-3-daniel@daquino.me
Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-01 09:56:44 -07:00
Daniel D’Aquino
bb321b6e8a contacts: save the users' latest contact event ID
... to a persistent setting, and try to load it from NostrDB on app start.

This commit causes the user's contact list event ID to be saved
persistently as a user-specific setting, and to be loaded immediately
after startup from the local NostrDB instance.

This helps improve reliability around contact lists, since we previously
relied on fetching that contact list from other relays.

Eventually we will not need the event ID to be stored at all, as we will
be able to query NostrDB, but for now having the latest event ID
persistently stored will allow us to get around this limitation in the
cleanest possible way (i.e. without having to store the event itself
into another mechanism, and migrating it later to NostrDB)

Other notes:

- It uses a mechanism similar to other user settings, so it is
  pubkey-specific and should handle login/logout cases

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Link: 20240422230912.65056-2-daniel@daquino.me
Signed-off-by: William Casarin <jb55@jb55.com>
2024-05-01 09:56:30 -07:00
Daniel D’Aquino
6d5a152c17 Fix Ghost notifications from Damus Purple Impending expiration
This commit fixes the "ghost notifications" experienced by Purple users
whose membership has expired (or about to expire).

It does that by using a similar mechanism as other notifications to keep
track of the last event date seen on the notifications tab in a
persistent way.

Testing
--------

iOS: 17.4
Device: iPhone 15 simulator
damus-api: bfe6c4240a0b3729724162896f0024a963586f7c
Damus: This commit
Setup:
1. Local Purple server
2. Damus running on local testing mode for Purple
3. An existing but expired Purple account (on the local server)

Steps:
1. Reopen app after pointing to the new server and setting things up.
2. Check that the bell icon shows there is a new notification. PASS
3. Check that purple expiration notifications are visible. PASS
4. Restart app.
5. Check the bell icon. This time there should be no new notifications. PASS
6. Using another account, engage with the primary test account to cause a new notification to appear.
7. After a second or two, the bell icon should indicate there is a new notification from the other user. PASS
8. Switch out and into the app. Check that the bell icon does not indicate any new notifications. PASS
9. Restart the app again. The bell icon should once again NOT indicate any new notifications. PASS

Changelog-Fixed: Fix ghost notifications caused by Purple impending expiration notifications
Closes: https://github.com/damus-io/damus/issues/2158
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
2024-04-29 17:04:06 -07:00
transifex-integration[bot]
4544d1548c Translate Localizable.strings in zh_TW
100% translated source file: 'Localizable.strings'
on 'zh_TW'.
2024-04-27 09:31:26 +00:00
transifex-integration[bot]
e1b787c7ed Translate Localizable.strings in zh_HK
100% translated source file: 'Localizable.strings'
on 'zh_HK'.
2024-04-27 09:31:21 +00:00
transifex-integration[bot]
108456fb59 Translate Localizable.strings in zh_CN
100% translated source file: 'Localizable.strings'
on 'zh_CN'.
2024-04-27 09:31:13 +00:00
transifex-integration[bot]
0982bfbb56 Translate Localizable.stringsdict in zh_TW
100% translated source file: 'Localizable.stringsdict'
on 'zh_TW'.
2024-04-27 08:44:47 +00:00
transifex-integration[bot]
a9e563663a Translate Localizable.stringsdict in zh_HK
100% translated source file: 'Localizable.stringsdict'
on 'zh_HK'.
2024-04-27 08:44:44 +00:00
transifex-integration[bot]
986cc715fa Translate Localizable.stringsdict in zh_CN
100% translated source file: 'Localizable.stringsdict'
on 'zh_CN'.
2024-04-27 08:44:30 +00:00
William Casarin
76529f69d0 Revert "Cache videos"
This reverts commit 26d2627a1c.
2024-04-25 14:56:49 -07:00
William Casarin
052ea9b727 Revert "Custom video loader caching technique"
This reverts commit ba494f94ab.
2024-04-25 14:56:29 -07:00
transifex-integration[bot]
ea4c2a1d1c Translate Localizable.stringsdict in vi
100% translated source file: 'Localizable.stringsdict'
on 'vi'.
2024-04-25 20:05:12 +00:00
transifex-integration[bot]
3c77d58b11 Translate Localizable.strings in vi
100% translated source file: 'Localizable.strings'
on 'vi'.
2024-04-25 20:05:02 +00:00
transifex-integration[bot]
3cea556827 Translate Localizable.strings in es_ES
100% translated source file: 'Localizable.strings'
on 'es_ES'.
2024-04-25 15:47:20 -04:00
transifex-integration[bot]
c5bdb22a86 Translate Localizable.strings in es_ES
100% translated source file: 'Localizable.strings'
on 'es_ES'.
2024-04-25 15:47:20 -04:00
transifex-integration[bot]
3142fd5700 Translate Localizable.strings in es_ES
100% translated source file: 'Localizable.strings'
on 'es_ES'.
2024-04-25 15:47:19 -04:00
William Casarin
95cf45073d Merge translations 2024-04-25 12:41:12 -07:00
transifex-integration[bot]
efd3c95c10 Translate Localizable.stringsdict in es_ES
100% translated source file: 'Localizable.stringsdict'
on 'es_ES'.
2024-04-25 19:08:23 +00:00
transifex-integration[bot]
d198206cc2 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-04-25 18:13:02 +00:00
transifex-integration[bot]
0b0bcedb1e Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-04-25 17:57:59 +00:00
transifex-integration[bot]
5f7855d6d3 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-04-25 17:56:48 +00:00
transifex-integration[bot]
e3db84778a Translate Localizable.stringsdict in de
100% translated source file: 'Localizable.stringsdict'
on 'de'.
2024-04-25 17:22:18 +00:00
transifex-integration[bot]
5363a0313f Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-04-25 17:21:37 +00:00
transifex-integration[bot]
cb116b0f85 Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2024-04-25 16:20:39 +00:00
transifex-integration[bot]
62e40d2824 Translate Localizable.stringsdict in nl
100% translated source file: 'Localizable.stringsdict'
on 'nl'.
2024-04-25 15:31:39 +00:00
e0b1985df5 Fix localization issues 2024-04-25 09:30:27 -04:00
be585e914d Add Finnish translations 2024-04-24 23:34:50 -04:00
transifex-integration[bot]
e515bf7322 Translate Localizable.strings in fa
100% translated source file: 'Localizable.strings'
on 'fa'.
2024-04-24 23:11:36 -04:00
transifex-integration[bot]
050f38feac Translate Localizable.strings in ko
100% translated source file: 'Localizable.strings'
on 'ko'.
2024-04-24 23:11:36 -04:00
transifex-integration[bot]
b94a435a9b Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2024-04-24 23:11:35 -04:00
Daniel D’Aquino
279854a9fd ui: add First Aid view to settings
also create the contact list reset First Aid action

Automatically detecting whether or not to create a blank contact list
when we could not find any is very tricky. It could mean that no contact
list exists, but it could also mean that a temporary network or relay
outage occurred.

Since resetting the contact list when one already exists is a
destructive action, we should make no assumptions. Instead, we should
provide users the tool to fix it based on their own judgement.

For that reason, the first aid view was created. It detects if no
contact list was found, and in those cases, it gives them an option to
reset (with appropriate warning messages).

Testing 1: Contact list creation robustness
-----------------------------

Setup:
1. Network Link Conditioner installed and configured to this profile:
  - DNS delay: 400 ms
  - Downlink bandwidth: 100 kbps
  - Uplink bandwidth: 50 kbps
  - Packets dropped: 50% (On both uplink and downlink)
  - Delay: 1000 ms (Both uplink and downlink)

Procedure:
1. Turn Network Link conditioner ON
2. Go through the account creation steps
3. At the moment the onboarding follow suggestions screen shows up, quit the app
3. Turn Network Link conditioner OFF
4. Start the app again
5. Verify the home screen. It should present notes from the Damus account (the default follow)
6. Follow someone and wait for 5 seconds
7. Restart app
8. Look at the home feed. Notes from user from step 6 should appear, and that user should appear as being followed by you.

- Repro details:
  - Damus version: ada99418f6
  - Device: iPhone 15 simulator
  - iOS: 17.4
  - Number of runs: 3 times
  - Result: FAILS (issue is reproduced) 3 out of 3 times
- Test details:
  - Damus version: This commit
  - Device: iPhone 15 simulator
  - iOS: 17.4
  - Number of runs: 3 times
  - Result: PASSES all criteria 3 out of 3 times

Testing 2: Contact list First Aid
------------------------------

Setup:
1. Reproduce the issue with the old version as outlined in "Testing 1" above
2. Upgrade to the version in this commit

Steps:
1. Go to Settings > First Aid
2. A button to reset the contact list (and some text for context) should appear. PASS
3. Click on the button. A warning message should appear. PASS
4. Click "cancel". The action should be cancelled and nothing should have changed. PASS
5. Click on the reset button again.
6. Click "Continue" on the warning prompt. The reset button will now show "Contact list has been reset" with a green checkmark. PASS
5. Go back to the home tab. Notes from the Damus account should immediately appear. PASS
6. Try to follow someone and restart the app. Follows should now stick persistently. PASS
7. Go to the First Aid screen again. The reset option should no longer be present. PASS

Changelog-Added: Add First Aid solution for users who do not have a contact list created for their account
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Link: 20240422230912.65056-4-daniel@daquino.me
Signed-off-by: William Casarin <jb55@jb55.com>
2024-04-23 16:41:01 -07:00
Daniel D’Aquino
19ba020bd0 contacts: save first list to storage during onboarding
This commit adds a mechanism to add the contact list to storage as soon
as it is generated, and thus it reduces the risk of poor network
conditions causing issues.

Changelog-Fixed: Improve reliability of contact list creation during onboarding
Closes: https://github.com/damus-io/damus/issues/2057
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Link: 20240422230912.65056-3-daniel@daquino.me
Signed-off-by: William Casarin <jb55@jb55.com>
2024-04-23 16:40:50 -07:00
Daniel D’Aquino
43a5bbd53a contacts: save the users' latest contact event ID
... to a persistent setting, and try to load it from NostrDB on app start.

This commit causes the user's contact list event ID to be saved
persistently as a user-specific setting, and to be loaded immediately
after startup from the local NostrDB instance.

This helps improve reliability around contact lists, since we previously
relied on fetching that contact list from other relays.

Eventually we will not need the event ID to be stored at all, as we will
be able to query NostrDB, but for now having the latest event ID
persistently stored will allow us to get around this limitation in the
cleanest possible way (i.e. without having to store the event itself
into another mechanism, and migrating it later to NostrDB)

Other notes:

- It uses a mechanism similar to other user settings, so it is
  pubkey-specific and should handle login/logout cases

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Link: 20240422230912.65056-2-daniel@daquino.me
Signed-off-by: William Casarin <jb55@jb55.com>
2024-04-23 16:40:25 -07:00
William Casarin
43630cbfa6 zaps: don't verify deschash
seems like most clients don't do this and apparently simplfies some
zapper implementations. It's not a huge deal for us since people can
fake bolt11s anyways.

Suggested-by: bumi, calle
Link: 20240418230321.1907519-1-jb55@jb55.com
Signed-off-by: William Casarin <jb55@jb55.com>
2024-04-23 09:05:13 -07:00
ae2f48484a Change reactions to use a native looking emoji picker
Changelog-Changed: Change reactions to use a native looking emoji picker
Signed-off-by: William Casarin <jb55@jb55.com>
2024-04-22 17:24:14 -07:00
William Casarin
2c9b280a04 translations: only translate kind 1s
Deepl-Savings: 8k/year
Reported-by: Semisol
Signed-off-by: William Casarin <jb55@jb55.com>
2024-04-22 17:11:19 -07:00
Daniel D’Aquino
ba494f94ab Custom video loader caching technique
This commit brings significant improvements to the video cache feature.

Previously, the cache would merely download the video when requested, in
parallel with AVPlayer which also triggers a video download.

The video cache has been updated to tap into the AVPlayer loading
process, removing the download duplication.

Here is how that works:
1. The player requests an AVAsset from the cache.
2. The cache will return a cached asset if possible, or a special AVURLAsset with a custom `AVAssetResourceLoaderDelegate`.
3. The video player will start sending loading requests to this loader delegate.
4. Upon receiving the first request, the loader delegate begins to download the video data on the background.
5. Upon receiving these requests, the loader delegate will also record the requests, so that it can serve them once possible
6. The loader delegate keeps track of all video data chunks as it receives them from the download task, through the `URLSessionDataDelegate` and `URLSessionTaskDelegate` protocols
7. As it receives data, it checks all pending loading requests from the AVPlayer, and fulfills them as soon as possible
8. If the download fails (e.g. timeout errors, loss of connection), it attempts to restart the download.
9. If the download succeeds, it saves the video to the cache on disk.

Closes: https://github.com/damus-io/damus/issues/1717
Changelog-Added: Add video cache to save network bandwidth
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Link: 20240411004129.84436-4-daniel@daquino.me
Signed-off-by: William Casarin <jb55@jb55.com>
2024-04-17 15:06:12 -07:00
Daniel D’Aquino
26d2627a1c Cache videos
This commit implements a simple but functional video cache.

It works by providing a method called `maybe_cached_url`, where a video
URL can be passed in, and this method will either return the URL of a
cached version of this video if available, or the original URL if not. It also
downloads new video URLs on the background into the cache folder for use
next time.

Functional testing
-------------------

PASS

Device: iPhone 15 simulator
iOS: 17.4
Damus: Approximately this commit
Setup:
- Debug connection
- Expiry time locally changed to 5 minutes

Steps:
1. Basic functionality
  1. Go to a profile with lots of videos
  2. Scroll down
  3. Filter logs to only logs that start with "Loading video with URL"
  4. Check that most videos are being loaded from external URLs. PASS
  5. Now restart the app and go to that same profile
  6. Scroll down and watch logs. Videos should now be loaded with an internal file URL. PASS
2. Automatic cache refresh after expiry
  1. Go to the video-heavy profile, make note of the external URL.
  2. Go to a different screen and then come back to that video. Make sure the file was loaded from cache. PASS
  3. Now go to a different screen and wait 5 minutes.
  4. Come back to the same video. It should be loaded from the external URL. PASS
3. "Clear cache" button functionality
  1. Go to the video-heavy profile, make note of the external URL.
  2. Go to a different screen and then come back to that video. Make sure the file was loaded from cache. PASS
  3. Now quit the app (to ensure file is not in use when trying to delete it)
  4. Clear cache in settings
  5. Go back to the same video. It should now be loaded from the external URL. PASS

Performance testing
-----------------------

Device: iPhone 13 mini
iOS: 17.3.1
Damus: This commit
Baseline: 87de88861adb3b41d73998452e7c876ab5ee06bf
Setup:
- Debug connection
- Expiry time locally changed to 5 minutes
- Running on Profile mode, with XCode Instruments

Steps:
1. Start recording network activity with XCode Instruments
2. Go to a video-heavy profile (e.g. Julian Figueroa)
3. Scroll down to a specific video (Make sure to scroll through at least 5 videos)
4. Stop recording and measure the "Bytes In" from "Network connections"
5. Repeat this for all test configurations

Results:
- Baseline (No caching support): 26.74 MiB
- This commit (First run, cleared cache): 40.52 MiB
- This commit (Second run, cache filled with videos): 8.13 MiB

Automated test coverage
------------------------

PASS

Device: iPhone 15 simulator
iOS: 17.4
Damus: This commit
Coverage:
- Ran new automated tests multiple times. PASS 3/3 times
- Ran all other automated tests. PASS

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Link: 20240411004129.84436-3-daniel@daquino.me
Signed-off-by: William Casarin <jb55@jb55.com>
2024-04-17 15:06:12 -07:00
Daniel D’Aquino
c2918aaf16 Remove no-op performance tests that were causing issues
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-04-17 15:05:35 -07:00
ericholguin
e332a7f82c ui: Longform Improvements
This patch improves longform previews by including the image title and tags.
In addition with minor UI touch ups.

Testing:

iPhone 15 Pro Max (17.3.1) Dark Mode:
https://v.nostr.build/9zgvv.mp4

iPhone SE (3rd generation) (16.4) Light Mode:
https://v.nostr.build/VwEKQ.mp4

Closes: https://github.com/damus-io/damus/issues/1742
Changelog-Added: Added title image and tags to longform events
Signed-off-by: ericholguin <ericholguin@apache.org>
Link: 20240415031636.68846-1-ericholguin@apache.org
Signed-off-by: William Casarin <jb55@jb55.com>
2024-04-17 15:04:38 -07:00
William Casarin
8fbc9dc773 nwc: disable mutinywallet button for now
async nwc with mutiny is wayyy too complicated for the average user.

Signed-off-by: William Casarin <jb55@jb55.com>
2024-04-13 17:51:00 -07:00
Daniel D’Aquino
90c68fedfc Bump version to 1.9
This commit bumps up the version to 1.9 to differentiate from 1.8 — which is now tracked on a separate release branch
2024-04-12 18:23:30 -07:00
William Casarin
ada99418f6 eden.nostr.land -> nostr.land
Suggested-by: Semisol
Signed-off-by: William Casarin <jb55@jb55.com>
2024-04-12 13:38:38 -07:00
ericholguin
5492d9f499 ux: Relay Detail Redesign
This patch redesigns the relay detail view. The first step needed to
further improve how relays are viewed. In addition, Fees are added to
the relay metadata to present to the user the admission, subscription,
or publication fees a relay may have.

There are various changes made, but mainly several relay details are
moved outside of the main RelayDetail view for easier tracking and
updates.

With this design we will be able to add ratings and relay previews, as
this patch is large enough I will submit that improvement in the future.

iPhone 15 Pro Max (17.3.1) Dark Mode:
https://i.nostr.build/VwVvq.png
https://i.nostr.build/KGO0v.png

iPhone SE (3rd generation) (16.4) Light Mode:
https://i.nostr.build/M5V26.png
https://i.nostr.build/gZKw3.png

Changelog-Added: Relay fees metadata
Changelog-Changed: Relay detail design
Signed-off-by: ericholguin <ericholguin@apache.org>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-04-04 10:33:28 -07:00
ericholguin
faec79d45d ui: fix cut off emoji reaction
This patch is a simple fix to emoji reactions being cut off.

Fixes: https://github.com/damus-io/damus/issues/1728
Changelog-Fixed: Fix emoji reactions being cut off
Signed-off-by: ericholguin <ericholguin@apache.org>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-04-04 10:33:28 -07:00
ericholguin
846a786fd0 ux: fix image indicators
Changelog-Fixed: Fix image indicators to limit number of dots to not spill screen beyond visible margins
Closes: https://github.com/damus-io/damus/issues/1227
Closes: https://github.com/damus-io/damus/issues/1295
Signed-off-by: ericholguin <ericholguin@apache.org>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-04-04 10:33:28 -07:00
Sean Kibler
517f3714e8 postview: add haptic feedback on media upload result
Closes: https://github.com/damus-io/damus/pull/2115
Signed-off-by: Sean Kibler <skibler@protonmail.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-04-04 10:33:28 -07:00
alltheseas
b733799567 Update issue templates
Added feature request template
2024-03-25 10:12:13 -05:00
alltheseas
db8dfc5edc Update issue templates
Modified issue bug report template.
2024-03-25 10:08:51 -05:00
William Casarin
07c504f701 relay: use hostname in relay list instead of full URL
We already have the full relay URL under the name, we don't need to
repeat it and this is a bit cleaner.

Before:

wss://damus.io
wss://damus.io

After:

damus.io
wss://damus.io

Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-25 09:51:01 +00:00
William Casarin
d58d777541 docs: specify that we are referring to Damus not the kernel 2024-03-25 10:34:00 +01:00
Daniel D’Aquino
e951370a76 Fix relay URL trailing slash issues
This commit tries to replace all usage of `String` to represent relay
URLs and use `RelayURL` which automatically converts strings to a
canonical relay URL format that is more reliable and avoids issues related to
trailing slashes.

Test 1: Main issue fix
-----------------------

PASS

Device: iPhone 15 Simulator
iOS: 17.4
Damus: This commit
Steps:
1. Delete all connected relays
2. Add `wss://relay.damus.io/` (with the trailing slash) to the relay list
3. Try to post. Post should succeed. PASS
4. Try removing this newly added relay. Relay should be removed successfully. PASS

Test 2: Persistent relay list after upgrade
--------------------------------------------

PASS

Device: iPhone 15 Simulator
iOS: 17.4
Damus: 1.8 (1) `247f313b` + This commit
Steps:
1. Downgrade to old version
2. Add some relays to the list, some without a trailing slash, some with
3. Upgrade to this commit
4. All relays added in step 2 should still be there, and ones with a trailing slash should have been corrected to remove the trailing slash

Test 3: Miscellaneous regression tests
--------------------------------------

Device: iPhone 15 Simulator
iOS: 17.4
Damus: This commit
Coverage:
1. Posting works
2. Search works
3. Relay connection status works
4. Adding relays work
5. Removing relays work
6. Adding relay with trailing slashes works (it fixes itself to remove the trailing slash)
7. Adding relays with different paths works (e.g. wss://yabu.me/v1 and wss://yabu.me/v2)
8. Adding duplicate relay (but with trailing slash) gets rejected as expected
9. Relay details page works. All items on that view loads correctly
10. Relay logs work
11. Getting follower counts and seeing follow lists on profiles still work
12. Relay list changes persist after app restart
13. Notifications view still work
14. Copying the user's pubkey and profile link works
15. Share note + copy link button still works
16. Connecting NWC wallet works
17. One-tap zaps work
18. Onboarding works
19. Unit tests all passing

Closes: https://github.com/damus-io/damus/issues/2072
Changelog-Fixed: Fix bug that would cause connection issues with relays defined with a trailing slash URL, and an inability to delete them.
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-25 09:24:17 +01:00
ericholguin
b18941383f wallet: add callbackuri for mutiny wallet nwc
Changelog-Added: Added callbackuri for a better ux when connecting mutiny wallet nwc
Signed-off-by: ericholguin <ericholguin@apache.org>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-25 08:58:05 +01:00
ericholguin
f71957e061 ui: update zeus logo for wallet selector
Changelog-Changed: Updated Zeus logo
Signed-off-by: ericholguin <ericholguin@apache.org>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-25 08:58:04 +01:00
William Casarin
68409f3440 docs: simplify contributing
Date: Fri, 22 Mar 2024 10:02:00 +0100
From: William Casarin <jb55@jb55.com>
To: dev@damus.io
Subject: Moving away from email code submissions

Hey there,

Since there are more people joining these days and the idea of training
everyone on how to do email code review is effectively impossible in
2024, I've decided to move away from them. I have scripts that can
convert github pull requests to do offline review on my end, so I no
longer need people to directly email them to me.

You of course can, but if you prefer to use github PRs then that is now
perfectly fine. I still may not use GitHub's code review interface, but
that is just me.

I want to encourage more code review from people other than me, if noone
is set up to do that via email then I would rather not encourage it.

Cheers,

        Will

Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-22 10:15:00 +01:00
William Casarin
247f313b54 Merge branch 'video-controls' 2024-03-20 10:19:41 +00:00
Daniel D’Aquino
79fef51f68 Improve Video visibility tracking and automatic play/pause
The DamusAVPlayerView (along with other classes) play or pause based on
their Y position relative to the user's viewport. This is ideal for a
vertical feed of notes.

However, this does not work well on a horizontal carousel, such as when
viewing videos on the full-screen carousel and swiping left/right.

This commit adds a new tracking method based on onAppear/onDisappear
triggers from a lazy stack (which only loads when it is visible), and
applied it to videos shown on a full screen carousel, so that videos
pause when we swipe away from the video.

Incidentally, this also fixes an issue I was seeing where a full screen
video would disappear as soon as I rotated the phone to landscape mode.

Testing
--------

Device: iPhone 13 Mini
iOS: 17.3.1
Damus: This version
Coverage:
1. Scroll down a feed full of videos and make sure videos still autoplay when passing through them. PASS
2. Check videos on the feed are muted by default. PASS
3. Check mute button on the video still works. PASS
4. Check clicking on the video brings it to a full-screen carousel view. PASS
5. Check that videos play unmuted by default on full-screen carousel view. PASS
6. Check that all playback controls work on the full-screen carousel view. PASS
7. Check that clicking outside the video shows/hides the carousel overlays. PASS
8. Check that a summary of the note shows up. PASS
9. Check that clicking on that note takes the user to the thread view. PASS
10. Check that changing phone orientation between portrait and landscape on both full-screen carousel AND full-screen video modes will work as expected. PASS
11. Check close button on full-screen carousel works. PASS
12. Check that swiping the video away exits full-screen carousel. PASS
13. Check that full-screen carousel works with images. PASS
14. Check that a carousel with multiple images/videos can be swiped left and right. PASS
15. Check that swiping away from a video on a full-screen carousel will pause it. PASS
16. Check that clicking on an unmuted video on the feed won't cause double-audio issues. PASS
17. Check that full-screen carousel view looks good on both dark and light modes. PASS

Closes: https://github.com/damus-io/damus/issues/1530
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Link: 20240318222048.14226-6-daniel@daquino.me
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-20 09:55:48 +00:00
Daniel D’Aquino
181d894df0 Improve SwiftUI previews around full-screen carousel
This is a minor SwiftUI preview improvement

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Link: 20240318222048.14226-5-daniel@daquino.me
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-20 09:55:48 +00:00
Daniel D’Aquino
250efd9755 Add event text to full-screen Carousel view
The full screen carousel looks quite empty. When viewing videos, it
looks like the video is being played full screen, when it is really not.

Alas, SwiftUI/UIKit does not provide an API for programmatically
bringing a video player full screen. The closest we can do is show the
system native playback controls.

This can cause confusion to users, and is not the best UX. To get around
these limitations and improve UX, event information and content is added to the full
screen carousel overlay, so that:

- Users can see a piece of the post while they are browsing images and videos
- Users can more clearly tell when the video is being displayed on the full screen carousel or on full screen
- Users have a way to directly go to the thread view within the full screen carousel

Changelog-Added: Add event content preview to the full screen carousel
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Link: 20240318222048.14226-4-daniel@daquino.me
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-20 09:55:48 +00:00
Daniel D’Aquino
671b0b67ce Add playback controls to videos
This commit includes several UX changes to give users better control
over video playback. It also, by design, work arounds a SwiftUI quirk*

Here are the changes to the UX:

1. Videos on the feed only have a mute/unmute button
2. When the user clicks on the video, they are taken to a full screen carousel view (similar to when you click on an image)
3. The full-screen carousel view shows all video playback controls (through a specific SwiftUI hack)
4. If the carousel has multiple videos/images, the user can swipe between them normally as expected

Other UI changes that were made:

- The full screen carousel now uses dark mode (black background, white close button)

* The SwiftUI quirk is that when video views are placed within a TabView with ".page" tab view style, the tabview consumes most of the user gestures, making the video playback controls unusable.

Changelog-Changed: Improve UX around video playback
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Link: 20240318222048.14226-3-daniel@daquino.me
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-20 09:55:48 +00:00
Daniel D’Aquino
98eddf1337 Small tweak to resolve build error
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Link: 20240318222048.14226-2-daniel@daquino.me
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-20 09:55:48 +00:00
William Casarin
b31b917b70 Merge remote-tracking branch 'github/quote-reposts'
This adds quote repost listing support to Damus!

Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-17 09:33:29 +00:00
William Casarin
c521998158 ui: add quoted reposts view to threads
This adds quote reposts as an additional detail view on threads. It will
list quoted reposts that have the `q` tag. Not all clients have updated
to this yet (like primal), but hopefully they will soon.

Changelog-Added: Show list of quoted reposts in threads
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-17 08:54:35 +00:00
William Casarin
3f1f257df2 model: upgrade EventsModel to support quote reposts queries
We also switch to using an eventholder because that is a bit nicer
when it comes to rendering quotes in timelines.

Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-17 08:54:35 +00:00
William Casarin
1339ec3ded note: add is_quote_repost helper
This will be used for excluding quote repost notes from threads

Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-17 08:54:35 +00:00
William Casarin
770a845b36 filters: add ContentFilters helper constructor
This is slightly faster for timeline code that needs default filters

Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-17 08:54:35 +00:00
William Casarin
68dd47130e eventsmodel: remove inheritence in Reactions/Reposts model
Simplify with new EventsModel constructors. This is slightly less
typesafe but its not a big deal, I hate inheritence more.

Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-17 08:54:35 +00:00
William Casarin
8cdbc84093 home: add quote repost counter and handler
This adds the initial support code for counting and handling
quote reposts.

Eventually we are going to replace all of the event counts by stats
within nostrdb, but we do this in the meantime now.

Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-17 08:54:35 +00:00
William Casarin
6111e244de filter: add reposts query filter helper
Add a filter helper to easily query quote repost queries.

Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-16 12:03:08 +00:00
William Casarin
0043f0059d strings: add pluralized quoted_repost_count string
We will be using this for translating "Quote{,s}" pluralization. For now
we add the english version.

Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-16 11:59:56 +00:00
alltheseas
4413ec0ec5 Update README.md
added reference to nip-04 encrypted DM support, including link to nostr-protocol nip-04 page
2024-03-13 10:06:01 -05:00
Fonta1n3
0a6e40798a docs: add NIP04 to readme for encrypted DM's 2024-03-13 09:23:18 +08:00
ericholguin
9a83872a22 ui: Add proxy view to selected events
This patch adds the proxy view to selected events.

Fixes: https://github.com/damus-io/damus/issues/2033
Changelog-Added: Proxy Tags are now viewable on Selected Events
Signed-off-by: ericholguin <ericholguin@apache.org>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-12 09:35:28 +00:00
ericholguin
988da17b06 Minor Fixes
This adds the recommended parameter to the relay view used in Wallet view.

Signed-off-by: ericholguin <ericholguin@apache.org>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-12 09:34:21 +00:00
ericholguin
5e530bfc9c ui: Wallet View redesign + Mutiny Wallet integration
This patch redesigns the wallet view to more closely match Rob's design.
In addition this patch allows users to connect to Mutiny Wallet by clicking a button.

iPhone SE (3rd generation) Dark Mode:
https://v.nostr.build/K9lk.mp4

iPhone 15 Pro Max Light Mode:
https://v.nostr.build/9mKA.mp4

Connected Alby Wallet:
https://i.nostr.build/kyd5.png

Changelog-Added: Connect to Mutiny Wallet Button
Changelog-Changed: Moved paste nwc button to main wallet view
Changelog-Changed: Errors with an NWC will show as an alert
Changelog-Fixed: Issue where NWC Scanner view would not dismiss after a failed scan/paste
Signed-off-by: ericholguin <ericholguin@apache.org>
Reviewed-by: William Casarin <jb55@jb55.com>
Link: 20240310223713.4541-1-ericholguin@apache.org
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-11 10:12:05 +00:00
ericholguin
0719e94fbc ux: Relay View Improvements
This patch removes the Recommended Relay View and the old representation of recommended relays.
Adds a tab view style to the Relay Config View allowing the user to switch between their connected relays
and recommended relays. They can add and remove from the recommended view as well. For users logged in with
a pubkey the add button will no longer be displayed.

Testing
——
iPhone 15 Pro Max (17.0) Light Mode:
https://v.nostr.build/QGMZ.mp4

iPhone SE (3rd generation) (16.4) Dark Mode:
https://v.nostr.build/Wlw3.mp4
——

Changelog-Changed: Relay config view user interface

Signed-off-by: ericholguin <ericholguin@apache.org>
Link: 20240307152808.47929-1-ericholguin@apache.org
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-11 10:03:49 +00:00
William Casarin
122775e586 Kick off v1.8 (1)
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-11 09:59:21 +00:00
William Casarin
d83d618829 Merge branch 'v1.7-madeira-release' 2024-03-11 09:58:50 +00:00
William Casarin
669ca0d91c v1.7.2
Fix a few appstore iap-subscription purple ux issues

Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-11 09:38:29 +00:00
Daniel D’Aquino
ad8d30ded1 Improve mechanism of IAP verification with the server right after purchase
It was observed that sending the receipt to the server right after
performing the StoreKit purchase caused issues due to the receipt not
being completely ready when it got sent, causing rejections by the
server.

This commit, in conjuction with backend changes, implements purchase
verification through transaction IDs directly.

Testing
--------

PASS

Device: iPhone 13 Mini (physical device)
iOS: 17.3.1
Damus: This commit
damus-api: `a878da5598a9344a4d351f9a9da16712ce0615b7`
Setup:
- Local server Setup
- Local testing mode on Damus developer settings
- Clean Sandbox account (Create new sandbox account if clearing purchase history does not work)
- Fresh DB
- App is closed before starting.
- Run app on release target
- MOCK_VERIFY=false on server, with all IAP environment correctly setup
Steps:
1. Make Purple IAP purchase. Make sure that:
    - Server logs indicate `/apple-iap/transaction-id` is being hit and returning HTTP 200. PASS
    - No errors appear on the iOS side. After purchase the welcome sheet should appear, and then the account info. PASS

Part of: https://github.com/damus-io/damus/issues/2036
Changelog-Fixed: Fix in-app purchase issue that would trigger an error on purchase before confirming the account information.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-11 09:29:02 +00:00
Daniel D’Aquino
f738aaf358 Increase verbosity of IAP-related logs
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-03-11 09:28:15 +00:00
William Casarin
5f4c342131 Revert "Merge remote-tracking branch 'github/translations'"
Reverting due to CI error:

```
damus file:///Volumes/workspace/repository/damus/cs.lproj/Localizable.stringsdict

validation failed: Couldn't parse property list because the input data
was in an invalid format (1)
```

This reverts commit fa738c4303, reversing
changes made to 9511ba767a.
2024-03-04 18:31:55 +00:00
William Casarin
fa738c4303 Merge remote-tracking branch 'github/translations'
034f31797e Translate Localizable.strings in de
adb6f66a4f Translate Localizable.strings in zh_TW
a5f438b9c7 Translate Localizable.strings in zh_HK
d7f04d9ab9 Translate Localizable.strings in zh_HK
0b2ea46ef4 Translate Localizable.strings in zh_HK
331ed96d57 Translate Localizable.strings in zh_CN
701a747ed6 Translate Localizable.strings in cs
118c2bf2b2 Translate Localizable.stringsdict in cs
7a4f82c97b Translate Localizable.strings in de
d1e3a06cc6 Translate Localizable.strings in de
b9d960b54b Translate InfoPlist.strings in cs
2024-03-04 17:36:52 +00:00
William Casarin
9511ba767a Merge branch 'v1.7-madeira-release' 2024-03-04 17:28:01 +00:00
William Casarin
d9f78cf805 v1.7.1 (12)
subscriber EULA and privacy policy notices on purple subscription
page

Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-04 16:40:55 +00:00
William Casarin
c2d81828d6 purple: mention privacy policy and eula
This is required to be accepted by the appstore

Suggested-by: Apple store reviewer
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-04 16:29:02 +00:00
William Casarin
8da3ab30a5 preview: add purple backdrop to IAPProductStateView
Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-04 16:28:30 +00:00
William Casarin
4e8359458f preview: use PurpleBackdrop in MarketingContentView preview
This improves the preview a bit

Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-04 14:04:56 +00:00
William Casarin
c6e37bd864 refactor: introduce PurpleBackdrop
We mainly want to use this to improve previews for now, but might be
useful in the future.

Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-04 14:03:53 +00:00
William Casarin
8a95e40e0c Merge branch 'translations'
* branch `translations` of https://github.com/damus-io/damus:
  Translate Localizable.strings in de
  Translate Localizable.strings in zh_TW
  Translate Localizable.strings in zh_HK
  Translate Localizable.strings in zh_HK
  Translate Localizable.strings in zh_HK
  Translate Localizable.strings in zh_CN
  Translate Localizable.strings in cs
  Translate Localizable.stringsdict in cs
  Translate Localizable.strings in de
  Translate Localizable.strings in de
  Translate InfoPlist.strings in cs
2024-03-02 09:19:50 +00:00
William Casarin
184fc865bb Bump to 1.7.1
Nothing has changed, but we need to submit a new minor version with
the subscription products since we missed those in the 1.7 appstore
release.

Signed-off-by: William Casarin <jb55@jb55.com>
2024-03-02 09:16:07 +00:00
transifex-integration[bot]
034f31797e Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-02-29 17:35:11 +00:00
kernelkind
556a5bcf4d use camera controller in EditPictureControl
Modify EditPictureControl to use the CameraController exactly as
PostView does.

Lightning-address: kernelkind@getalby.com
Signed-off-by: kernelkind <kernelkind@gmail.com>
Link: 20240228033235.66935-3-kernelkind@gmail.com
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-29 12:12:22 +00:00
kernelkind
6de44223f2 add performance upgrades to media picker
- Use jpeg instead of png data when processing a UIImage.
- Make processing of media occur after user confirms upload selection when possible for better responsiveness.
- Reduce redundant data fetching.

Lightning-address: kernelkind@getalby.com
Signed-off-by: kernelkind <kernelkind@gmail.com>
Link: 20240228033235.66935-2-kernelkind@gmail.com
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-29 12:12:22 +00:00
William Casarin
75d87fee9d Merge tag 'v1.7-rc2'
v1.7 Madeira release RC2

55c26d22cb v1.7 (11)
4c8134908c Enable IAP feature for release
3ac7d75235 Add UI error message when IAP succeeds but receipt verification fails
b49a5f4d29 Purple: Improve UX on Damus Purple renewals
3569919eaf Add Damus Purple impending expiry notification support
2024-02-29 03:11:15 -08:00
Daniel D’Aquino
f6a295dcda Update changelog 2024-02-28 23:56:13 -08:00
Daniel D’Aquino
55c26d22cb v1.7 (11) 2024-02-28 23:41:56 -08:00
Daniel D’Aquino
4c8134908c Enable IAP feature for release
Changelog-Changed: Add support for Apple In-App purchases
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-02-28 23:21:48 -08:00
Daniel D’Aquino
3ac7d75235 Add UI error message when IAP succeeds but receipt verification fails
This should not be visible to end-users on normal circumstances, but we
should regardless show an error message if something goes wrong with the
IAP receipt verification, to prompt them to contact support.

Testing
-------

PASS

Device: iPhone simulator
iOS: 17.2
Damus: This commit
damus-api: d3801376fa204433661be6de8b7974f12b0ad25f
Setup:
- Local Testing server
- Xcode StoreKit environment
Steps:
1. Set MOCK_VERIFY to false on the server (that means receipt verification will fail on Xcode environment)
2. Try to make IAP purchase. Error should appear on UI
3. Set MOCK_VERIFY to true on the server and restart StoreKit environment (Receipt validation will always work)
5. Try to make IAP purchase. Onboarding flow should go as normal with no error messages. PASS

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-02-28 23:21:34 -08:00
Daniel D’Aquino
b49a5f4d29 Purple: Improve UX on Damus Purple renewals
This commit changes when the Damus Purple onboarding gets triggered. Now
it only shows the onboarding if it is the first time they are purchasing
Damus Purple for a Nostr account. If it is not the first they see the
onboarding, they are directed to a sheet that displays their new account
info (e.g. a renewal)

However, if the website links to `damus:purple:welcome` links, the app
will still show the onboarding. This will be addressed on the
website-side by taking them to `damus:purple:landing` instead when it is
a renewal.

Testing
---------

PASS

Device: iPhone 13 mini (Physical device)
iOS: 17.3.1
Damus: This commit
damus-api: d3801376fa204433661be6de8b7974f12b0ad25f
damus-website: 6bb425e324c318ca474417cbd2b2f8bb74f9505f
Setup:
- iOS configured to use a local test environment
- Local damus-api server and local damus-website server setup and properly configured
- Fresh db (i.e. Delete mdb files before starting)
Coverage:
1. LN flow with switching to the app instead of clicking "continue". PASS
  a. Ensure that first purchase will result in onboarding being shown. PASS
  b. Ensure that second purchase will result in onboarding NOT being shown (User should be taken to the account info screen). PASS
  c. Restart app completely. Ensure third purchase will result in onboarding NOT being shown (only account info screen). PASS
2. LN flow with clicking continue (Since website changes are not ready, we will simulate them by manually generally appropriate "continue" URL QR codes)
  a. Note: Clear DB again before starting this portion, and completely restart app.
  b. Ensure that first purchase will result in onboarding being shown. PASS
  c. Ensure that second purchase (with continue URL manually hard-coded to `damus:purple:landing`) results in account info being shown with updated info. PASS
  d. Restart app completely and repeat test 2(c). PASS

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-02-28 23:21:14 -08:00
Daniel D’Aquino
3569919eaf Add Damus Purple impending expiry notification support
This commit adds Damus Purple expiry notification support.

How it works: Whenever the app initiates or enters the foreground, it
checks the user's account expiry, and calculates what notifications to
display (It is functional, not imperative, to better match how
the notifications view works)

The notification handlers work the same as every other notification
handler for Nostr events. However, local iOS notifications were not
implemented to maintain these reminders more discreet.

Current limitations:
- Notifications cannot be dismissed
- Notifications are dismissed only when Damus Purple is extended
- After making a purchase, notifications are not dismissed right away
- Bell icon with purple badge shows up on every app restart if user's account is expired

Testing
-------

Device: iPhone 13 Mini
iOS: 17.3.1
Damus: This commit
damus-api: d3801376fa204433661be6de8b7974f12b0ad25f
Setup:
- Local servers Setup
- Debug endpoints enabled for changing expiry date on the fly
Coverage:
1. Expired account
  1. Starting the app on home screen shows bell icon with purple badge. PASS
  2. 4 notifications appear on notifications view (7,3,1,0 days to expiry). PASS
  3. Notifications appear in correct chronological order. PASS
  4. Notifications look consistent in appearance. PASS
  5. Expiry notifications' text size follows text size settings. PASS
  6. Clicking on notification CTA takes user to account info page. PASS
2. Non-expired account (set expiry, restart app)
  1. No expiry notifications, no bell icon. PASS
3. Expiry in 6 days (set expiry, restart app)
  1. Starting the app on home screen shows bell icon with purple badge. PASS
  2. Starting the app on the notification screen renders notifications the same way. PASS
  3. Only one notification (7 days remaining) appears. PASS
4. Expiry in 2 days. PASS
5. General
  1. Clicking bell icon clears away "new notifications" badge. PASS
  2. Performance of notifications view does not seem affected. PASS
  3. Performance of app on startup does not seem affected. PASS
6. IAP
  1. Active IAP + expiry date in 2 days does not trigger reminder notification (Because it is auto-renewed). PASS

Closes: https://github.com/damus-io/damus/issues/1973
Changelog-Added: Notification reminders for Damus Purple impending expiration
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-02-28 23:20:48 -08:00
William Casarin
55000e9d4d Merge improved mute functionality from Charlie
This merge adds a bunch of new features from charlie's work on the new
mutelist changes:

- Muted words

- Mute performance optimizations

- New mute list UI

I needed to make a few changes to fix the tests in this merge. Otherwise
it seems to work ok!

Thank to Charlie for getting all of this working after many rounds of
review!

* branch `mute` of https://github.com/damus-io/damus:
  mute: fix bug with duplicate Indefinite items in MuteDurationMenu
  mute: fix mute hashtag from search view if no existing mutelist
  mute: integrate new MutelistManager
  mute: adding MutelistManager.swift
  mute: add maybe_get_content function to NdbNote
  mute: fix bug where mutes can't be added without existing mutelist
  mute: fix issue with not being able to change mute duration
  mute: don't mutate string when adding hashtag
  mute: implement fast MuteItem decoder
  tags: add u64 decoding function
  mute: migrating muted_threads to new mute list
  mute: adding ability to mute hashtag from SearchView
  mute: updating UI to support new mute list
  mute: adding filtering support for MuteItem events
  mute: receiving New Mute List Type
  mute: migrate Lists.swift to use new MuteItem
  mute: add new UI views for new mute list
  mute: adding new structs/enums for new mute list

Changelog-Added: Add ability to mute words, add new mutelist interface (Charlie)
2024-02-26 12:02:41 -08:00
Charlie Fish
68a18f5e40 mute: fix bug with duplicate Indefinite items in MuteDurationMenu
The `Indefinite` was added to DamusDuration in "Fixing issue with not
being able to change mute duration” so this needs to be removed so that
there isn’t a repeative item in the menu.

Lighting-Address: fishcharlie@strike.me
Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Link: 20240210163650.42884-6-contact@charlie.fish
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-26 11:31:04 -08:00
Charlie Fish
a18f50f250 mute: fix mute hashtag from search view if no existing mutelist
There is a bug where if a user creates a new account and tries to mute a
hashtag from the search view it will fail silently. This is due to the
fact that the user has no existing mutelist.

Lighting-Address: fishcharlie@strike.me
Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Link: 20240210163650.42884-5-contact@charlie.fish
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-26 11:30:21 -08:00
Charlie Fish
1d4d2b0204 mute: integrate new MutelistManager
This patch is slightly large (I think still within the guidelines tho) ,
but also pretty straightforward. I thought for a while about how I could
split this up in a straightforward way, and I couldn’t come up with
anything without breaking intermediate builds.

- Deleted a lot of old/unnecessary code (ie. the Collection extension
  for MuteItem, since we’re now using the MutelistManager sets)

- Changed damus_state.contacts to damus_state.mutelist_manager for all
  mute list manager work

- Updated MutelistView to take advantage of new code (this is probably
  the largest change in this patch)

Lighting-Address: fishcharlie@strike.me
Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Link: 20240210163650.42884-4-contact@charlie.fish
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-26 11:30:03 -08:00
Charlie Fish
a9baef7a21 mute: adding MutelistManager.swift
This patch adds MutelistManager which contains separate sets for each
type of muted item.

Whenever we receive a new event we parse it into those sets.

When checking to see if an event is muted, we simply check in each of
those sets if it contains the given mute. For words/phrases we have to
loop through each item in the set to see if the event contains the given
word.

In future patches I will be implementing and integrating this new
MutelistManager.

Lighting-Address: fishcharlie@strike.me
Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Link: 20240210163650.42884-3-contact@charlie.fish
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-26 11:29:41 -08:00
Charlie Fish
96ed6b7cc7 mute: add maybe_get_content function to NdbNote
This patch adds a maybe_get_content method to NdbNote which returns an
optional string instead of “*failed to decrypt content*” on DM
decryption failure.

This method will be used by the MutelistManager in future patches.

Lighting-Address: fishcharlie@strike.me
Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Link: 20240210163650.42884-2-contact@charlie.fish
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-26 11:29:30 -08:00
William Casarin
94f7e4d1e1 Merge branch 'iap-improvements'
Pull a few patches from v1.7-rc1

purple: show welcome sheet after ln payment
iap: add loading spinner to purchase actions
2024-02-26 10:23:27 -08:00
Daniel D’Aquino
bdc811aa82 purple: show welcome sheet after ln payment
Automatically show welcome sheet for people who completed a Lightning
checkout but did not click on the "Continue" button

Some people get confused in the last step of the lightning checkout and
they do not click on the "Continue in app", which causes the welcome
screen to not show up and the translation setting to not be setup.

This ticket addresses this issue to ensure they get the welcome screen
in such cases.

This is how it works:

- When they go through the mandatory checkout verification step, the
  verify screen registers that there is a checkout in progress (and
  which checkout is in progress) on the DamusPurple struct

- Every time the app enters the foreground, it checks for that flag:

  - If there are no checkouts in progress, it does nothing

  - If there is one or multiple checkouts in progress, it checks the
    checkout status for those. If any checkout is freshly completed and
    successful, and the account is now active, it shows the welcome
    screen and onboarding

- Once the welcome screen is shown, those checkouts are marked
  internally as handled (so that we only show it once)

===========
Testing
===========

PASS

Damus: This commit
Device: iPhone 13 Mini (real physical device)
iOS: 17.3.1
damus-api: 044150fedddc5ba4135a80579e41e9c1c5743fc0
damus-website: af8089128159e25df31141be624b4090c66d6ddf
Setup:
- Local test Setup
- Clean db before starting

PART 1: LN flow without clicking on the link
---------------------------------------------

Steps:

1. Go through the normal LN flow, EXCEPT at the last step. On the last
   step, instead of clicking "continue in the app", just switch to the
   app.

2. Ensure the welcome screen and onboarding shows up, and the purple
   screen updates to show account info. PASS

3. Switch out and into the app again. Welcome screen should NOT show up
   again. PASS

4. Close the app completely and open the app again. Purple welcome
   screen should NOT show up again. PASS

PART 2: Normal LN flow
---------------------------------------------
Steps:

1. Reset the entire test setup

2. Go through the normal LN flow (this time click on "continue in app").
   The welcome screen should show up without issues or glitches. PASS

PART 3: Crazy flow with multiple incomplete checkouts
---------------------------------------------
(This is to simulate a confused user who accidentally opens multiple new
checkouts, but in the end only completes one of them)

Steps:
1. Reset the entire test setup

2. Open 5 LN checkouts and verify npub with all of them.

3. Only pay one of those checkouts (to make it more confusing, don't pay
   the first or last one, but a random one in between)

4. Go to the app directly (Do not use the link, just go directly to the app)

Related: https://github.com/damus-io/damus/issues/1892
Changelog-Fixed: Fix welcome screen not showing if the user enters the app directly after a successful checkout without going through the link
Closes: https://github.com/damus-io/damus/issues/2021
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Link: 20240223212945.37384-2-daniel@daquino.me
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-26 09:54:07 -08:00
Daniel D’Aquino
003348c103 iap: add loading spinner to purchase actions
Apple In-app purchases do not respond immediately. This commit adds a
loading spinner element, to improve the UX and avoid the appearance of a
broken/unresponsive element.

Testing
--------

Device: iPhone 15 simulator
iOS: 17.2
Damus: This commit
Setup:
- Using the sandbox environment
- No tx to start with
- IAP experimental support enabled on developer settings in Damus
Steps:
1. Click "purchase" in any option on the damus purple screen
2. It should show a loading spinner while the purchase action is loading. PASS
3. On the confirmation screen, cancel the purchase.
4. Purchase buttons should show again. PASS

Closes: https://github.com/damus-io/damus/issues/2020
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Link: 20240223212945.37384-1-daniel@daquino.me
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-26 09:53:58 -08:00
kernelkind
1214f1839d media: fix gif upload regression
Uploaded gifs now upload as gifs again instead of still images.

Fixes: 904ae6c24d ("Fix gif upload as still images")
Lightning-address: kernelkind@getalby.com
Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Link: 20240220234818.27472-1-kernelkind@gmail.com
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-21 09:56:00 -08:00
kernelkind
7f6540b0c0 translate: remove redundant translation call
When auto-translate is enabled, translations are saved in cache through
preload_event in EventCache so it's redundant and wasteful to call
translate() as an async task

Closes: https://github.com/damus-io/damus/issues/1940
Lightning-address: kernelkind@getalby.com
Signed-off-by: kernelkind <kernelkind@gmail.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-20 11:20:31 -08:00
kernelkind
2be83560bc refactor: rename ImagePicker -> MediaPicker
The responsibility of MediaPicker is to 'pick' all sorts of media, not
just images.

Lightning-address: kernelkind@getalby.com
Signed-off-by: kernelkind <kernelkind@gmail.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-20 11:19:21 -08:00
kernelkind
4c0c8b6678 privacy: always strip GPS data from images
Images selected from the photo library will automatically have their
gps data stripped regardless of whether the user toggles the 'Location'
button in the system level photo library view.

Changelog-Changed: Always strip GPS data from images
Closes: https://github.com/damus-io/damus/issues/1990
Lightning-address: kernelkind@getalby.com
Signed-off-by: kernelkind <kernelkind@gmail.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-20 11:19:01 -08:00
kernelkind
904ae6c24d picker: upgrade to newer image picker controller
Upgrade the ImagePicker to use PHPickerViewController instead of
UIImagePickerController for the photo library since the
PHPickerViewController allows for more options for how the user
shares their media, such as whether location data should be
included.

Create a CameraController since PHPickerViewController is only used for
the photo library. After capturing media with the camera, it will be
saved to the user's photo library and the photo library view will
appear for the user to select the media they captured. The user can then
toggle whether they would like apple to strip their media of location
data before handing it off to Damus.

Lightning-address: kernelkind@getalby.com
Signed-off-by: kernelkind <kernelkind@gmail.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-20 11:18:05 -08:00
kernelkind
8d815fe4d6 privacy: add function to strip location data from photos
Add a function to strip GPS data from images before uploading to
hosting service.

Lightning-address: kernelkind@getalby.com
Signed-off-by: kernelkind <kernelkind@gmail.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-20 11:17:57 -08:00
kernelkind
58326f679e translate: implement string distance for close matches
Implement the levenshtein string distance algorithm for determining
if the translation is too similar to the original content.

Closes: https://github.com/damus-io/damus/issues/1996

Lightning-address: kernelkind@getalby.com
Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Link: 20240214032018.57812-1-kernelkind@gmail.com
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-19 13:10:13 -08:00
ericholguin
90180202b6 nip48: initial support
This patch adds a new ProxyView which is added to the Event Shell
whenever an event has the proxy tag. It includes images of the protocol
logos that are listed in the NIP docs.

Reviewed-by: William Casarin <jb55@jb55>.com
Link: 20240205032400.7069-1-ericholguin@apache.org
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-19 12:11:47 -08:00
Daniel D’Aquino
4a4a58c7b5 iap: handle login and logout
This commit adapts the functionality around login/logout with relation
to Damus Purple In-App purchases (IAP). Due to (apparent) limitations on
Renewable subscription In-app purchases (It seems that there can only be
one active IAP subscription per device or Apple ID), these changes add
support for only one IAP subscription at a time.

To prevent confusion, a customer who logs out and logs into a separate
account will see a message indicating the limitation. Any other Nostr
account won't be able to manage IAP on a device that contains an IAP
registered to a different user.

To make this feature possible, the following changes were made to the
code:

1. IAP purchases are now associated with an account UUID. This account
   UUID is generated by the server. Each npub gets one and only one UUID
   for this purpose.

2. This UUID is used to determine which npub owns the IAP on the device.
   It is used as the source of truth when determining whether a
   particular Purple account is manageable on a device or not

3. `DamusPurple` was changed to adhere to a new IAP flow API design
   changes. Previously, the client would create an (inactive) account,
   and then send the IAP receipt to the server for activation. Now, the
   client fetches the npub's UUID from the server, associates it with an
   IAP during purchase, and sends the IAP receipt to the server. The
   server will then bump the expiry (if it's a renewal) or create a new
   active account (if it's the first time).

4. Several changes were made to the StoreKit handling code to improve
   handling:

  a. The `DamusPurple.StoreKitManager` class now records all purchased
     product updates, and sends them to the delegate each time the
     delegate is updated. This helps ensure we do not miss purchased
     product updates regardless of when and if `DamusPurpleView` is ever
     instantiated.

  b. `DamusPurple.StoreKitManager` is now used by `DamusPurple` in a
     singleton pattern via `DamusPurple.StoreKitManager.standard`. This
     helps maintain the local purchase history consistent (and avoid
     losing such data) after `DamusState` or its `DamusPurple` are
     destroyed and re-initialized.

  c. Added logs (using the logger) to help us debug/troubleshoot
     problems in the future

5. Changed the views around DamusPurple, to only show IAP
   purchase/management options if applicable to a particular account. It
   also shows instructive messages in other scenarios.

Testing
-------

damus: This commit
damus-api: d3956ee004a358a39c8570fdbd481d2f5f6f94ab
Device: iPhone 15 simulator
iOS: 17.2
Setup:

- Xcode (local) StoreKit environment
- All StoreKit transactions deleted before starting
- Running `damus` app target (which contains test StoreKit products)

- Local damus-api server running with `npm run dev` and
  `MOCK_VERIFY=true` to disable real receipt verification

- Damus setup with experimental IAP support enabled, and Purple
  environment set to "Test (local)" (localhost)

- Two `nsecs` readily available for account switching
- Clean DB (Delete db files before starting)

Steps:
1. Open the app and sign in to the first account

2. Go to Damus Purple screen. Marketing screen with buttons to purchase
   products should be visible. PASS

3. Buy a product and monitor server logs as well as the screen.
  a. IAP confirmation dialog should appear. PASS

  b. After confirmation, server logs should show a receipt was sent
     IMMEDIATELY and the response should be an HTTP 200. PASS

  c. The welcome and onboarding screens should appear as normal. PASS

  d. Once the onboarding sheet goes away, the Purple screen should now
     show the account information. PASS

  e. The account information should be correct. PASS

  f. Under the account information, there should be a "manage" button. PASS

4. Click on "manage" and verify that the iOS subscription management
   screen appears. PASS

5. Now log out and sign in to the second account

6. Go to Damus Purple screen.
  a. Marketing screen should be visible. PASS

  b. There should be no purchase buttons. instead, there should be a
     message indicating that there can only be one active subscription
     at a time, and that the app is unable to manage subscription for
     this second acocunt. PASS

7. Log out and sign in to the first account. Go to the Purple screen.
  a. Account info with the manage button should be visible like before. PASS

8. Through Xcode, delete transactions, and restart the app. This will
   simulate the case where the user bought the subscription externally.

9. Go to the Purple screen.
  a. Account info should be visible and correct. PASS
  b. Below the account info, there should be a small note telling the
     user to visit the website to manage their billing. PASS

Closes: https://github.com/damus-io/damus/issues/1815
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-19 10:38:59 -08:00
Daniel D’Aquino
d694c26b83 iap: move StoreKit logic out of DamusPurpleView and into a new PurpleStoreKitManager
This commit moves most of StoreKit-specific logic that was embedded into
DamusPurpleView and places it into a new PurpleStoreKitManager struct,
to make code more reusable and readable by separating view concerns from
StoreKit-specific concerns.

Most of the code here should be in feature parity with the previous
behavior. However, a few logical improvements were made alongside this
refactoring:

- Improved StoreKit transaction update monitoring logic: Previously the
  view would stop listening for purchase updates after the first update.
  However, I made the program continuously listen for purchase updates,
  as recommended by Apple's documentation
  (https://developer.apple.com/documentation/storekit/transaction/3851206-updates)

- Improved/simplified logic around getting extra information from the
  products: Information and the handling of product information was
  spread in a few separate places. I incorporated those bits of
  information into central and uniform interfaces on DamusPurpleType, to
  simplify logic and future changes.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-19 10:38:43 -08:00
Daniel D’Aquino
2525799c8a refactor: split views from DamusPurpleView into separate files
This refactoring commit splits several view blocks from DamusPurpleView
into separate files.

- New view structs were defined within the DamusPurpleView namespace, to
  avoid polluting the global namespace

- No logical changes were made. The functionality should have stayed
  equivalent

- Changes were made conservatively, and as semantically as possible, to
  make the code easier to work with.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-19 10:38:21 -08:00
Daniel D’Aquino
b3b6fdc29e refactor: move primitive views from DamusPurpleView into a separate file
This refactoring commit moves primitive, low complexity, helper views
from DamusPurpleView into a separate file, to reduce complexity on
DamusPurpleView.swift.

Although functions were changed into View structs, no logical changes
were made. (New version is functionally equivalent)

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-19 10:38:13 -08:00
Daniel D’Aquino
1a131cd179 refactor: re-order items in DamusPurpleView
This is a purely non-functional refactor of DamusPurpleView consisting
only of code mark section comments, and re-ordering for better
organization and to facilitate further refactoring.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-19 10:37:55 -08:00
transifex-integration[bot]
adb6f66a4f Translate Localizable.strings in zh_TW
100% translated source file: 'Localizable.strings'
on 'zh_TW'.
2024-02-13 07:39:33 +00:00
transifex-integration[bot]
a5f438b9c7 Translate Localizable.strings in zh_HK
100% translated source file: 'Localizable.strings'
on 'zh_HK'.
2024-02-13 07:34:28 +00:00
transifex-integration[bot]
d7f04d9ab9 Translate Localizable.strings in zh_HK
100% translated source file: 'Localizable.strings'
on 'zh_HK'.
2024-02-13 07:34:05 +00:00
transifex-integration[bot]
0b2ea46ef4 Translate Localizable.strings in zh_HK
100% translated source file: 'Localizable.strings'
on 'zh_HK'.
2024-02-13 07:29:25 +00:00
transifex-integration[bot]
331ed96d57 Translate Localizable.strings in zh_CN
100% translated source file: 'Localizable.strings'
on 'zh_CN'.
2024-02-13 07:29:00 +00:00
transifex-integration[bot]
701a747ed6 Translate Localizable.strings in cs
100% translated source file: 'Localizable.strings'
on 'cs'.
2024-02-12 06:16:42 +00:00
William Casarin
ab529c43eb profile: fix bug where profile does not update
Changelog-Fixed: Fix profile not updating bug
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-11 09:05:41 -08:00
William Casarin
b486d5e102 add some more close guards
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-11 08:52:29 -08:00
William Casarin
881dae0954 v1.7 (10)
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-11 08:52:03 -08:00
transifex-integration[bot]
118c2bf2b2 Translate Localizable.stringsdict in cs
100% translated source file: 'Localizable.stringsdict'
on 'cs'.
2024-02-09 19:28:54 +00:00
transifex-integration[bot]
7a4f82c97b Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-02-09 17:59:18 +00:00
transifex-integration[bot]
d1e3a06cc6 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-02-09 17:58:50 +00:00
transifex-integration[bot]
b9d960b54b Translate InfoPlist.strings in cs
100% translated source file: 'InfoPlist.strings'
on 'cs'.
2024-02-09 10:05:08 +00:00
Daniel D’Aquino
da82663634 Add option for custom test host in Damus Purple developer settings
This commit adds a developer setting that allows the use of a custom
host for testing. This was added to allow testing on real devices
without the need for pushing changes into staging.

========
Testing
========

Test 1: Production not affected
-------------------------------

PASS

Device: iPhone 13 Mini
iOS: 17.3
Damus: This version
Steps:
1. Run app on a device logged into a real Damus Purple account
2. Scroll down the home feed. Make sure that other Purple members still show up with a star next to their profile. PASS
3. Go to the Damus Purple screen. Ensure that account info shows up and is correct. PASS
4. Ensure auto-translations appear. PASS

Test 2: Check custom test URL
-----------------------------

PASS

(Continued from test 1)
1. Run local damus-api and damus-website on the same computer
2. Change developer purple env setting to local test
3. Set the host url to the local IP address of the test server
4. Go through LN purchase flow. Ensure it works correctly. PASS

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-08 10:08:59 -08:00
Daniel D’Aquino
aeafdccb02 Non-functional auto-formatter refactor
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-08 10:08:24 -08:00
William Casarin
f37957f47e Merge remote-tracking branch 'github/translations' 2024-02-06 10:14:55 -08:00
alltheseas
b5f31ef714 Update README.md
added how Damus and nostr win
2024-02-06 10:06:07 -06:00
alltheseas
907a49813f Update README.md
added funding disclosure
2024-02-06 09:57:36 -06:00
alltheseas
eb383c6bf0 Update README.md
grammar, typos
2024-02-06 09:54:36 -06:00
alltheseas
e72e7b7196 Update README.md
-added purple
-added DM privacy warning
-updated NIPs
-added comparison vs twitter
2024-02-06 09:53:08 -06:00
transifex-integration[bot]
449b9f9f1e Translate Localizable.strings in ja
100% translated source file: 'Localizable.strings'
on 'ja'.
2024-02-06 06:28:48 +00:00
Daniel D’Aquino
035181b02a Fix local test Purple landing page URL
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-05 11:40:02 -08:00
transifex-integration[bot]
0ab5040caa Translate Localizable.strings in hu_HU
100% translated source file: 'Localizable.strings'
on 'hu_HU'.
2024-02-03 18:58:08 -05:00
transifex-integration[bot]
21cafc8f23 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-02-03 18:58:07 -05:00
transifex-integration[bot]
6c0ea0bb17 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-02-03 18:58:07 -05:00
transifex-integration[bot]
a1f3c481c5 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-02-03 18:58:07 -05:00
transifex-integration[bot]
cee5bd1a97 Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2024-02-03 18:58:07 -05:00
transifex-integration[bot]
a43fa7349c Translate Localizable.strings in sv_SE
100% translated source file: 'Localizable.strings'
on 'sv_SE'.
2024-02-03 18:58:07 -05:00
611cef712f Fix localization issues and export strings for translation 2024-02-03 18:58:07 -05:00
William Casarin
28f292f692 nostrscript: fix nscripts not loading
This was broken by the recent nip19 changes, let's fix it again

Fixes: cb4adf06f1 ("nip19: added swift enums")
Changelog-Fixed: Fix nostrscripts not loading
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-02 12:02:21 -08:00
William Casarin
4defab73d0 v1.7 (9)
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-01 11:28:32 -08:00
William Casarin
3d190a7388 purple: fix crash in account cache
otherwise there is contention and tends to crash

Fixes: f06b882139 ("purple: consolidate UserBadgeInfo with Account")
Changelog-Fixed: Fix crash when accessing cached purple accounts
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-01 11:08:07 -08:00
William Casarin
4a40c9987d v1.7 (8)
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-01 10:46:21 -08:00
William Casarin
9735a28b44 mention: add note about text suggestion mention inteference
Also doing this so I can add a changelog entry

Changelog-Changed: Disable inline text suggestions on 17.0 as they interfere with mention generation
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-01 10:46:21 -08:00
kernelkind
48e8c11929 media: remove minWidth on Load Media formatting
Minimum width is not needed since we rely on maxWidth set to .infinity
to fill the screen bounds.

Lightning-address: kernelkind@getalby.com
Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Link: 20240131165008.61990-1-kernelkind@gmail.com
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-01 10:42:02 -08:00
kernelkind
6ca6a76fdb repost: hide member signup date
The purple membership badge should be displayed as compact view
everywhere except the user's profile.

Lightning-address: kernelkind@getalby.com
Changelog-Fixed: Hide member signup date on reposts
Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-01 09:52:49 -08:00
ericholguin
504b21e91c note: fix previews not rendering
Fixes: dfcef0ba95 ("Fix previews not rendering")
Closes: https://github.com/damus-io/damus/issues/1932
Changelog-Fixed: Fixed previews not rendering
Signed-off-by: ericholguin <ericholguin@apache.org>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-01 09:51:29 -08:00
kernelkind
afec694432 post: disable inline text prediction
Inline text suggestions interfere with mentions generation so they
should be disabled.

Closes: https://github.com/damus-io/damus/issues/1970
Lightning-address: kernelkind@getalby.com
Signed-off-by: kernelkind <kernelkind@gmail.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-01 09:51:18 -08:00
William Casarin
3c24e707fc translate: disable translate DMs
It's just a bad idea until we have local translations

Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-31 09:34:15 -08:00
William Casarin
28d6715fda v1.7 (7)
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-31 09:24:55 -08:00
William Casarin
b25d7d1c0d enable purple
Changelog-Added: Damus Purple membership!
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-31 09:24:46 -08:00
William Casarin
528d1b89b6 profile: show subscriber date instead of number
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-30 19:02:24 -08:00
William Casarin
f05cd1b7e6 split up date formatting
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-30 19:01:31 -08:00
William Casarin
b73b1da46e purple: prevent screen dismiss on purple welcome view
Suggested-by: Daniel Daquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-30 17:38:42 -08:00
William Casarin
f06b882139 purple: consolidate UserBadgeInfo with Account
Rename get_account to fetch_account to make it clear that it is always a
call to the server.

Add get_maybe_cached_account method that checks cached before calling
fetch_account.

Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-30 17:30:12 -08:00
William Casarin
fe177bdb9e purple: speed up continue button fade-in
Cc: Daniel Daquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-30 16:31:55 -08:00
William Casarin
dd498cdee2 purple: add "continue" button to checkout verification step
Cc: Daniel Daquino <daniel@daquino.me>
Suggested-by: Vanessa Gray <vanessa@damus.io>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-30 16:27:22 -08:00
William Casarin
66f17ecd96 v1.7 (6)
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-30 16:26:23 -08:00
kernelkind
86e9ee16a0 ui: truncate visible media URL
The URL shown to the user before they click the 'Load Media' button can
take up a large portion of the screen, and doesn't offer any value to
the user.

This patch features a naive approach to truncate the URL to be a certain
number of characters if it is greater than the specified value. Right
now the maximum number of characters is set to 80.

Closes: https://github.com/damus-io/damus/issues/1950
Lightning-address: kernelkind@getalby.com
Signed-off-by: kernelkind <kernelkind@gmail.com>
Link: 20240130183525.50446-1-kernelkind@gmail.com
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-30 10:43:15 -08:00
Daniel D’Aquino
5f9477d55b purple: notify main DamusPurpleView when user gets a subsscription
Previously, if the user had the DamusPurpleView open and bought the
subscription, the DamusPurpleView would not change. It would stay in the
marketing pitch screen.

This commit makes sure that this view is automatically updated as soon
as the user sees the welcome screen, so that they can see their account
info in case they have DamusPurpleView open.

Testing
--------

PASS

iOS: 17.2
Damus: This commit
damus-api: Varying versions around `9a6af62`
Coverage:
1. Checked the entire LN flow through the local test environment using the simulator
2. Checked all LN flow views on both light and dark mode to ensure it looks good
3. Checked the entire LN flow using the staging environment using a physical iOS device

Closes: https://github.com/damus-io/damus/issues/1899
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-30 10:29:49 -08:00
Daniel D’Aquino
1421c34aeb purple: fix dark mode issues
Some views appeared broken when iOS was in dark mode, with a white
background. This commit fixes that and makes sure the background is
always dark.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-30 10:29:49 -08:00
Daniel D’Aquino
263389b2e6 purple: add translation setup view the onboarding
This commit changes the welcome view into a multi-step onboarding
process, where it makes it more clear that translations are unlocked,
and provides the user with some choices to set it up or not.

This flow also makes the translation setup possible on the LN flow

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-30 10:29:49 -08:00
Daniel D’Aquino
cdd5327829 purple: disable apple IAP ui by default
We do not have Apple In-app purchases ready for the upcoming release.

This commit hides IAP UI/UX behind a developer feature flag which is off
by default, and shows a link inviting the user to visit the website to
learn more.

It also makes the link go to the normal Damus website.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-30 10:29:48 -08:00
Daniel D’Aquino
e649c49981 purple: adapt to integrate better with the LN flow
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-30 10:29:48 -08:00
Daniel D’Aquino
a6b430284f purple: feature flag management
Originally the Damus Purple feature was gated behind a setting which
served as a feature flag.

However, when we release, we need to enable Purple features for all
users running on a particular version.

This commit improves feature flag management by replacing the setting
with a computed property (which still points to the setting, but can be
very easily replaced)

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-30 10:29:48 -08:00
Daniel D’Aquino
dd240899cf purple: improve environment handling
This commit improves the handling of different Purple environments:

- 3 environments were added (test, staging, production) to fulfill all
  testing needs

- Damus website constants were also added (This will become important
  for the next few commits)

- Toggle settings were replaced with a picker, where the user can select
  one of the 3 environments (test, staging, production)

- Damus purple page and website address links can now be obtained
  directly from the DamusPurple struct, which improves flexibility and
  reduces complexity for code that consumes these constants.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-30 10:29:48 -08:00
kernelkind
992b0f2eba ui: fix load media formatting on small screens
On small screens, specifically the iPhone 12 Mini in this case, the
'Load Media' has a minimum width which is too high so it causes
formatting problems when viewing replies.

The minimum width of the frame was arbitrarily decreased from 300 to
200.

Changelog-Fixed: Fix load media formatting on small screens
Closes: https://github.com/damus-io/damus/issues/1944
Lightning-address: kernelkind@getalby.com
Signed-off-by: kernelkind <kernelkind@gmail.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-30 10:29:48 -08:00
kernelkind
9d91856ea3 nip19: do not include tag for nevent mention
When creating an NostrEvent with an nevent1 mention, don't include the
nevent mentions in the event's tags. This matches the behavior for
note1 mentions.

Tests for content with npub1 and note1 mentions were added to confirm
tag generation behavior. Test for nevent mention tag generation was
modified to be the same as note1.

Closes: https://github.com/damus-io/damus/issues/1941
Lightning-address: kernelkind@getalby.com
Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-30 10:29:48 -08:00
kernelkind
7cc2825d89 nip19: fix shared nevents that are too long
There comes a point when the sharing a Nip19 mention becomes unwieldy
when there is too much TLV data. This patch features a naive approach to
making sure the relay portion of the TLV data doesn't contribute too
much data to the mention.

It adds a maximum number of relays that should be shared in the mention,
right now it is set to four.

Changelog-Fixed: Fix shared nevents that are too long
Lightning-address: kernelkind@getalby.com
Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-30 10:27:12 -08:00
William Casarin
5c76ffda8c fix build 2024-01-29 13:04:54 -08:00
William Casarin
f5f42528af txn: fix potential crash from transaction in view closure 2024-01-29 12:50:12 -08:00
William Casarin
ff6b19578e purple: switch local testing to staging testing 2024-01-28 17:32:44 -08:00
William Casarin
0c63f2ee26 purple: add staging option to DamusPurpleURL
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-28 16:09:14 -08:00
William Casarin
500f8bc2ec purple: add querystring helper
We will be pulling out more things so this simplifies querystring
lookups a bit

Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-28 16:09:14 -08:00
William Casarin
7a3be720b2 Revert "profile: rename Following to Frens"
This reverts commit 85e14de12d.
2024-01-28 16:09:14 -08:00
Daniel D’Aquino
854036b413 purple: add subscriber number on the Purple supporter badge
This change adds an subscriber number to the supporter badge.

Testing
--------

PASS

Device: iPhone 15 simulator
iOS: 17.2
Damus: This commit
damus-api: `53fd7fef1c8c0bbf82bb28d1d776e45379433d23`
Setup:
- `damus-api` running on `npm run dev` mode
- Damus configured to enable experimental Purple support + localhost mode
Test steps:
1. Wipe local database and rerun server
2. Purchase purple using In-app purchase (Xcode environment) flow
3. Make sure that badge to the side of the user's name on the event shows a golden star without any ordinal numbers
4. Make sure that the badge to the side of the user's name on the profile page shows a golden star with the text "1st"
5. Repeat steps 2–4 and make sure the text says "2nd"
6. Look at the SupporterBadge view previews. Ordinals should show up correctly for all previews (They should always show up, no matter the number)

Closes: https://github.com/damus-io/damus/issues/1873
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-28 15:43:13 -08:00
William Casarin
838ce26c64 test: fix test build error
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-28 15:43:13 -08:00
William Casarin
83d2fbf7fd project: remove TXNDEBUG 2024-01-28 15:13:55 -08:00
Charlie Fish
d5606aabca mute: fix bug where mutes can't be added without existing mutelist
There is currently an issue where if a user doesn’t have an existing
mutelist it won’t let the user add new mute items. This patch fixes that
by not including the existing mutelist variable in the guard statement.

Lighting-address: fishcharlie@strike.me
Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-27 21:01:46 -08:00
Charlie Fish
a6b508c25a mute: fix issue with not being able to change mute duration
This patch fixes an issue where the user can’t change the mute duration
when adding a new mute item.

The problem was that `expiration` was nil for indefinite which isn’t
really allowed for a Picker.

I fixed this by adding an indefinite case to DamusDuration.

Closes: https://github.com/damus-io/damus/issues/1907
Lighting-address: fishcharlie@strike.me
Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-27 19:50:38 -08:00
Charlie Fish
a4f0eeadec mute: don't mutate string when adding hashtag
Currently there is an issue where if you try to mute a hashtag, it will
mutate the `new_text` variable. This patch fixes that so that we aren’t
mutating `new_text`.

Lighting-address: fishcharlie@strike.me
Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-27 19:46:47 -08:00
ericholguin
e2361c1176 ux: Onboarding improvements
This patch removes the EULA view from the onboarding flow and can be
viewed from the create account view if the user so chooses.

This also removes the need to copy the public key in the save keys view,
now only the copying of the private key is required.

Finally it fixes minor issues with the onboarding views, such as padding
and spacing, where components were not aligned properly.

Testing

iPhone 15 Pro Max (17.0) Light Mode:
https://video.nostr.build/7c1d38c75069262a56a93fcf7cc447fa8808ed7fbbd18a8169b583913248b741.mp4

iPhone SE (3rd generation) (16.4) Dark Mode:
https://video.nostr.build/100085c84d84a75f41c45e3dc5347e5b4c35a8149b1b8a5edb9e6c059adc5949.mp4

Lightning-Address: eriic@getalby.com
Closes: https://github.com/damus-io/damus/issues/1902
Changelog-Added: Fixed minor spacing and padding issues in onboarding views
Changelog-Changed: EULA is not shown by default
Changelog-Removed: Removed copying public key action

Signed-off-by: ericholguin <ericholguin@apache.org>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-27 18:47:37 -08:00
William Casarin
9d87bc11dd friendfilter: extend to friend-of-friends
Suggested-by: Semisol
2024-01-26 18:11:54 -08:00
William Casarin
85e14de12d profile: rename Following to Frens
Suggested-by: Marie
2024-01-26 18:11:34 -08:00
William Casarin
beb3605411 v1.7 (5) 2024-01-26 14:06:21 -08:00
William Casarin
e3642b92d1 txn: fix subtle transaction inheritence bugs
This fixes subtle bugs with transaction inheritence. Since we were not
passing the inherited state to moved value, we were sometimes committing
transactions more than once.

Changelog-Fixed: Fix many nostrdb transaction related crashes
2024-01-26 14:03:49 -08:00
William Casarin
f190b6414c debug: enable txndebug 2024-01-26 14:02:29 -08:00
William Casarin
6ae326d193 ndb: use is_closed which also check nil ptrs
not an issue atm, but maybe in the future
2024-01-26 14:02:03 -08:00
William Casarin
851bffed0f damus_state: switch to class instead of struct
This was obscuring many state issues
2024-01-26 13:52:48 -08:00
William Casarin
5b820d6920 1.7 (4) 2024-01-26 12:00:27 -08:00
William Casarin
d04a29405d txn: don't attempt to close transactions from older db generations 2024-01-26 12:00:26 -08:00
William Casarin
d3c75ce42b Revert "lmdb: fix weird crash in lmdb. need to follow up upstream"
This reverts commit 4686b7aca6.
2024-01-26 12:00:26 -08:00
William Casarin
6731471c17 fix build 2024-01-26 11:15:46 -08:00
William Casarin
98359beb04 v1.7 (3) 2024-01-26 11:14:44 -08:00
William Casarin
4686b7aca6 lmdb: fix weird crash in lmdb. need to follow up upstream
I may be something dumb, so asking the LMDB why this is crashing here
might enlighten us.
2024-01-26 11:13:52 -08:00
William Casarin
b80bab35b8 ndb: fix crash with Ndb garbage collection on de-init 2024-01-26 10:57:50 -08:00
William Casarin
bfb0dbac56 txn: do another guard check before query just in case
probably won't do anything
2024-01-26 10:55:33 -08:00
William Casarin
fbdc5446f0 project: clean up some stuff 2024-01-26 10:55:16 -08:00
William Casarin
6e0ba3206d debug: add some transaction debugging 2024-01-26 10:55:05 -08:00
kernelkind
010d71d9ed mentions: fix regression on char handling after mention
Previously, when a post is being sent, the next character after a
mention was checked. If it wasn't a space, a space was added. This
implementation was insufficient because there are times where a
character immediately following a mention is desired, especially for
punctuation.

This was possible in the past because the C code counts anything non
alphanumeric as being part of the mention if it starts with 'nostr:'.

The fix included follows similar logic. If the character following the
mention is non alphanumeric, it allows it. Otherwise, a space is added
so that the C code can correctly parse the mention.

A test was added which verifies non alphanumeric characters after
mentions don't get whitespace added before them.

Fixes: af75eed ("mention: fix broken mentions when there is text is directly after")
Closes: https://github.com/damus-io/damus/issues/1915
Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-25 14:11:31 -08:00
William Casarin
deedf5577d Revert "camera: switch to new custom view"
This reverts commit ca779d472d.
2024-01-25 14:11:31 -08:00
William Casarin
4ddf647d5f Revert "camera: add ability to preview media taken with camera"
This reverts commit c67741983e.
2024-01-25 14:11:31 -08:00
William Casarin
e999e81e8f mute: implement fast MuteItem decoder
Just using strings is really bad performance wise, and is not the
proper way to implement a TagConvertible. The whole point of this
protocol is to parse tags efficiently.
2024-01-25 12:13:15 -08:00
William Casarin
3cce42eea1 tags: add u64 decoding function
This will be used for decoding expiries.
2024-01-25 12:13:15 -08:00
Charlie Fish
71c9bd63fc mute: migrating muted_threads to new mute list
This patch depends on: Adding ability to mute hashtag from SearchView

This is the last patch for the new mute list feature

- Removing MutedThreadsManager
- Adding system to migrate existing muted threads to new mute list

Closes: https://github.com/damus-io/damus/issues/1718
Closes: https://github.com/damus-io/damus/issues/856
Lighting Address: fishcharlie@strike.me
Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-25 12:12:59 -08:00
Charlie Fish
89f7c3ff30 mute: adding ability to mute hashtag from SearchView
This patch depends on: Updating UI to support new mute list

- Adding the ability to mute a hashtag from SearchView

Related: https://github.com/damus-io/damus/issues/1718
Related: https://github.com/damus-io/damus/issues/856
Lighting-address: fishcharlie@strike.me
Changelog-Added: Add ability to mute hashtag from SearchView
Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-25 12:12:59 -08:00
Charlie Fish
6003a501c1 mute: updating UI to support new mute list
This patch depends on: Adding filtering support for MuteItem events

- Gives more specific mute reason in EventMutedBoxView
- Showing all types of mutes in MutelistView
- Allowing for adding mutes directly from MutelistView
- Allowing for choosing duration of mute in EventMenu

Related: https://github.com/damus-io/damus/issues/1718
Related: https://github.com/damus-io/damus/issues/856
Lighting Address: fishcharlie@strike.me

Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-25 12:12:59 -08:00
Charlie Fish
7aaea97de0 mute: adding filtering support for MuteItem events
This patch depends on: Receiving New Mute List Type

- Changes NewMutesNotify, NewUnmutesNotify & MuteNotify to use MuteItem instead of Pubkey
- Changes is_muted in Contacts.swift to take in a MuteItem instead of a Pubkey
    - A lot of changes here were just modifying callers of that to accept the new parameter type

Related: https://github.com/damus-io/damus/issues/1718
Related: https://github.com/damus-io/damus/issues/856
Lighting Address: fishcharlie@strike.me

Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-25 12:12:59 -08:00
Charlie Fish
07b3146026 mute: receiving New Mute List Type
This patch depends on: Migrate Lists.swift to use new MuteItem

- Makes request for new mute list type (kind:10000)
- Processing new mute list type (kind:10000)

Related: https://github.com/damus-io/damus/issues/1718
Related: https://github.com/damus-io/damus/issues/856
Lighting Address: fishcharlie@strike.me

Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-25 12:12:59 -08:00
Charlie Fish
f36646116e mute: migrate Lists.swift to use new MuteItem
This patch depends on: Adding new structs/enums for new mute list

- Rewrites Lists.swift to use new mute list option
    - This leads to a lot of changes for changing the type from RefId to the new MuteItem
- Update & relay new mute list in AddMuteItemView.swift (fixing previous patch TODO)
- Renames `list` to `list_deprecated`
    - We need to keep this since existing users might have an old mute list

Related: https://github.com/damus-io/damus/issues/1718
Related: https://github.com/damus-io/damus/issues/856
Lighting Address: fishcharlie@strike.me

Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-25 12:12:59 -08:00
Charlie Fish
6d2c382469 mute: add new UI views for new mute list
- Adding MuteDurationMenu & AddMuteItemView
    - In a future patch I will update AddMuteItemView to actually update and relay the new mute list

Related: https://github.com/damus-io/damus/issues/1718
Related: https://github.com/damus-io/damus/issues/856
Lighting Address: fishcharlie@strike.me

Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-25 12:12:59 -08:00
Charlie Fish
068b89d087 mute: adding new structs/enums for new mute list
- Adding MuteItem & DamusDuration
- Changing RefId hashtag associated type from TagElem to Hashtag
    - This is done because in MuteItem, we can not create a RefId.hashtag TagElem instance since we don’t have a note associated with a given hashtag mute item.

Related: https://github.com/damus-io/damus/issues/1718
Related: https://github.com/damus-io/damus/issues/856
Lighting Address: fishcharlie@strike.me

Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-25 12:12:57 -08:00
William Casarin
f13267aeb2 Revert "mute: adding new structs/enums for new mute list"
This reverts commit 50f45288ce.
2024-01-25 12:11:34 -08:00
William Casarin
e6598928d0 Revert "mute: add new UI views for new mute list"
This reverts commit 9f332a148f.
2024-01-25 12:10:34 -08:00
William Casarin
a4253a613c Revert "mute: migrate Lists.swift to use new MuteItem"
This reverts commit 0f05123ef8.
2024-01-25 12:10:22 -08:00
William Casarin
66fd1dd444 Revert "mute: receiving New Mute List Type"
This reverts commit 2861ee2c12.
2024-01-25 12:10:15 -08:00
William Casarin
b5c384da43 Revert "mute: adding filtering support for MuteItem events"
This reverts commit 61a9e44898.
2024-01-25 12:10:08 -08:00
William Casarin
356ef45b91 Revert "mute: updating UI to support new mute list"
This reverts commit 75d66434f3.
2024-01-25 12:10:03 -08:00
William Casarin
f5d1401032 Revert "mute: adding ability to mute hashtag from SearchView"
This reverts commit ace8a7081b.
2024-01-25 12:09:56 -08:00
William Casarin
719cec449c Revert "mute: migrating muted_threads to new mute list"
This reverts commit f341a37902.
2024-01-25 12:09:49 -08:00
William Casarin
56b1efc6f1 txn: do not close txn if database is already closed
This is a potential fix for some of the crash reports that have been
streaming in. They all seem to be crashing within ndb_close_txn. I
suspect this means that the transactions are trying to get closed when
ndb is already closed. Closing Ndb will close the transactions
automatically, so it looks like we might be trying to close twice.
2024-01-25 12:09:15 -08:00
William Casarin
0f307ab8d5 project: only bump damus build from project 2024-01-24 10:01:00 -08:00
William Casarin
ecc880ac85 Revert "project: add 'updates files in place' to info.plist"
This reverts commit 7894cad4f4.
2024-01-24 10:00:51 -08:00
William Casarin
26e2c98e72 project: fix invalid info.plist entry
Looks like this was added accidentally
2024-01-24 09:57:53 -08:00
William Casarin
d2f6b40625 v1.7 (2) changelog 2024-01-24 09:45:40 -08:00
William Casarin
63ad13a2d5 v1.7 (2) 2024-01-24 09:41:14 -08:00
William Casarin
7894cad4f4 project: add 'updates files in place' to info.plist
xcode seems to be complaining about this. We technically open the LMDB
file in place? Maybe this is what it detected and is complaining about.
2024-01-24 09:39:59 -08:00
William Casarin
44bcae1485 project: upgrade to recommended settings
since xcode was complaining
2024-01-24 09:39:42 -08:00
William Casarin
d455d86a05 mention: fix missing @ on mentions
Fixes: d07ad67778 ("nip19: add bech32 TLV url parsing")
2024-01-22 15:00:20 -08:00
Suhail Saqan
c67741983e camera: add ability to preview media taken with camera
Closes: https://github.com/damus-io/damus/pull/1254
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
Changelog-Added: Add ability to preview media taken with camera
2024-01-22 14:20:20 -08:00
Suhail Saqan
ca779d472d camera: switch to new custom view
Closes: https://github.com/damus-io/damus/pull/1254
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
Changelog-Added: Added a custom camera view
2024-01-22 14:20:05 -08:00
Charlie Fish
f341a37902 mute: migrating muted_threads to new mute list
This patch depends on: Adding ability to mute hashtag from SearchView

This is the last patch for the new mute list feature

- Removing MutedThreadsManager
- Adding system to migrate existing muted threads to new mute list

Closes: https://github.com/damus-io/damus/issues/1718
Closes: https://github.com/damus-io/damus/issues/856
Lighting Address: fishcharlie@strike.me
Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-22 12:55:03 -08:00
Charlie Fish
ace8a7081b mute: adding ability to mute hashtag from SearchView
This patch depends on: Updating UI to support new mute list

- Adding the ability to mute a hashtag from SearchView

Related: https://github.com/damus-io/damus/issues/1718
Related: https://github.com/damus-io/damus/issues/856
Lighting-address: fishcharlie@strike.me
Changelog-Added: Add ability to mute hashtag from SearchView
Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-22 12:55:03 -08:00
Charlie Fish
75d66434f3 mute: updating UI to support new mute list
This patch depends on: Adding filtering support for MuteItem events

- Gives more specific mute reason in EventMutedBoxView
- Showing all types of mutes in MutelistView
- Allowing for adding mutes directly from MutelistView
- Allowing for choosing duration of mute in EventMenu

Related: https://github.com/damus-io/damus/issues/1718
Related: https://github.com/damus-io/damus/issues/856
Lighting Address: fishcharlie@strike.me

Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-22 12:55:03 -08:00
Charlie Fish
61a9e44898 mute: adding filtering support for MuteItem events
This patch depends on: Receiving New Mute List Type

- Changes NewMutesNotify, NewUnmutesNotify & MuteNotify to use MuteItem instead of Pubkey
- Changes is_muted in Contacts.swift to take in a MuteItem instead of a Pubkey
    - A lot of changes here were just modifying callers of that to accept the new parameter type

Related: https://github.com/damus-io/damus/issues/1718
Related: https://github.com/damus-io/damus/issues/856
Lighting Address: fishcharlie@strike.me

Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-22 11:03:06 -08:00
Charlie Fish
2861ee2c12 mute: receiving New Mute List Type
This patch depends on: Migrate Lists.swift to use new MuteItem

- Makes request for new mute list type (kind:10000)
- Processing new mute list type (kind:10000)

Related: https://github.com/damus-io/damus/issues/1718
Related: https://github.com/damus-io/damus/issues/856
Lighting Address: fishcharlie@strike.me

Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-22 11:03:06 -08:00
Charlie Fish
0f05123ef8 mute: migrate Lists.swift to use new MuteItem
This patch depends on: Adding new structs/enums for new mute list

- Rewrites Lists.swift to use new mute list option
    - This leads to a lot of changes for changing the type from RefId to the new MuteItem
- Update & relay new mute list in AddMuteItemView.swift (fixing previous patch TODO)
- Renames `list` to `list_deprecated`
    - We need to keep this since existing users might have an old mute list

Related: https://github.com/damus-io/damus/issues/1718
Related: https://github.com/damus-io/damus/issues/856
Lighting Address: fishcharlie@strike.me

Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-22 11:03:06 -08:00
Charlie Fish
9f332a148f mute: add new UI views for new mute list
- Adding MuteDurationMenu & AddMuteItemView
    - In a future patch I will update AddMuteItemView to actually update and relay the new mute list

Related: https://github.com/damus-io/damus/issues/1718
Related: https://github.com/damus-io/damus/issues/856
Lighting Address: fishcharlie@strike.me

Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-22 11:03:06 -08:00
Charlie Fish
50f45288ce mute: adding new structs/enums for new mute list
- Adding MuteItem & DamusDuration
- Changing RefId hashtag associated type from TagElem to Hashtag
    - This is done because in MuteItem, we can not create a RefId.hashtag TagElem instance since we don’t have a note associated with a given hashtag mute item.

Related: https://github.com/damus-io/damus/issues/1718
Related: https://github.com/damus-io/damus/issues/856
Lighting Address: fishcharlie@strike.me

Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-22 11:03:06 -08:00
kernelkind
5840b85213 nip19: generate nip19 data in ellipsis & share menus
In the ellipsis menu, when the user selects the 'Copy user public key',
an nprofile Bech32 entity will be generated which will include TLV data
with the user's public key and entire relay list, if available.
The nprofile will be added to the user's copy clipboard.

When the user presses the share menu on an event and clicks the 'copy
link' button, a damus link with the nevent for the note will be
generated and copied to the user's clipboard.

Closes: https://github.com/damus-io/damus/issues/1844
Changelog-Changed: Generate nprofile/nevent links in share menus
Lightning-url: LNURL1DP68GURN8GHJ7EM9W3SKCCNE9E3K7MF0D3H82UNVWQHKWUN9V4HXGCTHDC6RZVGR8SW3G
Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-22 10:50:52 -08:00
kernelkind
0650a62791 nip19: add search functionality for naddr, nprofile & nevent
The user is able to search for naddr, nprofile & nevent bech32 entities.
Additionally, these entities and others are able to have prefixes such
as damus:nostr: and damus.io links.

Closes: https://github.com/damus-io/damus/issues/1841
Closes: https://github.com/damus-io/damus/issues/1650
Changelog-Added: Add ability to search for naddr, nprofiles, nevents
Lightning-url: LNURL1DP68GURN8GHJ7EM9W3SKCCNE9E3K7MF0D3H82UNVWQHKWUN9V4HXGCTHDC6RZVGR8SW3G
Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-22 10:50:25 -08:00
kernelkind
a4a0465605 nip19: add naddr link support
Add functionality for when user taps on naddr link they get redirected
to the proper note.

Closes: https://github.com/damus-io/damus/issues/1806

Changelog-Added: Add naddr link support
Lightning-url: LNURL1DP68GURN8GHJ7EM9W3SKCCNE9E3K7MF0D3H82UNVWQHKWUN9V4HXGCTHDC6RZVGR8SW3G
Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-22 10:50:03 -08:00
kernelkind
3056ab9bfb nip19: add EventLoaderView for nevent mentions
Allow nevent mentions to be loaded in the MentionView since they are
just a note with extra metadata.

Lightning-url: LNURL1DP68GURN8GHJ7EM9W3SKCCNE9E3K7MF0D3H82UNVWQHKWUN9V4HXGCTHDC6RZVGR8SW3G
Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-22 10:49:51 -08:00
kernelkind
d07ad67778 nip19: add bech32 TLV url parsing
Create shortened URLs for bech32 with TLV data strings. Additionally,
upon clicking on an nevent URL the user is directed to the note.

Lightning-url: LNURL1DP68GURN8GHJ7EM9W3SKCCNE9E3K7MF0D3H82UNVWQHKWUN9V4HXGCTHDC6RZVGR8SW3G
Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-22 10:49:33 -08:00
kernelkind
af75eed83a mention: fix broken mentions when there is text is directly after
If a user adds text right after a mention without a space, a space gets
added so the mention can be parsed correctly after posting.

Closes: https://github.com/damus-io/damus/issues/1872
Changelog-Fixed: Fix broken mentions when there is text is directly after
Lightning-url: LNURL1DP68GURN8GHJ7EM9W3SKCCNE9E3K7MF0D3H82UNVWQHKWUN9V4HXGCTHDC6RZVGR8SW3G
Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-22 10:40:17 -08:00
kernelkind
aa1f75ad58 purple: polish UI/UX flow
Contributes to closing https://github.com/damus-io/damus/issues/1847.

- Fixes (1), the black bar at the top of the view is removed.
- Fixes (2), Scrolling down does not reveal an unappealing navigation bar
- Fixes (3), After the user presses 'continue', the user is taken back to
DamusPurpleView. If the user is shown a confirmation dialog, it waits
for user interaction, and after it dismisses DamusPurpleView to go back
to the home screen. If the user isn't shown the confirmation dialog, the
user is brought to the home screen straight away.
- May or may not fix (4)
- Fixes (5), the theme persists in light and dark mode

Lightning-url: LNURL1DP68GURN8GHJ7EM9W3SKCCNE9E3K7MF0D3H82UNVWQHKWUN9V4HXGCTHDC6RZVGR8SW3G
Signed-off-by: kernelkind <kernelkind@gmail.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-22 10:39:40 -08:00
kernelkind
f9bfa9dfa5 carousel: save current viewed image index when switching to fullscreen
When scrolling through the media carousel, the currently viewed media
should persist between fullscreen and carousel view instead of resetting
to the first index.

Closes: https://github.com/damus-io/damus/issues/1329

Changelog-Fixed: Save current viewed image index when switching to fullscreen
Lightning-url: LNURL1DP68GURN8GHJ7EM9W3SKCCNE9E3K7MF0D3H82UNVWQHKWUN9V4HXGCTHDC6RZVGR8SW3G
Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-22 10:38:01 -08:00
kernelkind
71b33fcd0b mention: allow mentioning users with punctuation characters in their names
Finds the range of characters which represents a mention the user is
typing in PostView. Previously the tokenizer used the standard
granularity UITextGranularity.word, but this is insufficient for our use
case for detecting when a mention ends.

The criteria is simple: a mention starts with the '@' character and ends
when there is a space, line break, or @, instead of when there is a new
word.

Closes: https://github.com/damus-io/damus/issues/1721

Changelog-Fixed: Allow mentioning users with punctuation characters in their names
Lightning-url: LNURL1DP68GURN8GHJ7EM9W3SKCCNE9E3K7MF0D3H82UNVWQHKWUN9V4HXGCTHDC6RZVGR8SW3G
Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-22 10:37:03 -08:00
Daniel D’Aquino
534969e616 purple: add support for LN checkout flow
This commit adds support for the LN checkout flow, and the Purple
landing page:

1. It adds a "learn more" button on the Damus Purple view, where the
   user can learn more
2. It adds new `damus:purple` urls to enable the LN checkout flow

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-18 10:13:01 -08:00
alltheseas
75a9b4df7f menu: move mute thread further away from bookmark
Closes: https://github.com/damus-io/damus/pull/1886
Closes: https://github.com/damus-io/damus/pull/1878
Changelog-Changed: Move mute thread in menu so it's not clicked by accident
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-16 21:04:51 -08:00
kernelkind
a9e9701243 nip19: add high level bech32 encoding method
Add encode method for converting Bech32Object to a bech32 encoded
string. This will be useful for generalized conversion of Bech32Objects
to its string representation.

Lightning-url: LNURL1DP68GURN8GHJ7EM9W3SKCCNE9E3K7MF0D3H82UNVWQHKWUN9V4HXGCTHDC6RZVGR8SW3G
Signed-off-by: kernelkind <kernelkind@gmail.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-16 16:55:31 -08:00
kernelkind
cb4adf06f1 nip19: added swift enums
Add enums to reflect Bech32 with TLV encoded data. Update parse method
to call C library for generalized parsing of bech32 data.

Lightning-url: LNURL1DP68GURN8GHJ7EM9W3SKCCNE9E3K7MF0D3H82UNVWQHKWUN9V4HXGCTHDC6RZVGR8SW3G
Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-16 16:55:31 -08:00
kernelkind
97fc415b8c nip19: add kind to naddr & nevent
Add support for type KIND for bech32-encoded entities naddr and nevent
as specified in NIP-19.

LNURL1DP68GURN8GHJ7EM9W3SKCCNE9E3K7MF0D3H82UNVWQHKWUN9V4HXGCTHDC6RZVGR8SW3G

Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-16 16:55:24 -08:00
William Casarin
d49cf5a505 perf: debounce scroll queue 2024-01-16 16:51:28 -08:00
William Casarin
89e01d823a debouncer: add new debounce methods 2024-01-16 16:03:10 -08:00
William Casarin
6edb3b1a40 timeline: remove dubious button styling setting 2024-01-11 13:05:26 -08:00
William Casarin
d591dc0a7a perf: reduce the number of swiftui updates in ImageCarousel
not sure if it helps much, but just trying to see reduce the number of
updates overall. Trying to fix swiftui scroll performance :(
2024-01-11 11:47:44 -08:00
William Casarin
3b436f9d58 perf: fix gif decoding performance issues
Preload as much as possible so that gif's don't use as much CPU

Changelog-Fixed: Fix performance issue with gifs
2024-01-11 11:07:03 -08:00
William Casarin
4cf92756f1 close nostrdb and disconnect from relays on logout
This was causing crashing and corruption issues. This should have been
handled by the garbage collector, but for some reason old references
still hang around.

Add a "close" method to DamusState which disconnects from relays and
closes nostrdb.

Changelog-Fixed: Fix crash when logging out and switching accounts
Changelog-Fixed: Fix persistent local notifications even after logout
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-10 16:25:07 -08:00
William Casarin
6834367386 ndb: fix crashed when trying to process client event on a closed db 2024-01-10 15:38:31 -08:00
William Casarin
afe3dcf039 test: switch test to use failable transactions 2024-01-10 15:38:21 -08:00
William Casarin
091a8ae090 test: fix crashing in AUTH test 2024-01-10 15:37:52 -08:00
William Casarin
ed30b123db notifications: don't hold onto note ref outside of txn
It's never safe to return an unsafeUnownedValue

Fixes: c4f0e833ff ("Reuse local notification logic with push notifications")
Cc: Daniel DAquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-10 14:43:47 -08:00
William Casarin
bfad2ab42d ndb/txn: make transactions failable
Since there may be situations where we close and re-open the database,
we need to make sure transactions fail when the database is not open.

Make NdbTxn an init?() constructor and check for ndb.closed. If it's
closed, then fail transaction construction.

This fixes crashes during high database activity when switching from
background to foreground and vice-versa.

Fixes: da2bdad18d ("nostrdb: close database when backgrounded")
2024-01-10 14:27:02 -08:00
William Casarin
227734d286 Revert "Revert "nostrdb: close database when backgrounded""
This reverts commit 26bd50c948.
2024-01-10 13:19:36 -08:00
William Casarin
909701ce7b profile: partially fix performance regression
This will be completely fixed once we switch to stored note blocks
2024-01-10 11:52:30 -08:00
Daniel D’Aquino
54674104ea Change DamusUserDefaults to mirror settings from app container
... to shared container instead of migrating

This commit is a reimplementation of DamusUserDefaults that mirrors
settings from the app to the shared container (instead of migrating
values over).

This new implementation brings the benefit of being backwards compatible
with the user's settings. That is, even if the user upgrades or
downgrades between various versions and changes settings along the way,
the main settings in the app will stay consistent between Damus versions
— that is, changes to the settings would not be lost between
downgrades/upgrades

General settings test
----------------------

PASS

Device: iPhone 15 Pro simulator
iOS: 17.0.1
Damus: This commit
Setup: A device with non-standard settings
Steps:
1. Flash Damus on the device
2. Check any non-default settings that were there before. Ensure that settings remained the same. PASS
3. Change one setting (any setting) to a non-default value
4. Restart Damus
5. Ensure settings change in step 3 persisted on the device

Notification settings test
--------------------------

PASS

Device: iPhone 15 Pro simulator
iOS: 17.0.1
Damus: This commit
Setup:
- Two phones running Damus on different accounts
- Local relay with strfry-push-notify test setup
- Apple push notification test tool

Coverage:
1. Mention notifications
2. DM notifications
3. Reaction notifications
4. Repost notifications

Steps for each notification type:
1. Use the secondary phone to generate a push notification
2. Trigger the push notification (Send push notification from test tool)
3. Ensure that the notification is received on the other device
4. Turn off notifications for that notification type on settings
5. Trigger the same push notification (Resend push notification from test tool)
6. Ensure that the notification is not received on the other device
7. Turn on notifications for that notification type on settings
8. Trigger the same push notification (Resend from test tool)
9. Ensure that notification appears on the device

Result: PASS (notifications are received when enabled and not received when disabled)
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-10 11:07:01 -08:00
Daniel D’Aquino
aab9e97a25 Fix test target build error
Unit tests
-----------

Device: iPhone 15 Pro simulator
iOS: 17.0.1
Damus: This commit

Steps: Run unit tests
Result: No regressions found (Tests either pass or they were already failing before the patches)

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-10 11:06:32 -08:00
Daniel D’Aquino
c10ce4b1ba Do not show notifications from muted users
Muted users + unfollowed users test
------------------------------------

PASS

Device: iPhone 15 Pro simulator
iOS: 17.0.1
Damus: This commit
Setup:
- Two phones running Damus on different accounts
- Local relay with strfry-push-notify test setup
- Apple push notification test tool

Steps:
1. Unfollow the user who is the author of the saved notifications
2. Disable notifications for people you don't follow
3. Trigger a push notification (Resend push notification from test tool)
4. Ensure that the notification is not received on the other device
5. Enable notifications for people you don't follow
6. Trigger a push notification (Resend push notification from test tool)
7. Ensure that the notification is received on the other device
8. Mute the user who is the author of the saved notifications
9. Trigger a push notification (Resend push notification from test tool)
10. Ensure that the notification is not received on the other device
11. Unmute the user who is the author of the saved notifications
12. Trigger a push notification (Resend push notification from test tool)
13. Ensure that the notification is received on the other device

Result: PASS

Closes: https://github.com/damus-io/damus/issues/1705
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-10 11:06:32 -08:00
Daniel D’Aquino
88801c2762 Fix user setting access issue in the notification extension
Notification settings test
--------------------------

PASS

Device: iPhone 15 Pro simulator
iOS: 17.0.1
Damus: This commit
Setup:
- Two phones running Damus on different accounts
- Local relay with strfry-push-notify test setup
- Apple push notification test tool

Coverage:
1. Mention notifications
2. DM notifications
3. Reaction notifications
4. Repost notifications

Steps for each notification type:
1. Trigger a push notification (Resend push notification from test tool)
2. Ensure that the notification is received on the other device
3. Turn off notifications for that type on settings
4. Trigger a push notification (Resend push notification from test tool)
5. Ensure that the notification is not received on the other device

Result: PASS (notifications are received when enabled and not received when disabled)

Closes: https://github.com/damus-io/damus/issues/1764
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-10 11:06:32 -08:00
Daniel D’Aquino
003482c971 Implement zap notification support for push notifications
The code paths for generating zap notifications were very different from
the paths used by most other notifications. In this commit, I include
the logic and data structures necessary for formatting zap notifications
in the same fashion as local notifications.

A good amount of refactoring and moving functions/structures around was
necessary to reuse zap local notification logic. I also attempted to
make the notification generation process more consistent between zaps
and other notifications, without changing too much of existing logic to
avoid even more regression risk.

General push notifications + local notifications test
-----------------------------------------------------

PASS

Device: iPhone 15 Pro simulator
iOS: 17.0.1
Damus: This commit
Setup:
- Two phones running Damus on different accounts
- Local relay with strfry-push-notify test setup
- Apple push notification test tool

Coverage:
1. Mention notifications
2. DM notifications
3. Reaction notifications
4. Repost notifications

Steps for each notification type:
1. Trigger a notification (local and then push)
2. Ensure that the notification is received on the other device
3. Ensure that the notification is formatted correctly
4. Ensure that DMs are decrypted correctly
5. Ensure that profile names are unfurled correctly
6. Click on the notification and ensure that the app opens to the correct screen

Result: PASS (all notifications received and formatted correctly)

Notes:
- For some reason my relay is not receiving zap events, so I could not
  test zap notifications yet.

- Reply notifications do not seem to be implemented yet

- These apply to the tests below as well

Changelog-Added: Zap notification support for push notifications
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-10 11:06:32 -08:00
Daniel D’Aquino
c4f0e833ff Reuse local notification logic with push notifications
Testing
-------

Conditional pass

Device: iPhone 15 Pro simulator
iOS: 17.0.1
Damus: This commit
Coverage:

1. Mention notification works (local and push). PASS

2. Thread replies do not appear (but upon code inspection it seems like
   it was not supported before). PASS?

3. DM notification works with decryption (local and push). PASS

4. Zaps not yet implemented. Coming later.

Closes: https://github.com/damus-io/damus/issues/1702
Closes: https://github.com/damus-io/damus/issues/1703
Changelog-Changed: Improve push notification support to match local notification support
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-10 11:06:32 -08:00
Daniel D’Aquino
5db22ae244 Migrate setting and key stores to shared UserDefaults
This is needed to allow the notification extension to process push notifications, respect user's notification settings, and decrypt DMs on the push notification

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-10 11:06:32 -08:00
Daniel D’Aquino
88f938d11c Bring local notification logic into the push notification target
This commit brings key local notification logic into the notification
extension target to allow the extension to reuse much of the
functionality surrounding the processing and formatting of
notifications. More specifically, the functions
`process_local_notification` and `create_local_notification` were
brought into the extension target.

This will enable us to reuse much of the pre-existing notification logic
(and avoid having to reimplement all of that)

However, those functions had high dependencies on other parts of the
code, so significant refactorings were needed to make this happen:

- `create_local_notification` and `process_local_notification` had its
  function signatures changed to avoid the need to `DamusState` (which
  pulls too many other dependecies)

- Other necessary dependencies, such as `Profiles`, `UserSettingsStore`
  had to be pulled into the extension target. Subsequently,
  sub-dependencies of those items had to be pulled in as well

- In several cases, files were split to avoid pulling too many
  dependencies (e.g. Some Model files depended on some functions in View
  files, so in those cases I moved those functions into their own
  separate file to avoid pulling in view logic into the extension
  target)

- Notification processing logic was changed a bit to remove dependency
  on `EventCache` in favor of using ndb directly (As instructed in a
  TODO comment in EventCache, and because EventCache has too many other
  dependencies)

tldr: A LOT of things were moved around, a bit of logic was changed
around local notifications to avoid using `EventCache`, but otherwise
this commit is meant to be a no-op without any new features or
user-facing functional changes.

Testing
-------

Device: iPhone 15 Pro
iOS: 17.0.1
Damus: This commit
Coverage:

1. Ran unit tests to check for regressions (none detected)

2. Launched the app and navigated around and did some interactions to
   perform a quick functional smoke test (no regressions found)

3. Sent a few push notifications to check they still work as expected (PASS)

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-10 11:06:30 -08:00
Daniel D’Aquino
4171252b18 Unfurl profile name on remote push notifications
This commit adds support for the unfurling of author profile names on remote push notifications

It also makes the following changes:
- Notification extension now uses NdbNote
- Some of the logic between push notifications and local notifications was unified

Testing
-------

Device: iPhone 15 Pro simulator
iOS: 17.0.1
Damus: This commit
Coverage:
1. Basic smoke tests on the app by browsing different notes and different tabs
2. Sent test push notifications for mentions and DMs to check the unfurling of profile names
3. Ran unit tests

Closes: https://github.com/damus-io/damus/issues/1703
Changelog-Added: Unfurl profile name on remote push notifications
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-10 10:37:45 -08:00
vuittont60
460f536fa3 docs: fix typos
Closes: https://github.com/damus-io/damus/pull/1856
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-10 10:06:15 -08:00
William Casarin
c3e94f367c docs: update CONTRIBUTING to mention v2 patch series
I realized we didn't have this in the contributor docs. Noone will read
it, but at least we'll have something to point to for future contributors.

Cc: kernelkind <kernelkind@gmail.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-10 09:56:19 -08:00
bf78c0a3a0 translation: add workaround to reduce wasteful translation requests
Signed-off-by: Terry Yiu <git@tyiu.xyz>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
Changelog-Fixed: Add workaround to fix note language recognition and reduce wasteful translation requests
2024-01-08 12:28:33 -08:00
Charlie Fish
eb41846bb9 search: prioritize friends when autocompleting
Lightning-Address: fishcharlie@strike.me
Closes: https://github.com/damus-io/damus/issues/1620
Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
Changelog-Changed: Prioritize friends when autocompleting
2024-01-08 12:28:29 -08:00
Charlie Fish
f7946b1a7c relay: fix case where content is empty when adding relay
Lightning-Address: fishcharlie@strike.me
Closes: https://github.com/damus-io/damus/issues/1849
Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
Changelog-Fixed: Fix issue where adding relays might not work on corrupted contact lists
2024-01-08 12:28:24 -08:00
Charlie Fish
84cfeb1604 nip42: add initial relay auth support
Lightning-Invoice: lnbc1pjcpaakpp5gjs4f626hf8w6slx84xz3wwhlf309z503rjutckdxv6wwg5ldavsdqqcqzpgxqrrs0fppqjaxxw43p7em4g59vedv7pzl76kt0qyjfsp5qcp9de7a7t8h6zs5mcssfaqp4exrnkehqtg2hf0ary3z5cjnasvs9qyyssq55523e4h3cazhkv7f8jqf5qp0n8spykls49crsu5t3m636u3yj4qdqjkdl2nxf6jet5t2r2pfrxmm8rjpqjd3ylrzqq89m4gqt5l6ycqf92c7h
Closes: https://github.com/damus-io/damus/issues/940
Signed-off-by: Charlie Fish <contact@charlie.fish>
Signed-off-by: William Casarin <jb55@jb55.com>
Changelog-Added: Add NIP-42 relay auth support
2024-01-05 10:36:03 -08:00
Charlie Fish
4c37bfc128 profile: filter events that don’t match pubkey
Closes: https://github.com/damus-io/damus/issues/1846
Lightning-Invoice: lnbc1pjef2gupp5ffv0he47r6s6us9s2pfxy023mx8lutwlh3sq365rzgmmj6efl8nsdqqcqzpgxqrrs0fppq65gwnyvf5pn5zj5ryx9s4n7y58clk7yqsp5v7pa2ges4rgvtt0nh6lnt4cevm8n2ql9p7kqstwfp4wutf8faa8q9qyyssqwx8t9kk0m3jj6vu0kvftl3nc8zqyfl5l8ne058q5dnqyad3cqfz8vdnna5g0vy9f2ttwugc0sr20p0hsem84g8xd85ptnwgmryrf4lqqmygv34
Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
Changelog-Fixed: Fixed bug where sometimes notes from other profiles appear on profile pages
2024-01-05 10:36:02 -08:00
kernelkind
548af2bf9d localization: add support for purple strings
Add string localizations so the purple landing pages can have multi language support.

LNURL1DP68GURN8GHJ7EM9W3SKCCNE9E3K7MF0D3H82UNVWQHKWUN9V4HXGCTHDC6RZVGR8SW3G

Closes: https://github.com/damus-io/damus/issues/1816
Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Reviewed-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-05 10:36:02 -08:00
kernelkind
692146fe00 add comma as disallowed char at end of url
Do not include comma as part of a URL if it is followed by whitespace.
This allows users to make lists of URLs.

Closes: https://github.com/damus-io/damus/issues/1833

LNURL1DP68GURN8GHJ7EM9W3SKCCNE9E3K7MF0D3H82UNVWQHKWUN9V4HXGCTHDC6RZVGR8SW3G

Signed-off-by: kernelkind <kernelkind@gmail.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-02 12:07:55 -08:00
William Casarin
40134b4365 Merge translations 2024-01-01 14:54:50 -08:00
Daniel D’Aquino
9a547077c1 Hook up Damus Purple translation service
This commit integrates the Damus Purple translation service:
- Automatically handles translation settings change after purchase
- Asks for permission to override translation settings if the user already has translation setup
- Translation settings can be changed with Damus Purple, if desired
- Translation requests working with the Damus API server

Testing
--------

PASS

Device: iPhone 15 simulator
iOS: 17.2
Damus: This commit
Damus Purple API server: `9397201d7d55ddcec4c18fcd337f759b61dce697` running on Ubuntu 22.04 LTS VM (npm run dev)
iOS setting: English set as the only preferred language.
Steps:
1. Enable Damus Purple feature flag on developer settings, set purple localhost mode, and restart app
2. Set translation setting to something other than none (e.g. DeepL)
3. Simulate Damus Purple purchase
4. Check that when dismissing welcome view, a confirmation prompt will ask the user whether they want to switch translator to Damus Purple. PASS
5. Click "Yes".
6. Go to translation settings. Check that translation settings are set to "Purple". PASS
7. Go to a non-English profile. Check that translations appear with "Mock translation" (Which is the translation text provided by the mock translation server). PASS
8. Reinstall app
9. Repeat the test, but this time starting with no translation settings. Make sure that translation settings will automatically switch to Damus Purple. PASS

Feature flag testing
--------------------

PASS

Preconditions: Same as above
Steps:
1. Turn off translation
2. Turn off Damus Purple feature flag
3. Go to translation settings. Make sure that Damus Purple is not an option. PASS

Closes: https://github.com/damus-io/damus/issues/1836
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-01-01 04:44:24 -08:00
transifex-integration[bot]
0d71cc18ad Translate Localizable.stringsdict in sv_SE
100% translated source file: 'Localizable.stringsdict'
on 'sv_SE'.
2023-12-31 07:59:57 +00:00
transifex-integration[bot]
d10554ab6c Translate InfoPlist.strings in sv_SE
100% translated source file: 'InfoPlist.strings'
on 'sv_SE'.
2023-12-31 07:58:53 +00:00
transifex-integration[bot]
2656c30832 Translate Localizable.strings in sv_SE
100% translated source file: 'Localizable.strings'
on 'sv_SE'.
2023-12-31 07:56:49 +00:00
Daniel D’Aquino
39b6dfb47e purple: update client to work with damus-api post express refactor
these changes were made to adapt the client-side to the new improvements
done to the Purple API server (See
https://github.com/damus-io/api/issues/1)

Testing
-------

PASS

Device: iPhone 15 Pro simulator
iOS: 17.2
Damus: This commit
damus-api: bda56590be7eb47e21dfd61ab94b17f6a8595d0c
Server: Ubuntu 22.04 (VM)
Setup:
1. On the server, delete the `mdb` database files to start from scratch
2. Enable subscriptions support via developer settings with localhost
   test mode and restart app
3. Get Damus Purple (Purchase it via Xcode test environment)
4. Start server with mock parameters (Run `npm run dev`)
5. Restart app

Steps
-----

1. Post something
2. Gold star should appear beside your name
3. Look at the server logs. There should be some requests to create the
   account (POST), to send the receipt (POST), and to get account
   status. Those three requests should have returned HTTP status 200.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2023-12-27 18:42:38 -08:00
Daniel D’Aquino
5ca5420ce2 Damus Purple: Add npub authentication for account management API calls
Testing
---------

PASS

Device: iPhone 15 Pro simulator
iOS: 17.2
Damus: This commit
damus-api: 626fb9665d8d6c576dd635d5224869cd9b69d190
Server: Ubuntu 22.04 (VM)
Setup:
1. On the server, delete the `mdb` database files to start from scratch
2. In iOS, reinstall the app if necessary to make sure there are no in-app purchases
3. Enable subscriptions support via developer settings with localhost test mode and restart app
4. Start server with mock parameters (Run `npm run dev`)

Steps:
1. Open top bar and click on "Purple"
2. Purple screen should appear and show both benefits and the purchase options. PASS
3. Click on "monthly". An Apple screen to confirm purchase should appear. PASS
4. Welcome screen with animation should appear. PASS
5. Click continue and restart app (Due to known issue tracked at damus-io#1814)
6. Post something
7. Gold star should appear beside your name
8. Look at the server logs. There should be some requests to create the account (POST), to send the receipt (POST), and to get account status
9. Go to purple view. There should be some information about the subscription, as well as a "manage" button. PASS
10. Click on "manage" button. An iOS sheet should appear allow the user to unsubscribe or manage their subscription to Damus Purple.

Closes: https://github.com/damus-io/damus/issues/1809
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
2023-12-24 09:30:26 -08:00
Daniel D’Aquino
4703ed80a7 Damus Purple initial Proof-of-Concept support
This commit includes various code changes necessary to get a basic proof of concept of the feature working.

This is NOT a full working feature yet, only a preliminary prototype/PoC. It includes:
- [X] Basic Storekit configuration
- [X] Basic purchase mechanism
- [X] Basic layout and copywriting
- [X] Basic design
- [X] Manage button (To help user cancel their subscription)
- [X] Thank you confirmation + special welcome view
- [X] Star badge on profile (by checking the Damus Purple API)
- [X] Connection to Damus purple API for fetching account info, registering for an account and sending over the App Store receipt data

The feature sits behind a feature flag which is OFF by default (it can be turned ON via Settings --> Developer settings --> Enable experimental Purple API and restarting the app)

Testing
---------

PASS

Device: iPhone 15 Pro simulator
iOS: 17.0.1
Damus: This commit
damus-api: 59ce44a92cff1c1aaed9886f9befbd5f1053821d
Server: Ubuntu 22.04 (VM)
Setup:
1. On the server, delete the `mdb` database files to start from scratch
2. In iOS, reinstall the app if necessary to make sure there are no in-app purchases
3. Enable subscriptions support via developer settings with localhost test mode and restart app
4. Start server with mock parameters (Run `npm run dev`)

Steps:
1. Open top bar and click on "Purple"
2. Purple screen should appear and show both benefits and the purchase options. PASS
3. Click on "monthly". An Apple screen to confirm purchase should appear. PASS
4. Welcome screen with animation should appear. PASS
5. Click continue and restart app (Due to known issue tracked at https://github.com/damus-io/damus/issues/1814)
6. Post something
7. Gold star should appear beside your name
8. Look at the server logs. There should be some requests to create the account (POST), to send the receipt (POST), and to get account status
9. Go to purple view. There should be some information about the subscription, as well as a "manage" button. PASS
10. Click on "manage" button. An iOS sheet should appear allow the user to unsubscribe or manage their subscription to Damus Purple.

Feature flag testing
--------------------

PASS

Preconditions: Continue from above test
Steps:
1. Disable Damus Purple experiment support on developer settings. Restart the app.
2. Check your post. There should be no star beside your profile name. PASS
3. Check side menu. There should be no "Damus Purple" option. PASS
4. Check server logs. There should be no new requests being done to the server. PASS

Closes: https://github.com/damus-io/damus/issues/1422
2023-12-24 09:30:26 -08:00
Daniel D’Aquino
f7e407e030 Fix crash on very large notes
This commit fixes a crash that occured on large notes with several artifacts.

The crash was caused by an empirically observed limitation on the amount
of `Text` objects that can be added together. Adding several `Text`
objects together causes a infinite recursion and subsequent stack
overflow.

The fix applied changes the `CompatibleText` class to store several
items in a list, which concatenates attributed strings when possible to
reducethe amount of `Text` objects used.

This commit also:

- Changes the structure to avoid storing SwiftUI objects on a variable,
  but making it into a computed property instead.

- Renders a nice error message when the note is too large to be rendered
  (instead of crashing)

With this new commit, we can render much larger notes, and the only ones
that will not be displayed are those containing more than 50 custom
hashtags.

Since we do not even have 50 custom hashtag types, the only notes that
won't be rendered are spammy notes that repeat the same hashtags over
and over again.

Testing
-------

PASS

Device: iPhone 13 Mini (Physical device)
iOS: 17.2
Damus: This commit
Setup:

- Local test relay and a test account running on a simulator to post
  those long test notes.

- Local web page server to serve a link to the problematic note.
  (nostr:note1ttfgneka3lt6yuutmr0uls5xd6z975fgdzpfkxwwf40dd38pjcqqvzvxaj)

Steps
-----

1. Click on the link to open the note
2. Check that no crash occurs and that the note is rendered correctly. PASS
3. Click on the note to render the SelectableText view (Different code
   path). Make sure that no crash occurs and that it is rendered
   correctly. PASS
4. On the simulator, post a note with 50 "#bitcoin" hashtags displayed
   (100 items).
5. Open the note on the physical iPhone. Make sure that no crash occurs
   and that the note is rendered correctly. PASS
6. Ensure that the hashtag and hashtag icons are rendered. PASS
7. Click on the note and make sure that the SelectableText view is
   rendered correctly. PASS[1]
8. On the simulator, post a note with 51 "#bitcoin" hashtags displayed
   (102 items).
9. Open the note on the physical iPhone. Make sure that a nice error
   message is displayed. PASS
10. Click on the note and make sure that the SelectableText view is
    rendered correctly. PASS[1]

Notes

[1] On the SelectableText view, special hashtags always render with the
purple color. This behavior was already present before the changes, so
it is not a regression.

Changelog-Fixed: Fix crash on very large notes
Closes: https://github.com/damus-io/damus/issues/1826
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2023-12-24 09:27:46 -08:00
kernelkind
e547e26d99 Handle period at end of URL
Fix parsing URL when encountering a period at the end of the url by
setting it as disallowed from being present at the end of a
URL.

Some characters are disallowed to be present at the end of URLs.
Presently, the period character is the only disallowed character.
A character is the last character in the URL if it is followed by
is_whitespace() or if it's the last character in the string.

Closes: https://github.com/damus-io/damus/issues/1638

LNURL1DP68GURN8GHJ7EM9W3SKCCNE9E3K7MF0D3H82UNVWQHKWUN9V4HXGCTHDC6RZVGR8SW3G

Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2023-12-22 14:00:15 -08:00
kunigaku
f6044a9eea Fix Issue #1820 Hashtags including U+5009 to U+500D are not correctly parsed
Closes: https://github.com/damus-io/damus/pull/1830
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2023-12-22 13:59:31 -08:00
Daniel D’Aquino
26bd50c948 Revert "nostrdb: close database when backgrounded"
This reverts commit da2bdad18d.

This commit was reverted because it was causing crashes (Occasional `EXC_BAD_ACCESS` errors when accessing database transactions), and because the push notification extension uses Ndb in a read-only manner, which means we no longer need these changes

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2023-12-22 13:57:21 -08:00
Daniel D’Aquino
6e0af0ba10 tests: Disable NostrScriptTests.test_bool_set to reduce noise on CI/CD
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2023-12-22 13:56:57 -08:00
transifex-integration[bot]
44b7ae2054 Translate Localizable.strings in zh_HK
100% translated source file: 'Localizable.strings'
on 'zh_HK'.
2023-12-21 15:15:36 +00:00
transifex-integration[bot]
9cc21fc860 Translate Localizable.strings in zh_TW
100% translated source file: 'Localizable.strings'
on 'zh_TW'.
2023-12-21 15:10:49 +00:00
transifex-integration[bot]
c9526b7aa6 Translate Localizable.strings in zh_TW
100% translated source file: 'Localizable.strings'
on 'zh_TW'.
2023-12-21 15:09:44 +00:00
transifex-integration[bot]
055b7af1a3 Translate Localizable.strings in zh_TW
100% translated source file: 'Localizable.strings'
on 'zh_TW'.
2023-12-21 15:09:26 +00:00
transifex-integration[bot]
de0b1dbda2 Translate Localizable.strings in zh_CN
100% translated source file: 'Localizable.strings'
on 'zh_CN'.
2023-12-21 15:06:43 +00:00
transifex-integration[bot]
7605af84b5 Translate Localizable.strings in zh_CN
100% translated source file: 'Localizable.strings'
on 'zh_CN'.
2023-12-21 14:58:13 +00:00
transifex-integration[bot]
d45eadef35 Translate Localizable.stringsdict in zh_HK
100% translated source file: 'Localizable.stringsdict'
on 'zh_HK'.
2023-12-21 14:54:30 +00:00
transifex-integration[bot]
9cae934062 Translate InfoPlist.strings in zh_HK
100% translated source file: 'InfoPlist.strings'
on 'zh_HK'.
2023-12-21 14:54:10 +00:00
transifex-integration[bot]
7fb5cdf6c0 Translate InfoPlist.strings in zh_TW
100% translated source file: 'InfoPlist.strings'
on 'zh_TW'.
2023-12-21 14:53:43 +00:00
transifex-integration[bot]
49bbe62d2a Translate Localizable.stringsdict in zh_TW
100% translated source file: 'Localizable.stringsdict'
on 'zh_TW'.
2023-12-21 14:51:53 +00:00
transifex-integration[bot]
705accd309 Translate Localizable.strings in zh_CN
100% translated source file: 'Localizable.strings'
on 'zh_CN'.
2023-12-21 12:49:48 +00:00
transifex-integration[bot]
3375ccc4fa Translate InfoPlist.strings in zh_CN
100% translated source file: 'InfoPlist.strings'
on 'zh_CN'.
2023-12-21 12:25:37 +00:00
transifex-integration[bot]
a9fecc3047 Translate Localizable.stringsdict in zh_CN
100% translated source file: 'Localizable.stringsdict'
on 'zh_CN'.
2023-12-21 12:23:51 +00:00
transifex-integration[bot]
31b3ad9825 Translate Localizable.strings in fa
100% translated source file: 'Localizable.strings'
on 'fa'.
2023-12-20 18:13:21 +00:00
transifex-integration[bot]
2bde3a9217 Translate Localizable.stringsdict in fa
100% translated source file: 'Localizable.stringsdict'
on 'fa'.
2023-12-20 15:26:19 +00:00
transifex-integration[bot]
6050116314 Translate InfoPlist.strings in fa
100% translated source file: 'InfoPlist.strings'
on 'fa'.
2023-12-20 15:23:11 +00:00
kernelkind
a2cac142c0 Remove private key leak warning
Remove the private key leak warning in the DMChatView and PostView since
they are no longer needed since nsec is automatically converted into
npub.

Changelog-Removed: Removed old nsec key warning, nsec automatically convert to npub when posting
Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2023-12-18 10:28:31 -08:00
kernelkind
8a20e5845e Remove line break at the end of direct messages
Closes: https://github.com/damus-io/damus/issues/1599
Changelog-Fixed: Remove extra space at the end of DM messages
Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2023-12-18 10:24:10 -08:00
William Casarin
641e2564fb Merge remote-tracking branch 'github/translations' 2023-12-18 10:24:01 -08:00
transifex-integration[bot]
50810033c0 Translate Localizable.stringsdict in es_ES
100% translated source file: 'Localizable.stringsdict'
on 'es_ES'.
2023-12-17 16:51:02 +00:00
transifex-integration[bot]
fab4e231b6 Translate Localizable.strings in es_ES
100% translated source file: 'Localizable.strings'
on 'es_ES'.
2023-12-17 16:50:15 +00:00
transifex-integration[bot]
42ff49a803 Translate Localizable.strings in es_ES
100% translated source file: 'Localizable.strings'
on 'es_ES'.
2023-12-17 16:50:09 +00:00
transifex-integration[bot]
8c878cbc4c Translate InfoPlist.strings in es_ES
100% translated source file: 'InfoPlist.strings'
on 'es_ES'.
2023-12-17 16:16:51 +00:00
transifex-integration[bot]
face4268bf Translate Localizable.strings in es_419
100% translated source file: 'Localizable.strings'
on 'es_419'.
2023-12-17 16:12:23 +00:00
transifex-integration[bot]
fed6c47835 Translate Localizable.strings in es_419
100% translated source file: 'Localizable.strings'
on 'es_419'.
2023-12-17 16:09:43 +00:00
transifex-integration[bot]
8e9fb308f9 Translate Localizable.strings in es_419
100% translated source file: 'Localizable.strings'
on 'es_419'.
2023-12-17 16:08:55 +00:00
transifex-integration[bot]
89b48db92d Translate Localizable.stringsdict in es_419
100% translated source file: 'Localizable.stringsdict'
on 'es_419'.
2023-12-17 16:06:28 +00:00
transifex-integration[bot]
9581cc994d Translate Localizable.strings in es_419
100% translated source file: 'Localizable.strings'
on 'es_419'.
2023-12-17 16:05:30 +00:00
kernelkind
34e32bc930 handle extra slashes for relay url
Closes: https://github.com/damus-io/damus/issues/1766

Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2023-12-16 17:43:00 -08:00
ericholguin
dfcef0ba95 refactor: rename show image references to blur images
Closes: https://github.com/damus-io/damus/pull/1745
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2023-12-13 11:02:15 -08:00
William Casarin
3c11ba53ce add transifex mailmap 2023-12-13 11:02:15 -08:00
transifex-integration[bot]
9759787c95 Translate Localizable.stringsdict in hu_HU
100% translated source file: 'Localizable.stringsdict'
on 'hu_HU'.
2023-12-12 12:09:45 +00:00
transifex-integration[bot]
eef428ce4f Translate Localizable.strings in hu_HU
100% translated source file: 'Localizable.strings'
on 'hu_HU'.
2023-12-12 12:09:10 +00:00
transifex-integration[bot]
d69647e071 Translate InfoPlist.strings in hu_HU
100% translated source file: 'InfoPlist.strings'
on 'hu_HU'.
2023-12-12 11:47:23 +00:00
Daniel D’Aquino
c22f5e90a3 Add regional relay suggestions to Relay Config View
This patch makes sure that regional relays (e.g. Japan relays) are suggested to the relevant users on the Relay configuration view.

It also makes some small improvements to the recommended relays view layout, to allow it to comfortably show more than 4 relay suggestions without "squeezing" items.

Testing
-------

PASS

Devices:
- iPhone 15 Pro simulator running iOS 17.0.1
- iPad Air 5th generation running iOS 16.4
Damus: This commit
Configuration: No relays selected, region set to Canada
Steps:
1. Go to Relay Config view
2. Check that recommended relays are: Damus, nos.lol, nostr.wine, nostr.land. PASS
3. Change region to Japan on settings
4. Open relay config view again
5. Check that Japan relays are suggested on top of the list presented in step 2. PASS
6. Check that layout looks ok, not broken. PASS
7. Check that it is possible to scroll horizontally to see all the options. PASS
8. Add some relays. Check that it is possible to add relays. PASS
9. Check that borders of the recommended relay view fades away nicely (not just clip). PASS
10. Remove some relays. Check that they return to the suggested relays list. PASS

Real device smoke test
----------------------

Device: iPhone 13 mini running iOS 17.1
Damus: This commit
Configuration: Several relays selected. Region set to Canada.
Steps:
1. Check relay config view does not look broken. PASS
2. Remove some recommended relays. Check that they display nicely on the recommended view. PASS
3. Scroll horizontally. Scrolling should work and layout should not be broken. PASS
4. Add those recommended relays again. Should work. PASS

Closes: https://github.com/damus-io/damus/issues/1730
Changelog-Added: Add regional relay recommendations to Relay configuration view (currently for Japanese users only)
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2023-12-11 14:59:56 -08:00
ericholguin
f2fe02032e refactor: add customizable properties to neutral button style
Closes: https://github.com/damus-io/damus/pull/1805
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2023-12-11 14:59:33 -08:00
William Casarin
da2bdad18d nostrdb: close database when backgrounded
Otherwise iOS gets mad because we are holding onto a lockfile in a
shared container which is apparently not allowed.

Fixes: a1e6be214e ("Migrate NostrDB files to shared app group file container")
2023-12-11 14:59:33 -08:00
William Casarin
c7cc8df5ba nostrdb: don't begin query if we have a bad lmdb env
In some weird multithreaded situations after we close the database,
this can be an issue.
2023-12-11 14:59:33 -08:00
William Casarin
92df446d72 Update Translations 2023-12-11 14:59:33 -08:00
transifex-integration[bot]
7ea2af6172 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2023-12-11 16:24:29 +00:00
transifex-integration[bot]
184eea6e68 Translate Localizable.stringsdict in pl_PL
100% translated source file: 'Localizable.stringsdict'
on 'pl_PL'.
2023-12-11 09:54:47 +00:00
transifex-integration[bot]
82372d1bf5 Translate InfoPlist.strings in pl_PL
100% translated source file: 'InfoPlist.strings'
on 'pl_PL'.
2023-12-11 09:53:49 +00:00
transifex-integration[bot]
39f59eb798 Translate Localizable.stringsdict in ja
100% translated source file: 'Localizable.stringsdict'
on 'ja'.
2023-12-11 06:13:42 +00:00
transifex-integration[bot]
639deec1a2 Translate Localizable.strings in ja
100% translated source file: 'Localizable.strings'
on 'ja'.
2023-12-11 06:12:32 +00:00
transifex-integration[bot]
18780002bb Translate Localizable.stringsdict in fi
100% translated source file: 'Localizable.stringsdict'
on 'fi'.
2023-12-10 16:32:31 +00:00
transifex-integration[bot]
722180bb9a Translate Localizable.stringsdict in fi
100% translated source file: 'Localizable.stringsdict'
on 'fi'.
2023-12-10 16:32:24 +00:00
transifex-integration[bot]
366a584934 Translate Localizable.stringsdict in fi
100% translated source file: 'Localizable.stringsdict'
on 'fi'.
2023-12-10 16:32:15 +00:00
transifex-integration[bot]
9ee09c3b59 Translate Localizable.stringsdict in fi
100% translated source file: 'Localizable.stringsdict'
on 'fi'.
2023-12-10 16:32:03 +00:00
transifex-integration[bot]
e8caf3a7f4 Translate Localizable.stringsdict in fi
100% translated source file: 'Localizable.stringsdict'
on 'fi'.
2023-12-10 16:31:50 +00:00
transifex-integration[bot]
b4ff6ee614 Translate Localizable.stringsdict in fi
100% translated source file: 'Localizable.stringsdict'
on 'fi'.
2023-12-10 16:31:35 +00:00
transifex-integration[bot]
ed652db3d3 Translate InfoPlist.strings in es_419
100% translated source file: 'InfoPlist.strings'
on 'es_419'.
2023-12-09 23:36:14 +00:00
transifex-integration[bot]
3d01c29148 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2023-12-09 17:39:48 +00:00
transifex-integration[bot]
1c1bb599ed Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2023-12-09 17:39:42 +00:00
transifex-integration[bot]
25e6c77d9b Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2023-12-09 17:39:18 +00:00
transifex-integration[bot]
5aae81c47d Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2023-12-09 17:39:10 +00:00
transifex-integration[bot]
6b2fd4cec1 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2023-12-09 17:37:59 +00:00
transifex-integration[bot]
d486af6704 Translate Localizable.stringsdict in de
100% translated source file: 'Localizable.stringsdict'
on 'de'.
2023-12-09 17:22:50 +00:00
transifex-integration[bot]
323f920848 Translate InfoPlist.strings in fi
100% translated source file: 'InfoPlist.strings'
on 'fi'.
2023-12-09 15:08:51 +00:00
transifex-integration[bot]
c58c200acb Translate Localizable.strings in fi
100% translated source file: 'Localizable.strings'
on 'fi'.
2023-12-09 15:06:07 +00:00
transifex-integration[bot]
c3786bf849 Translate Localizable.stringsdict in vi
100% translated source file: 'Localizable.stringsdict'
on 'vi'.
2023-12-09 14:56:42 +00:00
transifex-integration[bot]
a0e882db64 Translate InfoPlist.strings in vi
100% translated source file: 'InfoPlist.strings'
on 'vi'.
2023-12-09 14:55:29 +00:00
transifex-integration[bot]
eedf734dae Translate Localizable.strings in vi
100% translated source file: 'Localizable.strings'
on 'vi'.
2023-12-09 14:55:06 +00:00
transifex-integration[bot]
cfa06797b7 Translate Localizable.stringsdict in ko
100% translated source file: 'Localizable.stringsdict'
on 'ko'.
2023-12-09 11:43:35 +00:00
transifex-integration[bot]
824279742c Translate Localizable.strings in ko
100% translated source file: 'Localizable.strings'
on 'ko'.
2023-12-09 11:42:54 +00:00
transifex-integration[bot]
2cdbadd09d Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2023-12-09 10:25:50 +00:00
transifex-integration[bot]
1ea70c8427 Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2023-12-09 10:25:31 +00:00
transifex-integration[bot]
09876c06d0 Translate Localizable.stringsdict in de
100% translated source file: 'Localizable.stringsdict'
on 'de'.
2023-12-09 08:49:20 +00:00
transifex-integration[bot]
7a063f8aa0 Translate Localizable.stringsdict in de
100% translated source file: 'Localizable.stringsdict'
on 'de'.
2023-12-09 08:49:02 +00:00
transifex-integration[bot]
b8ac026a3d Translate InfoPlist.strings in de
100% translated source file: 'InfoPlist.strings'
on 'de'.
2023-12-09 08:48:06 +00:00
transifex-integration[bot]
c18853c957 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2023-12-09 08:45:58 +00:00
transifex-integration[bot]
0a09dbfe1c Translate InfoPlist.strings in ko
100% translated source file: 'InfoPlist.strings'
on 'ko'.
2023-12-09 08:10:15 +00:00
transifex-integration[bot]
78e840734a Translate InfoPlist.strings in nl
100% translated source file: 'InfoPlist.strings'
on 'nl'.
2023-12-08 10:06:05 +00:00
transifex-integration[bot]
1ccb300dd1 Translate InfoPlist.strings in nl
100% translated source file: 'InfoPlist.strings'
on 'nl'.
2023-12-08 10:05:59 +00:00
transifex-integration[bot]
049a32db41 Translate Localizable.stringsdict in nl
100% translated source file: 'Localizable.stringsdict'
on 'nl'.
2023-12-08 10:05:15 +00:00
transifex-integration[bot]
d82add1080 Translate InfoPlist.strings in ja
100% translated source file: 'InfoPlist.strings'
on 'ja'.
2023-12-08 08:01:06 +00:00
9d42715d76 Fix localization issues and export strings for translation 2023-12-07 15:13:57 -08:00
transifex-integration[bot]
14fd06c052 Translate Localizable.strings in cs
100% translated source file: 'Localizable.strings'
on 'cs'.
2023-12-07 14:46:17 -08:00
transifex-integration[bot]
e2ab3a41b4 Translate InfoPlist.strings in cs
100% translated source file: 'InfoPlist.strings'
on 'cs'.
2023-12-07 14:46:17 -08:00
transifex-integration[bot]
6d7c2af504 Translate Localizable.strings in zh_HK
100% translated source file: 'Localizable.strings'
on 'zh_HK'.
2023-12-07 14:46:17 -08:00
transifex-integration[bot]
f5fbd1d3c1 Translate Localizable.stringsdict in zh_HK
100% translated source file: 'Localizable.stringsdict'
on 'zh_HK'.
2023-12-07 14:46:16 -08:00
transifex-integration[bot]
c4333280dd Translate Localizable.stringsdict in zh_TW
100% translated source file: 'Localizable.stringsdict'
on 'zh_TW'.
2023-12-07 14:46:16 -08:00
transifex-integration[bot]
6b6a98b71f Translate Localizable.stringsdict in zh_CN
100% translated source file: 'Localizable.stringsdict'
on 'zh_CN'.
2023-12-07 14:46:15 -08:00
Grimless
fb8c470e9d Separate NIP-05 and username/display name onto their own lines.
Closes: https://github.com/damus-io/damus/pull/1534
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2023-09-05 10:45:35 -07:00
418 changed files with 18707 additions and 6107 deletions

35
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,35 @@
---
name: Bug report
about: Create a report to help us improve
title: 'Bug: '
labels: bug, Needs recreation
assignees: ''
---
**What happens**
When I perform action ___, _____ happens.
**What I expect to happen**
I expect _______ to happen.
**Link to noteID, npub**
Provide link to relevant noteID, npub etc.
**Screenshots/video recording**
If applicable, add screenshots to help explain your problem.
** Versions **
Damus version: [e.g. 1.7.2 (1()]
Operating system version: [e.g. iOS 17.2.1]
Device: e.g. iPhone 13 Pro
**Steps To Reproduce**
Steps to reproduce the behavior:
1. Open Damus
2. Tap on ___
3. Action ____
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,27 @@
---
name: Feature request
about: Suggest an idea for this project
title: 'Feature Request:'
labels: feature
assignees: ''
---
## Have a go at filling out the User Story template below
As a Damus user who is _____________, I would like to _________________, so that I achieve ___________.
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
** When does this problem happen? **
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -4,3 +4,4 @@ Suhail Saqan <suhail.saqan@gmail.com> <43693074+suhailsaqan@users.noreply.github
cr0bar <cr0bar@cr0.bar> <cr0bar@users.noreply.github.com>
Swift <scoder1747@gmail.com> <120697811+scoder1747@users.noreply.github.com>
Daniel D'Aquino <daniel@daquino.me> <patches@damus.io>
Transifex <transifex@transifex.com> <43880903+transifex-integration[bot]@users.noreply.github.com>

View File

@@ -1,3 +1,171 @@
## [1.9 (14)] - 2024-07-14
### Added
- Completely new threads experience that is easier and more pleasant to use (Daniel DAquino)
- Add emoji search to emoji picker (Terry Yiu)
### Changed
- Added first aid contact damus support email (alltheseas)
- Disable mutiny wallet button (William Casarin)
- Make friends show up first when searching for profiles (Terry Yiu)
### Fixed
- Fix crash on profile page when there are profile updates (William Casarin)
- Fix crash when adding duplicate mute items (William Casarin)
- Fix pretty bad crash when building flatbuffer profiles (William Casarin)
- Fix reactions view to not show reactions from replies on parent note (Terry Yiu)
- Fix missing Mute button in profile view menu (Terry Yiu)
- Fixed wallet not disconnecting when a user logs out (ericholguin)
- Fix stale feed issue when follow list is too big (Daniel DAquino)
[1.9 (14)]: https://github.com/damus-io/damus/releases/tag/v1.9-14
## [1.8] - 2024-05-11
### Added
- Added nip10 marker replies (William Casarin)
- Add marker nip10 support when reading notes (William Casarin)
- Added title image and tags to longform events (ericholguin)
- Add First Aid solution for users who do not have a contact list created for their account (Daniel DAquino)
- Relay fees metadata (ericholguin)
- Added callbackuri for a better ux when connecting mutiny wallet nwc (ericholguin)
- Add event content preview to the full screen carousel (Daniel DAquino)
- Show list of quoted reposts in threads (William Casarin)
- Proxy Tags are now viewable on Selected Events (ericholguin)
- Connect to Mutiny Wallet Button (ericholguin)
- Add ability to mute words, add new mutelist interface (Charlie) (William Casarin)
- Add ability to mute hashtag from SearchView (Charlie Fish)
### Changed
- Change reactions to use a native looking emoji picker (Terry Yiu)
- Relay detail design (ericholguin)
- Updated Zeus logo (ericholguin)
- Improve UX around video playback (Daniel DAquino)
- Moved paste nwc button to main wallet view (ericholguin)
- Errors with an NWC will show as an alert (ericholguin)
- Relay config view user interface (ericholguin)
- Always strip GPS data from images (kernelkind)
### Fixed
- Fix thread bug where a quote isn't picked up as a reply (William Casarin)
- Fixed threads not loading sometimes (William Casarin)
- Fixed issue where some replies were including the q tag (William Casarin)
- Fixed issue where timeline was scrolling when it isn't supposed to (William Casarin)
- Fix issue where bootstrap relays would inadvertently be added to the user's list on connectivity issues (Daniel DAquino)
- Fix broken GIF uploads (Daniel DAquino)
- Fix ghost notifications caused by Purple impending expiration notifications (Daniel DAquino)
- Improve reliability of contact list creation during onboarding (Daniel DAquino)
- Fix emoji reactions being cut off (ericholguin)
- Fix image indicators to limit number of dots to not spill screen beyond visible margins (ericholguin)
- Fix bug that would cause connection issues with relays defined with a trailing slash URL, and an inability to delete them. (Daniel DAquino)
- Issue where NWC Scanner view would not dismiss after a failed scan/paste (ericholguin)
[1.8]: https://github.com/damus-io/damus/releases/tag/v1.8
## [1.7-rc2] - 2024-02-28
### Added
- Add support for Apple In-App purchases (Daniel DAquino)
- Notification reminders for Damus Purple impending expiration (Daniel DAquino)
- Damus Purple membership! (William Casarin)
- Fixed minor spacing and padding issues in onboarding views (ericholguin)
### Changed
- Disable inline text suggestions on 17.0 as they interfere with mention generation (William Casarin)
- EULA is not shown by default (ericholguin)
### Fixed
- Fix welcome screen not showing if the user enters the app directly after a successful checkout without going through the link (Daniel DAquino)
- Fix profile not updating bug (William Casarin)
- Fix nostrscripts not loading (William Casarin)
- Fix crash when accessing cached purple accounts (William Casarin)
- Hide member signup date on reposts (kernelkind)
- Fixed previews not rendering (ericholguin)
- Fix load media formatting on small screens (kernelkind)
- Fix shared nevents that are too long (kernelkind)
- Fix many nostrdb transaction related crashes (William Casarin)
### Removed
- Removed copying public key action (ericholguin)
[1.7-rc2]: https://github.com/damus-io/damus/releases/tag/v1.7-rc2
## [1.7-2] - 2024-01-24
### Added
- New fulltext search engine (William Casarin)
- Add "Always show onboarding suggestions" developer setting (Daniel DAquino)
- Add NIP-42 relay auth support (Charlie Fish)
- Add ability to hide suggested hashtags (ericholguin)
- Add ability to mute hashtag from SearchView (Charlie Fish)
- Add ability to preview media taken with camera (Suhail Saqan)
- Add ability to search for naddr, nprofiles, nevents (kernelkind)
- Add experimental push notification support (Daniel DAquino)
- Add naddr link support (kernelkind)
- Add regional relay recommendations to Relay configuration view (currently for Japanese users only) (Daniel DAquino)
- Add regional relays for Germany (Daniel DAquino)
- Add regional relays for Thailand (Daniel DAquino)
- Added a custom camera view (Suhail Saqan)
- Always convert damus.io links to inline mentions (William Casarin)
- Unfurl profile name on remote push notifications (Daniel DAquino)
- Zap notification support for push notifications (Daniel DAquino)
### Changed
- Generate nprofile/nevent links in share menus (kernelkind)
- Improve push notification support to match local notification support (Daniel DAquino)
- Move mute thread in menu so it's not clicked by accident (alltheseas)
- Prioritize friends when autocompleting (Charlie Fish)
### Fixed
- Add workaround to fix note language recognition and reduce wasteful translation requests (Terry Yiu)
- Allow mentioning users with punctuation characters in their names (kernelkind)
- Fix broken mentions when there is text is directly after (kernelkind)
- Fix crash on very large notes (Daniel DAquino)
- Fix crash when logging out and switching accounts (William Casarin)
- Fix duplicate notes getting written to nostrdb (William Casarin)
- Fix issue where adding relays might not work on corrupted contact lists (Charlie Fish)
- Fix onboarding post view not being dismissed under certain conditions (Daniel DAquino)
- Fix performance issue with gifs (William Casarin)
- Fix persistent local notifications even after logout (William Casarin)
- Fixed bug where sometimes notes from other profiles appear on profile pages (Charlie Fish)
- Remove extra space at the end of DM messages (kernelkind)
- Save current viewed image index when switching to fullscreen (kernelkind)
### Removed
- Removed old nsec key warning, nsec automatically convert to npub when posting (kernelkind)
[1.7-2]: https://github.com/damus-io/damus/releases/tag/v1.7-2
## [1.6-25] - 2023-10-31
### Added
@@ -1651,4 +1819,3 @@
[0.1.2]: https://github.com/damus-io/damus/releases/tag/v0.1.2

View File

@@ -1,49 +0,0 @@
//
// NostrEventInfoFromPushNotification.swift
// DamusNotificationService
//
// Created by Daniel DAquino on 2023-11-13.
//
import Foundation
/// The representation of a JSON-encoded Nostr Event used by the push notification server
/// Needs to match with https://gitlab.com/soapbox-pub/strfry-policies/-/raw/433459d8084d1f2d6500fdf916f22caa3b4d7be5/src/types.ts
struct NostrEventInfoFromPushNotification: Codable {
let id: String // Hex-encoded
let sig: String // Hex-encoded
let kind: NostrKind
let tags: [[String]]
let pubkey: String // Hex-encoded
let content: String
let created_at: Int
static func from(dictionary: [AnyHashable: Any]) -> NostrEventInfoFromPushNotification? {
guard let id = dictionary["id"] as? String,
let sig = dictionary["sig"] as? String,
let kind_int = dictionary["kind"] as? UInt32,
let kind = NostrKind(rawValue: kind_int),
let tags = dictionary["tags"] as? [[String]],
let pubkey = dictionary["pubkey"] as? String,
let content = dictionary["content"] as? String,
let created_at = dictionary["created_at"] as? Int else {
return nil
}
return NostrEventInfoFromPushNotification(id: id, sig: sig, kind: kind, tags: tags, pubkey: pubkey, content: content, created_at: created_at)
}
func reactionEmoji() -> String? {
guard self.kind == NostrKind.like else {
return nil
}
switch self.content {
case "", "+":
return "❤️"
case "-":
return "👎"
default:
return self.content
}
}
}

View File

@@ -0,0 +1,45 @@
//
// NotificationExtensionState.swift
// DamusNotificationService
//
// Created by Daniel DAquino on 2023-11-27.
//
import Foundation
struct NotificationExtensionState: HeadlessDamusState {
let ndb: Ndb
let settings: UserSettingsStore
let contacts: Contacts
let mutelist_manager: MutelistManager
let keypair: Keypair
let profiles: Profiles
let zaps: Zaps
let lnurls: LNUrls
init?() {
guard let ndb = try? Ndb(owns_db_file: false) else { return nil }
self.ndb = ndb
guard let keypair = get_saved_keypair() else { return nil }
// dumb stuff needed for property wrappers
UserSettingsStore.pubkey = keypair.pubkey
self.settings = UserSettingsStore()
self.contacts = Contacts(our_pubkey: keypair.pubkey)
self.mutelist_manager = MutelistManager(user_keypair: keypair)
self.keypair = keypair
self.profiles = Profiles(ndb: ndb)
self.zaps = Zaps(our_pubkey: keypair.pubkey)
self.lnurls = LNUrls()
}
@discardableResult
func add_zap(zap: Zapping) -> Bool {
// store generic zap mapping
self.zaps.add_zap(zap: zap)
return true
}
}

View File

@@ -11,16 +11,17 @@ import UserNotifications
struct NotificationFormatter {
static var shared = NotificationFormatter()
// TODO: These is a very generic notification formatter. Once we integrate NostrDB into the extension, we should reuse various functions present in `HomeModel.swift`
func formatMessage(event: NostrEventInfoFromPushNotification) -> UNNotificationContent? {
// MARK: - Formatting with NdbNote
func format_message(event: NdbNote) -> UNMutableNotificationContent? {
let content = UNMutableNotificationContent()
if let event_json_data = try? JSONEncoder().encode(event), // Must be encoded, as the notification completion handler requires this object to conform to `NSSecureCoding`
let event_json_string = String(data: event_json_data, encoding: .utf8) {
content.userInfo = [
"nostr_event_info": event_json_string
NDB_NOTE_JSON_USER_INFO_KEY: event_json_string
]
}
switch event.kind {
switch event.known_kind {
case .text:
content.title = NSLocalizedString("Someone posted a note", comment: "Title label for push notification where someone posted a note")
content.body = event.content
@@ -30,7 +31,7 @@ struct NotificationFormatter {
content.body = NSLocalizedString("(Contents are encrypted)", comment: "Label on push notification indicating that the contents of the message are encrypted")
break
case .like:
guard let reactionEmoji = event.reactionEmoji() else {
guard let reactionEmoji = to_reaction_emoji(ev: event) else {
content.title = NSLocalizedString("Someone reacted to your note", comment: "Generic title label for push notifications where someone reacted to the user's post")
break
}
@@ -45,4 +46,91 @@ struct NotificationFormatter {
}
return content
}
// MARK: - Formatting with LocalNotification
func format_message(displayName: String, notify: LocalNotification) -> (content: UNMutableNotificationContent, identifier: String)? {
let content = UNMutableNotificationContent()
var title = ""
var identifier = ""
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("%@ reacted with %@", comment: "Reacted by heading in local notification"), displayName, to_reaction_emoji(ev: notify.event) ?? "")
identifier = "myLikeNotification"
case .dm:
title = displayName
identifier = "myDMNotification"
case .zap, .profile_zap:
// not handled here. Try `format_message(displayName: String, notify: LocalNotification, state: HeadlessDamusState) async -> (content: UNMutableNotificationContent, identifier: String)?`
return nil
}
content.title = title
content.body = notify.content
content.sound = UNNotificationSound.default
content.userInfo = notify.to_lossy().to_user_info()
return (content, identifier)
}
func format_message(displayName: String, notify: LocalNotification, state: HeadlessDamusState) async -> (content: UNMutableNotificationContent, identifier: String)? {
// Try sync method first and return if it works
if let sync_formatted_message = self.format_message(displayName: displayName, notify: notify) {
return sync_formatted_message
}
// If it does not work, try async formatting methods
let content = UNMutableNotificationContent()
switch notify.type {
case .zap, .profile_zap:
guard let zap = await get_zap(from: notify.event, state: state) else {
return nil
}
content.title = Self.zap_notification_title(zap)
content.body = Self.zap_notification_body(profiles: state.profiles, zap: zap)
content.sound = UNNotificationSound.default
content.userInfo = LossyLocalNotification(type: .zap, mention: .note(notify.event.id)).to_user_info()
return (content, "myZapNotification")
default:
// The sync method should have taken care of this.
return nil
}
}
// MARK: - Formatting zap utility notifications
static 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.")
}
}
static func zap_notification_body(profiles: Profiles, zap: Zap, locale: Locale = Locale.current) -> String {
let src = zap.request.ev
let pk = zap.is_anon ? ANON_PUBKEY : src.pubkey
let profile_txn = profiles.lookup(id: pk)
let profile = profile_txn?.unsafeUnownedValue
let name = Profile.displayName(profile: profile, pubkey: pk).displayName.truncate(maxLength: 50)
let sats = NSNumber(value: (Double(zap.invoice.amount) / 1000.0))
let formattedSats = format_msats_abbrev(zap.invoice.amount)
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)
}
}
}

View File

@@ -16,23 +16,63 @@ class NotificationService: UNNotificationServiceExtension {
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
let ndb: Ndb? = try? Ndb(owns_db_file: false)
// Modify the notification content here...
guard let nostrEventInfoDictionary = request.content.userInfo["nostr_event"] as? [AnyHashable: Any],
let nostrEventInfo = NostrEventInfoFromPushNotification.from(dictionary: nostrEventInfoDictionary) else {
guard let nostr_event_json = request.content.userInfo["nostr_event"] as? String,
let nostr_event = NdbNote.owned_from_json(json: nostr_event_json)
else {
// No nostr event detected. Just display the original notification
contentHandler(request.content)
return;
}
// Log that we got a push notification
if let pubkey = Pubkey(hex: nostrEventInfo.pubkey),
let txn = ndb?.lookup_profile(pubkey) {
Log.debug("Got push notification from %s (%s)", for: .push_notifications, (txn.unsafeUnownedValue?.profile?.display_name ?? "Unknown"), nostrEventInfo.pubkey)
Log.debug("Got nostr event push notification from pubkey %s", for: .push_notifications, nostr_event.pubkey.hex())
guard let state = NotificationExtensionState(),
let display_name = state.ndb.lookup_profile(nostr_event.pubkey)?.unsafeUnownedValue?.profile?.display_name // We are not holding the txn here.
else {
// Something failed to initialize so let's go for the next best thing
guard let improved_content = NotificationFormatter.shared.format_message(event: nostr_event) else {
// We cannot format this nostr event. Suppress notification.
contentHandler(UNNotificationContent())
return
}
contentHandler(improved_content)
return
}
if let improvedContent = NotificationFormatter.shared.formatMessage(event: nostrEventInfo) {
contentHandler(improvedContent)
// Don't show notification details that match mute list.
// TODO: Remove this code block once we get notification suppression entitlement from Apple. It will be covered by the `guard should_display_notification` block
if state.mutelist_manager.is_event_muted(nostr_event) {
// We cannot really suppress muted notifications until we have the notification supression entitlement.
// The best we can do if we ever get those muted notifications (which we generally won't due to server-side processing) is to obscure the details
let content = UNMutableNotificationContent()
content.title = NSLocalizedString("Muted event", comment: "Title for a push notification which has been muted")
content.body = NSLocalizedString("This is an event that has been muted according to your mute list rules. We cannot suppress this notification, but we obscured the details to respect your preferences", comment: "Description for a push notification which has been muted, and explanation that we cannot suppress it")
content.sound = UNNotificationSound.default
contentHandler(content)
return
}
guard should_display_notification(state: state, event: nostr_event, mode: .push) else {
// We should not display notification for this event. Suppress notification.
// contentHandler(UNNotificationContent())
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
contentHandler(request.content)
return
}
guard let notification_object = generate_local_notification_object(from: nostr_event, state: state) else {
// We could not process this notification. Probably an unsupported nostr event kind. Suppress.
// contentHandler(UNNotificationContent())
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
contentHandler(request.content)
return
}
Task {
if let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: display_name, notify: notification_object, state: state) {
contentHandler(improvedContent)
}
}
}

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>1C8F.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
</array>
</dict>
</plist>

27
PrivacyInfo.xcprivacy Normal file
View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>1C8F.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
</array>
</dict>
</plist>

125
Purple.storekit Normal file
View File

@@ -0,0 +1,125 @@
{
"identifier" : "64C21A2D",
"nonRenewingSubscriptions" : [
],
"products" : [
],
"settings" : {
"_applicationInternalID" : "1628663131",
"_developerTeamID" : "XK7H4JAB3D",
"_failTransactionsEnabled" : false,
"_lastSynchronizedDate" : 704848066.26849198,
"_locale" : "en_US",
"_storefront" : "USA",
"_storeKitErrors" : [
{
"current" : null,
"enabled" : false,
"name" : "Load Products"
},
{
"current" : null,
"enabled" : false,
"name" : "Purchase"
},
{
"current" : null,
"enabled" : false,
"name" : "Verification"
},
{
"current" : null,
"enabled" : false,
"name" : "App Store Sync"
},
{
"current" : null,
"enabled" : false,
"name" : "Subscription Status"
},
{
"current" : null,
"enabled" : false,
"name" : "App Transaction"
},
{
"current" : null,
"enabled" : false,
"name" : "Manage Subscriptions Sheet"
},
{
"current" : null,
"enabled" : false,
"name" : "Refund Request Sheet"
},
{
"current" : null,
"enabled" : false,
"name" : "Offer Code Redeem Sheet"
}
]
},
"subscriptionGroups" : [
{
"id" : "21283177",
"localizations" : [
],
"name" : "Purple",
"subscriptions" : [
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "6.99",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "6446591615",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "Support damus development with Damus Purple!",
"displayName" : "Damus Purple",
"locale" : "en_CA"
}
],
"productID" : "purple",
"recurringSubscriptionPeriod" : "P1M",
"referenceName" : "Purple",
"subscriptionGroupID" : "21283177",
"type" : "RecurringSubscription"
},
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "69.99",
"familyShareable" : false,
"groupNumber" : 2,
"internalID" : "6448764101",
"introductoryOffer" : null,
"localizations" : [
],
"productID" : "purpleyearly",
"recurringSubscriptionPeriod" : "P1Y",
"referenceName" : "Purple Yearly",
"subscriptionGroupID" : "21283177",
"type" : "RecurringSubscription"
}
]
}
],
"version" : {
"major" : 3,
"minor" : 0
}
}

View File

@@ -2,26 +2,56 @@
# damus
A twitter-like [nostr][nostr] client for iPhone, iPad and MacOS.
A twitter-like [nostr][nostr] client for iPhone, iPad and MacOS.
<img src="./ss.png" width="50%" height="50%" />
[nostr]: https://github.com/fiatjaf/nostr
## How is Damus better than twitter?
There are no toxic algorithms.\
You can send or receive zaps (satoshis) without asking for permission.\
[There is no central database](https://fiatjaf.com/nostr.html). Therefore, Damus is censorship resistant.\
There are no ads.\
You don't have to reveal sensitive personal information to sign up.\
No email is required. \
No phone number is required. \
Damus is free and open source software. \
There is no Big Tech moat. Therefore, seamless interoperability with thousands or millions of other nostr apps is possible, and is how [Damus and nostr win](https://www.youtube.com/watch?v=qTixqS-W1yo).
## If there are no ads, how is Damus funded?
Damus offers a paid subscription 🟣 purple 🟣 https://damus.io/purple/. \
Initial benefits include a unique subscriber number, subscriber badge, and auto-translate powered by DeepL.
Damus has also graciously received donations or grants from hundreds of Damus users, [Opensats](https://opensats.org/), and the [Human Rights Foundation](https://hrf.org/).
## Spec Compliance
damus implements the following [Nostr Implementation Possibilities][nips]
- [NIP-01: Basic protocol flow][nip01]
- [NIP-04: Encrypted direct message][nip04]
- [NIP-08: Mentions][nip08]
- [NIP-10: Reply conventions][nip10]
- [NIP-12: Generic tag queries (hashtags)][nip12]
- [NIP-19: bech32-encoded entities][NIP19]
- [NIP-21: nostr: URI scheme][NIP21]
- [NIP-25: Reactions][NIP25]
- [NIP-42: Authentication of clients to relays][nip42]
- [NIP-56: Reporting][nip56]
[nips]: https://github.com/nostr-protocol/nips
[nip01]: https://github.com/nostr-protocol/nips/blob/master/01.md
[nip04]: https://github.com/nostr-protocol/nips/blob/master/04.md
[nip08]: https://github.com/nostr-protocol/nips/blob/master/08.md
[nip10]: https://github.com/nostr-protocol/nips/blob/master/10.md
[nip12]: https://github.com/nostr-protocol/nips/blob/master/12.md
[nip19]: https://github.com/nostr-protocol/nips/blob/master/19.md
[nip21]: https://github.com/nostr-protocol/nips/blob/master/21.md
[nip25]: https://github.com/nostr-protocol/nips/blob/master/25.md
[nip42]: https://github.com/nostr-protocol/nips/blob/master/42.md
[nip56]: https://github.com/nostr-protocol/nips/blob/master/56.md
## Getting Started on Damus
@@ -32,7 +62,7 @@ damus implements the following [Nostr Implementation Possibilities][nips]
- Relays: You can add more relays to send your notes to by tapping the "+".
- Find more relays to add: https://nostr.info/relays/
- Public Key (pubkey): Your public, personal address and how people can find and tag you
- Secret Key: Your *private* key unique to you. Never share your private key publically and share with other clients at your own risk!
- Secret Key: Your *private* key unique to you. Never share your private key publicly and share with other clients at your own risk!
- Save your keys somewhere safe
- Log out
@@ -46,19 +76,15 @@ damus implements the following [Nostr Implementation Possibilities][nips]
1. Search their username in the search bar at the top of the 🔍 Global Feed and click their profile
2. Tap the 🔑 icon which will copy their pubkey to your clipboard
3. Go back to your 🏠 Personal Feed and tap the blue + button to compose your Note
4. Add @ direcly followed by the pubkey (e.g., `@npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s`)
- You can also long-press a Note to grab their User ID aka pubkey or Note ID to link directly to a Note.
4. Add @ directly followed by the pubkey (e.g., `@npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s`)
- You can also tap the ellipsis menu of a Note (three dots in top right of note) to grab their User ID aka pubkey or Note ID to link directly to a Note.
- Currently you can't delete your Notes in the iOS app
- Share images by pasting the image url which you can grab from imgbb, imgur, etc. (i.e., `https://i.ibb.co/2SHZbwm/alpha60.jpg`). Currently images only load for people you follow in the 🏠 Personal Feed. Images are not automatically loaded in 🔍 Global Feed
- Share images by pasting the image url which you can grab from nostr.build, imgbb, imgur, etc. (i.e., `https://i.ibb.co/2SHZbwm/alpha60.jpg`). Currently images only load for people you follow in the 🏠 Personal Feed. Images are not automatically loaded in 🔍 Global Feed
- Engaging with Notes
- 💬 Replying to a Note: Tap the chat icon underneath the note. This will show up in the users notifications and in your 🏠 Personal and 🔍 Global Feeds
- ♺ Reposts: Tap the repost icon which will show up in your 🏠 Personal and 🔍 Global Feeds
- ♡ Likes: Tap the heart icon. Users will not get a notification, and cannot see who liked their note (currently, web clients can see your pfp only)
- Formatting Notes (may not format as intended in other web clients)
- Italics: 1 asterisk `*italic*`
- Bold: 2 asterisk `**bold**`
- Strikethrough: 1 tildes `~strikethrough~`
- Code: 1 back-tick `` `code` ``
#### 💬 Encrypted DMs (chat app, bottom navigation)
- Tap the chat icon and you'll notice there's nothing to see at first. Go to a user profile and tap the 💬 chat icon next to the follow button to begin a DM
@@ -76,7 +102,9 @@ damus implements the following [Nostr Implementation Possibilities][nips]
4. For PFP, insert a URL containing your image (support video: https://cdn.jb55.com/vid/pfp-editor.mp4)
5. Save
#### ⚡️ Request Sats
Paste an invoice from your favorite LN wallet.
(Sats or Satoshis are the smallest denomination of bitcoin)
**Alby (browser extension)**
@@ -117,6 +145,8 @@ Your internet protocol (IP) address is exposed to the relays you connect to, and
The relay also learns which public keys you are requesting, meaning your public key will be tied to your IP address.
It is public information which other profiles (npubs) you are exchanging DMs with. The content of the DMs is encrypted.
### Translations
Translators welcome! Join the [Transifex][transifex] project.
@@ -127,8 +157,10 @@ All user-facing strings must have a comment in order to provide context to trans
### Awards
Damus lead dev and founder Will awards developers with satoshis!
There may be nostr badges awarded for contributors in the future... :)
First contributors:
1. @randymcmillan

View File

@@ -485,11 +485,37 @@ static inline int parse_str(struct cursor *cur, const char *str) {
return 1;
}
static inline int is_whitespace(char c) {
static inline int is_whitespace(int c) {
return c == ' ' || c == '\t' || c == '\n' || c == '\v' || c == '\f' || c == '\r';
}
static inline int is_underscore(char c) {
static inline int next_char_is_whitespace(unsigned char *curChar, unsigned char *endChar) {
unsigned char * next = curChar + 1;
if(next > endChar) return 0;
else if(next == endChar) return 1;
return is_whitespace(*next);
}
static int char_disallowed_at_end_url(char c){
return c == '.' || c == ',';
}
static inline int is_final_url_char(unsigned char *curChar, unsigned char *endChar){
if(is_whitespace(*curChar)){
return 1;
}
else if(next_char_is_whitespace(curChar, endChar)) {
// next char is whitespace so this char could be the final char in the url
return char_disallowed_at_end_url(*curChar);
}
else{
// next char isn't whitespace so it can't be a final char
return 0;
}
}
static inline int is_underscore(int c) {
return c == '_';
}
@@ -670,6 +696,23 @@ static inline int consume_until_whitespace(struct cursor *cur, int or_end) {
return or_end;
}
static inline int consume_until_end_url(struct cursor *cur, int or_end) {
char c;
int consumedAtLeastOne = 0;
while (cur->p < cur->end) {
c = *cur->p;
if (is_final_url_char(cur->p, cur->end))
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;

View File

@@ -117,7 +117,7 @@ static int consume_url_fragment(struct cursor *cur)
cur->p++;
return consume_until_whitespace(cur, 1);
return consume_until_end_url(cur, 1);
}
static int consume_url_path(struct cursor *cur)
@@ -134,7 +134,7 @@ static int consume_url_path(struct cursor *cur)
while (cur->p < cur->end) {
c = *cur->p;
if (c == '?' || c == '#' || is_whitespace(c)) {
if (c == '?' || c == '#' || is_final_url_char(cur->p, cur->end)) {
return 1;
}
@@ -152,7 +152,7 @@ static int consume_url_host(struct cursor *cur)
while (cur->p < cur->end) {
c = *cur->p;
// TODO: handle IDNs
if (is_alphanumeric(c) || c == '.' || c == '-')
if ((is_alphanumeric(c) || c == '.' || c == '-') && !is_final_url_char(cur->p, cur->end))
{
count++;
cur->p++;

View File

@@ -7,8 +7,10 @@
#include "nostr_bech32.h"
#include <stdlib.h>
#include "endian.h"
#include "cursor.h"
#include "bech32.h"
#include <stdbool.h>
#define MAX_TLVS 16
@@ -145,6 +147,11 @@ static int tlvs_to_relays(struct nostr_tlvs *tlvs, struct relays *relays) {
return 1;
}
static uint32_t decode_tlv_u32(const uint8_t *bytes) {
beint32_t *be32_bytes = (beint32_t*)bytes;
return be32_to_cpu(*be32_bytes);
}
static int parse_nostr_bech32_nevent(struct cursor *cur, struct bech32_nevent *nevent) {
struct nostr_tlvs tlvs;
struct nostr_tlv *tlv;
@@ -166,6 +173,13 @@ static int parse_nostr_bech32_nevent(struct cursor *cur, struct bech32_nevent *n
nevent->pubkey = NULL;
}
if(find_tlv(&tlvs, TLV_KIND, &tlv)) {
nevent->kind = decode_tlv_u32(tlv->value);
nevent->has_kind = true;
} else {
nevent->has_kind = false;
}
return tlvs_to_relays(&tlvs, &nevent->relays);
}
@@ -187,6 +201,11 @@ static int parse_nostr_bech32_naddr(struct cursor *cur, struct bech32_naddr *nad
naddr->pubkey = tlv->value;
if(!find_tlv(&tlvs, TLV_KIND, &tlv)) {
return 0;
}
naddr->kind = decode_tlv_u32(tlv->value);
return tlvs_to_relays(&tlvs, &naddr->relays);
}

View File

@@ -11,6 +11,8 @@
#include <stdio.h>
#include "str_block.h"
#include "cursor.h"
#include <stdbool.h>
typedef unsigned char u8;
#define MAX_RELAYS 10
@@ -45,6 +47,8 @@ struct bech32_nevent {
struct relays relays;
const u8 *event_id;
const u8 *pubkey; // optional
uint32_t kind;
bool has_kind;
};
struct bech32_nprofile {
@@ -56,6 +60,7 @@ struct bech32_naddr {
struct relays relays;
struct str_block identifier;
const u8 *pubkey;
uint32_t kind;
};
struct bech32_nrelay {

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,24 @@
{
"originHash" : "babaf4d5748afecf49bbb702530d8e9576460692f478b0a50ee43195dd4440e2",
"pins" : [
{
"identity" : "emojikit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/tyiu/EmojiKit",
"state" : {
"revision" : "05805f72d63a6d6a2d7dc7fe14abd37c1317b11a",
"version" : "0.1.2"
}
},
{
"identity" : "emojipicker",
"kind" : "remoteSourceControl",
"location" : "https://github.com/tyiu/EmojiPicker.git",
"state" : {
"revision" : "0c28b4a1a6b8840cf2580bda59517f6d0a733dc8",
"version" : "0.1.1"
}
},
{
"identity" : "gsplayer",
"kind" : "remoteSourceControl",
@@ -26,6 +45,15 @@
"revision" : "40b4b38b3b1c83f7088c76189a742870e0ca06a9"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "ee97538f5b81ae89698fd95938896dec5217b148",
"version" : "1.1.1"
}
},
{
"identity" : "swift-markdown-ui",
"kind" : "remoteSourceControl",
@@ -51,7 +79,25 @@
"revision" : "74203046135342e4a4a627476dd6caf8b28fe11b",
"version" : "509.0.0"
}
},
{
"identity" : "swift-trie",
"kind" : "remoteSourceControl",
"location" : "https://github.com/tyiu/swift-trie",
"state" : {
"revision" : "4c50bff6c168f74425f70476be62a072980d2da7",
"version" : "0.1.2"
}
},
{
"identity" : "swipeactions",
"kind" : "remoteSourceControl",
"location" : "https://github.com/aheze/SwipeActions",
"state" : {
"revision" : "41e6f6dce02d8cfa164f8c5461a41340850ca3ab",
"version" : "1.1.0"
}
}
],
"version" : 2
"version" : 3
}

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
LastUpgradeVersion = "1520"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
@@ -59,7 +59,7 @@
<RemoteRunnable
runnableDebuggingMode = "1"
BundleIdentifier = "com.jb55.damus2"
RemotePath = "/Users/danielnogueira/Library/Developer/CoreSimulator/Devices/99E60B35-CE5D-4B45-AC35-00818C0AF3CB/data/Containers/Bundle/Application/5A083DD0-FDE2-43D7-9172-2F97FAD18F20/damus.app">
RemotePath = "/Users/danielnogueira/Library/Developer/CoreSimulator/Devices/99E60B35-CE5D-4B45-AC35-00818C0AF3CB/data/Containers/Bundle/Application/7D0A5302-D07E-4C7C-B509-A7C552BD5A65/damus.app">
</RemoteRunnable>
<MacroExpansion>
<BuildableReference

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
LastUpgradeVersion = "1520"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
LastUpgradeVersion = "1520"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
@@ -71,6 +71,9 @@
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../../Purple.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"

View File

@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x1A",
"green" : "0x93",
"red" : "0xF7"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xD7",
"green" : "0xD1",
"red" : "0xD1"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x13",
"green" : "0x11",
"red" : "0x11"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xF9",
"green" : "0xF3",
"red" : "0xF3"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x25",
"green" : "0x22",
"red" : "0x22"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "244",
"green" : "218",
"red" : "244"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "92",
"green" : "45",
"red" : "93"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "236",
"green" : "194",
"red" : "238"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "109",
"green" : "49",
"red" : "111"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "197",
"green" : "67",
"red" : "204"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "255",
"green" : "194",
"red" : "255"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xF2",
"green" : "0xD8",
"red" : "0xF4"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x45",
"green" : "0x17",
"red" : "0x47"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "special-features.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "special-features.svg",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "special-features.svg",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

View File

@@ -0,0 +1,328 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:osb="http://www.openswatchbook.org/uri/2009/osb"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="500"
height="130"
viewBox="0 0 132.29166 34.395832"
version="1.1"
id="svg8"
inkscape:version="0.92.1 r15371"
sodipodi:docname="ActivityPub-logo.svg">
<title
id="title4590">ActivityPub logo</title>
<defs
id="defs2">
<linearGradient
id="AP-4-0"
osb:paint="solid">
<stop
style="stop-color:#5e5e5e;stop-opacity:1;"
offset="0"
id="stop5660" />
</linearGradient>
<linearGradient
id="linearGradient5640"
osb:paint="solid">
<stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop5638" />
</linearGradient>
<linearGradient
id="linearGradient5634"
osb:paint="solid">
<stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop5632" />
</linearGradient>
<linearGradient
id="linearGradient5628"
osb:paint="solid">
<stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop5626" />
</linearGradient>
<linearGradient
id="AP-3-7"
osb:paint="solid">
<stop
style="stop-color:#c678c5;stop-opacity:1;"
offset="0"
id="stop5498" />
</linearGradient>
<linearGradient
id="AP-2-3"
osb:paint="solid">
<stop
style="stop-color:#6d6d6d;stop-opacity:1;"
offset="0"
id="stop5230" />
</linearGradient>
<linearGradient
id="AP1-5"
osb:paint="solid">
<stop
style="stop-color:#f1007e;stop-opacity:1;"
offset="0"
id="stop5212" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#AP-3-7"
id="linearGradient5749"
gradientUnits="userSpaceOnUse"
x1="3319.292"
y1="-1291.2802"
x2="3344.3645"
y2="-1291.2802" />
<linearGradient
inkscape:collect="always"
xlink:href="#AP1-5"
id="linearGradient7297-7"
gradientUnits="userSpaceOnUse"
x1="3241.6836"
y1="-1355.4329"
x2="3254.9529"
y2="-1355.4329" />
<linearGradient
inkscape:collect="always"
xlink:href="#AP-2-3"
id="linearGradient7303-7"
gradientUnits="userSpaceOnUse"
x1="3225.7603"
y1="-1355.4329"
x2="3239.0295"
y2="-1355.4329" />
<linearGradient
inkscape:collect="always"
xlink:href="#AP1-5"
id="linearGradient8308"
gradientUnits="userSpaceOnUse"
x1="3241.6836"
y1="-1355.4329"
x2="3254.9529"
y2="-1355.4329" />
<linearGradient
inkscape:collect="always"
xlink:href="#AP1-5"
id="linearGradient8310"
gradientUnits="userSpaceOnUse"
x1="3241.6836"
y1="-1355.4329"
x2="3254.9529"
y2="-1355.4329" />
<linearGradient
inkscape:collect="always"
xlink:href="#AP1-5"
id="linearGradient8312"
gradientUnits="userSpaceOnUse"
x1="3241.6836"
y1="-1355.4329"
x2="3254.9529"
y2="-1355.4329" />
<linearGradient
inkscape:collect="always"
xlink:href="#AP-2-3"
id="linearGradient8314"
gradientUnits="userSpaceOnUse"
x1="3225.7603"
y1="-1355.4329"
x2="3239.0295"
y2="-1355.4329"
gradientTransform="matrix(3.7000834,0,0,3.7000834,-11935.582,4544.6634)" />
<linearGradient
inkscape:collect="always"
xlink:href="#AP-2-3"
id="linearGradient5188"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.42732603,0,0,0.42732603,-1363.3009,454.91899)"
x1="3269.126"
y1="-1354.6217"
x2="3322.1943"
y2="-1354.6217" />
<linearGradient
inkscape:collect="always"
xlink:href="#AP-2-3"
id="linearGradient4523"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(3.5811973,0,0,3.5811973,-11532.084,4918.1922)"
x1="3269.126"
y1="-1354.6217"
x2="3322.1943"
y2="-1354.6217" />
<linearGradient
inkscape:collect="always"
xlink:href="#AP1-5"
id="linearGradient4526"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(3.5811973,0,0,3.5811973,-11528.758,4918.1922)"
x1="3323.9951"
y1="-1356.5363"
x2="3349.0676"
y2="-1356.5363" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="0.14509804"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.5"
inkscape:cx="395.506"
inkscape:cy="-201.19903"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:snap-global="true"
showguides="false"
inkscape:guide-bbox="true"
showborder="true"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:showpageshadow="false"
borderlayer="false"
units="px">
<inkscape:grid
type="xygrid"
id="grid4572"
enabled="false"
originx="7.1437514"
originy="-404.28382" />
<inkscape:grid
type="axonomgrid"
id="grid4574"
units="mm"
empspacing="12"
originx="7.1437514"
originy="-404.28382"
enabled="false" />
<sodipodi:guide
position="3278.981,1256.5057"
orientation="0,1"
id="guide5059"
inkscape:locked="false" />
<sodipodi:guide
position="3278.981,1238.2495"
orientation="0,1"
id="guide5061"
inkscape:locked="false" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title>ActivityPub logo</dc:title>
<cc:license
rdf:resource="http://creativecommons.org/publicdomain/zero/1.0/" />
<dc:date>2017-04-15</dc:date>
<dc:creator>
<cc:Agent>
<dc:title>Robert Martinez</dc:title>
</cc:Agent>
</dc:creator>
<dc:subject>
<rdf:Bag>
<rdf:li>ActivityPub</rdf:li>
</rdf:Bag>
</dc:subject>
</cc:Work>
<cc:License
rdf:about="http://creativecommons.org/publicdomain/zero/1.0/">
<cc:permits
rdf:resource="http://creativecommons.org/ns#Reproduction" />
<cc:permits
rdf:resource="http://creativecommons.org/ns#Distribution" />
<cc:permits
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
</cc:License>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
style="opacity:1"
transform="translate(7.1437516,141.67967)">
<path
style="fill:#000000;stroke-width:0.26458335"
d=""
id="path5497"
inkscape:connector-curvature="0" />
<g
id="g5197"
transform="translate(1.3229166)">
<g
id="g5132-90"
style="fill:url(#linearGradient7297-7);fill-opacity:1"
transform="matrix(0.9789804,0,0,0.9789804,-3157.9561,1202.4422)">
<g
transform="matrix(0.2553682,0,0,0.2553682,2615.9213,-1125.3113)"
id="g5080-78"
style="fill:url(#linearGradient8312);fill-opacity:1">
<path
inkscape:connector-curvature="0"
id="path5404-0-0"
d="m 2450.431,-937.13662 51.9615,30 v 12 l -51.9615,30 v -12 l 41.5693,-24 -41.5692,-24 z"
style="fill:url(#linearGradient8308);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
sodipodi:nodetypes="cccccccc" />
<path
sodipodi:nodetypes="cccc"
style="fill:url(#linearGradient8310);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 2450.431,-913.13662 20.7847,12 -20.7847,12 z"
id="path5406-6-3"
inkscape:connector-curvature="0" />
</g>
</g>
<g
id="g5127-1"
style="fill:url(#linearGradient7303-7);fill-opacity:1"
transform="matrix(0.9789804,0,0,0.9789804,-3157.9561,1202.4422)">
<path
id="path5467-2-0"
transform="matrix(0.27026418,0,0,0.27026418,3225.7603,-1228.2597)"
d="M 49.097656,-504.56641 0,-476.2207 v 11.33789 l 39.277344,-22.67578 v 45.35351 l 9.820312,5.66992 z m -19.638672,34.01563 -19.6406246,11.33789 19.6406246,11.33789 z"
style="fill:url(#linearGradient8314);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.25000042px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
inkscape:connector-curvature="0" />
</g>
</g>
<g
id="g5203"
transform="matrix(2.2173353,0,0,2.2173353,-35.445741,150.88402)">
<g
id="g4523">
<path
sodipodi:nodetypes="scscscscsscscscscscccccccccccccccscsccccscscccccccccccscsccccscsccccccccccccscscccsccccscscsccccscccccccccccccccccccccccccccccscssccccccccc"
inkscape:connector-curvature="0"
id="text5037-6"
transform="matrix(0.1193249,0,0,0.1193249,12.763965,-131.94382)"
d="m 263.22656,34.349609 c -1.66644,0 -2.95278,0.477436 -3.85742,1.429688 -0.90464,0.904639 -1.35742,2.069669 -1.35742,3.498047 0,1.428378 0.45278,2.59536 1.35742,3.5 0.90464,0.857027 2.19098,1.285156 3.85742,1.285156 1.66644,0 2.99818,-0.428129 3.99805,-1.285156 0.99986,-0.857027 1.5,-2.024009 1.5,-3.5 0,-1.475991 -0.50014,-2.665673 -1.5,-3.570313 -0.99987,-0.904639 -2.33161,-1.357422 -3.99805,-1.357422 z m 43.95117,0 c -1.66644,0 -2.95082,0.477436 -3.85546,1.429688 -0.90464,0.904639 -1.35743,2.069669 -1.35743,3.498047 0,1.428378 0.45279,2.59536 1.35743,3.5 0.90464,0.857027 2.18902,1.285156 3.85546,1.285156 1.66645,0 3.00014,-0.428129 4,-1.285156 0.99987,-0.857027 1.5,-2.024009 1.5,-3.5 0,-1.475991 -0.50013,-2.665673 -1.5,-3.570313 -0.99986,-0.904639 -2.33355,-1.357422 -4,-1.357422 z m -118.46166,0.357422 -14.49805,50.351563 h 8.92774 l 2.92773,-11.285156 h 11.78516 l 3.07031,11.285156 h 9.42773 L 195.78638,34.707031 Z m 58.71166,5.285157 -8.49804,2.642578 v 6.71289 h -3.92774 v 7.570313 h 3.92774 v 18.71289 c 0,3.713784 0.66684,6.356519 2,7.927735 1.38076,1.571216 3.42866,2.355468 6.14258,2.355468 1.5236,0 2.9747,-0.189411 4.35546,-0.570312 1.38077,-0.333288 2.59511,-0.761418 3.64258,-1.285156 L 254,77.273438 c -0.66658,0.285675 -1.28607,0.49974 -1.85742,0.642578 -0.57135,0.142837 -1.2155,0.214843 -1.92969,0.214843 -1.04748,0 -1.78438,-0.430082 -2.21289,-1.287109 -0.3809,-0.857027 -0.57227,-2.308127 -0.57227,-4.355469 V 56.917969 h 6.92774 v -7.570313 h -6.92774 z m 80.23243,0 -8.49805,2.642578 v 6.71289 h -3.92969 v 7.570313 h 3.92969 v 18.71289 c 0,3.713784 0.66489,6.356519 1.99805,7.927735 1.38076,1.571216 3.42866,2.355468 6.14257,2.355468 1.52361,0 2.97666,-0.189411 4.35743,-0.570312 1.38076,-0.333288 2.5951,-0.761418 3.64257,-1.285156 l -1.07226,-6.785156 c -0.66658,0.285675 -1.28607,0.49974 -1.85742,0.642578 -0.57135,0.142837 -1.21355,0.214843 -1.92774,0.214843 -1.04747,0 -1.78437,-0.430082 -2.21289,-1.287109 -0.3809,-0.857027 -0.57226,-2.308127 -0.57226,-4.355469 V 56.917969 h 6.92773 v -7.570313 h -6.92773 z m -135.65894,6.855468 h 0.28516 l 1.14453,7.857422 2.85547,11.640625 h -8.2832 l 2.78515,-11.570312 z m 31.94605,1.572266 c -4.33275,0 -7.61963,1.570458 -9.85743,4.71289 -2.23779,3.142433 -3.35546,7.833061 -3.35546,14.070313 0,2.856756 0.21406,5.452139 0.64257,7.785156 0.47613,2.285405 1.23768,4.261293 2.28516,5.927735 1.04748,1.618828 2.38117,2.880516 4,3.785156 1.66644,0.857027 3.71238,1.285156 6.14062,1.285156 1.66645,0 3.33356,-0.238718 5,-0.714844 1.66645,-0.476126 3.09485,-1.190326 4.28516,-2.142578 l -1.78516,-6.570312 c -0.71418,0.476126 -1.50039,0.881555 -2.35742,1.214844 -0.80941,0.285675 -1.80968,0.427734 -3,0.427734 -2.23779,0 -3.88025,-1.022971 -4.92773,-3.070313 -0.99987,-2.047342 -1.5,-4.690077 -1.5,-7.927734 0,-3.856621 0.50013,-6.641415 1.5,-8.355469 0.99986,-1.761666 2.50027,-2.642578 4.5,-2.642578 1.09509,0 2.02335,0.117406 2.78515,0.355469 0.80942,0.19045 1.64298,0.501174 2.5,0.929687 l 2,-7.070312 c -1.04747,-0.571351 -2.26181,-1.048787 -3.64257,-1.429688 -1.33316,-0.3809 -3.07033,-0.570312 -5.21289,-0.570312 z m 35.06445,0.927734 v 35.710938 h 8.5 V 49.347656 Z m 11.05469,0 12.64257,36.066406 h 5.7129 l 11.99804,-36.066406 h -9.14062 l -4.42774,18.570313 -0.78711,5.71289 h -0.28515 l -0.85742,-5.642578 -4.92774,-18.640625 z m 32.89843,0 v 35.710938 h 8.49805 V 49.347656 Z m 33.53125,0 12.42774,35.710938 c -0.28568,1.571216 -0.64375,2.832904 -1.07227,3.785156 -0.42851,0.952252 -0.92865,1.641799 -1.5,2.070312 -0.52374,0.476127 -1.11858,0.714844 -1.78515,0.714844 -0.61897,0.04761 -1.23846,-0.04905 -1.85743,-0.287109 l -1.42773,7.285156 c 0.66658,0.380901 1.45278,0.642319 2.35742,0.785156 0.95225,0.190451 1.92787,0.28711 2.92774,0.28711 1.42837,0 2.64271,-0.430083 3.64257,-1.28711 1.04748,-0.809414 1.97575,-1.999096 2.78516,-3.570312 0.80941,-1.571216 1.57097,-3.475098 2.28516,-5.712891 0.71419,-2.237792 1.47574,-4.761168 2.28515,-7.570312 l 8.92774,-32.210938 h -8.71289 l -4.14258,19.998047 -0.64258,5.642578 h -0.35742 l -0.92774,-5.572265 -5,-20.06836 z"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:condensed;font-size:8.52205467px;line-height:2.53632545px;font-family:'PT Sans Narrow';-inkscape-font-specification:'PT Sans Narrow Bold Condensed';letter-spacing:0.22319667px;word-spacing:2.60024095px;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:url(#linearGradient4523);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.27365798px;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
<path
id="text5065-3"
transform="matrix(0.1193249,0,0,0.1193249,12.763965,-131.94382)"
d="m 386.9082,34.349609 c -2.04734,0 -4.09523,0.119359 -6.14258,0.357422 -2.04734,0.190451 -3.92657,0.476521 -5.64062,0.857422 v 49.494141 h 8.99805 V 67.845703 c 0.19045,0.04761 0.49922,0.09497 0.92773,0.142578 l 1.42969,0.142578 h 1.35742 0.92773 c 2.04735,0 4.02324,-0.30877 5.92774,-0.927734 1.95212,-0.618964 3.66659,-1.619234 5.14258,-3 1.47599,-1.380766 2.64102,-3.165289 3.49804,-5.355469 0.90464,-2.19018 1.35743,-4.857568 1.35743,-8 0,-3.47572 -0.52284,-6.285167 -1.57032,-8.427734 -1.04747,-2.19018 -2.40582,-3.879997 -4.07226,-5.070313 -1.66644,-1.190315 -3.57033,-1.976521 -5.71289,-2.357421 -2.09496,-0.428514 -4.23756,-0.642579 -6.42774,-0.642579 z m 51.72461,0.714844 v 48.564453 c 1.14271,0.571352 2.76052,1.09614 4.85547,1.572266 2.14257,0.476126 4.47653,0.71289 7,0.71289 4.61842,0 8.25948,-1.570458 10.92578,-4.71289 2.66631,-3.190045 4,-8.164791 4,-14.925781 0,-6.237252 -0.95292,-10.761169 -2.85742,-13.570313 -1.85689,-2.809144 -4.47497,-4.214844 -7.85547,-4.214844 -3.19004,0 -5.64141,1.047624 -7.35547,3.142578 h -0.21484 V 35.064453 Z m -50.86719,7.285156 c 0.99987,0 1.95279,0.142059 2.85743,0.427735 0.90464,0.285675 1.68889,0.761158 2.35547,1.427734 0.71418,0.618964 1.26167,1.477176 1.64257,2.572266 0.42852,1.09509 0.64258,2.428784 0.64258,4 0,1.856891 -0.21406,3.402697 -0.64258,4.640625 -0.42851,1.190315 -1.02335,2.143233 -1.78515,2.857422 -0.71419,0.666576 -1.54775,1.142058 -2.5,1.427734 -0.95225,0.285676 -1.95057,0.429687 -2.99805,0.429687 -0.28568,10e-7 -0.83316,-0.02465 -1.64258,-0.07227 -0.7618,-0.09522 -1.28659,-0.189931 -1.57226,-0.285156 v -17.06836 c 0.95225,-0.238063 2.16658,-0.357422 3.64257,-0.357422 z m 20.31836,6.998047 v 23.210938 c 0,2.666306 0.21407,4.880911 0.64258,6.642578 0.42852,1.714054 1.04606,3.070448 1.85547,4.070312 0.80942,0.999865 1.78699,1.691365 2.92969,2.072266 1.1427,0.428513 2.45174,0.642578 3.92773,0.642578 2.28541,0 4.18929,-0.547488 5.71289,-1.642578 1.57122,-1.09509 2.81021,-2.476137 3.71485,-4.142578 h 0.21289 l 1.5,4.857422 h 6.42773 c -0.3809,-1.523604 -0.64232,-3.215374 -0.78515,-5.072266 -0.14284,-1.904504 -0.21485,-3.833039 -0.21485,-5.785156 V 49.347656 h -8.49804 v 23.853516 c -0.38091,1.380765 -1.02505,2.572401 -1.92969,3.572266 -0.90464,0.952252 -2.02232,1.427734 -3.35547,1.427734 -1.38077,0 -2.33368,-0.547488 -2.85742,-1.642578 -0.52374,-1.09509 -0.78516,-3.046325 -0.78516,-5.855469 V 49.347656 Z m 43.83204,6.927735 c 1.61882,0 2.80851,0.858211 3.57031,2.572265 0.7618,1.666441 1.14258,4.307223 1.14258,7.925782 0,4.094684 -0.47549,7.023489 -1.42774,8.785156 -0.95225,1.714054 -2.3333,2.572265 -4.14258,2.572265 -1.61882,0 -2.92787,-0.26337 -3.92773,-0.787109 V 59.990234 c 0.80941,-2.475855 2.40453,-3.714843 4.78516,-3.714843 z"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:condensed;font-size:8.52205467px;line-height:2.53632545px;font-family:'PT Sans Narrow';-inkscape-font-specification:'PT Sans Narrow Bold Condensed';letter-spacing:0.22319667px;word-spacing:2.60024095px;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:url(#linearGradient4526);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.24196777px;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
inkscape:connector-curvature="0" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "ActivityPub-logo.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "atproto.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 511 KiB

View File

@@ -0,0 +1,20 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "mutiny.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "rss.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -12,31 +12,25 @@ let RECTANGLE_GRADIENT = LinearGradient(gradient: Gradient(colors: [
DamusColors.blue
]), startPoint: .leading, endPoint: .trailing)
struct CustomPicker<SelectionValue: Hashable, Content: View>: View {
struct CustomPicker<SelectionValue: Hashable>: View {
let tabs: [(String, SelectionValue)]
@Environment(\.colorScheme) var colorScheme
@Namespace var picker
@Binding var selection: SelectionValue
@ViewBuilder let content: Content
public var body: some View {
let contentMirror = Mirror(reflecting: content)
let blocksCount = Mirror(reflecting: contentMirror.descendant("value")!).children.count
HStack {
ForEach(0..<blocksCount, id: \.self) { index in
let tupleBlock = contentMirror.descendant("value", ".\(index)")
let text = Mirror(reflecting: tupleBlock!).descendant("content") as! Text
let tag = Mirror(reflecting: tupleBlock!).descendant("modifier", "value", "tagged") as! SelectionValue
ForEach(tabs, id: \.1) { (text, tag) in
Button {
withAnimation(.spring()) {
selection = tag
}
} label: {
text
.padding(EdgeInsets(top: 15, leading: 0, bottom: 10, trailing: 0))
Text(text).padding(EdgeInsets(top: 15, leading: 0, bottom: 10, trailing: 0))
.font(.system(size: 14, weight: .heavy))
.tag(tag)
}
.background(
Group {

View File

@@ -10,19 +10,27 @@ import SwiftUI
class DamusColors {
static let adaptableGrey = Color("DamusAdaptableGrey")
static let adaptableGrey2 = Color("DamusAdaptableGrey 2")
static let adaptableLighterGrey = Color("DamusAdaptableLighterGrey")
static let adaptablePurpleBackground = Color("DamusAdaptablePurpleBackground 1")
static let adaptablePurpleBackground2 = Color("DamusAdaptablePurpleBackground 2")
static let adaptablePurpleForeground = Color("DamusAdaptablePurpleForeground")
static let adaptableBlack = Color("DamusAdaptableBlack")
static let adaptableWhite = Color("DamusAdaptableWhite")
static let white = Color("DamusWhite")
static let black = Color("DamusBlack")
static let brown = Color("DamusBrown")
static let yellow = Color("DamusYellow")
static let gold = hex_col(r: 226, g: 168, b: 0)
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 highlight = Color("DamusHighlight")
static let blue = Color("DamusBlue")
static let bitcoin = Color("Bitcoin")
static let success = Color("DamusSuccessPrimary")
static let successSecondary = Color("DamusSuccessSecondary")
static let successTertiary = Color("DamusSuccessTertiary")
@@ -41,5 +49,15 @@ class DamusColors {
static let neutral1 = Color("DamusNeutral1")
static let neutral3 = Color("DamusNeutral3")
static let neutral6 = Color("DamusNeutral6")
static let pink = Color(red: 211/255.0, green: 76/255.0, blue: 217/255.0)
static let lighterPink = Color(red: 248/255.0, green: 105/255.0, blue: 182/255.0)
static let lightBackgroundPink = Color(red: 0xF8/255.0, green: 0xE7/255.0, blue: 0xF8/255.0)
}
func hex_col(r: UInt8, g: UInt8, b: UInt8) -> Color {
return Color(.sRGB,
red: Double(r) / Double(0xff),
green: Double(g) / Double(0xff),
blue: Double(b) / Double(0xff),
opacity: 1.0)
}

View File

@@ -7,7 +7,7 @@
import SwiftUI
fileprivate let gold_grad_c1 = hex_col(r: 226, g: 168, b: 0)
fileprivate let gold_grad_c1 = DamusColors.gold
fileprivate let gold_grad_c2 = hex_col(r: 249, g: 243, b: 100)
fileprivate let gold_grad = [gold_grad_c2, gold_grad_c1]

View File

@@ -0,0 +1,15 @@
//
// MutinyGradient.swift
// damus
//
// Created by eric on 3/9/24.
//
import SwiftUI
fileprivate let mutiny_grad_c1 = hex_col(r: 39, g: 95, b: 161)
fileprivate let mutiny_grad_c2 = hex_col(r: 13, g: 33, b: 56)
fileprivate let mutiny_grad = [mutiny_grad_c2, mutiny_grad_c1]
let MutinyGradient: LinearGradient =
LinearGradient(colors: mutiny_grad, startPoint: .top, endPoint: .bottom)

View File

@@ -31,6 +31,49 @@ struct ShareSheet: UIViewControllerRepresentable {
}
}
// Custom UIPageControl
struct PageControlView: UIViewRepresentable {
@Binding var currentPage: Int
var numberOfPages: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UIPageControl {
let uiView = UIPageControl()
uiView.backgroundStyle = .minimal
uiView.currentPageIndicatorTintColor = UIColor(Color("DamusPurple"))
uiView.pageIndicatorTintColor = UIColor(Color("DamusLightGrey"))
uiView.currentPage = currentPage
uiView.numberOfPages = numberOfPages
uiView.addTarget(context.coordinator, action: #selector(Coordinator.valueChanged), for: .valueChanged)
return uiView
}
func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
uiView.numberOfPages = numberOfPages
}
}
extension PageControlView {
final class Coordinator: NSObject {
var parent: PageControlView
init(_ parent: PageControlView) {
self.parent = parent
}
@objc func valueChanged(sender: UIPageControl) {
let currentPage = sender.currentPage
withAnimation {
parent.currentPage = currentPage
}
}
}
}
enum ImageShape {
case square
@@ -52,42 +95,64 @@ enum ImageShape {
}
}
class CarouselModel: ObservableObject {
var current_url: URL?
var fillHeight: CGFloat
var maxHeight: CGFloat
var firstImageHeight: CGFloat?
@Published var open_sheet: Bool
@Published var selectedIndex: Int
@Published var video_size: CGSize?
@Published var image_fill: ImageFill?
init(image_fill: ImageFill?) {
self.current_url = nil
self.fillHeight = 350
self.maxHeight = UIScreen.main.bounds.height * 1.2 // 1.2
self.firstImageHeight = nil
self.open_sheet = false
self.selectedIndex = 0
self.video_size = nil
self.image_fill = image_fill
}
}
// MARK: - Image Carousel
@MainActor
struct ImageCarousel: View {
struct ImageCarousel<Content: View>: View {
var urls: [MediaUrl]
let evid: NoteId
let state: DamusState
@State private var open_sheet: Bool = false
@State private var current_url: URL? = nil
@State private var image_fill: ImageFill? = nil
@ObservedObject var model: CarouselModel
let content: ((_ dismiss: @escaping (() -> Void)) -> Content)?
@State private var fillHeight: CGFloat = 350
@State private var maxHeight: CGFloat = UIScreen.main.bounds.height * 1.2 // 1.2
@State private var firstImageHeight: CGFloat? = nil
@State private var currentImageHeight: CGFloat?
@State private var selectedIndex = 0
@State private var video_size: CGSize? = nil
init(state: DamusState, evid: NoteId, urls: [MediaUrl]) {
_open_sheet = State(initialValue: false)
_current_url = State(initialValue: nil)
let media_model = state.events.get_cache_data(evid).media_metadata_model
_image_fill = State(initialValue: media_model.fill)
self.urls = urls
self.evid = evid
self.state = state
let media_model = state.events.get_cache_data(evid).media_metadata_model
self._model = ObservedObject(initialValue: CarouselModel(image_fill: media_model.fill))
self.content = nil
}
init(state: DamusState, evid: NoteId, urls: [MediaUrl], @ViewBuilder content: @escaping (_ dismiss: @escaping (() -> Void)) -> Content) {
self.urls = urls
self.evid = evid
self.state = state
let media_model = state.events.get_cache_data(evid).media_metadata_model
self._model = ObservedObject(initialValue: CarouselModel(image_fill: media_model.fill))
self.content = content
}
var filling: Bool {
image_fill?.filling == true
model.image_fill?.filling == true
}
var height: CGFloat {
firstImageHeight ?? image_fill?.height ?? fillHeight
model.firstImageHeight ?? model.image_fill?.height ?? model.fillHeight
}
func Placeholder(url: URL, geo_size: CGSize, num_urls: Int) -> some View {
@@ -105,9 +170,9 @@ struct ImageCarousel: View {
}
}
.onAppear {
if self.image_fill == nil, let size = state.video.size_for_url(url) {
let fill = ImageFill.calculate_image_fill(geo_size: geo_size, img_size: size, maxHeight: maxHeight, fillHeight: fillHeight)
self.image_fill = fill
if self.model.image_fill == nil, let size = state.video.size_for_url(url) {
let fill = ImageFill.calculate_image_fill(geo_size: geo_size, img_size: size, maxHeight: model.maxHeight, fillHeight: model.fillHeight)
self.model.image_fill = fill
}
}
}
@@ -118,23 +183,23 @@ struct ImageCarousel: View {
case .image(let url):
Img(geo: geo, url: url, index: index)
.onTapGesture {
open_sheet = true
model.open_sheet = true
}
case .video(let url):
DamusVideoPlayer(url: url, video_size: $video_size, controller: state.video)
.onChange(of: video_size) { size in
DamusVideoPlayer(url: url, video_size: $model.video_size, controller: state.video, style: .preview(on_tap: { model.open_sheet = true }))
.onChange(of: model.video_size) { size in
guard let size else { return }
let fill = ImageFill.calculate_image_fill(geo_size: geo.size, img_size: size, maxHeight: maxHeight, fillHeight: fillHeight)
let fill = ImageFill.calculate_image_fill(geo_size: geo.size, img_size: size, maxHeight: model.maxHeight, fillHeight: model.fillHeight)
print("video_size changed \(size)")
if self.image_fill == nil {
if self.model.image_fill == nil {
print("video_size firstImageHeight \(fill.height)")
firstImageHeight = fill.height
self.model.firstImageHeight = fill.height
state.events.get_cache_data(evid).media_metadata_model.fill = fill
}
self.image_fill = fill
self.model.image_fill = fill
}
}
}
@@ -150,7 +215,7 @@ struct ImageCarousel: View {
.configure { view in
view.framePreloadCount = 3
}
.imageFill(for: geo.size, max: maxHeight, fill: fillHeight) { fill in
.imageFill(for: geo.size, max: model.maxHeight, fill: model.fillHeight) { fill in
state.events.get_cache_data(evid).media_metadata_model.fill = fill
// blur hash can be discarded when we have the url
// NOTE: this is the wrong place for this... we need to remove
@@ -159,9 +224,9 @@ struct ImageCarousel: View {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
state.events.lookup_img_metadata(url: url)?.state = .not_needed
}
image_fill = fill
self.model.image_fill = fill
if index == 0 {
firstImageHeight = fill.height
self.model.firstImageHeight = fill.height
//maxHeight = firstImageHeight ?? maxHeight
} else {
//maxHeight = firstImageHeight ?? fill.height
@@ -181,7 +246,7 @@ struct ImageCarousel: View {
}
var Medias: some View {
TabView(selection: $selectedIndex) {
TabView(selection: $model.selectedIndex) {
ForEach(urls.indices, id: \.self) { index in
GeometryReader { geo in
Media(geo: geo, url: urls[index], index: index)
@@ -189,14 +254,22 @@ struct ImageCarousel: View {
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.fullScreenCover(isPresented: $open_sheet) {
ImageView(video_controller: state.video, urls: urls, settings: state.settings)
.fullScreenCover(isPresented: $model.open_sheet) {
if let content {
FullScreenCarouselView<Content>(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex) {
content({ // Dismiss closure
model.open_sheet = false
})
}
}
else {
FullScreenCarouselView<AnyView>(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex)
}
}
.frame(height: height)
.onChange(of: selectedIndex) { value in
selectedIndex = value
.onChange(of: model.selectedIndex) { value in
model.selectedIndex = value
}
.tabViewStyle(PageTabViewStyle())
}
var body: some View {
@@ -204,31 +277,11 @@ struct ImageCarousel: View {
Medias
.onTapGesture { }
// This is our custom carousel image indicator
CarouselDotsView(urls: urls, selectedIndex: $selectedIndex)
}
}
}
// MARK: - Custom Carousel
struct CarouselDotsView<T>: View {
let urls: [T]
@Binding var selectedIndex: Int
var body: some View {
if urls.count > 1 {
HStack {
ForEach(urls.indices, id: \.self) { index in
Circle()
.fill(index == selectedIndex ? Color("DamusPurple") : Color("DamusLightGrey"))
.frame(width: 10, height: 10)
.onTapGesture {
selectedIndex = index
}
}
if urls.count > 1 {
PageControlView(currentPage: $model.selectedIndex, numberOfPages: urls.count)
.frame(maxWidth: 0, maxHeight: 0)
.padding(.top, 5)
}
.padding(.top, CGFloat(8))
.id(UUID())
}
}
}
@@ -285,7 +338,9 @@ public struct ImageFill {
struct ImageCarousel_Previews: PreviewProvider {
static var previews: some View {
let url: MediaUrl = .image(URL(string: "https://jb55.com/red-me.jpg")!)
ImageCarousel(state: test_damus_state, evid: test_note.id, urls: [url, url])
let test_video_url: MediaUrl = .video(URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!)
ImageCarousel<AnyView>(state: test_damus_state, evid: test_note.id, urls: [test_video_url, url])
.environmentObject(OrientationTracker())
}
}

View File

@@ -45,7 +45,7 @@ struct NIP05Badge: View {
}
var username_matches_nip05: Bool {
guard let name = profiles.lookup(id: pubkey).map({ p in p?.name }).value
guard let name = profiles.lookup(id: pubkey)?.map({ p in p?.name }).value
else {
return false
}

View File

@@ -7,37 +7,49 @@
import SwiftUI
enum NeutralButtonShape {
case rounded, capsule, circle
var style: NeutralButtonStyle {
switch self {
case .rounded:
return NeutralButtonStyle(padding: EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10), cornerRadius: 12)
case .capsule:
return NeutralButtonStyle(padding: EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15), cornerRadius: 20)
case .circle:
return NeutralButtonStyle(padding: EdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20), cornerRadius: 9999)
}
}
}
struct NeutralButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
return configuration.label
let padding: EdgeInsets
let cornerRadius: CGFloat
let scaleEffect: CGFloat
init(padding: EdgeInsets = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0), cornerRadius: CGFloat = 15, scaleEffect: CGFloat = 0.95) {
self.padding = padding
self.cornerRadius = cornerRadius
self.scaleEffect = scaleEffect
}
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding(padding)
.background(DamusColors.neutral1)
.cornerRadius(12)
.cornerRadius(cornerRadius)
.overlay(
RoundedRectangle(cornerRadius: 12)
RoundedRectangle(cornerRadius: cornerRadius)
.stroke(DamusColors.neutral3, lineWidth: 1)
)
.scaleEffect(configuration.isPressed ? 0.95 : 1)
.scaleEffect(configuration.isPressed ? scaleEffect : 1)
}
}
struct NeutralCircleButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
return configuration.label
.padding(20)
.background(DamusColors.neutral1)
.cornerRadius(9999)
.overlay(
RoundedRectangle(cornerRadius: 9999)
.stroke(DamusColors.neutral3, lineWidth: 1)
)
.scaleEffect(configuration.isPressed ? 0.95 : 1)
}
}
struct NeutralButtonStyle_Previews: PreviewProvider {
static var previews: some View {
VStack {
Button(action: {
print("dynamic size")
}) {
@@ -45,8 +57,7 @@ struct NeutralButtonStyle_Previews: PreviewProvider {
.padding()
}
.buttonStyle(NeutralButtonStyle())
Button(action: {
print("infinite width")
}) {
@@ -58,6 +69,17 @@ struct NeutralButtonStyle_Previews: PreviewProvider {
}
.buttonStyle(NeutralButtonStyle())
.padding()
Button(String(stringLiteral: "Rounded Button"), action: {})
.buttonStyle(NeutralButtonShape.rounded.style)
.padding()
Button(String(stringLiteral: "Capsule Button"), action: {})
.buttonStyle(NeutralButtonShape.capsule.style)
.padding()
Button(action: {}, label: {Image("messages")})
.buttonStyle(NeutralButtonShape.circle.style)
}
}
}

View File

@@ -83,9 +83,9 @@ struct SingleCharacterAvatar: View {
var body: some View {
NonImageAvatar {
Text(verbatim: character)
Text(character)
.font(.largeTitle.bold())
.mask(Text(verbatim: character)
.mask(Text(character)
.font(.largeTitle.bold()))
}
}
@@ -101,7 +101,7 @@ struct NonImageAvatar<Content: View>: View {
var body: some View {
ZStack {
Circle()
.fill(Color(red: 0xF8/255.0, green: 0xE7/255.0, blue: 0xF8/255.0))
.fill(DamusColors.lightBackgroundPink)
.frame(width: 54, height: 54)
content

View File

@@ -9,16 +9,20 @@ import UIKit
import SwiftUI
struct SelectableText: View {
let damus_state: DamusState
let event: NostrEvent?
let attributedString: AttributedString
let textAlignment: NSTextAlignment
@State private var showHighlightPost = false
@State private var selectedText = ""
@State private var selectedTextHeight: CGFloat = .zero
@State private var selectedTextWidth: CGFloat = .zero
let size: EventViewKind
init(attributedString: AttributedString, textAlignment: NSTextAlignment? = nil, size: EventViewKind) {
init(damus_state: DamusState, event: NostrEvent?, attributedString: AttributedString, textAlignment: NSTextAlignment? = nil, size: EventViewKind) {
self.damus_state = damus_state
self.event = event
self.attributedString = attributedString
self.textAlignment = textAlignment ?? NSTextAlignment.natural
self.size = size
@@ -32,6 +36,9 @@ struct SelectableText: View {
font: eventviewsize_to_uifont(size),
fixedWidth: selectedTextWidth,
textAlignment: self.textAlignment,
enableHighlighting: self.enableHighlighting(),
showHighlightPost: $showHighlightPost,
selectedText: $selectedText,
height: $selectedTextHeight
)
.padding([.leading, .trailing], -1.0)
@@ -46,8 +53,48 @@ struct SelectableText: View {
self.selectedTextWidth = newSize.width
}
}
.sheet(isPresented: $showHighlightPost) {
if let event {
HighlightPostView(damus_state: damus_state, event: event, selectedText: $selectedText)
.presentationDragIndicator(.visible)
.presentationDetents([.height(selectedTextHeight + 150), .medium, .large])
}
}
.frame(height: selectedTextHeight)
}
func enableHighlighting() -> Bool {
self.event != nil
}
}
fileprivate class TextView: UITextView {
@Binding var showHighlightPost: Bool
@Binding var selectedText: String
init(frame: CGRect, textContainer: NSTextContainer?, showHighlightPost: Binding<Bool>, selectedText: Binding<String>) {
self._showHighlightPost = showHighlightPost
self._selectedText = selectedText
super.init(frame: frame, textContainer: textContainer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(highlightText(_:)) {
return true
}
return super.canPerformAction(action, withSender: sender)
}
@objc public func highlightText(_ sender: Any?) {
guard let selectedRange = self.selectedTextRange else { return }
selectedText = self.text(in: selectedRange) ?? ""
showHighlightPost.toggle()
}
}
fileprivate struct TextViewRepresentable: UIViewRepresentable {
@@ -57,11 +104,13 @@ struct SelectableText: View {
let font: UIFont
let fixedWidth: CGFloat
let textAlignment: NSTextAlignment
let enableHighlighting: Bool
@Binding var showHighlightPost: Bool
@Binding var selectedText: String
@Binding var height: CGFloat
func makeUIView(context: UIViewRepresentableContext<Self>) -> UITextView {
let view = UITextView()
func makeUIView(context: UIViewRepresentableContext<Self>) -> TextView {
let view = TextView(frame: .zero, textContainer: nil, showHighlightPost: $showHighlightPost, selectedText: $selectedText)
view.isEditable = false
view.dataDetectorTypes = .all
view.isSelectable = true
@@ -71,10 +120,15 @@ struct SelectableText: View {
view.textContainerInset.left = 1.0
view.textContainerInset.right = 1.0
view.textAlignment = textAlignment
let menuController = UIMenuController.shared
let highlightItem = UIMenuItem(title: "Highlight", action: #selector(view.highlightText(_:)))
menuController.menuItems = self.enableHighlighting ? [highlightItem] : []
return view
}
func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext<Self>) {
func updateUIView(_ uiView: TextView, context: UIViewRepresentableContext<Self>) {
let mutableAttributedString = createNSAttributedString()
uiView.attributedText = mutableAttributedString
uiView.textAlignment = self.textAlignment

View File

@@ -192,7 +192,7 @@ struct UserStatusSheet: View {
Picker(NSLocalizedString("Duration", comment: "Label for profile status expiration duration picker."), selection: $duration) {
ForEach(StatusDuration.allCases, id: \.self) { d in
Text(verbatim: d.description)
Text(d.description)
.tag(d)
}
}

View File

@@ -8,23 +8,52 @@
import SwiftUI
struct SupporterBadge: View {
let percent: Int
let percent: Int?
let purple_account: DamusPurple.Account?
let style: Style
init(percent: Int?, purple_account: DamusPurple.Account? = nil, style: Style) {
self.percent = percent
self.purple_account = purple_account
self.style = style
}
let size: CGFloat = 17
var body: some View {
if percent < 100 {
Image("star.fill")
.resizable()
.frame(width:size, height:size)
.foregroundColor(support_level_color(percent))
} else {
Image("star.fill")
.resizable()
.frame(width:size, height:size)
.foregroundStyle(GoldGradient)
HStack {
if let purple_account, purple_account.active == true {
HStack(spacing: 1) {
Image("star.fill")
.resizable()
.frame(width:size, height:size)
.foregroundStyle(GoldGradient)
if self.style == .full {
let date = format_date(date: purple_account.created_at, time_style: .none)
Text(date)
.foregroundStyle(.secondary)
.font(.caption)
}
}
}
else if let percent, percent < 100 {
Image("star.fill")
.resizable()
.frame(width:size, height:size)
.foregroundColor(support_level_color(percent))
} else if let percent, percent == 100 {
Image("star.fill")
.resizable()
.frame(width:size, height:size)
.foregroundStyle(GoldGradient)
}
}
}
enum Style {
case full // Shows the entire badge with a purple subscriber number if present
case compact // Does not show purple subscriber number. Only shows the star (if applicable)
}
}
func support_level_color(_ percent: Int) -> Color {
@@ -44,13 +73,24 @@ func support_level_color(_ percent: Int) -> Color {
struct SupporterBadge_Previews: PreviewProvider {
static func Level(_ p: Int) -> some View {
HStack(alignment: .center) {
SupporterBadge(percent: p)
SupporterBadge(percent: p, style: .full)
.frame(width: 50)
Text(verbatim: p.formatted())
.frame(width: 50)
}
}
static func Purple(_ subscriber_number: Int) -> some View {
HStack(alignment: .center) {
SupporterBadge(
percent: nil,
purple_account: DamusPurple.Account(pubkey: test_pubkey, created_at: .now, expiry: .now.addingTimeInterval(10000), subscriber_number: subscriber_number, active: true),
style: .full
)
.frame(width: 100)
}
}
static var previews: some View {
VStack(spacing: 0) {
VStack(spacing: 0) {
@@ -66,6 +106,12 @@ struct SupporterBadge_Previews: PreviewProvider {
Level(80)
Level(90)
Level(100)
Purple(1)
Purple(2)
Purple(3)
Purple(99)
Purple(100)
Purple(1971)
}
}
}

View File

@@ -21,6 +21,8 @@ enum TranslateStatus: Equatable {
case not_needed
}
fileprivate let MIN_UNIQUE_CHARS = 2
struct TranslateView: View {
let damus_state: DamusState
let event: NostrEvent
@@ -49,9 +51,9 @@ struct TranslateView: View {
.foregroundColor(.gray)
.font(.footnote)
.padding([.top, .bottom], 10)
if self.size == .selected {
SelectableText(attributedString: artifacts.content.attributed, size: self.size)
SelectableText(damus_state: damus_state, event: event, attributedString: artifacts.content.attributed, size: self.size)
} else {
artifacts.content.text
.font(eventviewsize_to_font(self.size, font_size: font_size))
@@ -64,21 +66,13 @@ struct TranslateView: View {
guard let note_language = translations_model.note_language else {
return
}
let res = await translate_note(profiles: damus_state.profiles, keypair: damus_state.keypair, event: event, settings: damus_state.settings, note_lang: note_language)
let res = await translate_note(profiles: damus_state.profiles, keypair: damus_state.keypair, event: event, settings: damus_state.settings, note_lang: note_language, purple: damus_state.purple)
DispatchQueue.main.async {
self.translations_model.state = res
}
}
}
func attempt_translation() {
guard should_translate(event: event, our_keypair: damus_state.keypair, settings: damus_state.settings, note_lang: self.translations_model.note_language), damus_state.settings.auto_translate else {
return
}
translate()
}
func should_transl(_ note_lang: String) -> Bool {
should_translate(event: event, our_keypair: damus_state.keypair, settings: damus_state.settings, note_lang: note_lang)
}
@@ -103,9 +97,10 @@ struct TranslateView: View {
Text("")
}
}
.task {
attempt_translation()
}
}
func translationMeetsStringDistanceRequirements(original: String, translated: String) -> Bool {
return levenshteinDistanceIsGreaterThanOrEqualTo(from: original, to: translated, threshold: MIN_UNIQUE_CHARS)
}
}
@@ -125,10 +120,10 @@ struct TranslateView_Previews: PreviewProvider {
}
}
func translate_note(profiles: Profiles, keypair: Keypair, event: NostrEvent, settings: UserSettingsStore, note_lang: String) async -> TranslateStatus {
func translate_note(profiles: Profiles, keypair: Keypair, event: NostrEvent, settings: UserSettingsStore, note_lang: String, purple: DamusPurple) async -> TranslateStatus {
// If the note language is different from our preferred languages, send a translation request.
let translator = Translator(settings)
let translator = Translator(settings, purple: purple)
let originalContent = event.get_content(keypair)
let translated_note = try? await translator.translate(originalContent, from: note_lang, to: current_language())
@@ -141,6 +136,10 @@ func translate_note(profiles: Profiles, keypair: Keypair, event: NostrEvent, set
// if its the same, give up and don't retry
return .not_needed
}
guard translationMeetsStringDistanceRequirements(original: originalContent, translated: translated_note) else {
return .not_needed
}
// Render translated note
let translated_blocks = parse_note_content(content: .content(translated_note, event.tags))
@@ -158,3 +157,50 @@ func current_language() -> String {
}
}
func levenshteinDistanceIsGreaterThanOrEqualTo(from source: String, to target: String, threshold: Int) -> Bool {
let sourceCount = source.count
let targetCount = target.count
// Early return if the difference in lengths is already greater than or equal to the threshold,
// indicating the edit distance meets the condition without further calculation.
if abs(sourceCount - targetCount) >= threshold {
return true
}
var matrix = [[Int]](repeating: [Int](repeating: 0, count: targetCount + 1), count: sourceCount + 1)
for i in 0...sourceCount {
matrix[i][0] = i
}
for j in 0...targetCount {
matrix[0][j] = j
}
for i in 1...sourceCount {
var rowMin = Int.max
for j in 1...targetCount {
let sourceIndex = source.index(source.startIndex, offsetBy: i - 1)
let targetIndex = target.index(target.startIndex, offsetBy: j - 1)
let cost = source[sourceIndex] == target[targetIndex] ? 0 : 1
matrix[i][j] = min(
matrix[i - 1][j] + 1, // Deletion
matrix[i][j - 1] + 1, // Insertion
matrix[i - 1][j - 1] + cost // Substitution
)
rowMin = min(rowMin, matrix[i][j])
}
// If the minimum edit distance found in any row is already greater than or equal to the threshold,
// you can conclude the edit distance meets the criteria.
if rowMin >= threshold {
return true
}
}
return matrix[sourceCount][targetCount] >= threshold
}
func translationMeetsStringDistanceRequirements(original: String, translated: String) -> Bool {
return levenshteinDistanceIsGreaterThanOrEqualTo(from: original, to: translated, threshold: MIN_UNIQUE_CHARS)
}

View File

@@ -9,7 +9,14 @@ import SwiftUI
struct TruncatedText: View {
let text: CompatibleText
let maxChars: Int = 280
let maxChars: Int
let show_show_more_button: Bool
init(text: CompatibleText, maxChars: Int = 280, show_show_more_button: Bool) {
self.text = text
self.maxChars = maxChars
self.show_show_more_button = show_show_more_button
}
var body: some View {
let truncatedAttributedString: AttributedString? = text.attributed.truncateOrNil(maxLength: maxChars)
@@ -24,8 +31,10 @@ struct TruncatedText: View {
if truncatedAttributedString != nil {
Spacer()
Button(NSLocalizedString("Show more", comment: "Button to show entire note.")) { }
.allowsHitTesting(false)
if self.show_show_more_button {
Button(NSLocalizedString("Show more", comment: "Button to show entire note.")) { }
.allowsHitTesting(false)
}
}
}
}
@@ -33,10 +42,10 @@ struct TruncatedText: View {
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"))
TruncatedText(text: CompatibleText(stringLiteral: "hello\nthere\none\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven"), show_show_more_button: true)
.frame(width: 200, height: 200)
TruncatedText(text: CompatibleText(stringLiteral: "hello\nthere\none\ntwo\nthree\nfour"))
TruncatedText(text: CompatibleText(stringLiteral: "hello\nthere\none\ntwo\nthree\nfour"), show_show_more_button: true)
.frame(width: 200, height: 200)
}
}

View File

@@ -59,66 +59,57 @@ func parse_note_content(content: NoteContent) -> Blocks {
}
}
func interpret_event_refs_ndb(blocks: [Block], tags: TagsSequence) -> [EventRef] {
if tags.count == 0 {
return []
}
/// build a set of indices for each event mention
let mention_indices = build_mention_indices(blocks, type: .e)
/// simpler case with no mentions
if mention_indices.count == 0 {
return interp_event_refs_without_mentions_ndb(tags.note.referenced_noterefs)
}
return interp_event_refs_with_mentions_ndb(tags: tags, mention_indices: mention_indices)
func interpret_event_refs(tags: TagsSequence) -> ThreadReply? {
// migration is long over, lets just do this to fix tests
return interpret_event_refs_ndb(tags: tags)
}
func interp_event_refs_without_mentions_ndb(_ ev_tags: References<NoteRef>) -> [EventRef] {
func interpret_event_refs_ndb(tags: TagsSequence) -> ThreadReply? {
if tags.count == 0 {
return nil
}
var count = 0
var evrefs: [EventRef] = []
return interp_event_refs_without_mentions_ndb(References<NoteRef>(tags: tags))
}
func interp_event_refs_without_mentions_ndb(_ ev_tags: References<NoteRef>) -> ThreadReply? {
var first: Bool = true
var first_ref: NoteRef? = nil
var root_id: NoteRef? = nil
var reply_id: NoteRef? = nil
var mention: NoteRef? = nil
var any_marker: Bool = false
for ref in ev_tags {
if first {
first_ref = ref
evrefs.append(.thread_id(ref))
first = false
} else {
evrefs.append(.reply(ref))
}
count += 1
}
if let first_ref, count == 1 {
let r = first_ref
return [.reply_to_root(r)]
}
return evrefs
}
func interp_event_refs_with_mentions_ndb(tags: TagsSequence, mention_indices: Set<Int>) -> [EventRef] {
var mentions: [EventRef] = []
var ev_refs: [NoteRef] = []
var i: Int = 0
for tag in tags {
if let note_id = NoteRef.from_tag(tag: tag) {
if mention_indices.contains(i) {
mentions.append(.mention(.noteref(note_id, index: i)))
if let marker = ref.marker {
any_marker = true
switch marker {
case .root: root_id = ref
case .reply: reply_id = ref
case .mention: mention = ref
}
// deprecated form, only activate if we don't have any markers set
} else if !any_marker {
if first {
root_id = ref
first = false
} else {
ev_refs.append(note_id)
reply_id = ref
}
}
i += 1
}
var replies = interp_event_refs_without_mentions(ev_refs)
replies.append(contentsOf: mentions)
return replies
// If either reply or root_id is blank while the other is not, then this is
// considered reply-to-root. We should always have a root and reply tag, if they
// are equal this is reply-to-root
if reply_id == nil && root_id != nil {
reply_id = root_id
} else if root_id == nil && reply_id != nil {
root_id = reply_id
}
guard let reply_id, let root_id else {
return nil
}
return ThreadReply(root: root_id, reply: reply_id, mention: mention.map { m in .noteref(m) })
}

View File

@@ -8,6 +8,7 @@
import SwiftUI
import AVKit
import MediaPlayer
import EmojiPicker
struct ZapSheet {
let target: ZapTarget
@@ -28,6 +29,8 @@ enum Sheets: Identifiable {
case filter
case user_status
case onboardingSuggestions
case purple(DamusPurpleURL)
case purple_onboarding
static func zap(target: ZapTarget, lnurl: String) -> Sheets {
return .zap(ZapSheet(target: target, lnurl: lnurl))
@@ -48,6 +51,8 @@ enum Sheets: Identifiable {
case .select_wallet: return "select-wallet"
case .filter: return "filter"
case .onboardingSuggestions: return "onboarding-suggestions"
case .purple(let purple_url): return "purple" + purple_url.url_string()
case .purple_onboarding: return "purple_onboarding"
}
}
}
@@ -69,75 +74,20 @@ struct ContentView: View {
@State var active_sheet: Sheets? = nil
@State var damus_state: DamusState!
@SceneStorage("ContentView.selected_timeline") var selected_timeline: Timeline = .home
@State var muting: Pubkey? = nil
@State var muting: MuteItem? = nil
@State var confirm_mute: Bool = false
@State var hide_bar: Bool = false
@State var user_muted_confirm: Bool = false
@State var confirm_overwrite_mutelist: Bool = false
@SceneStorage("ContentView.filter_state") var filter_state : FilterState = .posts_and_replies
@State private var isSideBarOpened = false
var home: HomeModel = HomeModel()
@StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
@AppStorage("has_seen_suggested_users") private var hasSeenOnboardingSuggestions = false
let sub_id = UUID().description
@Environment(\.colorScheme) var colorScheme
// connect retry timer
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var mystery: some View {
Text("Are you lost?", comment: "Text asking the user if they are lost in the app.")
.id("what")
}
func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) {
var filters = ContentFilters.defaults(damus_state: damus_state!)
filters.append(fstate.filter)
return ContentFilters(filters: filters).filter
}
var PostingTimelineView: some View {
VStack {
ZStack {
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: content_filter(.posts))
.tag(FilterState.posts)
.id(FilterState.posts)
contentTimelineView(filter: content_filter(.posts_and_replies))
.tag(FilterState.posts_and_replies)
.id(FilterState.posts_and_replies)
}
.tabViewStyle(.page(indexDisplayMode: .never))
if privkey != nil {
PostButtonContainer(is_left_handed: damus_state?.settings.left_handed ?? false) {
self.active_sheet = .post(.posting(.none))
}
}
}
}
.safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: 0) {
CustomPicker(selection: $filter_state, content: {
Text("Notes", comment: "Label for filter for seeing only notes (instead of notes and replies).").tag(FilterState.posts)
Text("Notes & Replies", comment: "Label for filter for seeing notes and replies (instead of only notes).").tag(FilterState.posts_and_replies)
})
Divider()
.frame(height: 1)
}
.background(colorScheme == .dark ? Color.black : Color.white)
}
}
func contentTimelineView(filter: (@escaping (NostrEvent) -> Bool)) -> some View {
TimelineView(events: home.events, loading: .constant(false), damus: damus_state, show_friend_icon: false, filter: filter) {
PullDownSearchView(state: damus_state, on_cancel: {})
}
}
func navIsAtRoot() -> Bool {
return navigationCoordinator.isAtRoot()
}
@@ -165,7 +115,7 @@ struct ContentView: View {
}
case .home:
PostingTimelineView
PostingTimelineView(damus_state: damus_state!, home: home, active_sheet: $active_sheet)
case .notifications:
NotificationsView(state: damus, notifications: home.notifications)
@@ -273,7 +223,7 @@ struct ContentView: View {
}
.tabViewStyle(.page(indexDisplayMode: .never))
.overlay(
SideMenuView(damus_state: damus, isSidebarVisible: $isSideBarOpened.animation())
SideMenuView(damus_state: damus_state!, isSidebarVisible: $isSideBarOpened.animation())
)
.navigationDestination(for: Route.self) { route in
route.view(navigationCoordinator: navigationCoordinator, damusState: damus_state!)
@@ -284,12 +234,17 @@ struct ContentView: View {
}
.navigationViewStyle(.stack)
TabBar(nstatus: home.notification_status, selected: $selected_timeline, settings: damus.settings, action: switch_timeline)
.padding([.bottom], 8)
.background(Color(uiColor: .systemBackground).ignoresSafeArea())
if !hide_bar {
TabBar(nstatus: home.notification_status, selected: $selected_timeline, settings: damus.settings, action: switch_timeline)
.padding([.bottom], 8)
.background(Color(uiColor: .systemBackground).ignoresSafeArea())
} else {
Text("")
}
}
}
.ignoresSafeArea(.keyboard)
.edgesIgnoringSafeArea(hide_bar ? [.bottom] : [])
.onAppear() {
self.connect()
try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default, options: .mixWithOthers)
@@ -298,7 +253,7 @@ struct ContentView: View {
active_sheet = .onboardingSuggestions
hasSeenOnboardingSuggestions = true
}
self.appDelegate?.settings = damus_state?.settings
self.appDelegate?.state = damus_state
}
.sheet(item: $active_sheet) { item in
switch item {
@@ -324,6 +279,10 @@ struct ContentView: View {
.presentationDragIndicator(.visible)
case .onboardingSuggestions:
OnboardingSuggestionsView(model: SuggestedUsersViewModel(damus_state: damus_state!))
case .purple(let purple_url):
DamusPurpleURLSheetView(damus_state: damus_state!, purple_url: purple_url)
case .purple_onboarding:
DamusPurpleNewUserOnboardingView(damus_state: damus_state)
}
}
.onOpenURL { url in
@@ -333,17 +292,36 @@ struct ContentView: View {
}
switch res {
case .filter(let filt): self.open_search(filt: filt)
case .profile(let pk): self.open_profile(pubkey: pk)
case .event(let ev): self.open_event(ev: ev)
case .wallet_connect(let nwc): self.open_wallet(nwc: nwc)
case .script(let data): self.open_script(data)
case .filter(let filt): self.open_search(filt: filt)
case .profile(let pk): self.open_profile(pubkey: pk)
case .event(let ev): self.open_event(ev: ev)
case .wallet_connect(let nwc): self.open_wallet(nwc: nwc)
case .script(let data): self.open_script(data)
case .purple(let purple_url):
if case let .welcome(checkout_id) = purple_url.variant {
// If this is a welcome link, do the following before showing the onboarding screen:
// 1. Check if this is legitimate and good to go.
// 2. Mark as complete if this is good to go.
Task {
let is_good_to_go = try? await damus_state.purple.check_and_mark_ln_checkout_is_good_to_go(checkout_id: checkout_id)
if is_good_to_go == true {
self.active_sheet = .purple(purple_url)
}
}
}
else {
self.active_sheet = .purple(purple_url)
}
}
}
}
.onReceive(handle_notify(.compose)) { action in
self.active_sheet = .post(action)
}
.onReceive(handle_notify(.display_tabbar)) { display in
let show = display
self.hide_bar = !show
}
.onReceive(timer) { n in
self.damus_state?.postbox.try_flushing_events()
self.damus_state!.profiles.profile_data(self.damus_state!.pubkey).status.try_expire()
@@ -351,8 +329,8 @@ struct ContentView: View {
.onReceive(handle_notify(.report)) { target in
self.active_sheet = .report(target)
}
.onReceive(handle_notify(.mute)) { pubkey in
self.muting = pubkey
.onReceive(handle_notify(.mute)) { mute_item in
self.muting = mute_item
self.confirm_mute = true
}
.onReceive(handle_notify(.attached_wallet)) { nwc in
@@ -360,14 +338,9 @@ struct ContentView: View {
// wallet with an associated
guard let ds = self.damus_state,
let lud16 = nwc.lud16,
let keypair = ds.keypair.to_full()
else {
return
}
let profile_txn = ds.profiles.lookup(id: ds.pubkey)
guard let profile = profile_txn.unsafeUnownedValue,
let keypair = ds.keypair.to_full(),
let profile_txn = ds.profiles.lookup(id: ds.pubkey),
let profile = profile_txn.unsafeUnownedValue,
lud16 != profile.lud16 else {
return
}
@@ -448,18 +421,54 @@ struct ContentView: View {
break
}
}
.onReceive(handle_notify(.disconnect_relays)) { () in
damus_state.pool.disconnect()
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { obj in
print("txn: 📙 DAMUS ACTIVE NOTIFY")
if damus_state.ndb.reopen() {
print("txn: NOSTRDB REOPENED")
} else {
print("txn: NOSTRDB FAILED TO REOPEN closed:\(damus_state.ndb.is_closed)")
}
if damus_state.purple.checkout_ids_in_progress.count > 0 {
// For extra assurance, run this after one second, to avoid race conditions if the app is also handling a damus purple welcome url.
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
Task {
let freshly_completed_checkout_ids = try? await damus_state.purple.check_status_of_checkouts_in_progress()
let there_is_a_completed_checkout: Bool = (freshly_completed_checkout_ids?.count ?? 0) > 0
let account_info = try await damus_state.purple.fetch_account(pubkey: self.keypair.pubkey)
if there_is_a_completed_checkout == true && account_info?.active == true {
if damus_state.purple.onboarding_status.user_has_never_seen_the_onboarding_before() {
// Show welcome sheet
self.active_sheet = .purple_onboarding
}
else {
self.active_sheet = .purple(DamusPurpleURL.init(is_staging: damus_state.purple.environment == .staging, variant: .landing))
}
}
}
}
}
Task {
await damus_state.purple.check_and_send_app_notifications_if_needed(handler: home.handle_damus_app_notification)
}
}
.onChange(of: scenePhase) { (phase: ScenePhase) in
guard let damus_state else { return }
switch phase {
case .background:
print("📙 DAMUS BACKGROUNDED")
print("txn: 📙 DAMUS BACKGROUNDED")
Task { @MainActor in
damus_state.ndb.close()
}
break
case .inactive:
print("📙 DAMUS INACTIVE")
print("txn: 📙 DAMUS INACTIVE")
break
case .active:
print("📙 DAMUS ACTIVE")
guard let ds = damus_state else { return }
ds.pool.ping()
print("txn: 📙 DAMUS ACTIVE")
damus_state.pool.ping()
@unknown default:
break
}
@@ -472,21 +481,15 @@ struct ContentView: View {
open_profile(pubkey: pubkey)
case .note(let noteId):
guard let target = damus_state.events.lookup(noteId) else {
return
}
switch local.type {
case .dm:
selected_timeline = .dms
damus_state.dms.set_active_dm(target.pubkey)
navigationCoordinator.push(route: Route.DMChat(dms: damus_state.dms.active_model))
case .like, .zap, .mention, .repost:
open_event(ev: target)
case .profile_zap:
// Handled separately above.
break
}
openEvent(noteId: noteId, notificationType: local.type)
case .nevent(let nevent):
openEvent(noteId: nevent.noteid, notificationType: local.type)
case .nprofile(let nprofile):
open_profile(pubkey: nprofile.author)
case .nrelay(_):
break
case .naddr(let naddr):
break
}
@@ -494,10 +497,9 @@ struct ContentView: View {
.onReceive(handle_notify(.onlyzaps_mode)) { hide in
home.filter_events()
guard let ds = damus_state else { return }
let profile_txn = ds.profiles.lookup(id: ds.pubkey)
guard let profile = profile_txn.unsafeUnownedValue,
guard let ds = damus_state,
let profile_txn = ds.profiles.lookup(id: ds.pubkey),
let profile = profile_txn.unsafeUnownedValue,
let keypair = ds.keypair.to_full()
else {
return
@@ -513,10 +515,10 @@ struct ContentView: View {
user_muted_confirm = false
}
}, message: {
if let pubkey = self.muting {
let name = damus_state!.profiles.lookup(id: pubkey).map { profile in
Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
}.value
if case let .user(pubkey, _) = self.muting {
let profile_txn = damus_state!.profiles.lookup(id: pubkey)
let profile = profile_txn?.unsafeUnownedValue
let name = Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
Text("\(name) has been muted", comment: "Alert message that informs a user was muted.")
} else {
Text("User has been muted", comment: "Alert message that informs a user was muted.")
@@ -531,13 +533,13 @@ struct ContentView: View {
Button(NSLocalizedString("Yes, Overwrite", comment: "Text of button that confirms to overwrite the existing mutelist.")) {
guard let ds = damus_state,
let keypair = ds.keypair.to_full(),
let pubkey = muting,
let mutelist = create_or_update_mutelist(keypair: keypair, mprev: nil, to_add: .pubkey(pubkey))
let muting,
let mutelist = create_or_update_mutelist(keypair: keypair, mprev: nil, to_add: muting)
else {
return
}
damus_state?.contacts.set_mutelist(mutelist)
ds.mutelist_manager.set_mutelist(mutelist)
ds.postbox.send(mutelist)
confirm_overwrite_mutelist = false
@@ -556,28 +558,28 @@ struct ContentView: View {
return
}
if ds.contacts.mutelist == nil {
if ds.mutelist_manager.event == nil {
confirm_overwrite_mutelist = true
} else {
guard let keypair = ds.keypair.to_full(),
let pubkey = muting
let muting
else {
return
}
guard let ev = create_or_update_mutelist(keypair: keypair, mprev: ds.contacts.mutelist, to_add: .pubkey(pubkey)) else {
guard let ev = create_or_update_mutelist(keypair: keypair, mprev: ds.mutelist_manager.event, to_add: muting) else {
return
}
damus_state?.contacts.set_mutelist(ev)
ds.mutelist_manager.set_mutelist(ev)
ds.postbox.send(ev)
}
}
}, message: {
if let pubkey = muting {
let name = damus_state?.profiles.lookup(id: pubkey).map({ profile in
Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
}).value ?? "unknown"
if case let .user(pubkey, _) = muting {
let profile_txn = damus_state?.profiles.lookup(id: pubkey)
let profile = profile_txn?.unsafeUnownedValue
let name = Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
Text("Mute \(name)?", comment: "Alert message prompt to ask if a user should be muted.")
} else {
Text("Could not find user to mute...", comment: "Alert message to indicate that the muted user could not be found.")
@@ -610,31 +612,26 @@ struct ContentView: View {
// out of space or something?? maybe we need a in-memory fallback
if mndb == nil {
notify(.logout)
logout(nil)
return
}
}
guard let ndb = mndb else { return }
let pool = RelayPool(ndb: ndb)
let pool = RelayPool(ndb: ndb, keypair: keypair)
let model_cache = RelayModelCache()
let relay_filters = RelayFilters(our_pubkey: pubkey)
let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey)
// dumb stuff needed for property wrappers
UserSettingsStore.pubkey = pubkey
let settings = UserSettingsStore()
UserSettingsStore.shared = settings
let settings = UserSettingsStore.globally_load_for(pubkey: pubkey)
let new_relay_filters = load_relay_filters(pubkey) == nil
for relay in bootstrap_relays {
if let url = RelayURL(relay) {
let descriptor = RelayDescriptor(url: url, info: .rw)
add_new_relay(model_cache: model_cache, relay_filters: relay_filters, pool: pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: settings.developer_mode)
}
let descriptor = RelayDescriptor(url: relay, info: .rw)
add_new_relay(model_cache: model_cache, relay_filters: relay_filters, pool: pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: settings.developer_mode)
}
pool.register_handler(sub_id: sub_id, handler: home.handle_event)
if let nwc_str = settings.nostr_wallet_connect,
@@ -647,6 +644,7 @@ struct ContentView: View {
likes: EventCounter(our_pubkey: pubkey),
boosts: EventCounter(our_pubkey: pubkey),
contacts: Contacts(our_pubkey: pubkey),
mutelist_manager: MutelistManager(user_keypair: keypair),
profiles: Profiles(ndb: ndb),
dms: home.dms,
previews: PreviewCache(),
@@ -661,15 +659,28 @@ struct ContentView: View {
postbox: PostBox(pool: pool),
bootstrap_relays: bootstrap_relays,
replies: ReplyCounter(our_pubkey: pubkey),
muted_threads: MutedThreadsManager(keypair: keypair),
wallet: WalletModel(settings: settings),
nav: self.navigationCoordinator,
music: MusicController(onChange: music_changed),
video: VideoController(),
ndb: ndb
ndb: ndb,
quote_reposts: .init(our_pubkey: pubkey),
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
)
home.damus_state = self.damus_state!
if let damus_state, damus_state.purple.enable_purple {
// Assign delegate so that we can send receipts to the Purple API server as soon as we get updates from user's purchases
StoreObserver.standard.delegate = damus_state.purple
Task {
await damus_state.purple.check_and_send_app_notifications_if_needed(handler: home.handle_damus_app_notification)
}
}
else {
// Purple API is an experimental feature. If not enabled, do not connect `StoreObserver` with Purple API to avoid leaking receipts
}
pool.connect()
}
@@ -697,6 +708,22 @@ struct ContentView: View {
}
}
private func openEvent(noteId: NoteId, notificationType: LocalNotificationType) {
guard let target = damus_state.events.lookup(noteId) else {
return
}
switch notificationType {
case .dm:
selected_timeline = .dms
damus_state.dms.set_active_dm(target.pubkey)
navigationCoordinator.push(route: Route.DMChat(dms: damus_state.dms.active_model))
case .like, .zap, .mention, .repost:
open_event(ev: target)
case .profile_zap:
break
}
}
}
struct ContentView_Previews: PreviewProvider {
@@ -751,6 +778,12 @@ func save_last_event(_ ev: NostrEvent, timeline: Timeline) {
UserDefaults.standard.set(String(ev.created_at), forKey: "last_\(str)_time")
}
func save_last_event(_ ev_id: NoteId, created_at: UInt32, timeline: Timeline) {
let str = timeline.rawValue
UserDefaults.standard.set(ev_id.hex(), forKey: "last_\(str)")
UserDefaults.standard.set(String(created_at), forKey: "last_\(str)_time")
}
func update_filters_with_since(last_of_kind: [UInt32: NostrEvent], filters: [NostrFilter]) -> [NostrFilter] {
return filters.map { filter in
@@ -802,13 +835,13 @@ func setup_notifications() {
struct FindEvent {
let type: FindEventType
let find_from: [String]?
static func profile(pubkey: Pubkey, find_from: [String]? = nil) -> FindEvent {
let find_from: [RelayURL]?
static func profile(pubkey: Pubkey, find_from: [RelayURL]? = nil) -> FindEvent {
return FindEvent(type: .profile(pubkey), find_from: find_from)
}
static func event(evid: NoteId, find_from: [String]? = nil) -> FindEvent {
static func event(evid: NoteId, find_from: [RelayURL]? = nil) -> FindEvent {
return FindEvent(type: .event(evid), find_from: find_from)
}
}
@@ -836,7 +869,8 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
switch query {
case .profile(let pubkey):
if let record = state.ndb.lookup_profile(pubkey).unsafeUnownedValue,
if let profile_txn = state.ndb.lookup_profile(pubkey),
let record = profile_txn.unsafeUnownedValue,
record.profile != nil
{
callback(.profile(pubkey))
@@ -895,11 +929,41 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
}
case .notice:
break
case .auth:
break
}
}
}
func naddrLookup(damus_state: DamusState, naddr: NAddr, callback: @escaping (NostrEvent?) -> ()) {
var nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
let filter = NostrFilter(kinds: nostrKinds, authors: [naddr.author])
let subid = UUID().description
damus_state.pool.subscribe_to(sub_id: subid, filters: [filter], to: nil) { relay_id, res in
guard case .nostr_event(let ev) = res else {
damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
return
}
if case .event(_, let ev) = ev {
for tag in ev.tags {
if(tag.count >= 2 && tag[0].string() == "d"){
if (tag[1].string() == naddr.identifier){
damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
callback(ev)
return
}
}
}
}
damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
}
}
func timeline_name(_ timeline: Timeline?) -> String {
guard let timeline else {
return ""
@@ -1017,9 +1081,15 @@ enum OpenResult {
case event(NostrEvent)
case wallet_connect(WalletConnectURL)
case script([UInt8])
case purple(DamusPurpleURL)
}
func on_open_url(state: DamusState, url: URL, result: @escaping (OpenResult?) -> Void) {
if let purple_url = DamusPurpleURL(url: url) {
result(.purple(purple_url))
return
}
if let nwc = WalletConnectURL(str: url.absoluteString) {
result(.wallet_connect(nwc))
return
@@ -1041,10 +1111,15 @@ func on_open_url(state: DamusState, url: URL, result: @escaping (OpenResult?) ->
result(.event(ev))
}
case .hashtag(let ht):
result(.filter(.filter_hashtag([ht.string()])))
result(.filter(.filter_hashtag([ht.hashtag])))
case .param, .quote:
// doesn't really make sense here
break
case .naddr(let naddr):
naddrLookup(damus_state: state, naddr: naddr) { res in
guard let res = res else { return }
result(.event(res))
}
}
case .filter(let filt):
result(.filter(filt))
@@ -1056,3 +1131,10 @@ func on_open_url(state: DamusState, url: URL, result: @escaping (OpenResult?) ->
}
}
func logout(_ state: DamusState?)
{
state?.close()
notify(.logout)
}

View File

@@ -16,10 +16,12 @@ enum Zapped {
class ActionBarModel: ObservableObject {
@Published var our_like: NostrEvent?
@Published var our_boost: NostrEvent?
@Published var our_quote_repost: NostrEvent?
@Published var our_reply: NostrEvent?
@Published var our_zap: Zapping?
@Published var likes: Int
@Published var boosts: Int
@Published var quote_reposts: Int
@Published private(set) var zaps: Int
@Published var zap_total: Int64
@Published var replies: Int
@@ -28,7 +30,7 @@ class ActionBarModel: ObservableObject {
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)
}
init(likes: Int = 0, boosts: Int = 0, zaps: Int = 0, zap_total: Int64 = 0, replies: Int = 0, our_like: NostrEvent? = nil, our_boost: NostrEvent? = nil, our_zap: Zapping? = nil, our_reply: NostrEvent? = nil) {
init(likes: Int = 0, boosts: Int = 0, zaps: Int = 0, zap_total: Int64 = 0, replies: Int = 0, our_like: NostrEvent? = nil, our_boost: NostrEvent? = nil, our_zap: Zapping? = nil, our_reply: NostrEvent? = nil, our_quote_repost: NostrEvent? = nil, quote_reposts: Int = 0) {
self.likes = likes
self.boosts = boosts
self.zaps = zaps
@@ -38,6 +40,8 @@ class ActionBarModel: ObservableObject {
self.our_boost = our_boost
self.our_zap = our_zap
self.our_reply = our_reply
self.our_quote_repost = our_quote_repost
self.quote_reposts = quote_reposts
}
func update(damus: DamusState, evid: NoteId) {
@@ -45,11 +49,13 @@ class ActionBarModel: ObservableObject {
self.boosts = damus.boosts.counts[evid] ?? 0
self.zaps = damus.zaps.event_counts[evid] ?? 0
self.replies = damus.replies.get_replies(evid)
self.quote_reposts = damus.quote_reposts.counts[evid] ?? 0
self.zap_total = damus.zaps.event_totals[evid] ?? 0
self.our_like = damus.likes.our_events[evid]
self.our_boost = damus.boosts.our_events[evid]
self.our_zap = damus.zaps.our_zaps[evid]?.first
self.our_reply = damus.replies.our_reply(evid)
self.our_quote_repost = damus.quote_reposts.our_events[evid]
self.objectWillChange.send()
}
@@ -68,4 +74,8 @@ class ActionBarModel: ObservableObject {
var boosted: Bool {
return our_boost != nil
}
var quoted: Bool {
return our_quote_repost != nil
}
}

View File

@@ -0,0 +1,149 @@
//
// Contacts+.swift
// damus
//
// Extra functionality and utilities for `Contacts.swift`
//
// Created by Daniel DAquino on 2023-11-24.
//
import Foundation
func follow_reference(box: PostBox, our_contacts: NostrEvent?, keypair: FullKeypair, follow: FollowRef) -> NostrEvent? {
guard let ev = follow_user_event(our_contacts: our_contacts, keypair: keypair, follow: follow) else {
return nil
}
box.send(ev)
return ev
}
func unfollow_reference(postbox: PostBox, our_contacts: NostrEvent?, keypair: FullKeypair, unfollow: FollowRef) -> NostrEvent? {
guard let cs = our_contacts else {
return nil
}
guard let ev = unfollow_reference_event(our_contacts: cs, keypair: keypair, unfollow: unfollow) else {
return nil
}
postbox.send(ev)
return ev
}
func unfollow_reference_event(our_contacts: NostrEvent, keypair: FullKeypair, unfollow: FollowRef) -> NostrEvent? {
let tags = our_contacts.tags.reduce(into: [[String]]()) { ts, tag in
if let tag = FollowRef.from_tag(tag: tag), tag == unfollow {
return
}
ts.append(tag.strings())
}
let kind = NostrKind.contacts.rawValue
return NostrEvent(content: our_contacts.content, keypair: keypair.to_keypair(), kind: kind, tags: Array(tags))
}
func follow_user_event(our_contacts: NostrEvent?, keypair: FullKeypair, follow: FollowRef) -> NostrEvent? {
guard let cs = our_contacts else {
// don't create contacts for now so we don't nuke our contact list due to connectivity issues
// we should only create contacts during profile creation
//return create_contacts(relays: relays, our_pubkey: our_pubkey, follow: follow)
return nil
}
guard let ev = follow_with_existing_contacts(keypair: keypair, our_contacts: cs, follow: follow) else {
return nil
}
return ev
}
func decode_json_relays(_ content: String) -> [RelayURL: RelayInfo]? {
return decode_json(content)
}
func remove_relay(ev: NostrEvent, current_relays: [RelayDescriptor], keypair: FullKeypair, relay: RelayURL) -> NostrEvent?{
var relays = ensure_relay_info(relays: current_relays, content: ev.content)
relays.removeValue(forKey: relay)
guard let content = encode_json(relays) else {
return nil
}
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 3, tags: ev.tags.strings())
}
func add_relay(ev: NostrEvent, keypair: FullKeypair, current_relays: [RelayDescriptor], relay: RelayURL, info: RelayInfo) -> NostrEvent? {
var relays = ensure_relay_info(relays: current_relays, content: ev.content)
// If kind:3 content is empty, or if the relay doesn't exist in the list,
// we want to create a kind:3 event with the new relay
guard ev.content.isEmpty || relays.index(forKey: relay) == nil else {
return nil
}
relays[relay] = info
guard let content = encode_json(relays) else {
return nil
}
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 3, tags: ev.tags.strings())
}
func ensure_relay_info(relays: [RelayDescriptor], content: String) -> [RelayURL: RelayInfo] {
return decode_json_relays(content) ?? make_contact_relays(relays)
}
func is_already_following(contacts: NostrEvent, follow: FollowRef) -> Bool {
return contacts.references.contains { ref in
switch (ref, follow) {
case let (.hashtag(ht), .hashtag(follow_ht)):
return ht.hashtag == follow_ht
case let (.pubkey(pk), .pubkey(follow_pk)):
return pk == follow_pk
case (.hashtag, .pubkey), (.pubkey, .hashtag),
(.event, _), (.quote, _), (.param, _), (.naddr, _):
return false
}
}
}
func follow_with_existing_contacts(keypair: FullKeypair, our_contacts: NostrEvent, follow: FollowRef) -> NostrEvent? {
// don't update if we're already following
if is_already_following(contacts: our_contacts, follow: follow) {
return nil
}
let kind = NostrKind.contacts.rawValue
var tags = our_contacts.tags.strings()
tags.append(follow.tag)
return NostrEvent(content: our_contacts.content, keypair: keypair.to_keypair(), kind: kind, tags: tags)
}
func make_contact_relays(_ relays: [RelayDescriptor]) -> [RelayURL: RelayInfo] {
return relays.reduce(into: [:]) { acc, relay in
acc[relay.url] = relay.info
}
}
func make_relay_metadata(relays: [RelayDescriptor], keypair: FullKeypair) -> NostrEvent? {
let tags = relays.compactMap { r -> [String]? in
var tag = ["r", r.url.absoluteString]
if (r.info.read ?? true) != (r.info.write ?? true) {
tag += r.info.read == true ? ["read"] : ["write"]
}
if ((r.info.read ?? true) || (r.info.write ?? true)) && r.variant == .regular {
return tag;
}
return nil
}
return NostrEvent(content: "", keypair: keypair.to_keypair(), kind: 10_002, tags: tags)
}

View File

@@ -7,57 +7,25 @@
import Foundation
class Contacts {
private var friends: Set<Pubkey> = Set()
private var friend_of_friends: Set<Pubkey> = Set()
/// Tracks which friends are friends of a given pubkey.
private var pubkey_to_our_friends = [Pubkey : Set<Pubkey>]()
private var muted: Set<Pubkey> = Set()
let our_pubkey: Pubkey
var event: NostrEvent?
var mutelist: NostrEvent?
var delegate: ContactsDelegate? = nil
var event: NostrEvent? {
didSet {
guard let event else { return }
self.delegate?.latest_contact_event_changed(new_event: event)
}
}
init(our_pubkey: Pubkey) {
self.our_pubkey = our_pubkey
}
func is_muted(_ pk: Pubkey) -> Bool {
return muted.contains(pk)
}
func set_mutelist(_ ev: NostrEvent) {
let oldlist = self.mutelist
self.mutelist = ev
let old = oldlist.map({ ev in Set(ev.referenced_pubkeys) }) ?? Set<Pubkey>()
let new = Set(ev.referenced_pubkeys)
let diff = old.symmetricDifference(new)
var new_mutes = Set<Pubkey>()
var new_unmutes = Set<Pubkey>()
for d in diff {
if new.contains(d) {
new_mutes.insert(d)
} else {
new_unmutes.insert(d)
}
}
// TODO: set local mutelist here
self.muted = Set(ev.referenced_pubkeys)
if new_mutes.count > 0 {
notify(.new_mutes(new_mutes))
}
if new_unmutes.count > 0 {
notify(.new_unmutes(new_unmutes))
}
}
func remove_friend(_ pubkey: Pubkey) {
friends.remove(pubkey)
@@ -126,144 +94,7 @@ class Contacts {
}
}
func follow_reference(box: PostBox, our_contacts: NostrEvent?, keypair: FullKeypair, follow: FollowRef) -> NostrEvent? {
guard let ev = follow_user_event(our_contacts: our_contacts, keypair: keypair, follow: follow) else {
return nil
}
box.send(ev)
return ev
}
func unfollow_reference(postbox: PostBox, our_contacts: NostrEvent?, keypair: FullKeypair, unfollow: FollowRef) -> NostrEvent? {
guard let cs = our_contacts else {
return nil
}
guard let ev = unfollow_reference_event(our_contacts: cs, keypair: keypair, unfollow: unfollow) else {
return nil
}
postbox.send(ev)
return ev
}
func unfollow_reference_event(our_contacts: NostrEvent, keypair: FullKeypair, unfollow: FollowRef) -> NostrEvent? {
let tags = our_contacts.tags.reduce(into: [[String]]()) { ts, tag in
if let tag = FollowRef.from_tag(tag: tag), tag == unfollow {
return
}
ts.append(tag.strings())
}
let kind = NostrKind.contacts.rawValue
return NostrEvent(content: our_contacts.content, keypair: keypair.to_keypair(), kind: kind, tags: Array(tags))
}
func follow_user_event(our_contacts: NostrEvent?, keypair: FullKeypair, follow: FollowRef) -> NostrEvent? {
guard let cs = our_contacts else {
// don't create contacts for now so we don't nuke our contact list due to connectivity issues
// we should only create contacts during profile creation
//return create_contacts(relays: relays, our_pubkey: our_pubkey, follow: follow)
return nil
}
guard let ev = follow_with_existing_contacts(keypair: keypair, our_contacts: cs, follow: follow) else {
return nil
}
return ev
}
func decode_json_relays(_ content: String) -> [String: RelayInfo]? {
return decode_json(content)
}
func decode_json_relays(_ content: String) -> [RelayURL: RelayInfo]? {
return decode_json(content)
}
func remove_relay(ev: NostrEvent, current_relays: [RelayDescriptor], keypair: FullKeypair, relay: RelayURL) -> NostrEvent?{
var relays = ensure_relay_info(relays: current_relays, content: ev.content)
relays.removeValue(forKey: relay)
guard let content = encode_json(relays) else {
return nil
}
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 3, tags: ev.tags.strings())
}
func add_relay(ev: NostrEvent, keypair: FullKeypair, current_relays: [RelayDescriptor], relay: RelayURL, info: RelayInfo) -> NostrEvent? {
var relays = ensure_relay_info(relays: current_relays, content: ev.content)
guard relays.index(forKey: relay) == nil else {
return nil
}
relays[relay] = info
guard let content = encode_json(relays) else {
return nil
}
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 3, tags: ev.tags.strings())
}
func make_relay_metadata(relays: [RelayDescriptor], keypair: FullKeypair) -> NostrEvent? {
let tags = relays.compactMap { r -> [String]? in
var tag = ["r", r.url.id]
if (r.info.read ?? true) != (r.info.write ?? true) {
tag += r.info.read == true ? ["read"] : ["write"]
}
if ((r.info.read ?? true) || (r.info.write ?? true)) && r.variant == .regular {
return tag;
}
return nil
}
return NostrEvent(content: "", keypair: keypair.to_keypair(), kind: 10_002, tags: tags)
}
func ensure_relay_info(relays: [RelayDescriptor], content: String) -> [RelayURL: RelayInfo] {
return decode_json_relays(content) ?? make_contact_relays(relays)
}
func is_already_following(contacts: NostrEvent, follow: FollowRef) -> Bool {
return contacts.references.contains { ref in
switch (ref, follow) {
case let (.hashtag(ht), .hashtag(follow_ht)):
return ht.string() == follow_ht
case let (.pubkey(pk), .pubkey(follow_pk)):
return pk == follow_pk
case (.hashtag, .pubkey), (.pubkey, .hashtag),
(.event, _), (.quote, _), (.param, _):
return false
}
}
}
func follow_with_existing_contacts(keypair: FullKeypair, our_contacts: NostrEvent, follow: FollowRef) -> NostrEvent? {
// don't update if we're already following
if is_already_following(contacts: our_contacts, follow: follow) {
return nil
}
let kind = NostrKind.contacts.rawValue
var tags = our_contacts.tags.strings()
tags.append(follow.tag)
return NostrEvent(content: our_contacts.content, keypair: keypair.to_keypair(), kind: kind, tags: tags)
}
func make_contact_relays(_ relays: [RelayDescriptor]) -> [RelayURL: RelayInfo] {
return relays.reduce(into: [:]) { acc, relay in
acc[relay.url] = relay.info
}
/// Delegate protocol for `Contacts`. Use this to listen to significant updates from a `Contacts` instance
protocol ContactsDelegate {
func latest_contact_event_changed(new_event: NostrEvent)
}

View File

@@ -16,7 +16,7 @@ enum FilterState : Int {
func filter(ev: NostrEvent) -> Bool {
switch self {
case .posts:
return ev.known_kind == .boost || !ev.is_reply(.empty)
return ev.known_kind == .boost || ev.known_kind == .highlight || !ev.is_reply()
case .posts_and_replies:
return true
}
@@ -31,8 +31,9 @@ func nsfw_tag_filter(ev: NostrEvent) -> Bool {
func get_repost_of_muted_user_filter(damus_state: DamusState) -> ((_ ev: NostrEvent) -> Bool) {
return { ev in
guard ev.known_kind == .boost else { return true }
guard let inner_ev = ev.get_inner_event(cache: damus_state.events) else { return true }
return should_show_event(keypair: damus_state.keypair, hellthreads: damus_state.muted_threads, contacts: damus_state.contacts, ev: inner_ev)
// This needs to use cached because it can be way too slow otherwise
guard let inner_ev = ev.get_cached_inner_event(cache: damus_state.events) else { return true }
return should_show_event(state: damus_state, ev: inner_ev)
}
}
@@ -52,6 +53,10 @@ struct ContentFilters {
}
extension ContentFilters {
static func default_filters(damus_state: DamusState) -> ContentFilters {
return ContentFilters(filters: ContentFilters.defaults(damus_state: damus_state))
}
static func defaults(damus_state: DamusState) -> [(NostrEvent) -> Bool] {
var filters = Array<(NostrEvent) -> Bool>()
if damus_state.settings.hide_nsfw_tagged_content {

View File

@@ -9,31 +9,31 @@ import Foundation
class CreateAccountModel: ObservableObject {
@Published var real_name: String = ""
@Published var nick_name: String = ""
@Published var display_name: String = ""
@Published var name: String = ""
@Published var about: String = ""
@Published var pubkey: Pubkey = .empty
@Published var privkey: Privkey = .empty
@Published var profile_image: URL? = nil
var rendered_name: String {
if real_name.isEmpty {
return nick_name
if display_name.isEmpty {
return name
}
return real_name
return display_name
}
var keypair: Keypair {
return Keypair(pubkey: self.pubkey, privkey: self.privkey)
}
init(real: String = "", nick: String = "", about: String = "") {
init(display_name: String = "", name: String = "", about: String = "") {
let keypair = generate_new_keypair()
self.pubkey = keypair.pubkey
self.privkey = keypair.privkey
self.real_name = real
self.nick_name = nick
self.display_name = display_name
self.name = name
self.about = about
}
}

View File

@@ -7,13 +7,16 @@
import Foundation
import LinkPresentation
import EmojiPicker
struct DamusState {
class DamusState: HeadlessDamusState {
let pool: RelayPool
let keypair: Keypair
let likes: EventCounter
let boosts: EventCounter
let quote_reposts: EventCounter
let contacts: Contacts
let mutelist_manager: MutelistManager
let profiles: Profiles
let dms: DirectMessagesModel
let previews: PreviewCache
@@ -26,14 +29,51 @@ struct DamusState {
let events: EventCache
let bookmarks: BookmarksManager
let postbox: PostBox
let bootstrap_relays: [String]
let bootstrap_relays: [RelayURL]
let replies: ReplyCounter
let muted_threads: MutedThreadsManager
let wallet: WalletModel
let nav: NavigationCoordinator
let music: MusicController?
let video: VideoController
let ndb: Ndb
var purple: DamusPurple
var push_notification_client: PushNotificationClient
let emoji_provider: EmojiProvider
init(pool: RelayPool, keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, postbox: PostBox, bootstrap_relays: [RelayURL], replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: VideoController, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider) {
self.pool = pool
self.keypair = keypair
self.likes = likes
self.boosts = boosts
self.contacts = contacts
self.mutelist_manager = mutelist_manager
self.profiles = profiles
self.dms = dms
self.previews = previews
self.zaps = zaps
self.lnurls = lnurls
self.settings = settings
self.relay_filters = relay_filters
self.relay_model_cache = relay_model_cache
self.drafts = drafts
self.events = events
self.bookmarks = bookmarks
self.postbox = postbox
self.bootstrap_relays = bootstrap_relays
self.replies = replies
self.wallet = wallet
self.nav = nav
self.music = music
self.video = video
self.ndb = ndb
self.purple = purple ?? DamusPurple(
settings: settings,
keypair: keypair
)
self.quote_reposts = quote_reposts
self.push_notification_client = PushNotificationClient(keypair: keypair, settings: settings)
self.emoji_provider = emoji_provider
}
@discardableResult
func add_zap(zap: Zapping) -> Bool {
@@ -59,7 +99,14 @@ struct DamusState {
var is_privkey_user: Bool {
keypair.privkey != nil
}
func close() {
print("txn: damus close")
wallet.disconnect()
pool.close()
ndb.close()
}
static var empty: DamusState {
let empty_pub: Pubkey = .empty
let empty_sec: Privkey = .empty
@@ -71,6 +118,7 @@ struct DamusState {
likes: EventCounter(our_pubkey: empty_pub),
boosts: EventCounter(our_pubkey: empty_pub),
contacts: Contacts(our_pubkey: empty_pub),
mutelist_manager: MutelistManager(user_keypair: kp),
profiles: Profiles(ndb: .empty),
dms: DirectMessagesModel(our_pubkey: empty_pub),
previews: PreviewCache(),
@@ -85,12 +133,13 @@ struct DamusState {
postbox: PostBox(pool: RelayPool(ndb: .empty)),
bootstrap_relays: [],
replies: ReplyCounter(our_pubkey: empty_pub),
muted_threads: MutedThreadsManager(keypair: kp),
wallet: WalletModel(settings: UserSettingsStore()),
nav: NavigationCoordinator(),
music: nil,
video: VideoController(),
ndb: .empty
ndb: .empty,
quote_reposts: .init(our_pubkey: empty_pub),
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
)
}
}

View File

@@ -0,0 +1,133 @@
//
// DamusUserDefaults.swift
// damus
//
// Created by Daniel DAquino on 2023-11-25.
//
import Foundation
/// # DamusUserDefaults
///
/// This struct acts like a UserDefaults object, but is also capable of automatically mirroring values to a separate store.
///
/// It works by using a specific store container as the main source of truth, and by optionally mirroring values to a different container if needed.
///
/// This is useful when the data of a UserDefaults object needs to be accessible from another store container,
/// as it offers ways to automatically mirror information over a different container (e.g. When using app extensions)
///
/// Since it mirrors items instead of migrating them, this object can be used in a backwards compatible manner.
///
/// The easiest way to use this is to use `DamusUserDefaults.standard` as a drop-in replacement for `UserDefaults.standard`
/// Or, you can initialize a custom object with customizable stores.
struct DamusUserDefaults {
// MARK: - Helper data structures
enum Store: Equatable {
case standard
case shared
case custom(UserDefaults)
func get_user_defaults() -> UserDefaults? {
switch self {
case .standard:
return UserDefaults.standard
case .shared:
return UserDefaults(suiteName: Constants.DAMUS_APP_GROUP_IDENTIFIER)
case .custom(let user_defaults):
return user_defaults
}
}
}
enum DamusUserDefaultsError: Error {
case cannot_initialize_user_defaults
case cannot_mirror_main_user_defaults
}
// MARK: - Stored properties
private let main: UserDefaults
private let mirrors: [UserDefaults]
// MARK: - Initializers
init?(main: Store, mirror mirrors: [Store] = []) throws {
guard let main_user_defaults = main.get_user_defaults() else { throw DamusUserDefaultsError.cannot_initialize_user_defaults }
let mirror_user_defaults: [UserDefaults] = try mirrors.compactMap({ mirror_store in
guard let mirror_user_default = mirror_store.get_user_defaults() else {
throw DamusUserDefaultsError.cannot_initialize_user_defaults
}
guard mirror_store != main else {
throw DamusUserDefaultsError.cannot_mirror_main_user_defaults
}
return mirror_user_default
})
self.main = main_user_defaults
self.mirrors = mirror_user_defaults
}
// MARK: - Functions for feature parity with UserDefaults
func string(forKey defaultName: String) -> String? {
let value = self.main.string(forKey: defaultName)
self.mirror(value, forKey: defaultName)
return value
}
func set(_ value: Any?, forKey defaultName: String) {
self.main.set(value, forKey: defaultName)
self.mirror(value, forKey: defaultName)
}
func removeObject(forKey defaultName: String) {
self.main.removeObject(forKey: defaultName)
self.mirror_object_removal(forKey: defaultName)
}
func object(forKey defaultName: String) -> Any? {
let value = self.main.object(forKey: defaultName)
self.mirror(value, forKey: defaultName)
return value
}
// MARK: - Mirroring utilities
private func mirror(_ value: Any?, forKey defaultName: String) {
for mirror in self.mirrors {
mirror.set(value, forKey: defaultName)
}
}
private func mirror_object_removal(forKey defaultName: String) {
for mirror in self.mirrors {
mirror.removeObject(forKey: defaultName)
}
}
}
// MARK: - Default convenience objects
/// # Convenience objects
///
/// - `DamusUserDefaults.standard`: will detect the bundle identifier and pick an appropriate object. You should generally use this one.
/// - `DamusUserDefaults.app`: stores things on its own container, and mirrors them to the shared container.
/// - `DamusUserDefaults.shared`: stores things on the shared container and does no mirroring
extension DamusUserDefaults {
static let app: DamusUserDefaults = try! DamusUserDefaults(main: .standard, mirror: [.shared])! // Since the underlying behavior is very static, the risk of crashing on force unwrap is low
static let shared: DamusUserDefaults = try! DamusUserDefaults(main: .shared)! // Since the underlying behavior is very static, the risk of crashing on force unwrap is low
static var standard: DamusUserDefaults {
get {
switch Bundle.main.bundleIdentifier {
case Constants.MAIN_APP_BUNDLE_IDENTIFIER:
return Self.app
case Constants.NOTIFICATION_EXTENSION_BUNDLE_IDENTIFIER:
return Self.shared
default:
return Self.shared
}
}
}
}

View File

@@ -1,147 +0,0 @@
//
// EventRef.swift
// damus
//
// Created by William Casarin on 2022-05-08.
//
import Foundation
enum EventRef: Equatable {
case mention(Mention<NoteRef>)
case thread_id(NoteRef)
case reply(NoteRef)
case reply_to_root(NoteRef)
var is_mention: NoteRef? {
if case .mention(let m) = self { return m.ref }
return nil
}
var is_direct_reply: NoteRef? {
switch self {
case .mention:
return nil
case .thread_id:
return nil
case .reply(let refid):
return refid
case .reply_to_root(let refid):
return refid
}
}
var is_thread_id: NoteRef? {
switch self {
case .mention:
return nil
case .thread_id(let referencedId):
return referencedId
case .reply:
return nil
case .reply_to_root(let referencedId):
return referencedId
}
}
var is_reply: NoteRef? {
switch self {
case .mention:
return nil
case .thread_id:
return nil
case .reply(let refid):
return refid
case .reply_to_root(let refid):
return refid
}
}
}
func build_mention_indices(_ blocks: [Block], type: MentionType) -> Set<Int> {
return blocks.reduce(into: []) { acc, block in
switch block {
case .mention(let m):
if m.ref.key == type, let idx = m.index {
acc.insert(idx)
}
case .relay:
return
case .text:
return
case .hashtag:
return
case .url:
return
case .invoice:
return
}
}
}
func interp_event_refs_without_mentions(_ refs: [NoteRef]) -> [EventRef] {
if refs.count == 0 {
return []
}
if refs.count == 1 {
return [.reply_to_root(refs[0])]
}
var evrefs: [EventRef] = []
var first: Bool = true
for ref in refs {
if first {
evrefs.append(.thread_id(ref))
first = false
} else {
evrefs.append(.reply(ref))
}
}
return evrefs
}
func interp_event_refs_with_mentions(tags: Tags, mention_indices: Set<Int>) -> [EventRef] {
var mentions: [EventRef] = []
var ev_refs: [NoteRef] = []
var i: Int = 0
for tag in tags {
if let ref = NoteRef.from_tag(tag: tag) {
if mention_indices.contains(i) {
let mention = Mention<NoteRef>(index: i, ref: ref)
mentions.append(.mention(mention))
} else {
ev_refs.append(ref)
}
}
i += 1
}
var replies = interp_event_refs_without_mentions(ev_refs)
replies.append(contentsOf: mentions)
return replies
}
func interpret_event_refs(blocks: [Block], tags: Tags) -> [EventRef] {
if tags.count == 0 {
return []
}
/// build a set of indices for each event mention
let mention_indices = build_mention_indices(blocks, type: .e)
/// simpler case with no mentions
if mention_indices.count == 0 {
return interp_event_refs_without_mentions_ndb(References<NoteRef>(tags: tags))
}
return interp_event_refs_with_mentions(tags: tags, mention_indices: mention_indices)
}
func event_is_reply(_ refs: [EventRef]) -> Bool {
return refs.contains { evref in
return evref.is_reply != nil
}
}

View File

@@ -7,25 +7,62 @@
import Foundation
class EventsModel: ObservableObject {
let state: DamusState
let target: NoteId
let kind: NostrKind
let kind: QueryKind
let sub_id = UUID().uuidString
let profiles_id = UUID().uuidString
@Published var events: [NostrEvent] = []
var events: EventHolder
@Published var loading: Bool
enum QueryKind {
case kind(NostrKind)
case quotes
}
init(state: DamusState, target: NoteId, kind: NostrKind) {
self.state = state
self.target = target
self.kind = kind
self.kind = .kind(kind)
self.loading = true
self.events = EventHolder(on_queue: { ev in
preload_events(state: state, events: [ev])
})
}
init(state: DamusState, target: NoteId, query: EventsModel.QueryKind) {
self.state = state
self.target = target
self.kind = query
self.loading = true
self.events = EventHolder(on_queue: { ev in
preload_events(state: state, events: [ev])
})
}
public static func quotes(state: DamusState, target: NoteId) -> EventsModel {
EventsModel(state: state, target: target, query: .quotes)
}
public static func reposts(state: DamusState, target: NoteId) -> EventsModel {
EventsModel(state: state, target: target, kind: .boost)
}
public static func likes(state: DamusState, target: NoteId) -> EventsModel {
EventsModel(state: state, target: target, kind: .like)
}
private func get_filter() -> NostrFilter {
var filter = NostrFilter(kinds: [kind])
filter.referenced_ids = [target]
var filter: NostrFilter
switch kind {
case .kind(let k):
filter = NostrFilter(kinds: [k])
filter.referenced_ids = [target]
case .quotes:
filter = NostrFilter(kinds: [.text])
filter.quotes = [target]
}
filter.limit = 500
return filter
}
@@ -39,23 +76,19 @@ class EventsModel: ObservableObject {
func unsubscribe() {
state.pool.unsubscribe(sub_id: sub_id)
}
private func handle_event(relay_id: String, ev: NostrEvent) {
guard ev.kind == kind.rawValue,
ev.referenced_ids.last == target else {
return
}
if insert_uniq_sorted_event(events: &self.events, new_ev: ev, cmp: { a, b in a.created_at < b.created_at } ) {
private func handle_event(relay_id: RelayURL, ev: NostrEvent) {
if events.insert(ev) {
objectWillChange.send()
}
}
func handle_nostr_event(relay_id: String, ev: NostrConnectionEvent) {
guard case .nostr_event(let nev) = ev else {
func handle_nostr_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
guard case .nostr_event(let nev) = ev, nev.subid == self.sub_id
else {
return
}
switch nev {
case .event(_, let ev):
handle_event(relay_id: relay_id, ev: ev)
@@ -63,9 +96,14 @@ class EventsModel: ObservableObject {
break
case .ok:
break
case .auth:
break
case .eose:
let txn = NdbTxn(ndb: self.state.ndb)
load_profiles(context: "events_model", profiles_subid: profiles_id, relay_id: relay_id, load: .from_events(events), damus_state: state, txn: txn)
self.loading = false
guard let txn = NdbTxn(ndb: self.state.ndb) else {
return
}
load_profiles(context: "events_model", profiles_subid: profiles_id, relay_id: relay_id, load: .from_events(events.all_events), damus_state: state, txn: txn)
}
}
}

View File

@@ -0,0 +1,15 @@
//
// FollowState.swift
// damus
//
// Created by Daniel DAquino on 2023-11-24.
//
import Foundation
enum FollowState {
case follows
case following
case unfollowing
case unfollows
}

View File

@@ -52,8 +52,8 @@ class FollowersModel: ObservableObject {
contacts?.append(ev.pubkey)
has_contact.insert(ev.pubkey)
}
func load_profiles<Y>(relay_id: String, txn: NdbTxn<Y>) {
func load_profiles<Y>(relay_id: RelayURL, txn: NdbTxn<Y>) {
let authors = find_profiles_to_fetch_from_keys(profiles: damus_state.profiles, pks: contacts ?? [], txn: txn)
if authors.isEmpty {
return
@@ -63,8 +63,8 @@ class FollowersModel: ObservableObject {
authors: authors)
damus_state.pool.subscribe_to(sub_id: profiles_id, filters: [filter], to: [relay_id], handler: handle_event)
}
func handle_event(relay_id: String, ev: NostrConnectionEvent) {
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
guard case .nostr_event(let nev) = ev else {
return
}
@@ -83,7 +83,7 @@ class FollowersModel: ObservableObject {
case .eose(let sub_id):
if sub_id == self.sub_id {
let txn = NdbTxn(ndb: self.damus_state.ndb)
guard let txn = NdbTxn(ndb: self.damus_state.ndb) else { return }
load_profiles(relay_id: relay_id, txn: txn)
} else if sub_id == self.profiles_id {
damus_state.pool.unsubscribe(sub_id: profiles_id, to: [relay_id])
@@ -91,6 +91,8 @@ class FollowersModel: ObservableObject {
case .ok:
break
case .auth:
break
}
}
}

View File

@@ -52,8 +52,8 @@ class FollowingModel {
print("unsubscribing from following \(sub_id)")
self.damus_state.pool.unsubscribe(sub_id: sub_id)
}
func handle_event(relay_id: String, ev: NostrConnectionEvent) {
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
// don't need to do anything here really
}
}

View File

@@ -0,0 +1,34 @@
//
// FriendFilter.swift
// damus
//
// Created by Daniel DAquino on 2023-11-24.
//
import Foundation
enum FriendFilter: String, StringCodable {
case all
case friends
init?(from string: String) {
guard let ff = FriendFilter(rawValue: string) else {
return nil
}
self = ff
}
func to_string() -> String {
self.rawValue
}
func filter(contacts: Contacts, pubkey: Pubkey) -> Bool {
switch self {
case .all:
return true
case .friends:
return contacts.is_in_friendosphere(pubkey)
}
}
}

View File

@@ -0,0 +1,26 @@
//
// HeadlessDamusState.swift
// damus
//
// Created by Daniel DAquino on 2023-11-27.
//
import Foundation
/// HeadlessDamusState
///
/// A protocl for a lighter headless alternative to DamusState that does not have dependencies on View objects or UI logic.
/// This is useful in limited environments (e.g. Notification Service Extension) where we do not want View/UI dependencies
protocol HeadlessDamusState {
var ndb: Ndb { get }
var settings: UserSettingsStore { get }
var contacts: Contacts { get }
var mutelist_manager: MutelistManager { get }
var keypair: Keypair { get }
var profiles: Profiles { get }
var zaps: Zaps { get }
var lnurls: LNUrls { get }
@discardableResult
func add_zap(zap: Zapping) -> Bool
}

View File

@@ -0,0 +1,34 @@
//
// HighlightEvent.swift
// damus
//
// Created by eric on 4/22/24.
//
import Foundation
struct HighlightEvent {
let event: NostrEvent
var event_ref: String? = nil
var url_ref: URL? = nil
var context: String? = nil
static func parse(from ev: NostrEvent) -> HighlightEvent {
var highlight = HighlightEvent(event: ev)
for tag in ev.tags {
guard tag.count >= 2 else { continue }
switch tag[0].string() {
case "e": highlight.event_ref = tag[1].string()
case "a": highlight.event_ref = tag[1].string()
case "r": highlight.url_ref = URL(string: tag[1].string())
case "context": highlight.context = tag[1].string()
default:
break
}
}
return highlight
}
}

View File

@@ -8,21 +8,6 @@
import Foundation
import UIKit
struct NewEventsBits: OptionSet {
let rawValue: Int
static let home = NewEventsBits(rawValue: 1 << 0)
static let zaps = NewEventsBits(rawValue: 1 << 1)
static let mentions = NewEventsBits(rawValue: 1 << 2)
static let reposts = NewEventsBits(rawValue: 1 << 3)
static let likes = NewEventsBits(rawValue: 1 << 4)
static let search = NewEventsBits(rawValue: 1 << 5)
static let dms = NewEventsBits(rawValue: 1 << 6)
static let all = NewEventsBits(rawValue: 0xFFFFFFFF)
static let notifications: NewEventsBits = [.zaps, .likes, .reposts, .mentions]
}
enum Resubscribe {
case following
case unfollowing(FollowRef)
@@ -56,16 +41,24 @@ enum HomeResubFilter {
}
}
class HomeModel {
// Don't trigger a user notification for events older than a certain age
static let event_max_age_for_notification: TimeInterval = 12 * 60 * 60
class HomeModel: ContactsDelegate {
// The maximum amount of contacts placed on a home feed subscription filter.
// If the user has more contacts, chunking or other techniques will be used to avoid sending huge filters
let MAX_CONTACTS_ON_FILTER = 500
var damus_state: DamusState
// Don't trigger a user notification for events older than a certain age
static let event_max_age_for_notification: TimeInterval = EVENT_MAX_AGE_FOR_NOTIFICATION
var damus_state: DamusState {
didSet {
self.load_our_stuff_from_damus_state()
}
}
// NDBTODO: let's get rid of this entirely, let nostrdb handle it
var has_event: [String: Set<NoteId>] = [:]
var deleted_events: Set<NoteId> = Set()
var last_event_of_kind: [String: [UInt32: NostrEvent]] = [:]
var last_event_of_kind: [RelayURL: [UInt32: NostrEvent]] = [:]
var done_init: Bool = false
var incoming_dms: [NostrEvent] = []
let dm_debouncer = Debouncer(interval: 0.5)
@@ -123,6 +116,32 @@ class HomeModel {
self.should_debounce_dms = false
}
}
// MARK: - Loading items from DamusState
/// This is called whenever DamusState gets set. This function is used to load or setup anything we need from the new DamusState
func load_our_stuff_from_damus_state() {
self.load_latest_contact_event_from_damus_state()
}
/// This loads the latest contact event we have on file from NostrDB. This should be called as soon as we get the new DamusState
/// Loading the latest contact list event into our `Contacts` instance from storage is important to avoid getting into weird states when the network is unreliable or when relays delete such information
func load_latest_contact_event_from_damus_state() {
guard let latest_contact_event_id_hex = damus_state.settings.latest_contact_event_id_hex else { return }
guard let latest_contact_event_id = NoteId(hex: latest_contact_event_id_hex) else { return }
guard let latest_contact_event: NdbNote = damus_state.ndb.lookup_note( latest_contact_event_id)?.unsafeUnownedValue?.to_owned() else { return }
process_contact_event(state: damus_state, ev: latest_contact_event)
damus_state.contacts.delegate = self
}
// MARK: - ContactsDelegate functions
func latest_contact_event_changed(new_event: NostrEvent) {
// When the latest user contact event has changed, save its ID so we know exactly where to find it next time
damus_state.settings.latest_contact_event_id_hex = new_event.id.hex()
}
// MARK: - Nostr event and subscription handling
func resubscribe(_ resubbing: Resubscribe) {
if self.should_debounce_dms {
@@ -150,7 +169,7 @@ class HomeModel {
}
@MainActor
func process_event(sub_id: String, relay_id: String, ev: NostrEvent) {
func process_event(sub_id: String, relay_id: RelayURL, ev: NostrEvent) {
if has_sub_id_event(sub_id: sub_id, ev_id: ev.id) {
return
}
@@ -165,15 +184,17 @@ class HomeModel {
}
switch kind {
case .chat, .longform, .text:
case .chat, .longform, .text, .highlight:
handle_text_event(sub_id: sub_id, ev)
case .contacts:
handle_contact_event(sub_id: sub_id, relay_id: relay_id, ev: ev)
case .metadata:
// profile metadata processing is handled by nostrdb
break
case .list:
handle_list_event(ev)
case .list_deprecated:
handle_old_list_event(ev)
case .mute_list:
handle_mute_list_event(ev)
case .boost:
handle_boost_event(sub_id: sub_id, ev)
case .like:
@@ -224,7 +245,7 @@ class HomeModel {
pdata.status.update_status(st)
}
func handle_nwc_response(_ ev: NostrEvent, relay: String) {
func handle_nwc_response(_ ev: NostrEvent, relay: RelayURL) {
Task { @MainActor in
// TODO: Adapt KeychainStorage to StringCodable and instead of parsing to WalletConnectURL every time
guard let nwc_str = damus_state.settings.nostr_wallet_connect,
@@ -232,10 +253,10 @@ class HomeModel {
let resp = await FullWalletResponse(from: ev, nwc: nwc) else {
return
}
// since command results are not returned for ephemeral events,
// remove the request from the postbox which is likely failing over and over
if damus_state.postbox.remove_relayer(relay_id: nwc.relay.id, event_id: resp.req_id) {
if damus_state.postbox.remove_relayer(relay_id: nwc.relay, event_id: resp.req_id) {
print("nwc: got response, removed \(resp.req_id) from the postbox [\(relay)]")
} else {
print("nwc: \(resp.req_id) not found in the postbox, nothing to remove [\(relay)]")
@@ -254,10 +275,10 @@ class HomeModel {
@MainActor
func handle_zap_event(_ ev: NostrEvent) {
process_zap_event(damus_state: damus_state, ev: ev) { zapres in
process_zap_event(state: damus_state, ev: ev) { zapres in
guard case .done(let zap) = zapres,
zap.target.pubkey == self.damus_state.keypair.pubkey,
should_show_event(keypair: self.damus_state.keypair, hellthreads: self.damus_state.muted_threads, contacts: self.damus_state.contacts, ev: zap.request.ev) else {
should_show_event(state: self.damus_state, ev: zap.request.ev) else {
return
}
@@ -289,13 +310,27 @@ class HomeModel {
}
@MainActor
func handle_damus_app_notification(_ notification: DamusAppNotification) async {
if self.notifications.insert_app_notification(notification: notification) {
let last_notification = get_last_event(.notifications)
if last_notification == nil || last_notification!.created_at < notification.last_event_at {
save_last_event(NoteId.empty, created_at: notification.last_event_at, timeline: .notifications)
// If we successfully inserted a new Damus App notification, switch ON the Damus App notification bit on our NewsEventsBits
// This will cause the bell icon on the tab bar to display the purple dot indicating there is an unread notification
self.notification_status.new_events = NewEventsBits(rawValue: self.notification_status.new_events.rawValue | NewEventsBits.damus_app_notifications.rawValue)
}
return
}
}
func filter_events() {
events.filter { ev in
!damus_state.contacts.is_muted(ev.pubkey)
!damus_state.mutelist_manager.is_muted(.user(ev.pubkey, nil))
}
self.dms.dms = dms.dms.filter { ev in
!damus_state.contacts.is_muted(ev.pubkey)
!damus_state.mutelist_manager.is_muted(.user(ev.pubkey, nil))
}
notifications.filter { ev in
@@ -303,7 +338,8 @@ class HomeModel {
return false
}
return !damus_state.contacts.is_muted(ev.pubkey) && !damus_state.muted_threads.isMutedThread(ev, keypair: damus_state.keypair)
let event_muted = damus_state.mutelist_manager.is_event_muted(ev)
return !event_muted
}
}
@@ -311,7 +347,7 @@ class HomeModel {
self.deleted_events.insert(ev.id)
}
func handle_contact_event(sub_id: String, relay_id: String, ev: NostrEvent) {
func handle_contact_event(sub_id: String, relay_id: RelayURL, ev: NostrEvent) {
process_contact_event(state: self.damus_state, ev: ev)
if sub_id == init_subid {
@@ -350,12 +386,19 @@ class HomeModel {
case .already_counted:
break
case .success(let n):
let boosted = Counted(event: ev, id: e, total: n)
notify(.reposted(boosted))
notify(.update_stats(note_id: e))
}
}
func handle_quote_repost_event(_ ev: NostrEvent, target: NoteId) {
switch damus_state.quote_reposts.add_event(ev, target: target) {
case .already_counted:
break
case .success(let n):
notify(.update_stats(note_id: target))
}
}
func handle_like_event(_ ev: NostrEvent) {
guard let e = ev.last_refid() else {
// no id ref? invalid like event
@@ -378,7 +421,7 @@ class HomeModel {
}
@MainActor
func handle_event(relay_id: String, conn_event: NostrConnectionEvent) {
func handle_event(relay_id: RelayURL, conn_event: NostrConnectionEvent) {
switch conn_event {
case .ws_event(let ev):
switch ev {
@@ -396,7 +439,7 @@ class HomeModel {
let r = pool.get_relay(relay_id),
r.descriptor.variant == .nwc,
let nwc = WalletConnectURL(str: nwc_str),
nwc.relay.id == relay_id
nwc.relay == relay_id
{
subscribe_to_nwc(url: nwc, pool: pool)
}
@@ -429,8 +472,10 @@ class HomeModel {
print(msg)
case .eose(let sub_id):
let txn = NdbTxn(ndb: damus_state.ndb)
guard let txn = NdbTxn(ndb: damus_state.ndb) else {
return
}
if sub_id == dms_subid {
var dms = dms.dms.flatMap { $0.events }
dms.append(contentsOf: incoming_dms)
@@ -446,6 +491,8 @@ class HomeModel {
case .ok:
break
case .auth:
break
}
}
@@ -453,14 +500,14 @@ class HomeModel {
/// Send the initial filters, just our contact list mostly
func send_initial_filters(relay_id: String) {
func send_initial_filters(relay_id: RelayURL) {
let filter = NostrFilter(kinds: [.contacts], limit: 1, authors: [damus_state.pubkey])
let subscription = NostrSubscribe(filters: [filter], sub_id: init_subid)
pool.send(.subscribe(subscription), to: [relay_id])
}
/// After initial connection or reconnect, send subscription filters for the home timeline, DMs, and notifications
func send_home_filters(relay_id: String?) {
func send_home_filters(relay_id: RelayURL?) {
// TODO: since times should be based on events from a specific relay
// perhaps we could mark this in the relay pool somehow
@@ -472,10 +519,13 @@ class HomeModel {
var our_contacts_filter = NostrFilter(kinds: [.contacts, .metadata])
our_contacts_filter.authors = [damus_state.pubkey]
var our_blocklist_filter = NostrFilter(kinds: [.list])
our_blocklist_filter.parameter = ["mute"]
var our_old_blocklist_filter = NostrFilter(kinds: [.list_deprecated])
our_old_blocklist_filter.parameter = ["mute"]
our_old_blocklist_filter.authors = [damus_state.pubkey]
var our_blocklist_filter = NostrFilter(kinds: [.mute_list])
our_blocklist_filter.authors = [damus_state.pubkey]
var dms_filter = NostrFilter(kinds: [.dm])
var our_dms_filter = NostrFilter(kinds: [.dm])
@@ -499,7 +549,8 @@ class HomeModel {
notifications_filter.limit = 500
var notifications_filters = [notifications_filter]
var contacts_filters = [contacts_filter, our_contacts_filter, our_blocklist_filter]
let contacts_filter_chunks = contacts_filter.chunked(on: .authors, into: MAX_CONTACTS_ON_FILTER)
var contacts_filters = contacts_filter_chunks + [our_contacts_filter, our_blocklist_filter, our_old_blocklist_filter]
var dms_filters = [dms_filter, our_dms_filter]
let last_of_kind = get_last_of_kind(relay_id: relay_id)
@@ -518,7 +569,7 @@ class HomeModel {
pool.send(.subscribe(.init(filters: dms_filters, sub_id: dms_subid)), to: relay_ids)
}
func get_last_of_kind(relay_id: String?) -> [UInt32: NostrEvent] {
func get_last_of_kind(relay_id: RelayURL?) -> [UInt32: NostrEvent] {
return relay_id.flatMap { last_event_of_kind[$0] } ?? [:]
}
@@ -532,10 +583,10 @@ class HomeModel {
return Array(friends)
}
func subscribe_to_home_filters(friends fs: [Pubkey]? = nil, relay_id: String? = nil) {
func subscribe_to_home_filters(friends fs: [Pubkey]? = nil, relay_id: RelayURL? = nil) {
// TODO: separate likes?
var home_filter_kinds: [NostrKind] = [
.text, .longform, .boost
.text, .longform, .boost, .highlight
]
if !damus_state.settings.onlyzaps_mode {
home_filter_kinds.append(.like)
@@ -552,7 +603,7 @@ class HomeModel {
home_filter.authors = friends
home_filter.limit = 500
var home_filters = [home_filter]
var home_filters = home_filter.chunked(on: .authors, into: MAX_CONTACTS_ON_FILTER)
let followed_hashtags = Array(damus_state.contacts.get_followed_hashtags())
if followed_hashtags.count != 0 {
@@ -568,13 +619,32 @@ class HomeModel {
pool.send(.subscribe(sub), to: relay_ids)
}
func handle_list_event(_ ev: NostrEvent) {
func handle_mute_list_event(_ ev: NostrEvent) {
// we only care about our mutelist
guard ev.pubkey == damus_state.pubkey else {
return
}
// we only care about the most recent mutelist
if let mutelist = damus_state.mutelist_manager.event {
if ev.created_at <= mutelist.created_at {
return
}
}
damus_state.mutelist_manager.set_mutelist(ev)
migrate_old_muted_threads_to_new_mutelist(keypair: damus_state.keypair, damus_state: damus_state)
}
func handle_old_list_event(_ ev: NostrEvent) {
// we only care about our lists
guard ev.pubkey == damus_state.pubkey else {
return
}
if let mutelist = damus_state.contacts.mutelist {
// we only care about the most recent mutelist
if let mutelist = damus_state.mutelist_manager.event {
if ev.created_at <= mutelist.created_at {
return
}
@@ -584,10 +654,12 @@ class HomeModel {
return
}
damus_state.contacts.set_mutelist(ev)
damus_state.mutelist_manager.set_mutelist(ev)
migrate_old_muted_threads_to_new_mutelist(keypair: damus_state.keypair, damus_state: damus_state)
}
func get_last_event_of_kind(relay_id: String, kind: UInt32) -> NostrEvent? {
func get_last_event_of_kind(relay_id: RelayURL, kind: UInt32) -> NostrEvent? {
guard let m = last_event_of_kind[relay_id] else {
last_event_of_kind[relay_id] = [:]
return nil
@@ -600,7 +672,7 @@ class HomeModel {
// don't show notifications from ourselves
guard ev.pubkey != damus_state.pubkey,
event_has_our_pubkey(ev, our_pubkey: self.damus_state.pubkey),
should_show_event(keypair: self.damus_state.keypair, hellthreads: damus_state.muted_threads, contacts: damus_state.contacts, ev: ev) else {
should_show_event(state: damus_state, ev: ev) else {
return
}
@@ -615,7 +687,7 @@ class HomeModel {
}
if handle_last_event(ev: ev, timeline: .notifications) {
process_local_notification(damus_state: damus_state, event: ev)
process_local_notification(state: damus_state, event: ev)
}
}
@@ -638,7 +710,7 @@ class HomeModel {
func handle_text_event(sub_id: String, _ ev: NostrEvent) {
guard should_show_event(keypair: damus_state.keypair, hellthreads: damus_state.muted_threads, contacts: damus_state.contacts, ev: ev) else {
guard should_show_event(state: damus_state, ev: ev) else {
return
}
@@ -647,6 +719,10 @@ class HomeModel {
damus_state.replies.count_replies(ev, keypair: self.damus_state.keypair)
damus_state.events.insert(ev)
if let quoted_event = ev.referenced_quote_ids.first {
handle_quote_repost_event(ev, target: quoted_event.note_id)
}
if sub_id == home_subid {
insert_home_event(ev)
} else if sub_id == notifications_subid {
@@ -657,15 +733,17 @@ class HomeModel {
func got_new_dm(notifs: NewEventsBits, ev: NostrEvent) {
notification_status.new_events = notifs
if damus_state.settings.dm_notification && ev.age < HomeModel.event_max_age_for_notification {
let convo = ev.decrypted(keypair: self.damus_state.keypair) ?? 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)
guard should_display_notification(state: damus_state, event: ev, mode: .local),
let notification_object = generate_local_notification_object(from: ev, state: damus_state)
else {
return
}
create_local_notification(profiles: damus_state.profiles, notify: notification_object)
}
func handle_dm(_ ev: NostrEvent) {
guard should_show_event(keypair: damus_state.keypair, hellthreads: damus_state.muted_threads, contacts: damus_state.contacts, ev: ev) else {
guard should_show_event(state: damus_state, ev: ev) else {
return
}
@@ -845,23 +923,23 @@ func process_contact_event(state: DamusState, ev: NostrEvent) {
}
func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
let bootstrap_dict: [String: RelayInfo] = [:]
let bootstrap_dict: [RelayURL: RelayInfo] = [:]
let old_decoded = m_old_ev.flatMap { decode_json_relays($0.content) } ?? state.bootstrap_relays.reduce(into: bootstrap_dict) { (d, r) in
d[r] = .rw
}
guard let decoded: [String: RelayInfo] = decode_json_relays(ev.content) else {
guard let decoded: [RelayURL: RelayInfo] = decode_json_relays(ev.content) else {
return
}
var changed = false
var new = Set<String>()
var new = Set<RelayURL>()
for key in decoded.keys {
new.insert(key)
}
var old = Set<String>()
var old = Set<RelayURL>()
for key in old_decoded.keys {
old.insert(key)
}
@@ -872,10 +950,8 @@ func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
for d in diff {
changed = true
if new.contains(d) {
if let url = RelayURL(d) {
let descriptor = RelayDescriptor(url: url, info: decoded[d] ?? .rw)
add_new_relay(model_cache: state.relay_model_cache, relay_filters: state.relay_filters, pool: state.pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: state.settings.developer_mode)
}
let descriptor = RelayDescriptor(url: d, info: decoded[d] ?? .rw)
add_new_relay(model_cache: state.relay_model_cache, relay_filters: state.relay_filters, pool: state.pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: state.settings.developer_mode)
} else {
state.pool.remove_relay(d)
}
@@ -891,8 +967,8 @@ func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
func add_new_relay(model_cache: RelayModelCache, relay_filters: RelayFilters, pool: RelayPool, descriptor: RelayDescriptor, new_relay_filters: Bool, logging_enabled: Bool) {
try? pool.add_relay(descriptor)
let url = descriptor.url
let relay_id = url.id
let relay_id = url
guard model_cache.model(withURL: url) == nil else {
return
}
@@ -918,10 +994,10 @@ func add_new_relay(model_cache: RelayModelCache, relay_filters: RelayFilters, po
}
}
func fetch_relay_metadata(relay_id: String) async throws -> RelayMetadata? {
var urlString = relay_id.replacingOccurrences(of: "wss://", with: "https://")
func fetch_relay_metadata(relay_id: RelayURL) async throws -> RelayMetadata? {
var urlString = relay_id.absoluteString.replacingOccurrences(of: "wss://", with: "https://")
urlString = urlString.replacingOccurrences(of: "ws://", with: "http://")
guard let url = URL(string: urlString) else {
return nil
}
@@ -1072,19 +1148,14 @@ func event_has_our_pubkey(_ ev: NostrEvent, our_pubkey: Pubkey) -> Bool {
func should_show_event(event: NostrEvent, damus_state: DamusState) -> Bool {
return should_show_event(
keypair: damus_state.keypair,
hellthreads: damus_state.muted_threads,
contacts: damus_state.contacts,
state: damus_state,
ev: event
)
}
func should_show_event(keypair: Keypair, hellthreads: MutedThreadsManager, contacts: Contacts, ev: NostrEvent) -> Bool {
if contacts.is_muted(ev.pubkey) {
return false
}
if hellthreads.isMutedThread(ev, keypair: keypair) {
func should_show_event(state: DamusState, ev: NostrEvent) -> Bool {
let event_muted = state.mutelist_manager.is_event_muted(ev)
if event_muted {
return false
}
@@ -1104,39 +1175,11 @@ func zap_vibrate(zap_amount: Int64) {
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.request.ev
let pk = zap.is_anon ? ANON_PUBKEY : src.pubkey
let name = profiles.lookup(id: pk).map { profile in
Profile.displayName(profile: profile, pubkey: pk).displayName.truncate(maxLength: 50)
}.value
let sats = NSNumber(value: (Double(zap.invoice.amount) / 1000.0))
let formattedSats = format_msats_abbrev(zap.invoice.amount)
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_profile_zap_notification(profiles: Profiles, zap: Zap, locale: Locale = Locale.current, profile_id: Pubkey) {
let content = UNMutableNotificationContent()
content.title = zap_notification_title(zap)
content.body = zap_notification_body(profiles: profiles, zap: zap, locale: locale)
content.title = NotificationFormatter.zap_notification_title(zap)
content.body = NotificationFormatter.zap_notification_body(profiles: profiles, zap: zap, locale: locale)
content.sound = UNNotificationSound.default
content.userInfo = LossyLocalNotification(type: .profile_zap, mention: .pubkey(profile_id)).to_user_info()
@@ -1156,8 +1199,8 @@ func create_in_app_profile_zap_notification(profiles: Profiles, zap: Zap, locale
func create_in_app_event_zap_notification(profiles: Profiles, zap: Zap, locale: Locale = Locale.current, evId: NoteId) {
let content = UNMutableNotificationContent()
content.title = zap_notification_title(zap)
content.body = zap_notification_body(profiles: profiles, zap: zap, locale: locale)
content.title = NotificationFormatter.zap_notification_title(zap)
content.body = NotificationFormatter.zap_notification_body(profiles: profiles, zap: zap, locale: locale)
content.sound = UNNotificationSound.default
content.userInfo = LossyLocalNotification(type: .zap, mention: .note(evId)).to_user_info()
@@ -1173,239 +1216,3 @@ func create_in_app_event_zap_notification(profiles: Profiles, zap: Zap, locale:
}
}
}
func render_notification_content_preview(cache: EventCache, ev: NostrEvent, profiles: Profiles, keypair: Keypair) -> String {
let prefix_len = 300
let artifacts = cache.get_cache_data(ev.id).artifacts.artifacts ?? render_note_content(ev: ev, profiles: profiles, keypair: keypair)
// special case for longform events
if ev.known_kind == .longform {
let longform = LongformEvent(event: ev)
return longform.title ?? longform.summary ?? "Longform Event"
}
switch artifacts {
case .longform:
// we should never hit this until we have more note types built out of parts
// since we handle this case above in known_kind == .longform
return String(ev.content.prefix(prefix_len))
case .separated(let artifacts):
return String(NSAttributedString(artifacts.content.attributed).string.prefix(prefix_len))
}
}
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, keypair: damus_state.keypair) {
return
}
// Don't show notifications for old events
guard ev.age < HomeModel.event_max_age_for_notification else {
return
}
if type == .text, damus_state.settings.mention_notification {
let blocks = ev.blocks(damus_state.keypair).blocks
for case .mention(let mention) in blocks {
guard case .pubkey(let pk) = mention.ref, pk == damus_state.keypair.pubkey else {
continue
}
let content_preview = render_notification_content_preview(cache: damus_state.events, ev: ev, profiles: damus_state.profiles, keypair: damus_state.keypair)
let notify = LocalNotification(type: .mention, event: ev, target: ev, content: content_preview)
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 content_preview = render_notification_content_preview(cache: damus_state.events, ev: inner_ev, profiles: damus_state.profiles, keypair: damus_state.keypair)
let notify = LocalNotification(type: .repost, event: ev, target: inner_ev, content: content_preview)
create_local_notification(profiles: damus_state.profiles, notify: notify)
} else if type == .like,
damus_state.settings.like_notification,
let evid = ev.referenced_ids.last,
let liked_event = damus_state.events.lookup(evid)
{
let content_preview = render_notification_content_preview(cache: damus_state.events, ev: liked_event, profiles: damus_state.profiles, keypair: damus_state.keypair)
let notify = LocalNotification(type: .like, event: ev, target: liked_event, content: content_preview)
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("%@ reacted with %@", comment: "Reacted by heading in local notification"), displayName, to_reaction_emoji(ev: notify.event) ?? "")
identifier = "myLikeNotification"
case .dm:
title = displayName
identifier = "myDMNotification"
case .zap, .profile_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")
}
}
}
enum ProcessZapResult {
case already_processed(Zap)
case done(Zap)
case failed
}
extension Sequence {
func just_one() -> Element? {
var got_one = false
var the_x: Element? = nil
for x in self {
guard !got_one else {
return nil
}
the_x = x
got_one = true
}
return the_x
}
}
// securely get the zap target's pubkey. this can be faked so we need to be
// careful
func get_zap_target_pubkey(ev: NostrEvent, events: EventCache) -> Pubkey? {
let etags = Array(ev.referenced_ids)
guard let etag = etags.first else {
// no etags, ptag-only case
guard let a = ev.referenced_pubkeys.just_one() else {
return nil
}
// TODO: just return data here
return a
}
// we have an e-tag
// ensure that there is only 1 etag to stop fake note zap attacks
guard etags.count == 1 else {
return nil
}
// we can't trust the p tag on note zaps because they can be faked
guard let pk = events.lookup(etag)?.pubkey else {
// We don't have the event in cache so we can't check the pubkey.
// We could return this as an invalid zap but that wouldn't be correct
// all of the time, and may reject valid zaps. What we need is a new
// unvalidated zap state, but for now we simply leak a bit of correctness...
return ev.referenced_pubkeys.just_one()
}
return pk
}
@MainActor
func process_zap_event(damus_state: DamusState, ev: NostrEvent, completion: @escaping (ProcessZapResult) -> Void) {
// These are zap notifications
guard let ptag = get_zap_target_pubkey(ev: ev, events: damus_state.events) else {
completion(.failed)
return
}
// just return the zap if we already have it
if let zap = damus_state.zaps.zaps[ev.id], case .zap(let z) = zap {
completion(.already_processed(z))
return
}
if let local_zapper = damus_state.profiles.lookup_zapper(pubkey: ptag) {
guard let zap = process_zap_event_with_zapper(damus_state: damus_state, ev: ev, zapper: local_zapper) else {
completion(.failed)
return
}
damus_state.add_zap(zap: .zap(zap))
completion(.done(zap))
return
}
guard let lnurl = damus_state.profiles.lookup_with_timestamp(ptag)
.map({ pr in pr?.lnurl }).value else {
completion(.failed)
return
}
Task { [lnurl] in
guard let zapper = await fetch_zapper_from_lnurl(lnurls: damus_state.lnurls, pubkey: ptag, lnurl: lnurl) else {
completion(.failed)
return
}
DispatchQueue.main.async {
damus_state.profiles.profile_data(ptag).zapper = zapper
guard let zap = process_zap_event_with_zapper(damus_state: damus_state, ev: ev, zapper: zapper) else {
completion(.failed)
return
}
damus_state.add_zap(zap: .zap(zap))
completion(.done(zap))
}
}
}
fileprivate func process_zap_event_with_zapper(damus_state: DamusState, ev: NostrEvent, zapper: Pubkey) -> Zap? {
let our_keypair = damus_state.keypair
guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper, our_privkey: our_keypair.privkey) else {
return nil
}
damus_state.add_zap(zap: .zap(zap))
return zap
}

View File

@@ -8,6 +8,13 @@
import Foundation
import UIKit
enum PreUploadedMedia {
case uiimage(UIImage)
case processed_image(URL)
case unprocessed_image(URL)
case processed_video(URL)
case unprocessed_video(URL)
}
enum MediaUpload {
case image(URL)
@@ -42,6 +49,32 @@ enum MediaUpload {
return false
}
var mime_type: String {
switch self.file_extension {
case "jpg", "jpeg":
return "image/jpg"
case "png":
return "image/png"
case "gif":
return "image/gif"
case "tiff", "tif":
return "image/tiff"
case "mp4":
return "video/mp4"
case "ogg":
return "video/ogg"
case "webm":
return "video/webm"
default:
switch self {
case .image:
return "image/jpg"
case .video:
return "video/mp4"
}
}
}
}
class ImageUploadModel: NSObject, URLSessionTaskDelegate, ObservableObject {
@@ -49,9 +82,20 @@ class ImageUploadModel: NSObject, URLSessionTaskDelegate, ObservableObject {
func start(media: MediaUpload, uploader: MediaUploader, keypair: Keypair? = nil) async -> ImageUploadResult {
let res = await create_upload_request(mediaToUpload: media, mediaUploader: uploader, progress: self, keypair: keypair)
DispatchQueue.main.async {
self.progress = nil
switch res {
case .success(_):
DispatchQueue.main.async {
self.progress = nil
UINotificationFeedbackGenerator().notificationOccurred(.success)
}
case .failed(_):
DispatchQueue.main.async {
self.progress = nil
UINotificationFeedbackGenerator().notificationOccurred(.error)
}
}
return res
}

View File

@@ -0,0 +1,41 @@
//
// LongformEvent.swift
// damus
//
// Created by Daniel Nogueira on 2023-11-24.
//
import Foundation
struct LongformEvent {
let event: NostrEvent
var title: String? = nil
var image: URL? = nil
var summary: String? = nil
var published_at: Date? = nil
var labels: [String]? = nil
static func parse(from ev: NostrEvent) -> LongformEvent {
var longform = LongformEvent(event: ev)
for tag in ev.tags {
guard tag.count >= 2 else { continue }
switch tag[0].string() {
case "title": longform.title = tag[1].string()
case "image": longform.image = URL(string: tag[1].string())
case "summary": longform.summary = tag[1].string()
case "published_at":
longform.published_at = Double(tag[1].string()).map { d in Date(timeIntervalSince1970: d) }
case "t":
if (longform.labels?.append(tag[1].string())) == nil {
longform.labels = [tag[1].string()]
}
default:
break
}
}
return longform
}
}

View File

@@ -0,0 +1,117 @@
//
// MediaUploader.swift
// damus
//
// Created by Daniel DAquino on 2023-11-24.
//
import Foundation
enum MediaUploader: String, CaseIterable, Identifiable, StringCodable {
var id: String { self.rawValue }
case nostrBuild
case nostrImg
init?(from string: String) {
guard let mu = MediaUploader(rawValue: string) else {
return nil
}
self = mu
}
func to_string() -> String {
return rawValue
}
var nameParam: String {
switch self {
case .nostrBuild:
return "\"fileToUpload\""
case .nostrImg:
return "\"image\""
}
}
var supportsVideo: Bool {
switch self {
case .nostrBuild:
return true
case .nostrImg:
return false
}
}
struct Model: Identifiable, Hashable {
var id: String { self.tag }
var index: Int
var tag: String
var displayName : String
}
var model: Model {
switch self {
case .nostrBuild:
return .init(index: -1, tag: "nostrBuild", displayName: "nostr.build")
case .nostrImg:
return .init(index: 0, tag: "nostrImg", displayName: "nostrimg.com")
}
}
var postAPI: String {
switch self {
case .nostrBuild:
return "https://nostr.build/api/v2/upload/files"
case .nostrImg:
return "https://nostrimg.com/api/upload"
}
}
func getMediaURL(from data: Data) -> String? {
switch self {
case .nostrBuild:
do {
if let jsonObject = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any],
let status = jsonObject["status"] as? String {
if status == "success", let dataArray = jsonObject["data"] as? [[String: Any]] {
var urls: [String] = []
for dataDict in dataArray {
if let mainUrl = dataDict["url"] as? String {
urls.append(mainUrl)
}
}
return urls.joined(separator: "\n")
} else if status == "error", let message = jsonObject["message"] as? String {
print("Upload Error: \(message)")
return nil
}
}
} catch {
print("Failed JSONSerialization")
return nil
}
return nil
case .nostrImg:
guard let responseString = String(data: data, encoding: String.Encoding(rawValue: String.Encoding.utf8.rawValue)) else {
print("Upload failed getting response string")
return nil
}
guard let startIndex = responseString.range(of: "https://i.nostrimg.com/")?.lowerBound else {
return nil
}
let stringContainingName = responseString[startIndex..<responseString.endIndex]
guard let endIndex = stringContainingName.range(of: "\"")?.lowerBound else {
return nil
}
let nostrBuildImageName = responseString[startIndex..<endIndex]
let nostrBuildURL = "\(nostrBuildImageName)"
return nostrBuildURL
}
}
}

View File

@@ -10,6 +10,8 @@ import Foundation
enum MentionType: AsciiCharacter, TagKey {
case p
case e
case a
case r
var keychar: AsciiCharacter {
self.rawValue
@@ -17,21 +19,26 @@ enum MentionType: AsciiCharacter, TagKey {
}
enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
case pubkey(Pubkey) // TODO: handle nprofile
case pubkey(Pubkey)
case note(NoteId)
case nevent(NEvent)
case nprofile(NProfile)
case nrelay(String)
case naddr(NAddr)
var key: MentionType {
switch self {
case .pubkey: return .p
case .note: return .e
case .nevent: return .e
case .nprofile: return .p
case .nrelay: return .r
case .naddr: return .a
}
}
var bech32: String {
switch self {
case .pubkey(let pubkey): return bech32_pubkey(pubkey)
case .note(let noteId): return bech32_note_id(noteId)
}
return Bech32Object.encode(toBech32Object())
}
static func from_bech32(str: String) -> MentionRef? {
@@ -46,6 +53,10 @@ enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
switch self {
case .pubkey(let pubkey): return pubkey
case .note: return nil
case .nevent(let nevent): return nevent.author
case .nprofile(let nprofile): return nprofile.author
case .nrelay: return nil
case .naddr: return nil
}
}
@@ -53,6 +64,10 @@ enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
switch self {
case .pubkey(let pubkey): return ["p", pubkey.hex()]
case .note(let noteId): return ["e", noteId.hex()]
case .nevent(let nevent): return ["e", nevent.noteid.hex()]
case .nprofile(let nprofile): return ["p", nprofile.author.hex()]
case .nrelay(let url): return ["r", url]
case .naddr(let naddr): return ["a", naddr.kind.description + ":" + naddr.author.hex() + ":" + naddr.identifier.string()]
}
}
@@ -64,14 +79,45 @@ enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
guard let t0 = i.next(),
let chr = t0.single_char,
let mention_type = MentionType(rawValue: chr),
let id = i.next()?.id()
let element = i.next()
else {
return nil
}
switch mention_type {
case .p: return .pubkey(Pubkey(id))
case .e: return .note(NoteId(id))
case .p:
guard let data = element.id() else { return nil }
return .pubkey(Pubkey(data))
case .e:
guard let data = element.id() else { return nil }
return .note(NoteId(data))
case .a:
let str = element.string()
let data = str.split(separator: ":")
if(data.count != 3) { return nil }
guard let pubkey = Pubkey(hex: String(data[1])) else { return nil }
guard let kind = UInt32(data[0]) else { return nil }
return .naddr(NAddr(identifier: String(data[2]), author: pubkey, relays: [], kind: kind))
case .r: return .nrelay(element.string())
}
}
func toBech32Object() -> Bech32Object {
switch self {
case .pubkey(let pk):
return .npub(pk)
case .note(let noteid):
return .note(noteid)
case .naddr(let naddr):
return .naddr(naddr)
case .nevent(let nevent):
return .nevent(nevent)
case .nprofile(let nprofile):
return .nprofile(nprofile)
case .nrelay(let url):
return .nrelay(url)
}
}
}
@@ -223,8 +269,11 @@ func make_post_tags(post_blocks: [Block], tags: [[String]]) -> PostTags {
for post_block in post_blocks {
switch post_block {
case .mention(let mention):
if case .note = mention.ref {
switch(mention.ref) {
case .note, .nevent:
continue
default:
break
}
new_tags.append(mention.ref.tag)
@@ -243,12 +292,10 @@ func make_post_tags(post_blocks: [Block], tags: [[String]]) -> PostTags {
}
func post_to_event(post: NostrPost, keypair: FullKeypair) -> NostrEvent? {
let tags = post.references.map({ r in r.tag }) + post.tags
let post_blocks = parse_post_blocks(content: post.content)
let post_tags = make_post_tags(post_blocks: post_blocks, tags: tags)
let post_tags = make_post_tags(post_blocks: post_blocks, tags: post.tags)
let content = post_tags.blocks
.map(\.asString)
.joined(separator: "")
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: post.kind.rawValue, tags: post_tags.tags)
}

View File

@@ -0,0 +1,8 @@
//
// MuteManager.swift
// damus
//
// Created by William Casarin on 2024-01-25.
//
import Foundation

202
damus/Models/MuteItem.swift Normal file
View File

@@ -0,0 +1,202 @@
//
// MuteItem.swift
// damus
//
// Created by Charlie Fish on 1/13/24.
//
import Foundation
/// Represents an item that is muted.
enum MuteItem: Hashable, Equatable {
/// A user that is muted.
///
/// The associated type is the ``Pubkey`` that is muted. The second associated type is the date that the item should expire at. If no date is supplied, assume the muted item should remain active until it expires.
case user(Pubkey, Date?)
/// A hashtag that is muted.
///
/// The associated type is the hashtag string that is muted. The second associated type is the date that the item should expire at. If no date is supplied, assume the muted item should remain active until it expires.
case hashtag(Hashtag, Date?)
/// A word/phrase that is muted.
///
/// The associated type is the word/phrase that is muted. The second associated type is the date that the item should expire at. If no date is supplied, assume the muted item should remain active until it expires.
case word(String, Date?)
/// A thread that is muted.
///
/// The associated type is the `id` of the note that is muted. The second associated type is the date that the item should expire at. If no date is supplied, assume the muted item should remain active until it expires.
case thread(NoteId, Date?)
func is_expired() -> Bool {
switch self {
case .user(_, let expiration_date):
return expiration_date ?? .distantFuture < Date()
case .hashtag(_, let expiration_date):
return expiration_date ?? .distantFuture < Date()
case .word(_, let expiration_date):
return expiration_date ?? .distantFuture < Date()
case .thread(_, let expiration_date):
return expiration_date ?? .distantFuture < Date()
}
}
static func == (lhs: MuteItem, rhs: MuteItem) -> Bool {
// lhs is the item we want to check (ie. the item the user is attempting to display)
// rhs is the item we want to check against (ie. the item in the mute list)
switch (lhs, rhs) {
case (.user(let lhs_pubkey, _), .user(let rhs_pubkey, let rhs_expiration_date)):
return lhs_pubkey == rhs_pubkey && !rhs.is_expired()
case (.hashtag(let lhs_hashtag, _), .hashtag(let rhs_hashtag, let rhs_expiration_date)):
return lhs_hashtag == rhs_hashtag && !rhs.is_expired()
case (.word(let lhs_word, _), .word(let rhs_word, let rhs_expiration_date)):
return lhs_word == rhs_word && !rhs.is_expired()
case (.thread(let lhs_thread, _), .thread(let rhs_thread, let rhs_expiration_date)):
return lhs_thread == rhs_thread && !rhs.is_expired()
default:
return false
}
}
private var refTags: [String] {
switch self {
case .user(let pubkey, _):
return RefId.pubkey(pubkey).tag
case .hashtag(let hashtag, _):
return RefId.hashtag(hashtag).tag
case .word(let string, _):
return ["word", string]
case .thread(let noteId, _):
return RefId.event(noteId).tag
}
}
var tag: [String] {
var tag = self.refTags
switch self {
case .user(_, let date):
if let date {
tag.append("\(Int(date.timeIntervalSince1970))")
}
case .hashtag(_, let date):
if let date {
tag.append("\(Int(date.timeIntervalSince1970))")
}
case .word(_, let date):
if let date {
tag.append("\(Int(date.timeIntervalSince1970))")
}
case .thread(_, let date):
if let date {
tag.append("\(Int(date.timeIntervalSince1970))")
}
}
return tag
}
var title: String {
switch self {
case .user:
return "user"
case .hashtag:
return "hashtag"
case .word:
return "word"
case .thread:
return "thread"
}
}
init?(_ tag: [String]) {
guard let tag_id = tag.first else { return nil }
guard let tag_content = tag[safe: 1] else { return nil }
let tag_expiration_date: Date? = {
if let tag_expiration_string: String = tag[safe: 2],
let tag_expiration_number: TimeInterval = Double(tag_expiration_string) {
return Date(timeIntervalSince1970: tag_expiration_number)
} else {
return nil
}
}()
switch tag_id {
case "p":
guard let pubkey = Pubkey(hex: tag_content) else { return nil }
self = MuteItem.user(pubkey, tag_expiration_date)
break
case "t":
self = MuteItem.hashtag(Hashtag(hashtag: tag_content), tag_expiration_date)
break
case "word":
self = MuteItem.word(tag_content, tag_expiration_date)
break
case "thread":
guard let note_id = NoteId(hex: tag_content) else { return nil }
self = MuteItem.thread(note_id, tag_expiration_date)
break
default:
return nil
}
}
}
// - MARK: TagConvertible
extension MuteItem: TagConvertible {
enum MuteKeys: String {
case p, t, word, e
init?(tag: NdbTagElem) {
let len = tag.count
if len == 1 {
switch tag.single_char {
case "p": self = .p
case "t": self = .t
case "e": self = .e
default: return nil
}
} else if len == 4 && tag.matches_str("word", tag_len: 4) {
self = .word
} else {
return nil
}
}
var description: String { self.rawValue }
}
static func from_tag(tag: TagSequence) -> MuteItem? {
guard tag.count >= 2 else { return nil }
var i = tag.makeIterator()
guard let t0 = i.next(),
let mkey = MuteKeys(tag: t0),
let t1 = i.next()
else {
return nil
}
var expiry: Date? = nil
if let expiry_str = i.next(), let ts = expiry_str.u64() {
expiry = Date(timeIntervalSince1970: Double(ts))
}
switch mkey {
case .p:
return t1.id().map({ .user(Pubkey($0), expiry) })
case .t:
return .hashtag(Hashtag(hashtag: t1.string()), expiry)
case .word:
return .word(t1.string(), expiry)
case .e:
guard let id = t1.id() else { return nil }
return .thread(NoteId(id), expiry)
}
}
}

View File

@@ -11,7 +11,7 @@ fileprivate func getMutedThreadsKey(pubkey: Pubkey) -> String {
pk_setting_key(pubkey, key: "muted_threads")
}
func loadMutedThreads(pubkey: Pubkey) -> [NoteId] {
func loadOldMutedThreads(pubkey: Pubkey) -> [NoteId] {
let key = getMutedThreadsKey(pubkey: pubkey)
let xs = UserDefaults.standard.stringArray(forKey: key) ?? []
return xs.reduce(into: [NoteId]()) { ids, k in
@@ -20,56 +20,20 @@ func loadMutedThreads(pubkey: Pubkey) -> [NoteId] {
}
}
func saveMutedThreads(pubkey: Pubkey, currentValue: [NoteId], value: [NoteId]) -> Bool {
let uniqueMutedThreads = Array(Set(value))
if uniqueMutedThreads != currentValue {
let ids = uniqueMutedThreads.map { note_id in return note_id.hex() }
UserDefaults.standard.set(ids, forKey: getMutedThreadsKey(pubkey: pubkey))
return true
}
return false
}
class MutedThreadsManager: ObservableObject {
private let keypair: Keypair
private var _mutedThreadsSet: Set<NoteId>
private var _mutedThreads: [NoteId]
var mutedThreads: [NoteId] {
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, keypair: Keypair) -> Bool {
return _mutedThreadsSet.contains(ev.thread_id(keypair: keypair))
}
func updateMutedThread(_ ev: NostrEvent) {
let threadId = ev.thread_id(keypair: keypair)
if isMutedThread(ev, keypair: keypair) {
mutedThreads = mutedThreads.filter { $0 != threadId }
_mutedThreadsSet.remove(threadId)
notify(.unmute_thread(ev))
} else {
mutedThreads.append(threadId)
_mutedThreadsSet.insert(threadId)
notify(.mute_thread(ev))
}
}
// We need to still use it since existing users might have their muted threads stored in UserDefaults
// So now all it's doing is moving a users muted threads to the new kind:10000 system
// It should not be used for any purpose beyond that
func migrate_old_muted_threads_to_new_mutelist(keypair: Keypair, damus_state: DamusState) {
// Ensure that keypair is fullkeypair
guard let fullKeypair = keypair.to_full() else { return }
// Load existing muted threads
let mutedThreads = loadOldMutedThreads(pubkey: fullKeypair.pubkey)
guard !mutedThreads.isEmpty else { return }
// Set new muted system for those existing threads
let previous_mute_list_event = damus_state.mutelist_manager.event
guard let new_mutelist_event = create_or_update_mutelist(keypair: fullKeypair, mprev: previous_mute_list_event, to_add: Set(mutedThreads.map { MuteItem.thread($0, nil) })) else { return }
damus_state.mutelist_manager.set_mutelist(new_mutelist_event)
damus_state.postbox.send(new_mutelist_event)
// Set existing muted threads to an empty array
UserDefaults.standard.set([], forKey: getMutedThreadsKey(pubkey: keypair.pubkey))
}

View File

@@ -0,0 +1,211 @@
//
// MutelistManager.swift
// damus
//
// Created by Charlie Fish on 1/28/24.
//
import Foundation
class MutelistManager {
let user_keypair: Keypair
private(set) var event: NostrEvent? = nil
var users: Set<MuteItem> = [] {
didSet { self.reset_cache() }
}
var hashtags: Set<MuteItem> = [] {
didSet { self.reset_cache() }
}
var threads: Set<MuteItem> = [] {
didSet { self.reset_cache() }
}
var words: Set<MuteItem> = [] {
didSet { self.reset_cache() }
}
var muted_notes_cache: [NoteId: EventMuteStatus] = [:]
init(user_keypair: Keypair) {
self.user_keypair = user_keypair
}
func refresh_sets() {
guard let referenced_mute_items = event?.referenced_mute_items else { return }
var new_users: Set<MuteItem> = []
var new_hashtags: Set<MuteItem> = []
var new_threads: Set<MuteItem> = []
var new_words: Set<MuteItem> = []
for mute_item in referenced_mute_items {
switch mute_item {
case .user:
new_users.insert(mute_item)
case .hashtag:
new_hashtags.insert(mute_item)
case .word:
new_words.insert(mute_item)
case .thread:
new_threads.insert(mute_item)
}
}
users = new_users
hashtags = new_hashtags
threads = new_threads
words = new_words
}
func reset_cache() {
self.muted_notes_cache = [:]
}
func is_muted(_ item: MuteItem) -> Bool {
switch item {
case .user(_, _):
return users.contains(item)
case .hashtag(_, _):
return hashtags.contains(item)
case .word(_, _):
return words.contains(item)
case .thread(_, _):
return threads.contains(item)
}
}
func is_event_muted(_ ev: NostrEvent) -> Bool {
return self.event_muted_reason(ev) != nil
}
func set_mutelist(_ ev: NostrEvent) {
let oldlist = self.event
self.event = ev
let old: Set<MuteItem> = oldlist?.mute_list ?? Set<MuteItem>()
let new: Set<MuteItem> = ev.mute_list ?? Set<MuteItem>()
let diff = old.symmetricDifference(new)
var new_mutes = Set<MuteItem>()
var new_unmutes = Set<MuteItem>()
for d in diff {
if new.contains(d) {
add_mute_item(d)
new_mutes.insert(d)
} else {
remove_mute_item(d)
new_unmutes.insert(d)
}
}
if new_mutes.count > 0 {
notify(.new_mutes(new_mutes))
}
if new_unmutes.count > 0 {
notify(.new_unmutes(new_unmutes))
}
}
private func add_mute_item(_ item: MuteItem) {
switch item {
case .user(_, _):
guard !users.contains(item) else { return }
users.insert(item)
case .hashtag(_, _):
guard !hashtags.contains(item) else { return }
hashtags.insert(item)
case .word(_, _):
guard !words.contains(item) else { return }
words.insert(item)
case .thread(_, _):
guard !threads.contains(item) else { return }
threads.insert(item)
}
}
private func remove_mute_item(_ item: MuteItem) {
switch item {
case .user(_, _):
users.remove(item)
case .hashtag(_, _):
hashtags.remove(item)
case .word(_, _):
words.remove(item)
case .thread(_, _):
threads.remove(item)
}
}
func event_muted_reason(_ ev: NostrEvent) -> MuteItem? {
if let cached_mute_status = self.muted_notes_cache[ev.id] {
return cached_mute_status.mute_reason()
}
if let reason = self.compute_event_muted_reason(ev) {
self.muted_notes_cache[ev.id] = .muted(reason: reason)
return reason
}
self.muted_notes_cache[ev.id] = .not_muted
return nil
}
/// Check if an event is muted given a collection of ``MutedItem``.
///
/// - Parameter ev: The ``NostrEvent`` that you want to check the muted reason for.
/// - Returns: The ``MuteItem`` that matched the event. Or `nil` if the event is not muted.
func compute_event_muted_reason(_ ev: NostrEvent) -> MuteItem? {
// Events from the current user should not be muted.
guard self.user_keypair.pubkey != ev.pubkey else { return nil }
// Check if user is muted
let check_user_item = MuteItem.user(ev.pubkey, nil)
if users.contains(check_user_item) {
return check_user_item
}
// Check if hashtag is muted
for hashtag in ev.referenced_hashtags {
let check_hashtag_item = MuteItem.hashtag(hashtag, nil)
if hashtags.contains(check_hashtag_item) {
return check_hashtag_item
}
}
// Check if thread is muted
for thread_id in ev.referenced_ids {
let check_thread_item = MuteItem.thread(thread_id, nil)
if threads.contains(check_thread_item) {
return check_thread_item
}
}
// Check if word is muted
if let content: String = ev.maybe_get_content(self.user_keypair)?.lowercased() {
for word in words {
if case .word(let string, _) = word {
if content.contains(string.lowercased()) {
return word
}
}
}
}
return nil
}
enum EventMuteStatus {
case muted(reason: MuteItem)
case not_muted
func mute_reason() -> MuteItem? {
switch self {
case .muted(reason: let reason):
return reason
case .not_muted:
return nil
}
}
}
}

View File

@@ -0,0 +1,24 @@
//
// NewEventsBits.swift
// damus
//
// Created by Daniel DAquino on 2023-11-24.
//
import Foundation
struct NewEventsBits: OptionSet {
let rawValue: Int
static let home = NewEventsBits(rawValue: 1 << 0)
static let zaps = NewEventsBits(rawValue: 1 << 1)
static let mentions = NewEventsBits(rawValue: 1 << 2)
static let reposts = NewEventsBits(rawValue: 1 << 3)
static let likes = NewEventsBits(rawValue: 1 << 4)
static let search = NewEventsBits(rawValue: 1 << 5)
static let dms = NewEventsBits(rawValue: 1 << 6)
static let damus_app_notifications = NewEventsBits(rawValue: 1 << 7)
static let all = NewEventsBits(rawValue: 0xFFFFFFFF)
static let notifications: NewEventsBits = [.zaps, .likes, .reposts, .mentions, .damus_app_notifications]
}

View File

@@ -0,0 +1,358 @@
//
// NoteContent.swift
// damus
//
// Created by Daniel DAquino on 2023-11-24.
//
import Foundation
import MarkdownUI
import UIKit
struct NoteArtifactsSeparated: Equatable {
static func == (lhs: NoteArtifactsSeparated, rhs: NoteArtifactsSeparated) -> Bool {
return lhs.content == rhs.content
}
let content: CompatibleText
let words: Int
let urls: [UrlType]
let invoices: [Invoice]
var media: [MediaUrl] {
return urls.compactMap { url in url.is_media }
}
var images: [URL] {
return urls.compactMap { url in url.is_img }
}
var links: [URL] {
return urls.compactMap { url in url.is_link }
}
static func just_content(_ content: String) -> NoteArtifactsSeparated {
let txt = CompatibleText(attributed: AttributedString(stringLiteral: content))
return NoteArtifactsSeparated(content: txt, words: 0, urls: [], invoices: [])
}
}
enum NoteArtifactState {
case not_loaded
case loading
case loaded(NoteArtifacts)
var artifacts: NoteArtifacts? {
if case .loaded(let artifacts) = self {
return artifacts
}
return nil
}
var should_preload: Bool {
switch self {
case .loaded:
return false
case .loading:
return false
case .not_loaded:
return true
}
}
}
func note_artifact_is_separated(kind: NostrKind?) -> Bool {
return kind != .longform
}
func render_note_content(ev: NostrEvent, profiles: Profiles, keypair: Keypair) -> NoteArtifacts {
let blocks = ev.blocks(keypair)
if ev.known_kind == .longform {
return .longform(LongformContent(ev.content))
}
return .separated(render_blocks(blocks: blocks, profiles: profiles))
}
func render_blocks(blocks bs: Blocks, profiles: Profiles) -> NoteArtifactsSeparated {
var invoices: [Invoice] = []
var urls: [UrlType] = []
let blocks = bs.blocks
let one_note_ref = blocks
.filter({
if case .mention(let mention) = $0,
case .note = mention.ref {
return true
}
else {
return false
}
})
.count == 1
var ind: Int = -1
let txt: CompatibleText = blocks.reduce(CompatibleText()) { str, block in
ind = ind + 1
switch block {
case .mention(let m):
if case .note = m.ref, one_note_ref {
return str
}
return str + mention_str(m, profiles: profiles)
case .text(let txt):
return str + CompatibleText(stringLiteral: reduce_text_block(blocks: blocks, ind: ind, txt: txt, one_note_ref: one_note_ref))
case .relay(let relay):
return str + CompatibleText(stringLiteral: relay)
case .hashtag(let htag):
return str + hashtag_str(htag)
case .invoice(let invoice):
invoices.append(invoice)
return str
case .url(let url):
let url_type = classify_url(url)
switch url_type {
case .media:
urls.append(url_type)
return str
case .link(let url):
urls.append(url_type)
return str + url_str(url)
}
}
}
return NoteArtifactsSeparated(content: txt, words: bs.words, urls: urls, invoices: invoices)
}
func reduce_text_block(blocks: [Block], ind: Int, txt: String, one_note_ref: Bool) -> String {
var trimmed = txt
if let prev = blocks[safe: ind-1],
case .url(let u) = prev,
classify_url(u).is_media != nil {
trimmed = " " + trim_prefix(trimmed)
}
if let next = blocks[safe: ind+1] {
if case .url(let u) = next, classify_url(u).is_media != nil {
trimmed = trim_suffix(trimmed)
} else if case .mention(let m) = next,
case .note = m.ref,
one_note_ref {
trimmed = trim_suffix(trimmed)
}
}
return trimmed
}
func url_str(_ url: URL) -> CompatibleText {
var attributedString = AttributedString(stringLiteral: url.absoluteString)
attributedString.link = url
attributedString.foregroundColor = DamusColors.purple
return CompatibleText(attributed: attributedString)
}
func classify_url(_ url: URL) -> UrlType {
let str = url.lastPathComponent.lowercased()
if str.hasSuffix(".png") || str.hasSuffix(".jpg") || str.hasSuffix(".jpeg") || str.hasSuffix(".gif") || str.hasSuffix(".webp") {
return .media(.image(url))
}
if str.hasSuffix(".mp4") || str.hasSuffix(".mov") || str.hasSuffix(".m3u8") {
return .media(.video(url))
}
return .link(url)
}
func attributed_string_attach_icon(_ astr: inout AttributedString, img: UIImage) {
let attachment = NSTextAttachment()
attachment.image = img
let attachmentString = NSAttributedString(attachment: attachment)
let wrapped = AttributedString(attachmentString)
astr.append(wrapped)
}
func getDisplayName(pk: Pubkey, profiles: Profiles) -> String {
let profile_txn = profiles.lookup(id: pk, txn_name: "getDisplayName")
let profile = profile_txn?.unsafeUnownedValue
return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 50)
}
func mention_str(_ m: Mention<MentionRef>, profiles: Profiles) -> CompatibleText {
let bech32String = Bech32Object.encode(m.ref.toBech32Object())
let display_str: String = {
switch m.ref {
case .pubkey(let pk): return getDisplayName(pk: pk, profiles: profiles)
case .note: return abbrev_pubkey(bech32String)
case .nevent: return abbrev_pubkey(bech32String)
case .nprofile(let nprofile): return getDisplayName(pk: nprofile.author, profiles: profiles)
case .nrelay(let url): return url
case .naddr: return abbrev_pubkey(bech32String)
}
}()
let display_str_with_at = "@\(display_str)"
var attributedString = AttributedString(stringLiteral: display_str_with_at)
attributedString.link = URL(string: "damus:nostr:\(bech32String)")
attributedString.foregroundColor = DamusColors.purple
return CompatibleText(attributed: attributedString)
}
// trim suffix whitespace and newlines
func trim_suffix(_ str: String) -> String {
return str.replacingOccurrences(of: "\\s+$", with: "", options: .regularExpression)
}
// trim prefix whitespace and newlines
func trim_prefix(_ str: String) -> String {
return str.replacingOccurrences(of: "^\\s+", with: "", options: .regularExpression)
}
struct LongformContent {
let markdown: MarkdownContent
let words: Int
init(_ markdown: String) {
let blocks = [BlockNode].init(markdown: markdown)
self.markdown = MarkdownContent(blocks: blocks)
self.words = count_markdown_words(blocks: blocks)
}
}
func count_markdown_words(blocks: [BlockNode]) -> Int {
return blocks.reduce(0) { words, block in
switch block {
case .paragraph(let content):
return words + count_inline_nodes_words(nodes: content)
case .blockquote, .bulletedList, .numberedList, .taskList, .codeBlock, .htmlBlock, .heading, .table, .thematicBreak:
return words
}
}
}
func count_words(_ s: String) -> Int {
return s.components(separatedBy: .whitespacesAndNewlines).count
}
func count_inline_nodes_words(nodes: [InlineNode]) -> Int {
return nodes.reduce(0) { words, node in
switch node {
case .text(let words):
return count_words(words)
case .emphasis(let children):
return words + count_inline_nodes_words(nodes: children)
case .strong(let children):
return words + count_inline_nodes_words(nodes: children)
case .strikethrough(let children):
return words + count_inline_nodes_words(nodes: children)
case .softBreak, .lineBreak, .code, .html, .image, .link:
return words
}
}
}
enum NoteArtifacts {
case separated(NoteArtifactsSeparated)
case longform(LongformContent)
var images: [URL] {
switch self {
case .separated(let arts):
return arts.images
case .longform:
return []
}
}
}
enum UrlType {
case media(MediaUrl)
case link(URL)
var url: URL {
switch self {
case .media(let media_url):
switch media_url {
case .image(let url):
return url
case .video(let url):
return url
}
case .link(let url):
return url
}
}
var is_video: URL? {
switch self {
case .media(let media_url):
switch media_url {
case .image:
return nil
case .video(let url):
return url
}
case .link:
return nil
}
}
var is_img: URL? {
switch self {
case .media(let media_url):
switch media_url {
case .image(let url):
return url
case .video:
return nil
}
case .link:
return nil
}
}
var is_link: URL? {
switch self {
case .media:
return nil
case .link(let url):
return url
}
}
var is_media: MediaUrl? {
switch self {
case .media(let murl):
return murl
case .link:
return nil
}
}
}
enum MediaUrl {
case image(URL)
case video(URL)
var url: URL {
switch self {
case .image(let url):
return url
case .video(let url):
return url
}
}
}

View File

@@ -0,0 +1,267 @@
//
// NotificationsManager.swift
// damus
//
// Handles several aspects of notification logic (Both local and push notifications)
//
// Created by Daniel DAquino on 2023-11-24.
//
import Foundation
import UIKit
let EVENT_MAX_AGE_FOR_NOTIFICATION: TimeInterval = 12 * 60 * 60
func process_local_notification(state: HeadlessDamusState, event ev: NostrEvent) {
guard should_display_notification(state: state, event: ev, mode: .local) else {
// We should not display notification. Exit.
return
}
guard let local_notification = generate_local_notification_object(from: ev, state: state) else {
return
}
create_local_notification(profiles: state.profiles, notify: local_notification)
}
func should_display_notification(state: HeadlessDamusState, event ev: NostrEvent, mode: UserSettingsStore.NotificationsMode) -> Bool {
// Do not show notification if it's coming from a mode different from the one selected by our user
guard state.settings.notifications_mode == mode else {
return false
}
if ev.known_kind == nil {
return false
}
if state.settings.notification_only_from_following,
state.contacts.follow_state(ev.pubkey) != .follows
{
return false
}
// Don't show notifications that match mute list.
if state.mutelist_manager.is_event_muted(ev) {
return false
}
// Don't show notifications for old events
guard ev.age < EVENT_MAX_AGE_FOR_NOTIFICATION else {
return false
}
return true
}
func generate_local_notification_object(from ev: NostrEvent, state: HeadlessDamusState) -> LocalNotification? {
guard let type = ev.known_kind else {
return nil
}
if type == .text, state.settings.mention_notification {
let blocks = ev.blocks(state.keypair).blocks
for case .mention(let mention) in blocks {
guard case .pubkey(let pk) = mention.ref, pk == state.keypair.pubkey else {
continue
}
let content_preview = render_notification_content_preview(ev: ev, profiles: state.profiles, keypair: state.keypair)
return LocalNotification(type: .mention, event: ev, target: ev, content: content_preview)
}
} else if type == .boost,
state.settings.repost_notification,
let inner_ev = ev.get_inner_event()
{
let content_preview = render_notification_content_preview(ev: inner_ev, profiles: state.profiles, keypair: state.keypair)
return LocalNotification(type: .repost, event: ev, target: inner_ev, content: content_preview)
} else if type == .like,
state.settings.like_notification,
let evid = ev.referenced_ids.last,
let txn = state.ndb.lookup_note(evid, txn_name: "local_notification_like"),
let liked_event = txn.unsafeUnownedValue?.to_owned()
{
let content_preview = render_notification_content_preview(ev: liked_event, profiles: state.profiles, keypair: state.keypair)
return LocalNotification(type: .like, event: ev, target: liked_event, content: content_preview)
}
else if type == .dm,
state.settings.dm_notification {
let convo = ev.decrypted(keypair: state.keypair) ?? NSLocalizedString("New encrypted direct message", comment: "Notification that the user has received a new direct message")
return LocalNotification(type: .dm, event: ev, target: ev, content: convo)
}
else if type == .zap,
state.settings.zap_notification {
return LocalNotification(type: .zap, event: ev, target: ev, content: ev.content)
}
return nil
}
func create_local_notification(profiles: Profiles, notify: LocalNotification) {
let displayName = event_author_name(profiles: profiles, pubkey: notify.event.pubkey)
guard let (content, identifier) = NotificationFormatter.shared.format_message(displayName: displayName, notify: notify) else { return }
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")
}
}
}
func render_notification_content_preview(ev: NostrEvent, profiles: Profiles, keypair: Keypair) -> String {
let prefix_len = 300
let artifacts = render_note_content(ev: ev, profiles: profiles, keypair: keypair)
// special case for longform events
if ev.known_kind == .longform {
let longform = LongformEvent(event: ev)
return longform.title ?? longform.summary ?? "Longform Event"
}
switch artifacts {
case .longform:
// we should never hit this until we have more note types built out of parts
// since we handle this case above in known_kind == .longform
return String(ev.content.prefix(prefix_len))
case .separated(let artifacts):
return String(NSAttributedString(artifacts.content.attributed).string.prefix(prefix_len))
}
}
func event_author_name(profiles: Profiles, pubkey: Pubkey) -> String {
let profile_txn = profiles.lookup(id: pubkey)
let profile = profile_txn?.unsafeUnownedValue
return Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
}
@MainActor
func get_zap(from ev: NostrEvent, state: HeadlessDamusState) async -> Zap? {
return await withCheckedContinuation { continuation in
process_zap_event(state: state, ev: ev) { zapres in
continuation.resume(returning: zapres.get_zap())
}
}
}
@MainActor
func process_zap_event(state: HeadlessDamusState, ev: NostrEvent, completion: @escaping (ProcessZapResult) -> Void) {
// These are zap notifications
guard let ptag = get_zap_target_pubkey(ev: ev, ndb: state.ndb) else {
completion(.failed)
return
}
// just return the zap if we already have it
if let zap = state.zaps.zaps[ev.id], case .zap(let z) = zap {
completion(.already_processed(z))
return
}
if let local_zapper = state.profiles.lookup_zapper(pubkey: ptag) {
guard let zap = process_zap_event_with_zapper(state: state, ev: ev, zapper: local_zapper) else {
completion(.failed)
return
}
state.add_zap(zap: .zap(zap))
completion(.done(zap))
return
}
guard let txn = state.profiles.lookup_with_timestamp(ptag),
let lnurl = txn.map({ pr in pr?.lnurl }).value else {
completion(.failed)
return
}
Task { [lnurl] in
guard let zapper = await fetch_zapper_from_lnurl(lnurls: state.lnurls, pubkey: ptag, lnurl: lnurl) else {
completion(.failed)
return
}
DispatchQueue.main.async {
state.profiles.profile_data(ptag).zapper = zapper
guard let zap = process_zap_event_with_zapper(state: state, ev: ev, zapper: zapper) else {
completion(.failed)
return
}
state.add_zap(zap: .zap(zap))
completion(.done(zap))
}
}
}
// securely get the zap target's pubkey. this can be faked so we need to be
// careful
func get_zap_target_pubkey(ev: NostrEvent, ndb: Ndb) -> Pubkey? {
let etags = Array(ev.referenced_ids)
guard let etag = etags.first else {
// no etags, ptag-only case
guard let a = ev.referenced_pubkeys.just_one() else {
return nil
}
// TODO: just return data here
return a
}
// we have an e-tag
// ensure that there is only 1 etag to stop fake note zap attacks
guard etags.count == 1 else {
return nil
}
// we can't trust the p tag on note zaps because they can be faked
guard let txn = ndb.lookup_note(etag),
let pk = txn.unsafeUnownedValue?.pubkey else {
// We don't have the event in cache so we can't check the pubkey.
// We could return this as an invalid zap but that wouldn't be correct
// all of the time, and may reject valid zaps. What we need is a new
// unvalidated zap state, but for now we simply leak a bit of correctness...
return ev.referenced_pubkeys.just_one()
}
return pk
}
fileprivate func process_zap_event_with_zapper(state: HeadlessDamusState, ev: NostrEvent, zapper: Pubkey) -> Zap? {
let our_keypair = state.keypair
guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper, our_privkey: our_keypair.privkey) else {
return nil
}
state.add_zap(zap: .zap(zap))
return zap
}
enum ProcessZapResult {
case already_processed(Zap)
case done(Zap)
case failed
func get_zap() -> Zap? {
switch self {
case .already_processed(let zap):
return zap
case .done(let zap):
return zap
default:
return nil
}
}
}

View File

@@ -13,6 +13,7 @@ enum NotificationItem {
case profile_zap(ZapGroup)
case event_zap(NoteId, ZapGroup)
case reply(NostrEvent)
case damus_app_notification(DamusAppNotification)
var is_reply: NostrEvent? {
if case .reply(let ev) = self {
@@ -33,6 +34,8 @@ enum NotificationItem {
return nil
case .repost:
return nil
case .damus_app_notification(_):
return nil
}
}
@@ -48,6 +51,8 @@ enum NotificationItem {
return zapgrp.last_event_at
case .reply(let reply):
return reply.created_at
case .damus_app_notification(let notification):
return notification.last_event_at
}
}
@@ -63,6 +68,8 @@ enum NotificationItem {
return zapgrp.would_filter(isIncluded)
case .reply(let ev):
return !isIncluded(ev)
case .damus_app_notification(_):
return true
}
}
@@ -79,6 +86,8 @@ enum NotificationItem {
case .reply(let ev):
if isIncluded(ev) { return .reply(ev) }
return nil
case .damus_app_notification(_):
return self
}
}
}
@@ -94,6 +103,9 @@ class NotificationsModel: ObservableObject, ScrollQueue {
var reactions: [NoteId: EventGroup] = [:]
var reposts: [NoteId: EventGroup] = [:]
var replies: [NostrEvent] = []
var incoming_app_notifications: [DamusAppNotification] = []
var app_notifications: [DamusAppNotification] = []
var has_app_notification = Set<DamusAppNotification.Content>()
var has_reply = Set<NoteId>()
var has_ev = Set<NoteId>()
@@ -160,6 +172,10 @@ class NotificationsModel: ObservableObject, ScrollQueue {
notifs.append(.reply(reply))
}
for app_notification in app_notifications {
notifs.append(.damus_app_notification(app_notification))
}
notifs.sort { $0.last_event_at > $1.last_event_at }
return notifs
}
@@ -254,6 +270,33 @@ class NotificationsModel: ObservableObject, ScrollQueue {
return false
}
func insert_app_notification(notification: DamusAppNotification) -> Bool {
if has_app_notification.contains(notification.content) {
return false
}
if should_queue {
incoming_app_notifications.append(notification)
return true
}
if insert_app_notification_immediate(notification: notification) {
self.notifications = build_notifications()
return true
}
return false
}
func insert_app_notification_immediate(notification: DamusAppNotification) -> Bool {
if has_app_notification.contains(notification.content) {
return false
}
self.app_notifications.append(notification)
has_app_notification.insert(notification.content)
return true
}
func insert_zap(_ zap: Zapping) -> Bool {
if should_queue {
return insert_uniq_sorted_zap_by_created(zaps: &incoming_zaps, new_zap: zap)
@@ -319,6 +362,10 @@ class NotificationsModel: ObservableObject, ScrollQueue {
inserted = insert_event_immediate(event, cache: damus_state.events) || inserted
}
for incoming_app_notification in incoming_app_notifications {
inserted = insert_app_notification_immediate(notification: incoming_app_notification) || inserted
}
if inserted {
self.notifications = build_notifications()
}
@@ -326,3 +373,19 @@ class NotificationsModel: ObservableObject, ScrollQueue {
return inserted
}
}
struct DamusAppNotification {
let notification_timestamp: Date
var last_event_at: UInt32 { UInt32(notification_timestamp.timeIntervalSince1970) }
let content: Content
init(content: Content, timestamp: Date) {
self.notification_timestamp = timestamp
self.content = content
}
enum Content: Hashable, Equatable {
case purple_impending_expiration(days_remaining: Int, expiry_date: UInt64)
case purple_expired(expiry_date: UInt64)
}
}

View File

@@ -10,12 +10,10 @@ import Foundation
struct NostrPost {
let kind: NostrKind
let content: String
let references: [RefId]
let tags: [[String]]
init(content: String, references: [RefId], kind: NostrKind = .text, tags: [[String]] = []) {
init(content: String, kind: NostrKind = .text, tags: [[String]] = []) {
self.content = content
self.references = references
self.kind = kind
self.tags = tags
}

View File

@@ -10,8 +10,10 @@ import Foundation
class ProfileModel: ObservableObject, Equatable {
@Published var contacts: NostrEvent? = nil
@Published var following: Int = 0
@Published var relays: [String: RelayInfo]? = nil
@Published var relays: [RelayURL: RelayInfo]? = nil
@Published var progress: Int = 0
private let MAX_SHARE_RELAYS = 4
var events: EventHolder
let pubkey: Pubkey
@@ -20,6 +22,7 @@ class ProfileModel: ObservableObject, Equatable {
var seen_event: Set<NoteId> = Set()
var sub_id = UUID().description
var prof_subid = UUID().description
var findRelay_subid = UUID().description
init(pubkey: Pubkey, damus: DamusState) {
self.pubkey = pubkey
@@ -57,11 +60,11 @@ class ProfileModel: ObservableObject, Equatable {
damus.pool.unsubscribe(sub_id: sub_id)
damus.pool.unsubscribe(sub_id: prof_subid)
}
func subscribe() {
var text_filter = NostrFilter(kinds: [.text, .longform])
var text_filter = NostrFilter(kinds: [.text, .longform, .highlight])
var profile_filter = NostrFilter(kinds: [.contacts, .metadata, .boost])
profile_filter.authors = [pubkey]
text_filter.authors = [pubkey]
@@ -105,8 +108,8 @@ class ProfileModel: ObservableObject, Equatable {
}
seen_event.insert(ev.id)
}
private func handle_event(relay_id: String, ev: NostrConnectionEvent) {
private func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
switch ev {
case .ws_event:
return
@@ -118,20 +121,48 @@ class ProfileModel: ObservableObject, Equatable {
case .ok:
break
case .event(_, let ev):
// Ensure the event public key matches this profiles public key
// This is done to protect against a relay not properly filtering events by the pubkey
// See https://github.com/damus-io/damus/issues/1846 for more information
guard self.pubkey == ev.pubkey else { break }
add_event(ev)
case .notice:
break
//notify(.notice, notice)
case .eose:
let txn = NdbTxn(ndb: damus.ndb)
guard let txn = NdbTxn(ndb: damus.ndb) else { return }
if resp.subid == sub_id {
load_profiles(context: "profile", profiles_subid: prof_subid, relay_id: relay_id, load: .from_events(events.events), damus_state: damus, txn: txn)
}
progress += 1
break
case .auth:
break
}
}
}
private func findRelaysHandler(relay_id: RelayURL, ev: NostrConnectionEvent) {
if case .nostr_event(let resp) = ev, case .event(_, let event) = resp, case .contacts = event.known_kind {
self.relays = decode_json_relays(event.content)
}
}
func subscribeToFindRelays() {
var profile_filter = NostrFilter(kinds: [.contacts])
profile_filter.authors = [pubkey]
damus.pool.subscribe(sub_id: findRelay_subid, filters: [profile_filter], handler: findRelaysHandler)
}
func unsubscribeFindRelays() {
damus.pool.unsubscribe(sub_id: findRelay_subid)
}
func getCappedRelayStrings() -> [String] {
return relays?.keys.prefix(MAX_SHARE_RELAYS).map { $0.absoluteString } ?? []
}
}

View File

@@ -0,0 +1,506 @@
//
// DamusPurple.swift
// damus
//
// Created by Daniel DAquino on 2023-12-08.
//
import Foundation
import StoreKit
class DamusPurple: StoreObserverDelegate {
let settings: UserSettingsStore
let keypair: Keypair
var storekit_manager: StoreKitManager
var checkout_ids_in_progress: Set<String> = []
var onboarding_status: OnboardingStatus
@MainActor
var account_cache: [Pubkey: Account]
@MainActor
var account_uuid_cache: [Pubkey: UUID]
init(settings: UserSettingsStore, keypair: Keypair) {
self.settings = settings
self.keypair = keypair
self.account_cache = [:]
self.account_uuid_cache = [:]
self.storekit_manager = StoreKitManager.standard // Use singleton to avoid losing local purchase data
self.onboarding_status = OnboardingStatus()
Task {
let account: Account? = try await self.fetch_account(pubkey: self.keypair.pubkey)
if account == nil {
self.onboarding_status.account_existed_at_the_start = false
}
else {
self.onboarding_status.account_existed_at_the_start = true
}
}
}
// MARK: Functions
func is_profile_subscribed_to_purple(pubkey: Pubkey) async -> Bool? {
return try? await self.get_maybe_cached_account(pubkey: pubkey)?.active
}
var environment: DamusPurpleEnvironment {
return self.settings.purple_enviroment
}
var enable_purple: Bool {
return true
// TODO: On release, we could just replace this with `true` (or some other feature flag)
//return self.settings.enable_experimental_purple_api
}
// Whether to enable Apple In-app purchase support
var enable_purple_iap_support: Bool {
// TODO: When we have full support for Apple In-app purchases, we can replace this with `true` (or another feature flag)
// return self.settings.enable_experimental_purple_iap_support
return true
}
func account_exists(pubkey: Pubkey) async -> Bool? {
guard let account_data = try? await self.get_account_data(pubkey: pubkey) else { return nil }
if let account_info = try? JSONDecoder().decode(AccountInfo.self, from: account_data) {
return account_info.pubkey == pubkey.hex()
}
return false
}
@MainActor
func get_maybe_cached_account(pubkey: Pubkey) async throws -> Account? {
if let account = self.account_cache[pubkey] {
return account
}
return try await fetch_account(pubkey: pubkey)
}
@MainActor
func fetch_account(pubkey: Pubkey) async throws -> Account? {
guard let data = try await self.get_account_data(pubkey: pubkey) ,
let account = Account.from(json_data: data) else {
return nil
}
self.account_cache[pubkey] = account
return account
}
func get_account_data(pubkey: Pubkey) async throws -> Data? {
let url = environment.api_base_url().appendingPathComponent("accounts/\(pubkey.hex())")
let (data, response) = try await make_nip98_authenticated_request(
method: .get,
url: url,
payload: nil,
payload_type: nil,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
return data
case 404:
return nil
default:
throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data)
}
}
throw PurpleError.error_processing_response
}
func make_iap_purchase(product: Product) async throws {
let account_uuid = try await self.get_maybe_cached_uuid_for_account()
let result = try await self.storekit_manager.purchase(product: product, id: account_uuid)
switch result {
case .success(.verified(let tx)):
// Record the purchase with the storekit manager, to make sure we have the update on the UIs as soon as possible.
// During testing I found that the purchase initiated via `purchase` was not emitted via the listener `StoreKit.Transaction.updates` until the app was restarted.
self.storekit_manager.record_purchased_product(StoreKitManager.PurchasedProduct(tx: tx, product: product))
await tx.finish()
// Send the transaction id to the server
try await self.send_transaction_id(transaction_id: tx.originalID)
default:
// Any time we get a non-verified result, it means that the purchase was not successful, and thus we should throw an error.
throw PurpleError.iap_purchase_error(result: result)
}
}
@MainActor
func get_maybe_cached_uuid_for_account() async throws -> UUID {
if let account_uuid = self.account_uuid_cache[self.keypair.pubkey] {
return account_uuid
}
return try await fetch_uuid_for_account()
}
@MainActor
func fetch_uuid_for_account() async throws -> UUID {
let url = self.environment.api_base_url().appending(path: "/accounts/\(self.keypair.pubkey)/account-uuid")
let (data, response) = try await make_nip98_authenticated_request(
method: .get,
url: url,
payload: nil,
payload_type: nil,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
Log.info("Got user UUID from Damus Purple server", for: .damus_purple)
default:
Log.error("Error in getting user UUID with Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data)
}
}
let account_uuid_info = try JSONDecoder().decode(AccountUUIDInfo.self, from: data)
self.account_uuid_cache[self.keypair.pubkey] = account_uuid_info.account_uuid
return account_uuid_info.account_uuid
}
func send_receipt() async throws {
// Get the receipt if it's available.
if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL,
FileManager.default.fileExists(atPath: appStoreReceiptURL.path) {
let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
let receipt_base64_string = receiptData.base64EncodedString()
let account_uuid = try await self.get_maybe_cached_uuid_for_account()
let json_text: [String: String] = ["receipt": receipt_base64_string, "account_uuid": account_uuid.uuidString]
let json_data = try JSONSerialization.data(withJSONObject: json_text)
let url = environment.api_base_url().appendingPathComponent("accounts/\(keypair.pubkey.hex())/apple-iap/app-store-receipt")
Log.info("Sending in-app purchase receipt to Damus Purple server: %s", for: .damus_purple, receipt_base64_string)
let (data, response) = try await make_nip98_authenticated_request(
method: .post,
url: url,
payload: json_data,
payload_type: .json,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
Log.info("Sent in-app purchase receipt to Damus Purple server successfully", for: .damus_purple)
default:
Log.error("Error in sending in-app purchase receipt to Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
throw DamusPurple.PurpleError.iap_receipt_verification_error(status: httpResponse.statusCode, response: data)
}
}
}
}
func send_transaction_id(transaction_id: UInt64) async throws {
let account_uuid = try await self.get_maybe_cached_uuid_for_account()
let json_text: [String: Any] = ["transaction_id": transaction_id, "account_uuid": account_uuid.uuidString]
let json_data = try JSONSerialization.data(withJSONObject: json_text)
let url = environment.api_base_url().appendingPathComponent("accounts/\(keypair.pubkey.hex())/apple-iap/transaction-id")
let (data, response) = try await make_nip98_authenticated_request(
method: .post,
url: url,
payload: json_data,
payload_type: .json,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
Log.info("Sent transaction ID to Damus Purple server and activated successfully", for: .damus_purple)
default:
Log.error("Error in sending or verifying transaction ID with Damus Purple server. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
throw DamusPurple.PurpleError.iap_receipt_verification_error(status: httpResponse.statusCode, response: data)
}
}
}
func translate(text: String, source source_language: String, target target_language: String) async throws -> String {
var url = environment.api_base_url()
url.append(path: "/translate")
url.append(queryItems: [
.init(name: "source", value: source_language),
.init(name: "target", value: target_language),
.init(name: "q", value: text)
])
let (data, response) = try await make_nip98_authenticated_request(
method: .get,
url: url,
payload: nil,
payload_type: nil,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
return try JSONDecoder().decode(TranslationResult.self, from: data).text
default:
Log.error("Translation error with Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
throw PurpleError.translation_error(status_code: httpResponse.statusCode, response: data)
}
}
else {
throw PurpleError.translation_no_response
}
}
func verify_npub_for_checkout(checkout_id: String) async throws {
var url = environment.api_base_url()
url.append(path: "/ln-checkout/\(checkout_id)/verify")
let (data, response) = try await make_nip98_authenticated_request(
method: .put,
url: url,
payload: nil,
payload_type: nil,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
Log.info("Verified npub for checkout id `%s` with Damus Purple server", for: .damus_purple, checkout_id)
default:
Log.error("Error in verifying npub with Damus Purple. HTTP status code: %d; Response: %s; Checkout id: ", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown", checkout_id)
throw PurpleError.checkout_npub_verification_error
}
}
}
@MainActor
func fetch_ln_checkout_object(checkout_id: String) async throws -> LNCheckoutInfo? {
let url = environment.api_base_url().appendingPathComponent("ln-checkout/\(checkout_id)")
let (data, response) = try await make_nip98_authenticated_request(
method: .get,
url: url,
payload: nil,
payload_type: nil,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
return try JSONDecoder().decode(LNCheckoutInfo.self, from: data)
case 404:
return nil
default:
throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data)
}
}
throw PurpleError.error_processing_response
}
@MainActor
func new_ln_checkout(product_template_name: String) async throws -> LNCheckoutInfo? {
let url = environment.api_base_url().appendingPathComponent("ln-checkout")
let json_text: [String: String] = ["product_template_name": product_template_name]
let json_data = try JSONSerialization.data(withJSONObject: json_text)
let (data, response) = try await make_nip98_authenticated_request(
method: .post,
url: url,
payload: json_data,
payload_type: .json,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
return try JSONDecoder().decode(LNCheckoutInfo.self, from: data)
case 404:
return nil
default:
throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data)
}
}
throw PurpleError.error_processing_response
}
@MainActor
func generate_verified_ln_checkout_link(product_template_name: String) async throws -> URL {
let checkout = try await self.new_ln_checkout(product_template_name: product_template_name)
guard let checkout_id = checkout?.id.uuidString.lowercased() else { throw PurpleError.error_processing_response }
try await self.verify_npub_for_checkout(checkout_id: checkout_id)
return self.environment.purple_landing_page_url()
.appendingPathComponent("checkout")
.appending(queryItems: [URLQueryItem(name: "id", value: checkout_id)])
}
@MainActor
/// This function checks the status of all checkout objects in progress with the server, and it does two things:
/// - It returns the ones that were freshly completed
/// - It internally marks them as "completed"
/// Important note: If you call this function, you must use the result, as those checkouts will not be returned the next time you call this function
///
/// - Returns: An array of checkout objects that have been successfully completed.
func check_status_of_checkouts_in_progress() async throws -> [String] {
var freshly_completed_checkouts: [String] = []
for checkout_id in self.checkout_ids_in_progress {
let checkout_info = try await self.fetch_ln_checkout_object(checkout_id: checkout_id)
if checkout_info?.is_all_good() == true {
freshly_completed_checkouts.append(checkout_id)
}
if checkout_info?.completed == true {
self.checkout_ids_in_progress.remove(checkout_id)
}
}
return freshly_completed_checkouts
}
@MainActor
/// This function checks the status of a specific checkout id with the server
/// You should use this result immediately, since it will internally be marked as handled
///
/// - Returns: true if this checkout is all good to go. false if not. nil if checkout was not found.
func check_and_mark_ln_checkout_is_good_to_go(checkout_id: String) async throws -> Bool? {
let checkout_info = try await self.fetch_ln_checkout_object(checkout_id: checkout_id)
if checkout_info?.completed == true {
self.checkout_ids_in_progress.remove(checkout_id) // Remove if from the list of checkouts in progress
}
return checkout_info?.is_all_good()
}
struct Account {
let pubkey: Pubkey
let created_at: Date
let expiry: Date
let subscriber_number: Int
let active: Bool
func ordinal() -> String? {
let number = Int(self.subscriber_number)
let formatter = NumberFormatter()
formatter.numberStyle = .ordinal
return formatter.string(from: NSNumber(integerLiteral: number))
}
static func from(json_data: Data) -> Self? {
guard let payload = try? JSONDecoder().decode(Payload.self, from: json_data) else { return nil }
return Self.from(payload: payload)
}
static func from(payload: Payload) -> Self? {
guard let pubkey = Pubkey(hex: payload.pubkey) else { return nil }
return Self(
pubkey: pubkey,
created_at: Date.init(timeIntervalSince1970: TimeInterval(payload.created_at)),
expiry: Date.init(timeIntervalSince1970: TimeInterval(payload.expiry)),
subscriber_number: Int(payload.subscriber_number),
active: payload.active
)
}
struct Payload: Codable {
let pubkey: String // Hex-encoded string
let created_at: UInt64 // Unix timestamp
let expiry: UInt64 // Unix timestamp
let subscriber_number: UInt
let active: Bool
}
}
}
// MARK: API types
extension DamusPurple {
fileprivate struct AccountInfo: Codable {
let pubkey: String
let created_at: UInt64
let expiry: UInt64?
let active: Bool
}
struct LNCheckoutInfo: Codable {
// Note: Swift will decode a JSON full of extra fields into a Struct with only a subset of them, but not the other way around
// Therefore, to avoid compatibility concerns and complexity, we should only use the fields we need
// The ones we do not need yet will be left commented out until we need them.
let id: UUID
/*
let product_template_name: String
let verified_pubkey: String?
*/
let invoice: Invoice?
let completed: Bool
struct Invoice: Codable {
/*
let bolt11: String
let label: String
let connection_params: ConnectionParams
*/
let paid: Bool?
/*
struct ConnectionParams: Codable {
let nodeid: String
let address: String
let rune: String
}
*/
}
/// Indicates whether this checkout is all good to go.
/// The checkout is good to go if it is marked as complete and the invoice has been successfully paid
/// - Returns: true if this checkout is all good to go. false otherwise
func is_all_good() -> Bool {
return self.completed == true && self.invoice?.paid == true
}
}
fileprivate struct AccountUUIDInfo: Codable {
let account_uuid: UUID
}
}
// MARK: Helper structures
extension DamusPurple {
enum PurpleError: Error {
case translation_error(status_code: Int, response: Data)
case http_response_error(status_code: Int, response: Data)
case error_processing_response
case iap_purchase_error(result: Product.PurchaseResult)
case iap_receipt_verification_error(status: Int, response: Data)
case translation_no_response
case checkout_npub_verification_error
}
struct TranslationResult: Codable {
let text: String
}
struct OnboardingStatus {
var account_existed_at_the_start: Bool? = nil
var onboarding_was_shown: Bool = false
init() {
}
init(account_active_at_the_start: Bool, onboarding_was_shown: Bool) {
self.account_existed_at_the_start = account_active_at_the_start
self.onboarding_was_shown = onboarding_was_shown
}
func user_has_never_seen_the_onboarding_before() -> Bool {
return onboarding_was_shown == false && account_existed_at_the_start == false
}
}
}

View File

@@ -0,0 +1,120 @@
//
// DamusPurpleEnvironment.swift
// damus
//
// Created by Daniel DAquino on 2024-01-29.
//
import Foundation
enum DamusPurpleEnvironment: CaseIterable, Codable, Identifiable, StringCodable, Equatable, Hashable {
static var allCases: [DamusPurpleEnvironment] = [.local_test(host: nil), .staging, .production]
case local_test(host: String?)
case staging
case production
func text_description() -> String {
switch self {
case .local_test:
return NSLocalizedString("Test (local)", comment: "Label indicating a local test environment for Damus Purple functionality (Developer feature)")
case .staging:
return NSLocalizedString("Staging", comment: "Label indicating a staging test environment for Damus Purple functionality (Developer feature)")
case .production:
return NSLocalizedString("Production", comment: "Label indicating the production environment for Damus Purple")
}
}
func api_base_url() -> URL {
switch self {
case .local_test(let host):
URL(string: "http://\(host ?? "localhost"):8989") ?? Constants.PURPLE_API_LOCAL_TEST_BASE_URL
case .staging:
Constants.PURPLE_API_STAGING_BASE_URL
case .production:
Constants.PURPLE_API_PRODUCTION_BASE_URL
}
}
func purple_landing_page_url() -> URL {
switch self {
case .local_test(let host):
URL(string: "http://\(host ?? "localhost"):3000/purple") ?? Constants.PURPLE_LANDING_PAGE_LOCAL_TEST_URL
case .staging:
Constants.PURPLE_LANDING_PAGE_STAGING_URL
case .production:
Constants.PURPLE_LANDING_PAGE_PRODUCTION_URL
}
}
func damus_website_url() -> URL {
switch self {
case .local_test(let host):
URL(string: "http://\(host ?? "localhost"):3000") ?? Constants.DAMUS_WEBSITE_LOCAL_TEST_URL
case .staging:
Constants.DAMUS_WEBSITE_STAGING_URL
case .production:
Constants.DAMUS_WEBSITE_PRODUCTION_URL
}
}
func custom_host() -> String? {
switch self {
case .local_test(let host):
return host
default:
return nil
}
}
init?(from string: String) {
switch string {
case "local_test":
self = .local_test(host: nil)
case "staging":
self = .staging
case "production":
self = .production
default:
let components = string.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
if components.count == 2 && components[0] == "local_test" {
self = .local_test(host: String(components[1]))
} else {
return nil
}
}
}
func to_string() -> String {
switch self {
case .local_test(let host):
if let host {
return "local_test:\(host)"
}
return "local_test"
case .staging:
return "staging"
case .production:
return "production"
}
}
var id: String {
switch self {
case .local_test(let host):
if let host {
return "local_test:\(host)"
}
else {
return "local_test"
}
case .staging:
return "staging"
case .production:
return "production"
}
}
}

View File

@@ -0,0 +1,63 @@
//
// DamusPurpleURL.swift
// damus
//
// Created by Daniel Nogueira on 2024-01-13.
//
import Foundation
struct DamusPurpleURL: Equatable {
let is_staging: Bool
let variant: Self.Variant
enum Variant: Equatable {
case verify_npub(checkout_id: String)
case welcome(checkout_id: String)
case landing
}
init(is_staging: Bool, variant: Self.Variant) {
self.is_staging = is_staging
self.variant = variant
}
init?(url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
guard components.scheme == "damus" else { return nil }
let is_staging = components.find("staging") != nil
switch components.path {
case "purple:verify":
guard let checkout_id = components.find("id") else { return nil }
self = .init(is_staging: is_staging, variant: .verify_npub(checkout_id: checkout_id))
case "purple:welcome":
guard let checkout_id = components.find("id") else { return nil }
self = .init(is_staging: is_staging, variant: .welcome(checkout_id: checkout_id))
case "purple:landing":
self = .init(is_staging: is_staging, variant: .landing)
default:
return nil
}
}
func url_string() -> String {
let staging = is_staging ? "&staging=true" : ""
switch self.variant {
case .verify_npub(let id):
return "damus:purple:verify?id=\(id)\(staging)"
case .welcome(let id):
return "damus:purple:welcome?id=\(id)\(staging)"
case .landing:
let staging = is_staging ? "?staging=true" : ""
return "damus:purple:landing\(staging)"
}
}
}
extension URLComponents {
func find(_ name: String) -> String? {
self.queryItems?.first(where: { qi in qi.name == name })?.value
}
}

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