Compare commits

...

99 Commits

Author SHA1 Message Date
tyiu f28b15e84a WIP 2024-10-04 19:00:16 +02:00
tyiu e2cf6ffab2 Add Apple offline language downloads in settings 2024-09-28 00:28:04 -04:00
tyiu 2a19d5d831 Add Apple translation popovers for notes for iOS 17.4+ and macOS 14.4+
Changelog-Added: Add Apple translation popovers for notes for iOS 17.4+ and macOS 14.4+
2024-09-23 20:14:31 -07:00
William Casarin 51ee4046a0 Merge ios18-release_1.10 fixes
Pull some things from the 1.10 release branch into master:

Daniel D’Aquino (1):
      Fix unclickable elements

William Casarin (4):
      relays: add some ping/pong and connection logs
      relay: don't reconnect when we don't have to
2024-09-22 10:29:36 +09:00
William Casarin 1e85bb946d Merge 'ios18-fixes-round1' into 'release_1.10'
Merge some additional ios18 fixes

Daniel D’Aquino (1):
      Fix unclickable elements

William Casarin (3):
      relays: add some ping/pong and connection logs
      relay: don't reconnect when we don't have to
2024-09-22 10:22:45 +09:00
William Casarin 6639c002ed relay: don't reconnect when we don't have to
We are reconnecting multiple times for two separate reasons:

1. On a cancellation "error" which does not warrant a reconnect

2. In our reconnection backoff it doesn't check If we are already
   connecting or connected. We check this so we don't reconnect multiple
   times.

This fixes many reconnection issues and makes Damus feel wayyy snappier.

Signed-off-by: William Casarin <jb55@jb55.com>
2024-09-22 09:06:02 +09:00
William Casarin 2a61440aed relays: add some ping/pong and connection logs
need this for debugging connection issues

Signed-off-by: William Casarin <jb55@jb55.com>
2024-09-22 09:06:02 +09:00
Daniel D’Aquino 823c2565da Fix unclickable elements
The introduction of iOS 18 brought a new bug that made `KFAnimatedImage`
not recognize tap gestures and become unclickable. (https://github.com/onevcat/Kingfisher/issues/2295)

This commit addresses the issue with a workaround found here:
https://github.com/onevcat/Kingfisher/issues/2046#issuecomment-1554068070

The workaround was suggested by the author of the library to fix a
slightly different issue, but that property seems to work for our
purposes.

The issue is addressed by adding a `contentShape` property to usages
of `KFAnimatedImage`, in order to make them clickable. A custom modifier
was created to make the solution less obscure and more obvious.

Furthermore, one empty tap gesture handler was removed as it was
preventing other tap gesture handlers on the image carousel from being
triggered on iOS 18

Testing
-------

PASS

Configurations:
- iPhone 13 mini on iOS 18.0
- iPhone SE simulator on iOS 17.5
Damus: This commit
Coverage:
- Check that the following views are clickable:
    - Images in the carousel
    - Profile picture on notes
    - Profile picture on thread comments
    - Profile picture on profile page

Changelog-Fixed: Fix items that became unclickable on iOS 18
Closes: https://github.com/damus-io/damus/issues/2342
Closes: https://github.com/damus-io/damus/issues/2370
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-09-20 20:02:24 -07:00
Daniel D’Aquino b5a81e2586 Merge branch 'release_1.10' 2024-09-18 19:14:07 -07:00
Daniel D’Aquino 6254cea600 Improve notification view filtering UX
- Add subtitle below the toolbar title to indicate the state of the filter
- Add settings icon to take user to the notification settings page, and
  thus make that more discoverable

Testing
-------

PASS

Device: iPhone 13 mini
iOS: 17.6.1
Coverage:
1. Switching back and forth between the notifications tab and other tabs
   causes subtitle to show/hide as expected in both filter options
   (all, friends)
2. Subtitle follows the friends filter
3. Subtitle shows after restarting the app
4. Settings icon appears and takes user to the notification setting view
5. Notification settings can be updated from that view.

Changelog-Changed: Improve notification view filtering UX
Closes: https://github.com/damus-io/damus/issues/2480
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-09-18 18:31:11 -07:00
Daniel D’Aquino ce63f6a96b Make friends filter button more visible
This commit changes the appearance of the friends filter button to make it more visible

Testing:
- Checked appearance in both light mode and dark mode
- Checked appearance in all usages (notifications view and DM view)
- Checked consistency against the filter button in Universe view

Changelog-Changed: Improve visibility of friends filter button
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-09-18 18:31:11 -07:00
cr0bar 6fa2e8b5c6 Fix issue where theme would be changed to black and can't be switched back on iOS 18
Removed line which forces preferred colour scheme to dark on iOS 18, and
made adjustments to the styling to maintain text legibility

Changelog-Fixed: Fixed issue where theme would be changed to black and can't be switched back on iOS 18
Closes: https://github.com/damus-io/damus/issues/2373
Co-authored-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-09-18 17:50:44 -07:00
Daniel D’Aquino 2278ab09a4 Improve local contact list handling
Unless the user signed up after changes from Github issue #2057, the contact list delegate would never be set due to a logic error, which means latest_contact_event_changed would never get called and the app would never save a local contact list reference to pull from — which caused issues when switching to different relays.

Testing
--------

PASS

Device: iPhone 15 simulator
iOS: 17.5
Setup: Manually removed UserSettingsStore::latest_contact_event_id_hex value to replicate the entry condition for the bug
Steps:
1. Add new relay (relay.zap.store)
2. Remove all other relays
3. Attempt to add relay. Ensure new relay can be added
4. Remove all relays
5. Add the `wss://notify-staging.damus.io` relay (which will not save any events)
6. Restart app
7. Try to add a new relay. Ensure a new relay can be added
8. Make a test post. Ensure the new test post is posted successfully.

Changelog-Fixed: Fixed some scenarios where the contact list would never be saved locally and cause issues when switching relays.
Closes: https://github.com/damus-io/damus/issues/2293
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-09-18 16:58:43 -07:00
Daniel D’Aquino dfa72fceb1 Fix unit test build
A change around the NostrPost interfaces caused unit tests to fail
compilation. This commit fixes that.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-09-13 15:57:55 -07:00
Daniel D’Aquino 9e0b9debb4 Improve local contact list handling
Unless the user signed up after changes from Github issue #2057, the contact list delegate would never be set due to a logic error, which means latest_contact_event_changed would never get called and the app would never save a local contact list reference to pull from — which caused issues when switching to different relays.

Testing
--------

PASS

Device: iPhone 15 simulator
iOS: 17.5
Setup: Manually removed UserSettingsStore::latest_contact_event_id_hex value to replicate the entry condition for the bug
Steps:
1. Add new relay (relay.zap.store)
2. Remove all other relays
3. Attempt to add relay. Ensure new relay can be added
4. Remove all relays
5. Add the `wss://notify-staging.damus.io` relay (which will not save any events)
6. Restart app
7. Try to add a new relay. Ensure a new relay can be added
8. Make a test post. Ensure the new test post is posted successfully.

Changelog-Fixed: Fixed some scenarios where the contact list would never be saved locally and cause issues when switching relays.
Closes: https://github.com/damus-io/damus/issues/2293
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-09-11 16:24:54 -07:00
Daniel D’Aquino 3902fe7b30 Enable push notifications feature for everyone and set notification mode to push
This commit hardcodes the push notification feature flag to true, in
preparation for purple testflight release.

It also changes the notification mode setting string, to ensure that we
won't have issues with people being stuck with local notification mode.

Testing
-------

Steps:
1. Run app
2. Ensure push notification flag is gone from developer Settings
3. Ensure notification mode is set to push, and that the push option is available
4. Ensure push notification settings appear as "synced successfully"

Conditions:
- iPhone 13 mini, iOS 17.6.1, on a device that was already under testing
- iPad simulator, iOS 17.5, brand new account

Changelog-Added: Push notification support
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-09-06 14:29:19 -07:00
William Casarin 471bb4638a Merge 'Add localization data to extensions' into release_1.10
Daniel D’Aquino (1):
      Add localization data to extensions
2024-09-06 09:15:57 -07:00
Daniel D’Aquino 379de6ff8e Merge branch 'release_1.10' 2024-09-05 20:08:51 -07:00
William Casarin cb241741e3 notifications: add more logging
needed this to debug stuff

Signed-off-by: William Casarin <jb55@jb55.com>
2024-09-05 20:02:07 -07:00
William Casarin 1dbf7101b9 notifications: add support for tagged mentions
These are text notes that have you tagged but do not have inline
mentions or are replies.

Signed-off-by: William Casarin <jb55@jb55.com>
2024-09-05 20:02:07 -07:00
William Casarin d9bbca1005 notifications: don't fail if we don't have display_name
This isn't even a standard field anyways

Signed-off-by: William Casarin <jb55@jb55.com>
2024-09-05 20:02:07 -07:00
William Casarin d2acf61e5a Merge 'Add localization data to extensions'
Daniel D’Aquino (1):
      Add localization data to extensions
2024-09-05 16:41:51 -07:00
Daniel D’Aquino d6898c77d8 Add localization data to extensions
This commit links localization data to the extension targets so that
those targets can successfully localize data

Testing
-------

PASS

Device: iPhone 13 Mini
Damus: This commit
iOS: 17.6.1
Setup:
- Staging environment
- Push notifications enabled and configured
Steps:
1. Send a zap without message to the device with push notifications setup
2. Ensure message appears localized, not a localization key (`zap_notification_no_message`)
3. Change language to Portuguese
4. Make a highlight via the extension. Ensure some or all of the UI elements are localized into Portuguese

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Closes: https://github.com/damus-io/damus/issues/2419
2024-09-04 17:48:20 -07:00
ericholguin dd1fdf159b Reapply and rework "ux: Mute selected text"
This commit reapplies the "ux: Mute selected text" commit, with some
manual rework to solve logical conflicts during merge.

Rework testing
--------------

PASS

Device: iPhone 13 Mini
iOS: 17.6.1
Steps:
1. Go to a note
2. Select text and click on the "highlight" button. Ensure that highlight sheet appears with the correct text
3. Select text and click on the "mute" button. Ensure that mute sheet appears with the correct text

Original commit: d663155941
Original author: ericholguin <ericholguin@apache.org>
Reworked-by: Daniel D’Aquino <daniel@daquino.me>
Retested-by: Daniel D’Aquino <daniel@daquino.me>
2024-09-04 12:10:12 -07:00
Daniel D’Aquino 51b1b81c0e Merge branch 'release_1.10' into master 2024-09-04 10:53:59 -07:00
Daniel D’Aquino da7af491d0 Implement support for reply notification formatting
This commit implements support for nicely formatting reply push
notifications.

Testing
-------

PASS

Device: iPhone 15 simulator
notepush: 11568aa6285142e4c19bb0da30977957a92b7d9b
Damus: This commit
Settings: Local push notification setup
Steps:
1. Create a post from account 1
2. On account 2, make a reply to that post
3. Ensure we get a push notification with:
  - A title formatted as "<ACCOUNT_2_NAME> replied to your note"
  - A body with the contents of that reply
4. Click on that push notification. Ensure you are taken to the reply
5. Now make a post from account 2 and mention account 1 in it
6. Ensure push notification says that account 2 mentioned account 1 (i.e. does not talk about a reply)

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Closes: https://github.com/damus-io/damus/issues/2403
2024-09-04 10:16:55 -07:00
Daniel D’Aquino 90b284fb6e Add option to specify custom push notification server for testing
This commit adds an option that allows a user to choose a custom push
notification server, as well as the staging notify server, to help with testing

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-09-04 10:16:09 -07:00
William Casarin c1a89bd617 build: fix versions again 2024-09-01 09:03:26 -07:00
William Casarin a20f3ab2ab highlighter: fix deploy 2024-09-01 08:57:56 -07:00
William Casarin 7b9d0edef4 highlighter: add missing PostingTimelineView
This was refactored by eric on master, let's make sure we
add it to the highlighter extension build

Signed-off-by: William Casarin <jb55@jb55.com>
2024-09-01 07:33:19 -07:00
William Casarin c22fc8613d Merge Highlighter
This brings Daniel's highlighter safari extension to master/testflight.
Previously we only had it on the 1.10 release branch. This also includes
some extended virtual addressing fixes to fix push notifications, we
also update the push notification server address since that seems to
have been missed.

Daniel D’Aquino (8):
      Update push notification server address
      Add convenience functions
      Simplify SelectableText state management
      Add support for rendering highlights with comments
      Add support for adding comments when creating a highlight
      Add highlighter extension
      Fix highlight tag ambiguity with specifiers
      Improve handling of NostrDB when switching apps

William Casarin (5):
      lmdb: patch semaphore names to use shared group container prefix
      Revert "ux: Mute selected text"
      notifications: add extended virtual addressing entitlement
      highlighter: add extended virtual addressing entitlement
2024-09-01 07:27:32 -07:00
William Casarin f61308e573 highlighter: add extended virtual addressing entitlement
This is needed for opening nostrdb

Signed-off-by: William Casarin <jb55@jb55.com>
2024-09-01 07:23:16 -07:00
William Casarin d93b04a54c notifications: add extended virtual addressing entitlement
It looks like our push notification service was missing the extended
virtual memory entitlement. This is required to open nostrdb databases.

Signed-off-by: William Casarin <jb55@jb55.com>
2024-09-01 07:23:16 -07:00
Daniel D’Aquino 4b881e6839 Improve handling of NostrDB when switching apps
There was an issue where profiles on Damus would not load when switching
back and forth between the extension and Damus.

This commit fixes that by closing NostrDB when the extension is backgrounded

Testing
-------

PASS

Device: iPhone 13 Mini
iOS: 17.6.1
Damus: This commit
Steps:
1. Go to a webpage in safari, and open the highlight extension
2. With the highlight extension open, switch apps to Damus (without closing the extension)
3. Make sure profiles can be loaded on Damus

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-09-01 07:23:16 -07:00
Daniel D’Aquino 63b0661728 Fix highlight tag ambiguity with specifiers
This commit fixes the ambiguity in tags used in highlights with comments, by adding specifiers to help clients understand:
- If a URL reference is the source of the highlight or just a URL mentioned in the comment
- If a pubkey reference is the author of the highlighted content, or just a generic mention in the comment

This tries to be backwards compatible with previous versions of NIP-84.

Testing
--------

PASS

Device: iPhone 15 simulator
iOS: 17.5
Damus: This commit
Steps:
1. Create a new highlight from a webpage using the extension. Tag a user and attach an image
2. Check the newly-created highlight:
  1. Highlight description line should just say "Highlighted", not "Highlighted <username>"
  2. Highlight source link preview should present the URL of the highlighted page, NOT the image URL
3. Inspect the JSON for the newly-created highlight:
  1. "r" tags should include specifiers in the 3rd slot, such as "source" or "mention"
  2. "p" tags should include specifiers in the 3rd slot, such as "mention"
4. Go to an older, generic highlight (without comment) to another nostr event and check the view.
  1. Highlight description line should say "Highlighted <author_name_of_other_event>"
  2. Clicking on the highlight should lead to the highlighted event itself.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-09-01 07:23:16 -07:00
Daniel D’Aquino 46a66bc69d Add highlighter extension
This commit adds a highlighting extension for web pages. This works on
Safari, and can be used by selecting a text on a page and hitting the
share button at the bottom of the Safari UI

To make this possible, some refactoring was necessary:
1. Several sources were included in the extension bundle to provide access to DamusState, PostView, and the postbox
2. UIApplication.shared was replaced with `this_app`, which routes to UIApplication.shared on the main app bundle,
   and routes to a bogus UIApplication() in the extension. This is needed because UIApplication.shared cannot be used on an extension.
3. Some items were moved to different files to facilitate the transition.

The extension itself uses PostView, and implements views for several edge cases, and tries to handle the note publishing process gracefully.

Changelog-Added: Add highlighter for web pages
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-09-01 07:23:16 -07:00
Daniel D’Aquino c09018be48 Add support for adding comments when creating a highlight
Changelog-Added: Add support for adding comments when creating a highlight
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-09-01 07:23:16 -07:00
Daniel D’Aquino d71d448ac8 Add support for rendering highlights with comments
This commit implements rendering comments from the `["comment",
<COMMENT_TEXT>]` tag in a highlight note.

Comment contents get rendered like a kind 1 note's "content" field

This commit also adds the `r` "reference" tag as a standard tag reference type

Changelog-Added: Add support for rendering highlights with comments
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-09-01 07:23:16 -07:00
Daniel D’Aquino 5834e1ee9b Simplify SelectableText state management
This commit simplifies the state management and information flow for SelectableText.

This also fixes issues and inconsistencies with the selected text for a highlight action,
which often appeared in some scenarios with the symptom of a highlight
action showing the incorrect or outdated selected text.

Previously, the state of the selected text and highlight action was
tracked in two independent state/binding variables which caused
re-renders when they were modified, often leading to inconsistencies as
those two independent variables would not be changed atomically across
renders leading to inconsistent, undefined behavior

The commit addresses this by using a single state object instead of two,
and a direct callback interface when the highlight button is pressed,
which eliminates the need of relying on view re-renders to apply.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-09-01 07:23:16 -07:00
Daniel D’Aquino d51179189c Add convenience functions
This commit adds a convenience initializer for DamusState that is
simpler than the normal initializer, to allow extensions to more easily
use it.

It also includes a new convenience function for `should_blur_images`

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-09-01 07:23:16 -07:00
William Casarin b01243b101 Revert "ux: Mute selected text"
I had to revert this for now because it conflicts too heavily
with the highlighter feature which we definitely want in master.

Let's rework this using Daniel's refactor

This reverts commit d663155941.
2024-09-01 07:22:28 -07:00
William Casarin d2a80cce4e Merge Highlighter into release_1.10
Daniel D’Aquino (7):
      Add convenience functions
      Simplify SelectableText state management
      Add support for rendering highlights with comments
      Add support for adding comments when creating a highlight
      Add highlighter extension
      Fix highlight tag ambiguity with specifiers
      Improve handling of NostrDB when switching apps

William Casarin (4):
      lmdb: patch semaphore names to use group container prefix
      notifications: add extended virtual addressing entitlement
      highlighter: add extended virtual addressing entitlement
2024-09-01 07:00:50 -07:00
William Casarin 0cc9fc1670 highlighter: add extended virtual addressing entitlement
This is needed for opening nostrdb

Signed-off-by: William Casarin <jb55@jb55.com>
2024-09-01 06:44:34 -07:00
William Casarin 1279791d65 notifications: add extended virtual addressing entitlement
It looks like our push notification service was missing the extended
virtual memory entitlement. This is required to open nostrdb databases.

Signed-off-by: William Casarin <jb55@jb55.com>
2024-09-01 06:42:35 -07:00
William Casarin 5d2fc0ed54 lmdb: patch semaphore names to use group container prefix
This is an attempt to fix various issues when acquiring a IPC
semaphore on iOS

See: https://github.com/damus-io/damus/issues/2323#issuecomment-2323181204

Running this patch gives us these names:

mdb_env_setup_locks: using semnames
  'group.com.damus/MDBrwDDi_FHxD' (29),
  'group.com.damus/MDBwwDDi_FHxD' (29)

From old Apple docs:

> IPC and POSIX Semaphores and Shared Memory
>
> Normally, sandboxed apps cannot use Mach IPC, POSIX semaphores and
> shared memory, or UNIX domain sockets (usefully). However, by specifying
> an entitlement that requests membership in an application group, an app
> can use these technologies to communicate with other members of that
> application group.
>
> Note: System V semaphores are not supported in sandboxed apps.
>
> UNIX domain sockets are straightforward; they work just like any other
> file.
>
> Any semaphore or Mach port that you wish to access within a sandboxed
> app must be named according to a special convention:
>
> POSIX semaphores and shared memory names must begin with the application
> group identifier, followed by a slash (/), followed by a name of your
> choosing.
>
> Mach port names must begin with the application group identifier,
> followed by a period (.), followed by a name of your choosing.
>
> For example, if your application group’s name is
> Z123456789.com.example.app-group, you might create two semaphores named
> Z123456789.myappgroup/rdyllwflg and Z123456789.myappgroup/bluwhtflg. You
> might create a Mach port named
> Z123456789.com.example.app-group.Port_of_Kobe.
>
> Note: The maximum length of a POSIX semaphore name is only 31 bytes, so
> if you need to use POSIX semaphores, you should keep your app group
> names short.

Link: https://github.com/damus-io/damus/issues/2323#issuecomment-2323305949
Signed-off-by: William Casarin <jb55@jb55.com>
2024-09-01 06:42:35 -07:00
William Casarin dcafcd9184 lmdb: patch semaphore names to use shared group container prefix
mdb_env_setup_locks: using semnames
  'group.com.damus/MDBrwDDi_FHxD' (29),
  'group.com.damus/MDBwwDDi_FHxD' (29)

From old Apple docs:

> IPC and POSIX Semaphores and Shared Memory
>
> Normally, sandboxed apps cannot use Mach IPC, POSIX semaphores and
> shared memory, or UNIX domain sockets (usefully). However, by specifying
> an entitlement that requests membership in an application group, an app
> can use these technologies to communicate with other members of that
> application group.
>
> Note: System V semaphores are not supported in sandboxed apps.
>
> UNIX domain sockets are straightforward; they work just like any other
> file.
>
> Any semaphore or Mach port that you wish to access within a sandboxed
> app must be named according to a special convention:
>
> POSIX semaphores and shared memory names must begin with the application
> group identifier, followed by a slash (/), followed by a name of your
> choosing.
>
> Mach port names must begin with the application group identifier,
> followed by a period (.), followed by a name of your choosing.
>
> For example, if your application group’s name is
> Z123456789.com.example.app-group, you might create two semaphores named
> Z123456789.myappgroup/rdyllwflg and Z123456789.myappgroup/bluwhtflg. You
> might create a Mach port named
> Z123456789.com.example.app-group.Port_of_Kobe.
>
> Note: The maximum length of a POSIX semaphore name is only 31 bytes, so
> if you need to use POSIX semaphores, you should keep your app group
> names short.

Link: https://github.com/damus-io/damus/issues/2323#issuecomment-2323305949
Signed-off-by: William Casarin <jb55@jb55.com>
2024-09-01 06:20:11 -07:00
Daniel D’Aquino cf16a9cd10 Improve handling of NostrDB when switching apps
There was an issue where profiles on Damus would not load when switching
back and forth between the extension and Damus.

This commit fixes that by closing NostrDB when the extension is backgrounded

Testing
-------

PASS

Device: iPhone 13 Mini
iOS: 17.6.1
Damus: This commit
Steps:
1. Go to a webpage in safari, and open the highlight extension
2. With the highlight extension open, switch apps to Damus (without closing the extension)
3. Make sure profiles can be loaded on Damus

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-08-30 10:17:43 -07:00
Daniel D’Aquino 3a9dda5eb3 Update push notification server address
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-08-28 12:21:16 +03:00
William Casarin c69ddd7241 Merge 'allow spaces when tagging' into release_1.10
Daniel D’Aquino (1):
      Improve handling of escape characters of mention suggestion menu
2024-08-28 12:16:24 +03:00
William Casarin bfcb3e4c88 Fix AlbyHub zaps
AlbyHub does not use description hash invoices. We had some code that
looked for zap request invoices inside the description which albyhub
does not do.

Change our code to always get the zap_request from the description.

Changelog-Fixed: Fix albyhub zaps not appearing
Signed-off-by: William Casarin <jb55@jb55.com>
2024-08-28 11:10:03 +03:00
Daniel D’Aquino 27083669fa Improve handling of escape characters of mention suggestion menu
It was noticed that adding a space inadvertently escapes the user
mention suggestion menu (even though several users have an escape
character in their name)

This commit fixes that issue, and improves overall handling of user
mention escape sequences, by allowing those sequences to be made up of
multiple characters instead of a single one.

Testing
-------

Device: iPhone 13 Mini
iOS: 17.6.1
Damus: This commit
Steps:
1. Type normally. Make sure Text editing works normally
2. Try to type a mention with a long name with spaces. Make sure typing
   spaces does not cause the mention suggestions menu to be dismissed.
3. Select a user, make sure mention suggestions menu gets dismissed
4. Try to type a mention with a long name with spaces, but this time
   instead of selecting a user, just add a punctuation mark. Make sure
   the mention suggestions menu gets dismissed
5. Repeat the step above with the following escape sequences:
    1. Newline
    2. Another "@"
    3. ", "
    4. "  " (double-space)
    5. ". "
6. Delete characters all the way back to an existing mention. Make sure
   mention gets broken with a backspace, showing the mention suggestions
   menu once again.
7. Type a mention and select a user
8. Right after the new user mention, with a single space, start typing something
   else ("e.g. @daniel blah"). Make sure that the mention menu does NOT show up when cursor is at the end of "blah"
9. Right after the new user mention, with a single space, start typing a
   mention ("e.g. @daniel @jb"). Make sure the mention menu DOES show
   up, and suggests "@jb55"

Changelog-Fixed: Fix inadvertent escape from mention suggestion menu when typing a space character
Closes: https://github.com/damus-io/damus/issues/2008
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-08-28 10:56:25 +03:00
William Casarin aaddbd847a Merge 'Fix AlbyHub zaps'
William Casarin (2):
      Fix AlbyHub zaps
2024-08-28 10:49:54 +03:00
William Casarin 1537501127 Fix AlbyHub zaps
AlbyHub does not use description hash invoices. We had some code that
looked for zap request invoices inside the description which albyhub
does not do.

Change our code to always get the zap_request from the description.

Fixes: https://github.com/damus-io/damus/issues/2363
Changelog-Fixed: Fix albyhub zaps not appearing
Signed-off-by: William Casarin <jb55@jb55.com>
2024-08-28 10:44:44 +03:00
William Casarin 8b020e2bd6 Merge 'Fix broken QR code scanner'
Terry Yiu (1):
      Fix broken QR code scanner and fix landscape mode
2024-08-27 14:24:40 +03:00
William Casarin ad614f3e42 Merge 'Add nostrcheck'
Quentin (1):
      Remove non-functioning servers,add nostrcheck,NIP96 for all servers
2024-08-27 14:23:55 +03:00
William Casarin 01497d0288 Merge 'Fix push notification DM decryption' 2024-08-27 14:23:24 +03:00
Quentin eaad552273 Remove non-functioning servers,add nostrcheck,NIP96 for all servers 2024-08-27 11:26:27 +02:00
tyiu 83ecc3142e Fix broken QR code scanner and fix landscape mode
Changelog-Fixed: Fix broken QR code scanner and fix landscape mode
2024-08-26 22:04:14 +03:00
Daniel D’Aquino ef4afbc720 Update push notification server address
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-08-26 11:30:01 -07:00
Daniel D’Aquino a5cc3aec92 Fix push notification DM decryption
This commit fixes an issue where DM contents would not be displayed on a
push notification, by giving the notification extension access to the
keychain group which contains the user's private key

Testing
--------

PASS

Device: iPhone 13 mini
iOS: 17.6.1
Damus: This commit
Setup:
- Make sure that device is setup with push notifications
- DM notifications enabled
- Device registered with push notification server
Steps:
1. Send a DM push notification to yourself
2. Ensure DM contents can be decrypted on the push notification body

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Closes: https://github.com/damus-io/damus/issues/2388
2024-08-26 10:02:40 -07:00
Daniel D’Aquino 2b140d4279 Fix push notification DM decryption
This commit fixes an issue where DM contents would not be displayed on a
push notification, by giving the notification extension access to the
keychain group which contains the user's private key

Testing
--------

PASS

Device: iPhone 13 mini
iOS: 17.6.1
Damus: This commit
Setup:
- Make sure that device is setup with push notifications
- DM notifications enabled
- Device registered with push notification server
Steps:
1. Send a DM push notification to yourself
2. Ensure DM contents can be decrypted on the push notification body

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Closes: https://github.com/damus-io/damus/issues/2388
2024-08-23 16:43:54 -07:00
Daniel D’Aquino b43dcd2bc7 Fix highlight tag ambiguity with specifiers
This commit fixes the ambiguity in tags used in highlights with comments, by adding specifiers to help clients understand:
- If a URL reference is the source of the highlight or just a URL mentioned in the comment
- If a pubkey reference is the author of the highlighted content, or just a generic mention in the comment

This tries to be backwards compatible with previous versions of NIP-84.

Testing
--------

PASS

Device: iPhone 15 simulator
iOS: 17.5
Damus: This commit
Steps:
1. Create a new highlight from a webpage using the extension. Tag a user and attach an image
2. Check the newly-created highlight:
  1. Highlight description line should just say "Highlighted", not "Highlighted <username>"
  2. Highlight source link preview should present the URL of the highlighted page, NOT the image URL
3. Inspect the JSON for the newly-created highlight:
  1. "r" tags should include specifiers in the 3rd slot, such as "source" or "mention"
  2. "p" tags should include specifiers in the 3rd slot, such as "mention"
4. Go to an older, generic highlight (without comment) to another nostr event and check the view.
  1. Highlight description line should say "Highlighted <author_name_of_other_event>"
  2. Clicking on the highlight should lead to the highlighted event itself.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-08-22 14:43:52 -07:00
Daniel D’Aquino c67a75d740 Improve handling of escape characters of mention suggestion menu
It was noticed that adding a space inadvertently escapes the user
mention suggestion menu (even though several users have an escape
character in their name)

This commit fixes that issue, and improves overall handling of user
mention escape sequences, by allowing those sequences to be made up of
multiple characters instead of a single one.

Testing
-------

Device: iPhone 13 Mini
iOS: 17.6.1
Damus: This commit
Steps:
1. Type normally. Make sure Text editing works normally
2. Try to type a mention with a long name with spaces. Make sure typing
   spaces does not cause the mention suggestions menu to be dismissed.
3. Select a user, make sure mention suggestions menu gets dismissed
4. Try to type a mention with a long name with spaces, but this time
   instead of selecting a user, just add a punctuation mark. Make sure
   the mention suggestions menu gets dismissed
5. Repeat the step above with the following escape sequences:
    1. Newline
    2. Another "@"
    3. ", "
    4. "  " (double-space)
    5. ". "
6. Delete characters all the way back to an existing mention. Make sure
   mention gets broken with a backspace, showing the mention suggestions
   menu once again.
7. Type a mention and select a user
8. Right after the new user mention, with a single space, start typing something
   else ("e.g. @daniel blah"). Make sure that the mention menu does NOT show up when cursor is at the end of "blah"
9. Right after the new user mention, with a single space, start typing a
   mention ("e.g. @daniel @jb"). Make sure the mention menu DOES show
   up, and suggests "@jb55"

Changelog-Fixed: Fix inadvertent escape from mention suggestion menu when typing a space character
Closes: https://github.com/damus-io/damus/issues/2008
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-08-21 18:02:43 -07:00
chungwwei 7f00ef5d9d Add mute button to ProfileActionSheet
This PR adds mute button to ProfileActionSheet, allowing user to quick mute npubs/bots

Changelog-Added: Added mute button to ProfileActionSheet
Signed-off-by: chungwwei <chungwwei223@gmail.com>
2024-08-21 16:25:29 -07:00
ericholguin d663155941 ux: Mute selected text
This PR adds the Mute action to the selected text menu. Pressing the mute
action will pop up a sheet which allows users to confirm their selection and
choose for how long they would like to mute the selected text for.

Changelog-Added: Added mute action to selected text menu

Signed-off-by: ericholguin <ericholguin@apache.org>
2024-08-19 17:46:24 -07:00
Eric Holguin abfe0f642f ux: Profile Edit Improvements (#2376)
This PR adds improvements to the profile edit view.  The banner image is
changed from the old ostrich image to the fresh new damoose. The image and
banner url text entries have been removed from the edit form and now live under
the image selector menu. Selecting the Image URL menu option presents a sheet
where a user can update the image URL. There are now safe guards in place for
users who update their profile, if they make any changes and try to navigate
back to home they will get an alert asking if they want to discard changes. The
Save button is also more prominent.

Changelog-Changed: Changed the default banner from ostriches to damoose
Changelog-Added: Added profile edit safe guards
Changelog-Changed: Changed image and banner url text fields to new sheet view

Signed-off-by: ericholguin <ericholguin@apache.org>
2024-08-19 13:23:00 -07:00
tyiu f0b5162205 Fix profile view toolbar alignment bug in iOS 18
Changelog-Fixed: Fix profile view toolbar alignment bug in iOS 18
2024-08-19 13:20:53 -07:00
ericholguin a9bb2ef98b relay: Add Tor Relay Image
This PR just adds the tor icon to relays ending with .onion

Changelog-Added: Tor relay icon

Closes: #2318
Signed-off-by: ericholguin <ericholguin@apache.org>
2024-08-19 13:20:22 -07:00
Daniel D’Aquino eff4525720 Implement push notification preferences and update API
This commit implements push notification preferences with the push
notifications server, as well as updates itself to the new push
notifications API.

Testing
-------

Device: iPhone 15 simulator
iOS: 17.5
Damus: this commit
notepush: 3ca3a8325707535fdbc98d681d5e4a47dc313c67
Steps:
1. Enable push notifications. Settings should get synced and success message should appear
2. Disable push notifications. Sync message should disappear as it no longer applies
3. Enable push notifications again, and tweak notifications. Settings should sync with no errors
4. Leave settings screen and come back. Settings should be declared as synced

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Closes: https://github.com/damus-io/damus/issues/2360
2024-08-19 13:19:46 -07:00
William Casarin 858d9dc6f0 restore localization for custom tabs
Signed-off-by: William Casarin <jb55@jb55.com>
2024-08-19 13:18:53 -07:00
Daniel D’Aquino 55090bc102 Add highlighter extension
This commit adds a highlighting extension for web pages. This works on
Safari, and can be used by selecting a text on a page and hitting the
share button at the bottom of the Safari UI

To make this possible, some refactoring was necessary:
1. Several sources were included in the extension bundle to provide access to DamusState, PostView, and the postbox
2. UIApplication.shared was replaced with `this_app`, which routes to UIApplication.shared on the main app bundle,
   and routes to a bogus UIApplication() in the extension. This is needed because UIApplication.shared cannot be used on an extension.
3. Some items were moved to different files to facilitate the transition.

The extension itself uses PostView, and implements views for several edge cases, and tries to handle the note publishing process gracefully.

Changelog-Added: Add highlighter for web pages
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-08-17 16:22:26 -07:00
Daniel D’Aquino 40d3d273f0 Add support for adding comments when creating a highlight
Changelog-Added: Add support for adding comments when creating a highlight
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-08-17 16:22:26 -07:00
Daniel D’Aquino f9271da11c Add support for rendering highlights with comments
This commit implements rendering comments from the `["comment",
<COMMENT_TEXT>]` tag in a highlight note.

Comment contents get rendered like a kind 1 note's "content" field

This commit also adds the `r` "reference" tag as a standard tag reference type

Changelog-Added: Add support for rendering highlights with comments
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-08-17 15:58:43 -07:00
Daniel D’Aquino 4f881a5667 Simplify SelectableText state management
This commit simplifies the state management and information flow for SelectableText.

This also fixes issues and inconsistencies with the selected text for a highlight action,
which often appeared in some scenarios with the symptom of a highlight
action showing the incorrect or outdated selected text.

Previously, the state of the selected text and highlight action was
tracked in two independent state/binding variables which caused
re-renders when they were modified, often leading to inconsistencies as
those two independent variables would not be changed atomically across
renders leading to inconsistent, undefined behavior

The commit addresses this by using a single state object instead of two,
and a direct callback interface when the highlight button is pressed,
which eliminates the need of relying on view re-renders to apply.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-08-17 15:45:18 -07:00
Daniel D’Aquino 9d97886e3f Add convenience functions
This commit adds a convenience initializer for DamusState that is
simpler than the normal initializer, to allow extensions to more easily
use it.

It also includes a new convenience function for `should_blur_images`

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-08-17 14:24:48 -07:00
Daniel D’Aquino e70cfbbe63 Fix crash with blurhashes with reported dimension of 0x0
This commit fixes a consistent crash noticed when visiting a particular
profile.

The crash was occuring when trying to display the blurhash of a specific Event, where the metadata claimed the image dimensions were 0px x 0px.

The null dimensions caused a division by zero to occur when scaling the image down, yielding a NaN (Not a Number) size value, which crashed the app when trying to cast that CGFloat value down to an integer.

The crash was fixed by modifying the down-scaling computations to check for invalid dimensions, and return nil. The callers were then updated to fallback to a default display dimension.

Issue repro
-------

Device: iPhone 15 simulator
iOS: 17.5
Damus: dba1799df0
Steps:
1. Visit the profile npub1gujeqakgt7fyp6zjggxhyy7ft623qtcaay5lkc8n8gkry4cvnrzqd3f67z
2. Check accessing the profile does not crash Damus.
3. Visit the event that had invalid 0x0 dimensions on the metadata (note1qmqdualjezamcjun23l4d9xw7529m7fee6hklgtnhack2fwznxysuzuuyz)
4. Check that Damus does not crash.

Results: Steps 2 and 4 crash 100% of the time (3/3)

Testing
--------

PASS

Device: iPhone 15 simulator
iOS: 17.5
Damus: This commit
Steps: Same as repro
Results:
1. Crash no longer occurs
2. Blurhash looks ok

Closes: https://github.com/damus-io/damus/issues/2341
Changelog-Fixed: Fix crash when viewing notes with invalid image dimension metadata
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-08-13 10:43:24 -07:00
Eric Holguin 8a75537ea3 ux: Profile Edit Improvements (#2376)
This PR adds improvements to the profile edit view.  The banner image is
changed from the old ostrich image to the fresh new damoose. The image and
banner url text entries have been removed from the edit form and now live under
the image selector menu. Selecting the Image URL menu option presents a sheet
where a user can update the image URL. There are now safe guards in place for
users who update their profile, if they make any changes and try to navigate
back to home they will get an alert asking if they want to discard changes. The
Save button is also more prominent.

Changelog-Changed: Changed the default banner from ostriches to damoose
Changelog-Added: Added profile edit safe guards
Changelog-Changed: Changed image and banner url text fields to new sheet view

Signed-off-by: ericholguin <ericholguin@apache.org>
2024-08-12 11:54:32 -07:00
Daniel D’Aquino 49c8d63d0b Merge pull request #2365 from danieldaquino/#2341
Fix crash with blurhashes with reported dimension of 0x0
2024-08-08 10:18:52 -07:00
Daniel D’Aquino 6480023c96 Fix crash with blurhashes with reported dimension of 0x0
This commit fixes a consistent crash noticed when visiting a particular
profile.

The crash was occuring when trying to display the blurhash of a specific Event, where the metadata claimed the image dimensions were 0px x 0px.

The null dimensions caused a division by zero to occur when scaling the image down, yielding a NaN (Not a Number) size value, which crashed the app when trying to cast that CGFloat value down to an integer.

The crash was fixed by modifying the down-scaling computations to check for invalid dimensions, and return nil. The callers were then updated to fallback to a default display dimension.

Issue repro
-------

Device: iPhone 15 simulator
iOS: 17.5
Damus: dba1799df0
Steps:
1. Visit the profile npub1gujeqakgt7fyp6zjggxhyy7ft623qtcaay5lkc8n8gkry4cvnrzqd3f67z
2. Check accessing the profile does not crash Damus.
3. Visit the event that had invalid 0x0 dimensions on the metadata (note1qmqdualjezamcjun23l4d9xw7529m7fee6hklgtnhack2fwznxysuzuuyz)
4. Check that Damus does not crash.

Results: Steps 2 and 4 crash 100% of the time (3/3)

Testing
--------

PASS

Device: iPhone 15 simulator
iOS: 17.5
Damus: This commit
Steps: Same as repro
Results:
1. Crash no longer occurs
2. Blurhash looks ok

Closes: https://github.com/damus-io/damus/issues/2341
Changelog-Fixed: Fix crash when viewing notes with invalid image dimension metadata
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-08-05 12:52:43 -07:00
William Casarin 774da239b9 Merge remote-tracking branches 'pr/2362', 'pr/2361', 'pr/2319' and 'pr/2355' 2024-08-05 10:57:02 -07:00
transifex-integration[bot] 90c80645ec Translate Localizable.stringsdict in ja
100% translated source file: 'Localizable.stringsdict'
on 'ja'.
2024-08-05 10:10:20 +00:00
transifex-integration[bot] 613ec23f7f Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2024-08-05 09:42:45 +00:00
transifex-integration[bot] 1d73ae1d32 Translate Localizable.strings in hu_HU
100% translated source file: 'Localizable.strings'
on 'hu_HU'.
2024-08-05 08:22:18 +00:00
tyiu 63e364ce5b Export strings for translation 2024-08-04 23:59:48 -04:00
transifex-integration[bot] ee5f53e4eb Translate Localizable.strings in hu_HU
100% translated source file: 'Localizable.strings'
on 'hu_HU'.
2024-08-04 23:56:25 -04:00
transifex-integration[bot] 9de21a730a Translate Localizable.strings in zh_TW
100% translated source file: 'Localizable.strings'
on 'zh_TW'.
2024-08-04 23:56:25 -04:00
transifex-integration[bot] 36c09c8657 Translate Localizable.strings in zh_HK
100% translated source file: 'Localizable.strings'
on 'zh_HK'.
2024-08-04 23:56:25 -04:00
transifex-integration[bot] e8ac143192 Translate Localizable.strings in zh_CN
100% translated source file: 'Localizable.strings'
on 'zh_CN'.
2024-08-04 23:56:24 -04:00
transifex-integration[bot] 93f44939e3 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-08-04 23:56:24 -04:00
transifex-integration[bot] 48078b9b6a Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2024-08-04 23:56:24 -04:00
tyiu d6d6858e0b Export strings for translations 2024-08-04 23:56:24 -04:00
transifex-integration[bot] 0187ff1dc0 Translate Localizable.stringsdict in sv_SE
100% translated source file: 'Localizable.stringsdict'
on 'sv_SE'.
2024-08-04 23:56:24 -04:00
transifex-integration[bot] 4f9fef8515 Translate Localizable.strings in sv_SE
100% translated source file: 'Localizable.strings'
on 'sv_SE'.
2024-08-04 23:56:24 -04:00
transifex-integration[bot] 1ebadd42f0 Translate Localizable.stringsdict in hu_HU
100% translated source file: 'Localizable.stringsdict'
on 'hu_HU'.
2024-08-04 23:56:24 -04:00
transifex-integration[bot] 4fb4f3a2de Translate Localizable.strings in hu_HU
100% translated source file: 'Localizable.strings'
on 'hu_HU'.
2024-08-04 23:56:24 -04:00
transifex-integration[bot] f49169c03c Translate InfoPlist.strings in sw
100% translated source file: 'InfoPlist.strings'
on 'sw'.
2024-08-04 23:56:23 -04:00
Daniel D’Aquino 740c10c9b2 Implement push notification preferences and update API
This commit implements push notification preferences with the push
notifications server, as well as updates itself to the new push
notifications API.

Testing
-------

Device: iPhone 15 simulator
iOS: 17.5
Damus: this commit
notepush: 3ca3a8325707535fdbc98d681d5e4a47dc313c67
Steps:
1. Enable push notifications. Settings should get synced and success message should appear
2. Disable push notifications. Sync message should disappear as it no longer applies
3. Enable push notifications again, and tweak notifications. Settings should sync with no errors
4. Leave settings screen and come back. Settings should be declared as synced

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Closes: https://github.com/damus-io/damus/issues/2360
2024-08-04 12:03:05 -07:00
ericholguin 653f9fbcbe relay: Add Tor Relay Image
This PR just adds the tor icon to relays ending with .onion

Changelog-Added: Tor relay icon

Closes: #2318
Signed-off-by: ericholguin <ericholguin@apache.org>
2024-07-30 21:22:55 -06:00
ericholguin b2ba1e0e3b 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-19 10:53:26 -07:00
115 changed files with 4027 additions and 644 deletions
@@ -2,6 +2,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>com.apple.developer.kernel.extended-virtual-addressing</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
@@ -10,5 +12,9 @@
</array>
<key>com.apple.security.network.client</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.jb55.damus2</string>
</array>
</dict>
</plist>
@@ -55,6 +55,9 @@ struct NotificationFormatter {
var identifier = ""
switch notify.type {
case .tagged:
title = String(format: NSLocalizedString("Tagged by %@", comment: "Tagged by heading in local notification"), displayName)
identifier = "myMentionNotification"
case .mention:
title = String(format: NSLocalizedString("Mentioned by %@", comment: "Mentioned by heading in local notification"), displayName)
identifier = "myMentionNotification"
@@ -70,6 +73,9 @@ struct NotificationFormatter {
case .zap, .profile_zap:
// not handled here. Try `format_message(displayName: String, notify: LocalNotification, state: HeadlessDamusState) async -> (content: UNMutableNotificationContent, identifier: String)?`
return nil
case .reply:
title = String(format: NSLocalizedString("%@ replied to your note", comment: "Heading for local notification indicating a new reply"), displayName)
identifier = "myReplyNotification"
}
content.title = title
content.body = notify.content
@@ -87,10 +93,11 @@ struct NotificationFormatter {
// 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 {
Log.debug("format_message: async get_zap failed", for: .push_notifications)
return nil
}
content.title = Self.zap_notification_title(zap)
@@ -27,9 +27,9 @@ class NotificationService: UNNotificationServiceExtension {
// Log that we got a push notification
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 {
guard let state = NotificationExtensionState() else {
Log.debug("Failed to open nostrdb", for: .push_notifications)
// 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.
@@ -39,7 +39,11 @@ class NotificationService: UNNotificationServiceExtension {
contentHandler(improved_content)
return
}
let txn = state.ndb.lookup_profile(nostr_event.pubkey)
let profile = txn?.unsafeUnownedValue?.profile
let name = Profile.displayName(profile: profile, pubkey: nostr_event.pubkey).displayName
// 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) {
@@ -54,6 +58,7 @@ class NotificationService: UNNotificationServiceExtension {
}
guard should_display_notification(state: state, event: nostr_event, mode: .push) else {
Log.debug("should_display_notification failed", for: .push_notifications)
// 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
@@ -62,6 +67,7 @@ class NotificationService: UNNotificationServiceExtension {
}
guard let notification_object = generate_local_notification_object(from: nostr_event, state: state) else {
Log.debug("generate_local_notification_object failed", for: .push_notifications)
// 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
@@ -70,9 +76,13 @@ class NotificationService: UNNotificationServiceExtension {
}
Task {
if let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: display_name, notify: notification_object, state: state) {
contentHandler(improvedContent)
guard let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: name, notify: notification_object, state: state) else {
Log.debug("NotificationFormatter.format_message failed", for: .push_notifications)
return
}
contentHandler(improvedContent)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1540"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D703D7162C66E47100A400EA"
BuildableName = "HighlighterActionExtension.appex"
BlueprintName = "HighlighterActionExtension"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
BuildableName = "damus.app"
BlueprintName = "damus"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<RemoteRunnable
runnableDebuggingMode = "1"
BundleIdentifier = "com.apple.mobilesafari"
RemotePath = "/Library/Developer/CoreSimulator/Volumes/iOS_21F79/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 17.5.simruntime/Contents/Resources/RuntimeRoot/Applications/MobileSafari.app">
</RemoteRunnable>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
BuildableName = "damus.app"
BlueprintName = "damus"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</MacroExpansion>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
BuildableName = "damus.app"
BlueprintName = "damus"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+12
View File
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "damoose.jpeg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

+12
View File
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "tor.svg.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+9 -2
View File
@@ -236,6 +236,7 @@ struct ImageCarousel<Content: View>: View {
Placeholder(url: url, geo_size: geo.size, num_urls: urls.count)
}
.aspectRatio(contentMode: filling ? .fill : .fit)
.kfClickable()
.position(x: geo.size.width / 2, y: geo.size.height / 2)
.tabItem {
Text(url.absoluteString)
@@ -274,8 +275,14 @@ struct ImageCarousel<Content: View>: View {
var body: some View {
VStack {
Medias
.onTapGesture { }
if #available(iOS 18.0, *) {
Medias
} else {
// An empty tap gesture recognizer is needed on iOS 17 and below to suppress other overlapping tap recognizers
// Otherwise it will both open the carousel and go to a note at the same time
Medias.onTapGesture { }
}
if urls.count > 1 {
PageControlView(currentPage: $model.selectedIndex, numberOfPages: urls.count)
+4 -9
View File
@@ -94,8 +94,8 @@ enum OpenWalletError: Error {
}
func open_with_wallet(wallet: Wallet.Model, invoice: String) throws {
if let url = URL(string: "\(wallet.link)\(invoice)"), UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url)
if let url = URL(string: "\(wallet.link)\(invoice)"), this_app.canOpenURL(url) {
this_app.open(url)
} else {
guard let store_link = wallet.appStoreLink else {
throw OpenWalletError.no_wallet_to_open
@@ -105,11 +105,11 @@ func open_with_wallet(wallet: Wallet.Model, invoice: String) throws {
throw OpenWalletError.store_link_invalid
}
guard UIApplication.shared.canOpenURL(url) else {
guard this_app.canOpenURL(url) else {
throw OpenWalletError.system_cannot_open_store_link
}
UIApplication.shared.open(url)
this_app.open(url)
}
}
@@ -122,8 +122,3 @@ struct InvoiceView_Previews: PreviewProvider {
.frame(width: 300, height: 200)
}
}
func present_sheet(_ sheet: Sheets) {
notify(.present_sheet(sheet))
}
+168
View File
@@ -0,0 +1,168 @@
//
// OfflineTranslateView.swift
// damus
//
// Created by Terry Yiu on 9/29/24.
//
import SwiftUI
import SwiftUI
import NaturalLanguage
import Translation
fileprivate let MIN_UNIQUE_CHARS = 2
@available(iOS 18.0, macOS 15.0, *)
@available(macCatalyst, unavailable)
struct OfflineTranslateView: View {
let damus_state: DamusState
let event: NostrEvent
let size: EventViewKind
@ObservedObject var translations_model: TranslationModel
@State private var translationConfiguration: TranslationSession.Configuration?
// @State private var languageStatus: LanguageAvailability.Status?
init(damus_state: DamusState, event: NostrEvent, size: EventViewKind) {
self.damus_state = damus_state
self.event = event
self.size = size
self._translations_model = ObservedObject(wrappedValue: damus_state.events.get_cache_data(event.id).translations_model)
}
var TranslateButton: some View {
Button(NSLocalizedString("Translate Note", comment: "Button to translate note from different language.")) {
translate()
}
.translate_button_style()
}
func TranslatedView(lang: String?, artifacts: NoteArtifactsSeparated, font_size: Double) -> some View {
return VStack(alignment: .leading) {
let translatedFromLanguageString = String(format: NSLocalizedString("Translated from %@", comment: "Button to indicate that the note has been translated from a different language."), lang ?? "ja")
Text(translatedFromLanguageString)
.foregroundColor(.gray)
.font(.footnote)
.padding([.top, .bottom], 10)
if self.size == .selected {
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))
}
}
}
func translate() {
guard /*let languageStatus, */translations_model.state == .havent_tried && damus_state.settings.translation_service == .none && damus_state.settings.translate_offline/* && languageStatus != .unsupported*/, let note_language = translations_model.note_language else {
return
}
guard translationConfiguration == nil else {
translationConfiguration?.invalidate()
return
}
translationConfiguration = TranslationSession.Configuration(
source: Locale.Language(identifier: note_language))
}
// func setLanguageStatus() async {
// guard languageStatus == nil else {
// return
// }
//
// guard let note_language = translations_model.note_language else {
// languageStatus = .unsupported
// return
// }
//
// let languageAvailability = LanguageAvailability()
// let language = Locale.Language(identifier: note_language)
// languageStatus = await languageAvailability.status(from: language, to: nil)
// }
var body: some View {
if let note_lang = translations_model.note_language, damus_state.settings.translation_service == .none && damus_state.settings.translate_offline && should_translate(event: event, our_keypair: damus_state.keypair, note_lang: note_lang) {
Group {
switch self.translations_model.state {
case .havent_tried:
if damus_state.settings.auto_translate/* && languageStatus == .installed*/ {
Text("")
} else {
TranslateButton
}
case .translating:
Text("")
case .translated(let translated):
let languageName = Locale.current.localizedString(forLanguageCode: translated.language)
TranslatedView(lang: languageName, artifacts: translated.artifacts, font_size: damus_state.settings.font_size)
case .not_needed:
Text("")
}
}
.onAppear {
// Task { @MainActor in
// await setLanguageStatus()
// }
translate()
}
.translationTask(translationConfiguration) { translationSession in
Task { @MainActor in
do {
guard let note_language = translations_model.note_language, translations_model.state == .havent_tried/*, languageStatus != .unsupported*/ else {
return
}
translations_model.state = .translating
let originalContent = event.get_content(damus_state.keypair)
let response = try await translationSession.translate(originalContent)
let translated_note = response.targetText
guard originalContent != translated_note else {
// if its the same, give up and don't retry
translations_model.state = .not_needed
return
}
guard translationMeetsStringDistanceRequirements(original: originalContent, translated: translated_note) else {
translations_model.state = .not_needed
return
}
// Render translated note
let translated_blocks = parse_note_content(content: .content(translated_note, event.tags))
let artifacts = render_blocks(blocks: translated_blocks, profiles: damus_state.profiles)
// and cache it
translations_model.state = .translated(Translated(artifacts: artifacts, language: note_language))
} catch {
// code to handle error
print("Error translating note: \(error.localizedDescription)")
translations_model.state = .not_needed
}
}
}
} else {
Text("")
}
}
func translationMeetsStringDistanceRequirements(original: String, translated: String) -> Bool {
return levenshteinDistanceIsGreaterThanOrEqualTo(from: original, to: translated, threshold: MIN_UNIQUE_CHARS)
}
}
@available(iOS 18.0, macOS 15.0, *)
@available(macCatalyst, unavailable)
struct OfflineTranslateView_Previews: PreviewProvider {
static var previews: some View {
let ds = test_damus_state
OfflineTranslateView(damus_state: ds, event: test_note, size: .normal)
}
}
+84 -21
View File
@@ -13,8 +13,7 @@ struct SelectableText: View {
let event: NostrEvent?
let attributedString: AttributedString
let textAlignment: NSTextAlignment
@State private var showHighlightPost = false
@State private var selectedText = ""
@State private var selectedTextActionState: SelectedTextActionState = .hide
@State private var selectedTextHeight: CGFloat = .zero
@State private var selectedTextWidth: CGFloat = .zero
@@ -37,8 +36,12 @@ struct SelectableText: View {
fixedWidth: selectedTextWidth,
textAlignment: self.textAlignment,
enableHighlighting: self.enableHighlighting(),
showHighlightPost: $showHighlightPost,
selectedText: $selectedText,
postHighlight: { selectedText in
self.selectedTextActionState = .show_highlight_post_view(highlighted_text: selectedText)
},
muteWord: { selectedText in
self.selectedTextActionState = .show_mute_word_view(highlighted_text: selectedText)
},
height: $selectedTextHeight
)
.padding([.leading, .trailing], -1.0)
@@ -53,11 +56,29 @@ struct SelectableText: View {
self.selectedTextWidth = newSize.width
}
}
.sheet(isPresented: $showHighlightPost) {
if let event {
HighlightPostView(damus_state: damus_state, event: event, selectedText: $selectedText)
.sheet(isPresented: Binding(get: {
return self.selectedTextActionState.should_show_highlight_post_view()
}, set: { newValue in
self.selectedTextActionState = newValue ? .show_highlight_post_view(highlighted_text: self.selectedTextActionState.highlighted_text() ?? "") : .hide
})) {
if let event, case .show_highlight_post_view(let highlighted_text) = self.selectedTextActionState {
PostView(
action: .highlighting(.init(selected_text: highlighted_text, source: .event(event))),
damus_state: damus_state
)
.presentationDragIndicator(.visible)
.presentationDetents([.height(selectedTextHeight + 450), .medium, .large])
}
}
.sheet(isPresented: Binding(get: {
return self.selectedTextActionState.should_show_mute_word_view()
}, set: { newValue in
self.selectedTextActionState = newValue ? .show_mute_word_view(highlighted_text: self.selectedTextActionState.highlighted_text() ?? "") : .hide
})) {
if case .show_mute_word_view(let highlighted_text) = selectedTextActionState {
AddMuteItemView(state: damus_state, new_text: .constant(highlighted_text))
.presentationDragIndicator(.visible)
.presentationDetents([.height(selectedTextHeight + 150), .medium, .large])
.presentationDetents([.height(300), .medium, .large])
}
}
.frame(height: selectedTextHeight)
@@ -66,15 +87,42 @@ struct SelectableText: View {
func enableHighlighting() -> Bool {
self.event != nil
}
enum SelectedTextActionState {
case hide
case show_highlight_post_view(highlighted_text: String)
case show_mute_word_view(highlighted_text: String)
func should_show_highlight_post_view() -> Bool {
guard case .show_highlight_post_view(let highlighted_text) = self else { return false }
return true
}
func should_show_mute_word_view() -> Bool {
guard case .show_mute_word_view(let highlighted_text) = self else { return false }
return true
}
func highlighted_text() -> String? {
switch self {
case .hide:
return nil
case .show_mute_word_view(highlighted_text: let highlighted_text):
return highlighted_text
case .show_highlight_post_view(highlighted_text: let highlighted_text):
return highlighted_text
}
}
}
}
fileprivate class TextView: UITextView {
@Binding var showHighlightPost: Bool
@Binding var selectedText: String
var postHighlight: (String) -> Void
var muteWord: (String) -> Void
init(frame: CGRect, textContainer: NSTextContainer?, showHighlightPost: Binding<Bool>, selectedText: Binding<String>) {
self._showHighlightPost = showHighlightPost
self._selectedText = selectedText
init(frame: CGRect, textContainer: NSTextContainer?, postHighlight: @escaping (String) -> Void, muteWord: @escaping (String) -> Void) {
self.postHighlight = postHighlight
self.muteWord = muteWord
super.init(frame: frame, textContainer: textContainer)
}
@@ -86,18 +134,32 @@ fileprivate class TextView: UITextView {
if action == #selector(highlightText(_:)) {
return true
}
if action == #selector(muteText(_:)) {
return true
}
return super.canPerformAction(action, withSender: sender)
}
func getSelectedText() -> String? {
guard let selectedRange = self.selectedTextRange else { return nil }
return self.text(in: selectedRange)
}
@objc public func highlightText(_ sender: Any?) {
guard let selectedRange = self.selectedTextRange else { return }
selectedText = self.text(in: selectedRange) ?? ""
showHighlightPost.toggle()
guard let selectedText = self.getSelectedText() else { return }
self.postHighlight(selectedText)
}
@objc public func muteText(_ sender: Any?) {
guard let selectedText = self.getSelectedText() else { return }
self.muteWord(selectedText)
}
}
fileprivate struct TextViewRepresentable: UIViewRepresentable {
fileprivate struct TextViewRepresentable: UIViewRepresentable {
let attributedString: AttributedString
let textColor: UIColor
@@ -105,12 +167,12 @@ fileprivate class TextView: UITextView {
let fixedWidth: CGFloat
let textAlignment: NSTextAlignment
let enableHighlighting: Bool
@Binding var showHighlightPost: Bool
@Binding var selectedText: String
let postHighlight: (String) -> Void
let muteWord: (String) -> Void
@Binding var height: CGFloat
func makeUIView(context: UIViewRepresentableContext<Self>) -> TextView {
let view = TextView(frame: .zero, textContainer: nil, showHighlightPost: $showHighlightPost, selectedText: $selectedText)
let view = TextView(frame: .zero, textContainer: nil, postHighlight: postHighlight, muteWord: muteWord)
view.isEditable = false
view.dataDetectorTypes = .all
view.isSelectable = true
@@ -123,7 +185,8 @@ fileprivate class TextView: UITextView {
let menuController = UIMenuController.shared
let highlightItem = UIMenuItem(title: "Highlight", action: #selector(view.highlightText(_:)))
menuController.menuItems = self.enableHighlighting ? [highlightItem] : []
let muteItem = UIMenuItem(title: "Mute", action: #selector(view.muteText(_:)))
menuController.menuItems = self.enableHighlighting ? [highlightItem, muteItem] : []
return view
}
+4 -2
View File
@@ -11,11 +11,13 @@ struct SupporterBadge: View {
let percent: Int?
let purple_account: DamusPurple.Account?
let style: Style
let text_color: Color
init(percent: Int?, purple_account: DamusPurple.Account? = nil, style: Style) {
init(percent: Int?, purple_account: DamusPurple.Account? = nil, style: Style, text_color: Color = .secondary) {
self.percent = percent
self.purple_account = purple_account
self.style = style
self.text_color = text_color
}
let size: CGFloat = 17
@@ -31,7 +33,7 @@ struct SupporterBadge: View {
if self.style == .full {
let date = format_date(date: purple_account.created_at, time_style: .none)
Text(date)
.foregroundStyle(.secondary)
.foregroundStyle(text_color)
.font(.caption)
}
}
+24 -7
View File
@@ -27,19 +27,26 @@ struct TranslateView: View {
let damus_state: DamusState
let event: NostrEvent
let size: EventViewKind
@Binding var isAppleTranslationPopoverPresented: Bool
@ObservedObject var translations_model: TranslationModel
init(damus_state: DamusState, event: NostrEvent, size: EventViewKind) {
init(damus_state: DamusState, event: NostrEvent, size: EventViewKind, isAppleTranslationPopoverPresented: Binding<Bool>) {
self.damus_state = damus_state
self.event = event
self.size = size
self._isAppleTranslationPopoverPresented = isAppleTranslationPopoverPresented
self._translations_model = ObservedObject(wrappedValue: damus_state.events.get_cache_data(event.id).translations_model)
}
var TranslateButton: some View {
Button(NSLocalizedString("Translate Note", comment: "Button to translate note from different language.")) {
translate()
if damus_state.settings.translation_service == .none {
isAppleTranslationPopoverPresented = true
} else {
translate()
}
}
.translate_button_style()
}
@@ -74,17 +81,25 @@ struct TranslateView: View {
}
func should_transl(_ note_lang: String) -> Bool {
should_translate(event: event, our_keypair: damus_state.keypair, settings: damus_state.settings, note_lang: note_lang)
guard should_translate(event: event, our_keypair: damus_state.keypair, note_lang: note_lang) else {
return false
}
if TranslationService.isAppleTranslationSupported {
return damus_state.settings.translation_service == .none || damus_state.settings.can_translate
} else {
return damus_state.settings.can_translate
}
}
var body: some View {
Group {
switch self.translations_model.state {
case .havent_tried:
if damus_state.settings.auto_translate {
if damus_state.settings.auto_translate && damus_state.settings.translation_service != .none {
Text("")
} else if let note_lang = translations_model.note_language, should_transl(note_lang) {
TranslateButton
TranslateButton
} else {
Text("")
}
@@ -114,9 +129,11 @@ extension View {
}
struct TranslateView_Previews: PreviewProvider {
@State static var isAppleTranslationPopoverPresented: Bool = false
static var previews: some View {
let ds = test_damus_state
TranslateView(damus_state: ds, event: test_note, size: .normal)
TranslateView(damus_state: ds, event: test_note, size: .normal, isAppleTranslationPopoverPresented: $isAppleTranslationPopoverPresented)
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ enum NoteContent {
case content(String, TagsSequence?)
init(note: NostrEvent, keypair: Keypair) {
if note.known_kind == .dm {
if note.known_kind == .dm || note.known_kind == .highlight {
self = .content(note.get_content(keypair), note.tags)
} else {
self = .note(note)
+25 -9
View File
@@ -57,6 +57,10 @@ enum Sheets: Identifiable {
}
}
func present_sheet(_ sheet: Sheets) {
notify(.present_sheet(sheet))
}
struct ContentView: View {
let keypair: Keypair
let appDelegate: AppDelegate?
@@ -73,7 +77,12 @@ 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 menu_subtitle: String? = nil
@SceneStorage("ContentView.selected_timeline") var selected_timeline: Timeline = .home {
willSet {
self.menu_subtitle = nil
}
}
@State var muting: MuteItem? = nil
@State var confirm_mute: Bool = false
@State var hide_bar: Bool = false
@@ -97,9 +106,16 @@ struct ContentView: View {
isSideBarOpened = false
}
var timelineNavItem: Text {
return Text(timeline_name(selected_timeline))
.bold()
var timelineNavItem: some View {
VStack {
Text(timeline_name(selected_timeline))
.bold()
if let menu_subtitle {
Text(menu_subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
func MainContent(damus: DamusState) -> some View {
@@ -118,7 +134,7 @@ struct ContentView: View {
PostingTimelineView(damus_state: damus_state!, home: home, active_sheet: $active_sheet)
case .notifications:
NotificationsView(state: damus, notifications: home.notifications)
NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle)
case .dms:
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
@@ -718,7 +734,7 @@ struct ContentView: View {
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:
case .like, .zap, .mention, .repost, .reply, .tagged:
open_event(ev: target)
case .profile_zap:
break
@@ -819,7 +835,7 @@ func update_filters_with_since(last_of_kind: [UInt32: NostrEvent], filters: [Nos
func setup_notifications() {
UIApplication.shared.registerForRemoteNotifications()
this_app.registerForRemoteNotifications()
let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in
@@ -1051,7 +1067,7 @@ func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: Ev
//let post = tup.0
//let to_relays = tup.1
print("post \(post.content)")
guard let new_ev = post_to_event(post: post, keypair: keypair) else {
guard let new_ev = post.to_event(keypair: keypair) else {
return false
}
postbox.send(new_ev)
@@ -1112,7 +1128,7 @@ func on_open_url(state: DamusState, url: URL, result: @escaping (OpenResult?) ->
}
case .hashtag(let ht):
result(.filter(.filter_hashtag([ht.hashtag])))
case .param, .quote:
case .param, .quote, .reference:
// doesn't really make sense here
break
case .naddr(let naddr):
+1 -1
View File
@@ -151,7 +151,7 @@ public class CameraService: NSObject, Identifiable {
DispatchQueue.main.async {
self.alertError = AlertError(title: "Camera Access", message: "Damus needs camera and microphone access. Enable in settings.", primaryButtonTitle: "Go to settings", secondaryButtonTitle: nil, primaryAction: {
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!,
this_app.open(URL(string: UIApplication.openSettingsURLString)!,
options: [:], completionHandler: nil)
}, secondaryAction: nil)
+23
View File
@@ -0,0 +1,23 @@
//
// CommentItem.swift
// damus
//
// Created by Daniel DAquino on 2024-08-14.
//
import Foundation
struct CommentItem: TagConvertible {
static let TAG_KEY: String = "comment"
let content: String
var tag: [String] {
return [Self.TAG_KEY, content]
}
static func from_tag(tag: TagSequence) -> CommentItem? {
guard tag.count == 2 else { return nil }
guard tag[0].string() == Self.TAG_KEY else { return nil }
return CommentItem(content: tag[1].string())
}
}
+1 -1
View File
@@ -109,7 +109,7 @@ func is_already_following(contacts: NostrEvent, follow: FollowRef) -> Bool {
case let (.pubkey(pk), .pubkey(follow_pk)):
return pk == follow_pk
case (.hashtag, .pubkey), (.pubkey, .hashtag),
(.event, _), (.quote, _), (.param, _), (.naddr, _):
(.event, _), (.quote, _), (.param, _), (.naddr, _), (.reference(_), _):
return false
}
}
+73
View File
@@ -74,6 +74,79 @@ class DamusState: HeadlessDamusState {
self.push_notification_client = PushNotificationClient(keypair: keypair, settings: settings)
self.emoji_provider = emoji_provider
}
@MainActor
convenience init?(keypair: Keypair) {
// nostrdb
var mndb = Ndb()
if mndb == nil {
// try recovery
print("DB ISSUE! RECOVERING")
mndb = Ndb.safemode()
// out of space or something?? maybe we need a in-memory fallback
if mndb == nil {
logout(nil)
return nil
}
}
let navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
let home: HomeModel = HomeModel()
let sub_id = UUID().uuidString
guard let ndb = mndb else { return nil }
let pubkey = keypair.pubkey
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)
let settings = UserSettingsStore.globally_load_for(pubkey: pubkey)
let new_relay_filters = load_relay_filters(pubkey) == nil
for relay in bootstrap_relays {
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,
let nwc = WalletConnectURL(str: nwc_str) {
try? pool.add_relay(.nwc(url: nwc.relay))
}
self.init(
pool: pool,
keypair: keypair,
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(),
zaps: Zaps(our_pubkey: pubkey),
lnurls: LNUrls(),
settings: settings,
relay_filters: relay_filters,
relay_model_cache: model_cache,
drafts: Drafts(),
events: EventCache(ndb: ndb),
bookmarks: BookmarksManager(pubkey: pubkey),
postbox: PostBox(pool: pool),
bootstrap_relays: bootstrap_relays,
replies: ReplyCounter(our_pubkey: pubkey),
wallet: WalletModel(settings: settings),
nav: navigationCoordinator,
music: MusicController(onChange: { _ in }),
video: VideoController(),
ndb: ndb,
quote_reposts: .init(our_pubkey: pubkey),
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
)
}
@discardableResult
func add_zap(zap: Zapping) -> Bool {
+1
View File
@@ -28,4 +28,5 @@ class Drafts: ObservableObject {
@Published var post: DraftArtifacts? = nil
@Published var replies: [NostrEvent: DraftArtifacts] = [:]
@Published var quotes: [NostrEvent: DraftArtifacts] = [:]
@Published var highlights: [HighlightSource: DraftArtifacts] = [:]
}
+11 -2
View File
@@ -9,7 +9,7 @@ import Foundation
enum FriendFilter: String, StringCodable {
case all
case friends
case friends_of_friends
init?(from string: String) {
guard let ff = FriendFilter(rawValue: string) else {
@@ -27,8 +27,17 @@ enum FriendFilter: String, StringCodable {
switch self {
case .all:
return true
case .friends:
case .friends_of_friends:
return contacts.is_in_friendosphere(pubkey)
}
}
func description() -> String {
switch self {
case .all:
return NSLocalizedString("All", comment: "Human-readable short description of the 'friends filter' when it is set to 'all'")
case .friends_of_friends:
return NSLocalizedString("Friends of friends", comment: "Human-readable short description of the 'friends filter' when it is set to 'friends-of-friends'")
}
}
}
+182 -1
View File
@@ -13,22 +13,203 @@ struct HighlightEvent {
var event_ref: String? = nil
var url_ref: URL? = nil
var context: String? = nil
// MARK: - Initializers and parsers
static func parse(from ev: NostrEvent) -> HighlightEvent {
var highlight = HighlightEvent(event: ev)
var best_url_source: (url: URL, tagged_as_source: Bool)? = nil
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 "r":
if tag.count >= 3,
tag[2].string() == HighlightSource.TAG_SOURCE_ELEMENT,
let url = URL(string: tag[1].string()) {
// URL marked as source. Very good candidate
best_url_source = (url: url, tagged_as_source: true)
}
else if tag.count >= 3 && tag[2].string() != HighlightSource.TAG_SOURCE_ELEMENT {
// URL marked as something else (not source). Not the source we are after
}
else if let url = URL(string: tag[1].string()), tag.count == 2 {
// Unmarked URL. This might be what we are after (For NIP-84 backwards compatibility)
if (best_url_source?.tagged_as_source ?? false) == false {
// No URL candidates marked as the source. Mark this as the best option we have
best_url_source = (url: url, tagged_as_source: false)
}
}
case "context": highlight.context = tag[1].string()
default:
break
}
}
if let best_url_source {
highlight.url_ref = best_url_source.url
}
return highlight
}
// MARK: - Getting information about source
func source_description_info(highlighted_event: NostrEvent?) -> ReplyDesc {
var others_count = 0
var highlighted_authors: [Pubkey] = []
var i = event.tags.count
if let highlighted_event {
highlighted_authors.append(highlighted_event.pubkey)
}
for tag in event.tags {
if let pubkey_with_role = PubkeyWithRole.from_tag(tag: tag) {
others_count += 1
if highlighted_authors.count < 2 {
if let highlighted_event, pubkey_with_role.pubkey == highlighted_event.pubkey {
continue
} else {
switch pubkey_with_role.role {
case .author:
highlighted_authors.append(pubkey_with_role.pubkey)
default:
break
}
}
}
}
i -= 1
}
return ReplyDesc(pubkeys: highlighted_authors, others: others_count)
}
func source_description_text(ndb: Ndb, highlighted_event: NostrEvent?, locale: Locale = Locale.current) -> String {
let description_info = self.source_description_info(highlighted_event: highlighted_event)
let pubkeys = description_info.pubkeys
let bundle = bundleForLocale(locale: locale)
if pubkeys.count == 0 {
return NSLocalizedString("Highlighted", bundle: bundle, comment: "Label to indicate that the user is highlighting their own post.")
}
guard let profile_txn = NdbTxn(ndb: ndb) else {
return ""
}
let names: [String] = pubkeys.map { pk in
let prof = ndb.lookup_profile_with_txn(pk, txn: profile_txn)
return Profile.displayName(profile: prof?.profile, pubkey: pk).username.truncate(maxLength: 50)
}
let uniqueNames: [String] = Array(Set(names))
return String(format: NSLocalizedString("Highlighted %@", bundle: bundle, comment: "Label to indicate that the user is highlighting 1 user."), locale: locale, uniqueNames.first ?? "")
}
}
// MARK: - Helper structures
extension HighlightEvent {
struct PubkeyWithRole: TagKey, TagConvertible {
let pubkey: Pubkey
let role: Role
var tag: [String] {
if let role_text = self.role.rawValue {
return [keychar.description, self.pubkey.hex(), role_text]
}
else {
return [keychar.description, self.pubkey.hex()]
}
}
var keychar: AsciiCharacter { "p" }
static func from_tag(tag: TagSequence) -> HighlightEvent.PubkeyWithRole? {
var i = tag.makeIterator()
guard tag.count >= 2,
let t0 = i.next(),
let key = t0.single_char,
key == "p",
let t1 = i.next(),
let pubkey = t1.id().map(Pubkey.init)
else { return nil }
let t3: String? = i.next()?.string()
let role = Role(rawValue: t3)
return PubkeyWithRole(pubkey: pubkey, role: role)
}
enum Role: RawRepresentable {
case author
case editor
case mention
case other(String)
case no_role
typealias RawValue = String?
var rawValue: String? {
switch self {
case .author: "author"
case .editor: "editor"
case .mention: "mention"
case .other(let role): role
case .no_role: nil
}
}
init(rawValue: String?) {
switch rawValue {
case "author": self = .author
case "editor": self = .editor
case "mention": self = .mention
default:
if let rawValue {
self = .other(rawValue)
}
else {
self = .no_role
}
}
}
}
}
}
struct HighlightContentDraft: Hashable {
let selected_text: String
let source: HighlightSource
}
enum HighlightSource: Hashable {
static let TAG_SOURCE_ELEMENT = "source"
case event(NostrEvent)
case external_url(URL)
func tags() -> [[String]] {
switch self {
case .event(let event):
return [ ["e", "\(event.id)", HighlightSource.TAG_SOURCE_ELEMENT] ]
case .external_url(let url):
return [ ["r", "\(url)", HighlightSource.TAG_SOURCE_ELEMENT] ]
}
}
func ref() -> RefId {
switch self {
case .event(let event):
return .event(event.id)
case .external_url(let url):
return .reference(url.absoluteString)
}
}
}
+1 -1
View File
@@ -127,11 +127,11 @@ class HomeModel: ContactsDelegate {
/// 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() {
damus_state.contacts.delegate = self
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
+32 -54
View File
@@ -10,7 +10,7 @@ import Foundation
enum MediaUploader: String, CaseIterable, Identifiable, StringCodable {
var id: String { self.rawValue }
case nostrBuild
case nostrImg
case nostrcheck
init?(from string: String) {
guard let mu = MediaUploader(rawValue: string) else {
@@ -23,95 +23,73 @@ enum MediaUploader: String, CaseIterable, Identifiable, StringCodable {
func to_string() -> String {
return rawValue
}
var nameParam: String {
switch self {
case .nostrBuild:
return "\"fileToUpload\""
case .nostrImg:
return "\"image\""
default:
return "\"file\""
}
}
var supportsVideo: Bool {
switch self {
case .nostrBuild:
return true
case .nostrImg:
return false
case .nostrcheck:
return true
}
}
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")
case .nostrcheck:
return .init(index: 0, tag: "nostrcheck", displayName: "nostrcheck.me")
}
}
var postAPI: String {
switch self {
case .nostrBuild:
return "https://nostr.build/api/v2/upload/files"
case .nostrImg:
return "https://nostrimg.com/api/upload"
return "https://nostr.build/api/v2/nip96/upload"
case .nostrcheck:
return "https://nostrcheck.me/api/v2/media"
}
}
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)
do {
if let jsonObject = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any],
let status = jsonObject["status"] as? String {
if status == "success", let nip94Event = jsonObject["nip94_event"] as? [String: Any] {
if let tags = nip94Event["tags"] as? [[String]] {
for tagArray in tags {
if tagArray.count > 1, tagArray[0] == "url" {
return tagArray[1]
}
}
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 {
} else if status == "error", let message = jsonObject["message"] as? String {
print("Upload Error: \(message)")
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
} catch {
print("Failed JSONSerialization")
return nil
}
return nil
}
}
-43
View File
@@ -256,46 +256,3 @@ func find_tag_ref(type: String, id: String, tags: [[String]]) -> Int? {
return nil
}
struct PostTags {
let blocks: [Block]
let tags: [[String]]
}
/// Convert
func make_post_tags(post_blocks: [Block], tags: [[String]]) -> PostTags {
var new_tags = tags
for post_block in post_blocks {
switch post_block {
case .mention(let mention):
switch(mention.ref) {
case .note, .nevent:
continue
default:
break
}
new_tags.append(mention.ref.tag)
case .hashtag(let hashtag):
new_tags.append(["t", hashtag.lowercased()])
case .text: break
case .invoice: break
case .relay: break
case .url(let url):
new_tags.append(["r", url.absoluteString])
break
}
}
return PostTags(blocks: post_blocks, tags: new_tags)
}
func post_to_event(post: NostrPost, keypair: FullKeypair) -> NostrEvent? {
let post_blocks = parse_post_blocks(content: post.content)
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)
}
+32 -13
View File
@@ -27,7 +27,7 @@ func process_local_notification(state: HeadlessDamusState, event ev: NostrEvent)
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 {
guard state.settings.notification_mode == mode else {
return false
}
@@ -61,36 +61,55 @@ func generate_local_notification_object(from ev: NostrEvent, state: HeadlessDamu
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)
return LocalNotification(type: .mention, event: ev, target: .note(ev), content: content_preview)
}
if ev.referenced_ids.contains(where: { note_id in
guard let note_author: Pubkey = state.ndb.lookup_note(note_id)?.unsafeUnownedValue?.pubkey else { return false }
guard note_author == state.keypair.pubkey else { return false }
return true
}) {
// This is a reply to one of our posts
let content_preview = render_notification_content_preview(ev: ev, profiles: state.profiles, keypair: state.keypair)
return LocalNotification(type: .reply, event: ev, target: .note(ev), content: content_preview)
}
if ev.referenced_pubkeys.contains(state.keypair.pubkey) {
// not mentioned or replied to, just tagged
let content_preview = render_notification_content_preview(ev: ev, profiles: state.profiles, keypair: state.keypair)
return LocalNotification(type: .tagged, event: ev, target: .note(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)
return LocalNotification(type: .repost, event: ev, target: .note(inner_ev), content: content_preview)
} else if type == .like, state.settings.like_notification, let evid = ev.referenced_ids.last {
if let txn = state.ndb.lookup_note(evid, txn_name: "local_notification_like"),
let liked_event = txn.unsafeUnownedValue
{
let content_preview = render_notification_content_preview(ev: liked_event, profiles: state.profiles, keypair: state.keypair)
return LocalNotification(type: .like, event: ev, target: .note(liked_event), content: content_preview)
} else {
return LocalNotification(type: .like, event: ev, target: .note_id(evid), content: "")
}
}
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)
return LocalNotification(type: .dm, event: ev, target: .note(ev), content: convo)
}
else if type == .zap,
state.settings.zap_notification {
return LocalNotification(type: .zap, event: ev, target: ev, content: ev.content)
return LocalNotification(type: .zap, event: ev, target: .note(ev), content: ev.content)
}
return nil
+77 -1
View File
@@ -17,10 +17,86 @@ struct NostrPost {
self.kind = kind
self.tags = tags
}
func to_event(keypair: FullKeypair) -> NostrEvent? {
let post_blocks = self.parse_blocks()
let post_tags = self.make_post_tags(post_blocks: post_blocks, tags: self.tags)
let content = post_tags.blocks
.map(\.asString)
.joined(separator: "")
if self.kind == .highlight {
var new_tags = post_tags.tags.filter({ $0[safe: 0] != "comment" })
if content.count > 0 {
new_tags.append(["comment", content])
}
return NostrEvent(content: self.content, keypair: keypair.to_keypair(), kind: self.kind.rawValue, tags: new_tags)
}
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: self.kind.rawValue, tags: post_tags.tags)
}
func parse_blocks() -> [Block] {
guard let content_for_parsing = self.default_content_for_block_parsing() else { return [] }
return parse_post_blocks(content: content_for_parsing)
}
private func default_content_for_block_parsing() -> String? {
switch kind {
case .highlight:
return tags.filter({ $0[safe: 0] == "comment" }).first?[safe: 1]
default:
return self.content
}
}
/// Parse the post's contents to find more tags to apply to the final nostr event
func make_post_tags(post_blocks: [Block], tags: [[String]]) -> PostTags {
var new_tags = tags
for post_block in post_blocks {
switch post_block {
case .mention(let mention):
switch(mention.ref) {
case .note, .nevent:
continue
default:
break
}
if self.kind == .highlight, case .pubkey(_) = mention.ref {
var new_tag = mention.ref.tag
new_tag.append("mention")
new_tags.append(new_tag)
}
else {
new_tags.append(mention.ref.tag)
}
case .hashtag(let hashtag):
new_tags.append(["t", hashtag.lowercased()])
case .text: break
case .invoice: break
case .relay: break
case .url(let url):
new_tags.append(self.kind == .highlight ? ["r", url.absoluteString, "mention"] : ["r", url.absoluteString])
break
}
}
return PostTags(blocks: post_blocks, tags: new_tags)
}
}
// MARK: - Helper structures and functions
extension NostrPost {
/// A struct used for temporarily holding tag information that was parsed from a post contents to aid in building a nostr event
struct PostTags {
let blocks: [Block]
let tags: [[String]]
}
}
/// Return a list of tags
func parse_post_blocks(content: String) -> [Block] {
return parse_note_content(content: .content(content, nil)).blocks
}
+207 -23
View File
@@ -11,35 +11,34 @@ struct PushNotificationClient {
let keypair: Keypair
let settings: UserSettingsStore
private(set) var device_token: Data? = nil
var device_token_hex: String? {
guard let device_token else { return nil }
return device_token.map { String(format: "%02.2hhx", $0) }.joined()
}
mutating func set_device_token(new_device_token: Data) async throws {
self.device_token = new_device_token
if settings.enable_experimental_push_notifications && settings.notifications_mode == .push {
if settings.enable_push_notifications && settings.notification_mode == .push {
try await self.send_token()
}
}
func send_token() async throws {
guard let device_token else { return }
// Send the device token and pubkey to the server
let token = device_token.map { String(format: "%02.2hhx", $0) }.joined()
guard let token = device_token_hex else { return }
Log.info("Sending device token to server: %s", for: .push_notifications, token)
let pubkey = self.keypair.pubkey
// Send those as JSON to the server
let json: [String: Any] = ["deviceToken": token, "pubkey": pubkey.hex()]
// create post request
let url = self.settings.send_device_token_to_localhost ? Constants.DEVICE_TOKEN_RECEIVER_TEST_URL : Constants.DEVICE_TOKEN_RECEIVER_PRODUCTION_URL
let json_data = try JSONSerialization.data(withJSONObject: json)
let url = self.current_push_notification_environment().api_base_url()
.appendingPathComponent("user-info")
.appendingPathComponent(self.keypair.pubkey.hex())
.appendingPathComponent(token)
let (data, response) = try await make_nip98_authenticated_request(
method: .post,
method: .put,
url: url,
payload: json_data,
payload: nil,
payload_type: .json,
auth_keypair: self.keypair
)
@@ -58,26 +57,23 @@ struct PushNotificationClient {
}
func revoke_token() async throws {
guard let device_token else { return }
// Send the device token and pubkey to the server
let token = device_token.map { String(format: "%02.2hhx", $0) }.joined()
guard let token = device_token_hex else { return }
Log.info("Revoking device token from server: %s", for: .push_notifications, token)
let pubkey = self.keypair.pubkey
// Send those as JSON to the server
let json: [String: Any] = ["deviceToken": token, "pubkey": pubkey.hex()]
// create post request
let url = self.settings.send_device_token_to_localhost ? Constants.DEVICE_TOKEN_REVOKER_TEST_URL : Constants.DEVICE_TOKEN_REVOKER_PRODUCTION_URL
let json_data = try JSONSerialization.data(withJSONObject: json)
let url = self.current_push_notification_environment().api_base_url()
.appendingPathComponent("user-info")
.appendingPathComponent(pubkey.hex())
.appendingPathComponent(token)
let (data, response) = try await make_nip98_authenticated_request(
method: .post,
method: .delete,
url: url,
payload: json_data,
payload: nil,
payload_type: .json,
auth_keypair: self.keypair
)
@@ -94,6 +90,78 @@ struct PushNotificationClient {
return
}
func set_settings(_ new_settings: NotificationSettings? = nil) async throws {
// Send the device token and pubkey to the server
guard let token = device_token_hex else { return }
Log.info("Sending notification preferences to the server", for: .push_notifications)
let url = self.current_push_notification_environment().api_base_url()
.appendingPathComponent("user-info")
.appendingPathComponent(self.keypair.pubkey.hex())
.appendingPathComponent(token)
.appendingPathComponent("preferences")
let json_payload = try JSONEncoder().encode(new_settings ?? NotificationSettings.from(settings: settings))
let (data, response) = try await make_nip98_authenticated_request(
method: .put,
url: url,
payload: json_payload,
payload_type: .json,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
Log.info("Sent notification settings to Damus push notification server successfully", for: .push_notifications)
default:
Log.error("Error in sending notification settings to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data)
}
}
return
}
func get_settings() async throws -> NotificationSettings {
// Send the device token and pubkey to the server
guard let token = device_token_hex else {
throw ClientError.no_device_token
}
let url = self.current_push_notification_environment().api_base_url()
.appendingPathComponent("user-info")
.appendingPathComponent(self.keypair.pubkey.hex())
.appendingPathComponent(token)
.appendingPathComponent("preferences")
let (data, response) = try await make_nip98_authenticated_request(
method: .get,
url: url,
payload: nil,
payload_type: .json,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
guard let notification_settings = NotificationSettings.from(json_data: data) else { throw ClientError.json_decoding_error }
return notification_settings
default:
Log.error("Error in getting notification settings to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data)
}
}
throw ClientError.could_not_process_response
}
func current_push_notification_environment() -> Environment {
return self.settings.push_notification_environment
}
}
// MARK: Helper structures
@@ -101,5 +169,121 @@ struct PushNotificationClient {
extension PushNotificationClient {
enum ClientError: Error {
case http_response_error(status_code: Int, response: Data)
case could_not_process_response
case no_device_token
case json_decoding_error
}
struct NotificationSettings: Codable, Equatable {
let zap_notifications_enabled: Bool
let mention_notifications_enabled: Bool
let repost_notifications_enabled: Bool
let reaction_notifications_enabled: Bool
let dm_notifications_enabled: Bool
let only_notifications_from_following_enabled: Bool
static func from(json_data: Data) -> Self? {
guard let decoded = try? JSONDecoder().decode(Self.self, from: json_data) else { return nil }
return decoded
}
static func from(settings: UserSettingsStore) -> Self {
return NotificationSettings(
zap_notifications_enabled: settings.zap_notification,
mention_notifications_enabled: settings.mention_notification,
repost_notifications_enabled: settings.repost_notification,
reaction_notifications_enabled: settings.like_notification,
dm_notifications_enabled: settings.dm_notification,
only_notifications_from_following_enabled: settings.notification_only_from_following
)
}
}
enum Environment: CaseIterable, Codable, Identifiable, StringCodable, Equatable, Hashable {
static var allCases: [Environment] = [.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 Push notification functionality (Developer feature)")
case .production:
return NSLocalizedString("Production", comment: "Label indicating the production environment for Push notification functionality")
case .staging:
return NSLocalizedString("Staging (for dev builds)", comment: "Label indicating the staging environment for Push notification functionality")
}
}
func api_base_url() -> URL {
switch self {
case .local_test(let host):
URL(string: "http://\(host ?? "localhost:8000")") ?? Constants.PUSH_NOTIFICATION_SERVER_TEST_BASE_URL
case .production:
Constants.PUSH_NOTIFICATION_SERVER_PRODUCTION_BASE_URL
case .staging:
Constants.PUSH_NOTIFICATION_SERVER_STAGING_BASE_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 "production":
self = .production
case "staging":
self = .staging
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 .production:
return "production"
case .staging:
return "staging"
}
}
}
}
+19 -1
View File
@@ -38,7 +38,13 @@ enum TranslationService: String, CaseIterable, Identifiable, StringCodable {
var model: Model {
switch self {
case .none:
return .init(tag: self.rawValue, displayName: NSLocalizedString("none_translation_service", value: "None", comment: "Dropdown option for selecting no translation service."))
let displayName: String
if TranslationService.isAppleTranslationSupported {
displayName = NSLocalizedString("apple_translation_service", value: "Apple", comment: "Dropdown option for selecting Apple as a translation service.")
} else {
displayName = NSLocalizedString("none_translation_service", value: "None", comment: "Dropdown option for selecting no translation service.")
}
return .init(tag: self.rawValue, displayName: displayName)
case .purple:
return .init(tag: self.rawValue, displayName: NSLocalizedString("Damus Purple", comment: "Dropdown option for selecting Damus Purple as a translation service."))
case .libretranslate:
@@ -51,4 +57,16 @@ enum TranslationService: String, CaseIterable, Identifiable, StringCodable {
return .init(tag: self.rawValue, displayName: NSLocalizedString("translate.nostr.wine (DeepL, Pay with BTC)", comment: "Dropdown option for selecting translate.nostr.wine as the translation service."))
}
}
static var isAppleTranslationSupported: Bool {
#if targetEnvironment(macCatalyst)
return false
#else
if #available(iOS 17.4, macOS 14.4, *) {
return true
} else {
return false
}
#endif
}
}
+10 -6
View File
@@ -155,8 +155,8 @@ class UserSettingsStore: ObservableObject {
@Setting(key: "like_notification", default_value: true)
var like_notification: Bool
@StringSetting(key: "notifications_mode", default_value: .local)
var notifications_mode: NotificationsMode
@StringSetting(key: "notification_mode", default_value: .push)
var notification_mode: NotificationsMode
@Setting(key: "notification_only_from_following", default_value: false)
var notification_only_from_following: Bool
@@ -180,6 +180,9 @@ class UserSettingsStore: ObservableObject {
@Setting(key: "auto_translate", default_value: true)
var auto_translate: Bool
@Setting(key: "translate_offline", default_value: true)
var translate_offline: Bool
@Setting(key: "show_general_statuses", default_value: true)
var show_general_statuses: Bool
@@ -207,11 +210,12 @@ class UserSettingsStore: ObservableObject {
@Setting(key: "always_show_onboarding_suggestions", default_value: false)
var always_show_onboarding_suggestions: Bool
@Setting(key: "enable_experimental_push_notifications", default_value: false)
var enable_experimental_push_notifications: Bool
// @Setting(key: "enable_experimental_push_notifications", default_value: false)
// This was a feature flag setting during early development, but now this is enabled for everyone.
var enable_push_notifications: Bool = true
@Setting(key: "send_device_token_to_localhost", default_value: false)
var send_device_token_to_localhost: Bool
@StringSetting(key: "push_notification_environment", default_value: .production)
var push_notification_environment: PushNotificationClient.Environment
@Setting(key: "enable_experimental_purple_api", default_value: false)
var enable_experimental_purple_api: Bool
+12 -7
View File
@@ -122,20 +122,22 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
case hashtag(Hashtag)
case param(TagElem)
case naddr(NAddr)
case reference(String)
var key: RefKey {
switch self {
case .event: return .e
case .pubkey: return .p
case .quote: return .q
case .hashtag: return .t
case .param: return .d
case .naddr: return .a
case .event: return .e
case .pubkey: return .p
case .quote: return .q
case .hashtag: return .t
case .param: return .d
case .naddr: return .a
case .reference: return .r
}
}
enum RefKey: AsciiCharacter, TagKey, CustomStringConvertible {
case e, p, t, d, q, a
case e, p, t, d, q, a, r
var keychar: AsciiCharacter {
self.rawValue
@@ -159,6 +161,8 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
case .param(let string): return string.string()
case .naddr(let naddr):
return naddr.kind.description + ":" + naddr.author.hex() + ":" + naddr.identifier
case .reference(let string):
return string
}
}
@@ -179,6 +183,7 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
case .t: return .hashtag(Hashtag(hashtag: t1.string()))
case .d: return .param(t1)
case .a: return .naddr(NAddr(identifier: "", author: Pubkey(Data()), relays: [], kind: 0))
case .r: return .reference(t1.string())
}
}
}
+16 -4
View File
@@ -46,9 +46,10 @@ final class RelayConnection: ObservableObject {
if err == nil {
self.last_pong = .now
Log.info("Got pong from '%s'", for: .networking, self.relay_url.absoluteString)
self.log?.add("Successful ping")
} else {
print("pong failed, reconnecting \(self.relay_url.id)")
Log.info("Ping failed, reconnecting to '%s'", for: .networking, self.relay_url.absoluteString)
self.isConnected = false
self.isConnecting = false
self.reconnect_with_backoff()
@@ -126,7 +127,7 @@ final class RelayConnection: ObservableObject {
self.receive(message: message)
case .disconnected(let closeCode, let reason):
if closeCode != .normalClosure {
print("⚠️ Warning: RelayConnection (\(self.relay_url)) closed with code \(closeCode), reason: \(String(describing: reason))")
Log.error("⚠️ Warning: RelayConnection (%d) closed with code: %s", for: .networking, String(describing: closeCode), String(describing: reason))
}
DispatchQueue.main.async {
self.isConnected = false
@@ -134,12 +135,16 @@ final class RelayConnection: ObservableObject {
self.reconnect()
}
case .error(let error):
print("⚠️ Warning: RelayConnection (\(self.relay_url)) error: \(error)")
Log.error("⚠️ Warning: RelayConnection (%s) error: %s", for: .networking, self.relay_url.absoluteString, error.localizedDescription)
let nserr = error as NSError
if nserr.domain == NSPOSIXErrorDomain && nserr.code == 57 {
// ignore socket not connected?
return
}
if nserr.domain == NSURLErrorDomain && nserr.code == -999 {
// these aren't real error, it just means task was cancelled
return
}
DispatchQueue.main.async {
self.isConnected = false
self.isConnecting = false
@@ -156,14 +161,21 @@ final class RelayConnection: ObservableObject {
}
func reconnect_with_backoff() {
self.backoff *= 1.5
self.backoff *= 2.0
self.reconnect_in(after: self.backoff)
}
func reconnect() {
guard !isConnecting && !isDisabled else {
self.log?.add("Cancelling reconnect, already connecting")
return // we're already trying to connect or we're disabled
}
guard !self.isConnected else {
self.log?.add("Cancelling reconnect, already connected")
return
}
disconnect()
connect()
log?.add("Reconnecting...")
+1
View File
@@ -89,6 +89,7 @@ class RelayPool {
}
func ping() {
Log.info("Pinging %d relays", for: .networking, relays.count)
for relay in relays {
relay.connection.ping()
}
+5 -4
View File
@@ -10,13 +10,14 @@ import Foundation
class Constants {
//static let EXAMPLE_DEMOS: DamusState = .empty
static let DAMUS_APP_GROUP_IDENTIFIER: String = "group.com.damus"
static let DEVICE_TOKEN_RECEIVER_PRODUCTION_URL: URL = URL(string: "http://45.33.32.5:8000/user-info")!
static let DEVICE_TOKEN_RECEIVER_TEST_URL: URL = URL(string: "http://localhost:8000/user-info")!
static let DEVICE_TOKEN_REVOKER_PRODUCTION_URL: URL = URL(string: "http://45.33.32.5:8000/user-info/remove")!
static let DEVICE_TOKEN_REVOKER_TEST_URL: URL = URL(string: "http://localhost:8000/user-info/remove")!
static let MAIN_APP_BUNDLE_IDENTIFIER: String = "com.jb55.damus2"
static let NOTIFICATION_EXTENSION_BUNDLE_IDENTIFIER: String = "com.jb55.damus2.DamusNotificationService"
// MARK: Push notification server
static let PUSH_NOTIFICATION_SERVER_PRODUCTION_BASE_URL: URL = URL(string: "https://notify.damus.io")!
static let PUSH_NOTIFICATION_SERVER_STAGING_BASE_URL: URL = URL(string: "https://notify-staging.damus.io")!
static let PUSH_NOTIFICATION_SERVER_TEST_BASE_URL: URL = URL(string: "http://localhost:8000")!
// MARK: Purple
// API
static let PURPLE_API_LOCAL_TEST_BASE_URL: URL = URL(string: "http://localhost:8989")!
+11
View File
@@ -0,0 +1,11 @@
//
// DamusAliases.swift
// damus
//
// Created by Daniel DAquino on 2024-08-12.
//
import Foundation
import UIKit
let this_app: UIApplication = UIApplication.shared
+15 -11
View File
@@ -244,16 +244,12 @@ class EventCache {
}
}
func should_translate(event: NostrEvent, our_keypair: Keypair, settings: UserSettingsStore, note_lang: String?) -> Bool {
guard settings.can_translate else {
return false
}
func should_translate(event: NostrEvent, our_keypair: Keypair, note_lang: String?) -> Bool {
// don't translate reposts, longform, etc
if event.kind != 1 {
return false;
}
// Do not translate self-authored notes if logged in with a private key
// as we can assume the user can understand their own notes.
// The detected language prediction could be incorrect and not in the list of preferred languages.
@@ -261,25 +257,33 @@ func should_translate(event: NostrEvent, our_keypair: Keypair, settings: UserSet
if our_keypair.privkey != nil && our_keypair.pubkey == event.pubkey {
return false
}
if let note_lang {
let preferredLanguages = Set(Locale.preferredLanguages.map { localeToLanguage($0) })
// Don't translate if its in our preferred languages
guard !preferredLanguages.contains(note_lang) else {
// if its the same, give up and don't retry
return false
}
}
// we should start translating if we have auto_translate on
return true
}
func can_and_should_translate(event: NostrEvent, our_keypair: Keypair, settings: UserSettingsStore, note_lang: String?) -> Bool {
guard settings.can_translate else {
return false
}
return should_translate(event: event, our_keypair: our_keypair, note_lang: note_lang)
}
func should_preload_translation(event: NostrEvent, our_keypair: Keypair, current_status: TranslateStatus, settings: UserSettingsStore, note_lang: String?) -> Bool {
switch current_status {
case .havent_tried:
return should_translate(event: event, our_keypair: our_keypair, settings: settings, note_lang: note_lang) && settings.auto_translate
return can_and_should_translate(event: event, our_keypair: our_keypair, settings: settings, note_lang: note_lang) && settings.auto_translate
case .translating: return false
case .translated: return false
case .not_needed: return false
@@ -413,7 +417,7 @@ func preload_event(plan: PreloadPlan, state: DamusState) async {
var translations: TranslateStatus? = nil
// We have to recheck should_translate here now that we have note_language
if plan.load_translations && should_translate(event: plan.event, our_keypair: our_keypair, settings: settings, note_lang: note_language) && settings.auto_translate
if plan.load_translations && can_and_should_translate(event: plan.event, our_keypair: our_keypair, settings: settings, note_lang: note_language) && settings.auto_translate
{
translations = await translate_note(profiles: profiles, keypair: our_keypair, event: plan.event, settings: settings, note_lang: note_language, purple: state.purple)
}
+5 -3
View File
@@ -60,7 +60,8 @@ struct ImageMetadata: Equatable {
func process_blurhash(blurhash: String, size: CGSize?) async -> UIImage? {
let res = Task.detached(priority: .low) {
let size = get_blurhash_size(img_size: size ?? CGSize(width: 100.0, height: 100.0))
let default_size = CGSize(width: 100.0, height: 100.0)
let size = get_blurhash_size(img_size: size ?? default_size) ?? default_size
guard let img = UIImage.init(blurHash: blurhash, size: size) else {
let noimg: UIImage? = nil
return noimg
@@ -135,7 +136,8 @@ extension UIImage {
}
}
func get_blurhash_size(img_size: CGSize) -> CGSize {
func get_blurhash_size(img_size: CGSize) -> CGSize? {
guard img_size.width > 0 && img_size.height > 0 else { return nil }
return CGSize(width: 100.0, height: (100.0/img_size.width) * img_size.height)
}
@@ -145,7 +147,7 @@ func calculate_blurhash(img: UIImage) async -> String? {
}
let res = Task.detached(priority: .low) {
let bhs = get_blurhash_size(img_size: img.size)
let bhs = get_blurhash_size(img_size: img.size) ?? CGSize(width: 100.0, height: 100.0)
let smaller = img.resized(to: bhs)
guard let blurhash = smaller.blurHash(numberOfComponents: (5,5)) else {
+1 -1
View File
@@ -30,7 +30,7 @@ public struct DismissKeyboardOnTap: ViewModifier {
}
public func end_editing() {
UIApplication.shared.connectedScenes
this_app.connectedScenes
.filter {$0.activationState == .foregroundActive}
.map {$0 as? UIWindowScene}
.compactMap({$0})
+51
View File
@@ -0,0 +1,51 @@
//
// LanguageSortComparator.swift
// damus
//
// Created by Terry Yiu on 9/22/24.
//
import Foundation
struct LanguageSortComparator: SortComparator {
var order: SortOrder
func compare(_ lhs: Locale.Language, _ rhs: Locale.Language) -> ComparisonResult {
let comparisonResult = compareForward(lhs, rhs)
switch order {
case .forward:
return comparisonResult
case .reverse:
switch comparisonResult {
case .orderedAscending:
return .orderedDescending
case .orderedDescending:
return .orderedAscending
case .orderedSame:
return .orderedSame
}
}
}
private func compareForward(_ lhs: Locale.Language, _ rhs: Locale.Language) -> ComparisonResult {
let currentLocale = Locale.current
let localizedLhs = currentLocale.localizedString(forLanguage: lhs)
let localizedRhs = currentLocale.localizedString(forLanguage: rhs)
return localizedLhs.localizedCompare(localizedRhs)
}
}
extension Locale {
func localizedString(forLanguage language: Locale.Language) -> String {
guard let languageCode = language.languageCode, let localizedLanguageCode = localizedString(forLanguageCode: languageCode.identifier) else {
return language.languageCode?.identifier ?? language.minimalIdentifier
}
if let region = language.region, let localizedRegion = localizedString(forRegionCode: region.identifier) {
return "\(localizedLanguageCode) (\(localizedRegion))"
} else {
return localizedLanguageCode
}
}
}
+17 -1
View File
@@ -48,10 +48,24 @@ struct LossyLocalNotification {
}
}
enum NotificationTarget {
case note(NostrEvent)
case note_id(NoteId)
var id: NoteId {
switch self {
case .note(let note):
return note.id
case .note_id(let id):
return id
}
}
}
struct LocalNotification {
let type: LocalNotificationType
let event: NostrEvent
let target: NostrEvent
let target: NotificationTarget
let content: String
func to_lossy() -> LossyLocalNotification {
@@ -63,6 +77,8 @@ enum LocalNotificationType: String {
case dm
case like
case mention
case reply
case tagged
case repost
case zap
case profile_zap
+1
View File
@@ -13,6 +13,7 @@ enum LogCategory: String {
case nav
case render
case storage
case networking
case push_notifications
case damus_purple
case image_uploading
+1 -2
View File
@@ -11,8 +11,7 @@ import UIKit
class Theme {
static var safeAreaInsets: UIEdgeInsets? {
return UIApplication
.shared
return this_app
.connectedScenes
.flatMap { ($0 as? UIWindowScene)?.windows ?? [] }
.first { $0.isKeyWindow }?.safeAreaInsets
+6 -19
View File
@@ -309,14 +309,10 @@ struct Zap {
return nil
}
*/
guard let desc = get_zap_description(zap_ev, inv_desc: zap_invoice.description) else {
guard let zap_req = get_zap_request(zap_ev) else {
return nil
}
guard let zap_req = decode_nostr_event_json(desc) else {
return nil
}
guard validate_event(ev: zap_req) == .ok else {
return nil
}
@@ -399,21 +395,12 @@ func event_has_tag(ev: NostrEvent, tag: String) -> Bool {
return false
}
/// Fetches the description from either the invoice, or tags, depending on the type of invoice
func get_zap_description(_ ev: NostrEvent, inv_desc: InvoiceDescription) -> String? {
switch inv_desc {
case .description(let string):
return string
case .description_hash(let deschash):
guard let desc = event_tag(ev, name: "description") else {
return nil
}
guard let data = desc.data(using: .utf8) else {
return nil
}
return desc
func get_zap_request(_ ev: NostrEvent) -> NostrEvent? {
guard let desc = event_tag(ev, name: "description") else {
return nil
}
return decode_nostr_event_json(desc)
}
func invoice_to_zap_invoice(_ invoice: Invoice) -> ZapInvoice? {
+1 -1
View File
@@ -116,7 +116,7 @@ struct AddRelayView: View {
}
new_relay = ""
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
this_app.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
dismiss()
}) {
+1 -1
View File
@@ -40,7 +40,7 @@ func create_upload_request(mediaToUpload: MediaUpload, mediaUploader: MediaUploa
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
// If uploading to a media host that support NIP-98 authorization, add the header
if mediaUploader == .nostrBuild,
if mediaUploader == .nostrBuild || mediaUploader == .nostrcheck,
let keypair,
let method = request.httpMethod,
let signature = create_nip98_signature(keypair: keypair, method: method, url: url) {
+4 -2
View File
@@ -13,7 +13,7 @@ struct EditBannerImageView: View {
var damus_state: DamusState
@ObservedObject var viewModel: ImageUploadingObserver
let callback: (URL?) -> Void
let defaultImage = UIImage(named: "profile-banner") ?? UIImage()
let defaultImage = UIImage(named: "damoose") ?? UIImage()
@State var banner_image: URL? = nil
@@ -29,6 +29,7 @@ struct EditBannerImageView: View {
Color(uiColor: .secondarySystemBackground)
}
.onFailureImage(defaultImage)
.kfClickable()
EditPictureControl(uploader: damus_state.settings.default_media_uploader, pubkey: damus_state.pubkey, image_url: $banner_image, uploadObserver: viewModel, callback: callback)
}
@@ -38,7 +39,7 @@ struct EditBannerImageView: View {
struct InnerBannerImageView: View {
let disable_animation: Bool
let url: URL?
let defaultImage = UIImage(named: "profile-banner") ?? UIImage()
let defaultImage = UIImage(named: "damoose") ?? UIImage()
var body: some View {
ZStack {
@@ -54,6 +55,7 @@ struct InnerBannerImageView: View {
Color(uiColor: .secondarySystemBackground)
}
.onFailureImage(defaultImage)
.kfClickable()
} else {
Image(uiImage: defaultImage).resizable()
}
+4 -4
View File
@@ -14,12 +14,12 @@ struct FriendsButton: View {
Button(action: {
switch self.filter {
case .all:
self.filter = .friends
case .friends:
self.filter = .friends_of_friends
case .friends_of_friends:
self.filter = .all
}
}) {
if filter == .friends {
if filter == .friends_of_friends {
LINEAR_GRADIENT
.mask(Image("user-added")
.resizable()
@@ -28,7 +28,7 @@ struct FriendsButton: View {
Image("user-added")
.resizable()
.frame(width: 28, height: 28)
.foregroundColor(DamusColors.adaptableGrey)
.foregroundColor(.gray)
}
}
.buttonStyle(.plain)
+2 -2
View File
@@ -165,7 +165,7 @@ struct ChatBubble<T: View, U: ShapeStyle, V: View>: View {
stroke_style: .init(lineWidth: 4),
background_style: Color.accentColor
) {
Text("Hello there")
Text(verbatim: "Hello there")
.padding()
}
.foregroundColor(.white)
@@ -176,7 +176,7 @@ struct ChatBubble<T: View, U: ShapeStyle, V: View>: View {
stroke_style: .init(lineWidth: 4),
background_style: Color.accentColor
) {
Text("Hello there")
Text(verbatim: "Hello there")
.padding()
}
.foregroundColor(.white)
@@ -182,25 +182,6 @@ extension CodeScannerView {
delegate?.didFail(reason: .badOutput)
return
}
}
override public func viewWillLayoutSubviews() {
previewLayer?.frame = view.layer.bounds
}
@objc func updateOrientation() {
guard let orientation = view.window?.windowScene?.interfaceOrientation else { return }
guard let connection = captureSession.connections.last, connection.isVideoOrientationSupported else { return }
connection.videoOrientation = AVCaptureVideoOrientation(rawValue: orientation.rawValue) ?? .portrait
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
updateOrientation()
}
override public func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if previewLayer == nil {
previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
@@ -220,6 +201,21 @@ extension CodeScannerView {
}
}
override public func viewWillLayoutSubviews() {
previewLayer?.frame = view.layer.bounds
}
@objc func updateOrientation() {
guard let orientation = view.window?.windowScene?.interfaceOrientation else { return }
guard let connection = captureSession.connections.last, connection.isVideoOrientationSupported else { return }
connection.videoOrientation = AVCaptureVideoOrientation(rawValue: orientation.rawValue) ?? .portrait
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
updateOrientation()
}
private func addviewfinder() {
guard showViewfinder, let imageView = viewFinder else { return }
+1 -1
View File
@@ -103,7 +103,7 @@ struct DirectMessagesView: View {
func would_filter_non_friends_from_dms(contacts: Contacts, dms: [DirectMessageModel]) -> Bool {
for dm in dms {
if !FriendFilter.friends.filter(contacts: contacts, pubkey: dm.pubkey) {
if !FriendFilter.friends_of_friends.filter(contacts: contacts, pubkey: dm.pubkey) {
return true
}
}
+10
View File
@@ -73,6 +73,16 @@ func should_blur_images(settings: UserSettingsStore, contacts: Contacts, ev: Nos
return true
}
// blame the porn bots for this code too
func should_blur_images(damus_state: DamusState, ev: NostrEvent) -> Bool {
return should_blur_images(
settings: damus_state.settings,
contacts: damus_state.contacts,
ev: ev,
our_pubkey: damus_state.pubkey
)
}
func format_relative_time(_ created_at: UInt32) -> String
{
return time_ago_since(Date(timeIntervalSince1970: Double(created_at)))
@@ -17,7 +17,8 @@ struct ReplyPart: View {
Group {
if event.known_kind == .highlight {
let highlighted_note = event.highlighted_note_id().flatMap { events.lookup($0) }
HighlightDescription(event: event, highlighted_event: highlighted_note, ndb: ndb)
let highlight_note = HighlightEvent.parse(from: event)
HighlightDescription(highlight_event: highlight_note, highlighted_event: highlighted_note, ndb: ndb)
} else if let reply_ref = event.thread_reply()?.reply {
let replying_to = events.lookup(reply_ref.note_id)
ReplyDescription(event: event, replying_to: replying_to, ndb: ndb)
@@ -9,12 +9,12 @@ import SwiftUI
// Modified from Reply Description
struct HighlightDescription: View {
let event: NostrEvent
let highlight_event: HighlightEvent
let highlighted_event: NostrEvent?
let ndb: Ndb
var body: some View {
(Text(Image(systemName: "highlighter")) + Text(verbatim: " \(highlight_desc(ndb: ndb, event: event, highlighted_event: highlighted_event))"))
(Text(Image(systemName: "highlighter")) + Text(verbatim: " \(highlight_event.source_description_text(ndb: ndb, highlighted_event: highlighted_event))"))
.font(.footnote)
.foregroundColor(.gray)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -24,30 +24,6 @@ struct HighlightDescription: View {
struct HighlightDescription_Previews: PreviewProvider {
static var previews: some View {
HighlightDescription(event: test_note, highlighted_event: test_note, ndb: test_damus_state.ndb)
HighlightDescription(highlight_event: HighlightEvent.parse(from: test_note), highlighted_event: nil, ndb: test_damus_state.ndb)
}
}
func highlight_desc(ndb: Ndb, event: NostrEvent, highlighted_event: NostrEvent?, locale: Locale = Locale.current) -> String {
let desc = make_reply_description(event, replying_to: highlighted_event)
let pubkeys = desc.pubkeys
let bundle = bundleForLocale(locale: locale)
if pubkeys.count == 0 {
return NSLocalizedString("Highlighted", bundle: bundle, comment: "Label to indicate that the user is highlighting their own post.")
}
guard let profile_txn = NdbTxn(ndb: ndb) else {
return ""
}
let names: [String] = pubkeys.map { pk in
let prof = ndb.lookup_profile_with_txn(pk, txn: profile_txn)
return Profile.displayName(profile: prof?.profile, pubkey: pk).username.truncate(maxLength: 50)
}
let uniqueNames: [String] = Array(Set(names))
return String(format: NSLocalizedString("Highlighted %@", bundle: bundle, comment: "Label to indicate that the user is highlighting 1 user."), locale: locale, uniqueNames.first ?? "")
}
@@ -0,0 +1,42 @@
//
// HighlightDraftContentView.swift
// damus
//
// Created by eric on 5/26/24.
//
import SwiftUI
struct HighlightDraftContentView: View {
let draft: HighlightContentDraft
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
var attributedString: AttributedString {
var attributedString = AttributedString(draft.selected_text)
if let range = attributedString.range(of: draft.selected_text) {
attributedString[range].backgroundColor = DamusColors.highlight
}
return attributedString
}
Text(attributedString)
.lineSpacing(5)
.padding(10)
}
.overlay(
RoundedRectangle(cornerRadius: 25).fill(DamusColors.highlight).frame(width: 4),
alignment: .leading
)
if case .external_url(let url) = draft.source {
LinkViewRepresentable(meta: .url(url))
.frame(height: 50)
}
}
}
}
@@ -50,6 +50,7 @@ struct HighlightEventRef: View {
FailedImage()
}
.frame(width: 35, height: 35)
.kfClickable()
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.gray.opacity(0.5), lineWidth: 0.5))
.scaledToFit()
@@ -61,6 +61,7 @@ struct HighlightLink: View {
.background(DamusColors.adaptableWhite)
}
.frame(width: 35, height: 35)
.kfClickable()
.clipShape(RoundedRectangle(cornerRadius: 10))
.scaledToFit()
} else {
@@ -1,77 +0,0 @@
//
// HighlightPostView.swift
// damus
//
// Created by eric on 5/26/24.
//
import SwiftUI
struct HighlightPostView: View {
let damus_state: DamusState
let event: NostrEvent
@Binding var selectedText: String
@Environment(\.dismiss) var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 0) {
VStack {
HStack(spacing: 5.0) {
Button(action: {
dismiss()
}, label: {
Text("Cancel", comment: "Button to cancel out of highlighting a note.")
.padding(10)
})
.buttonStyle(NeutralButtonStyle())
Spacer()
Button(NSLocalizedString("Post", comment: "Button to post a highlight.")) {
let tags: [[String]] = [ ["e", "\(self.event.id)"] ]
let kind = NostrKind.highlight.rawValue
guard let ev = NostrEvent(content: selectedText, keypair: damus_state.keypair, kind: kind, tags: tags) else {
return
}
damus_state.postbox.send(ev)
dismiss()
}
.bold()
.buttonStyle(GradientButtonStyle(padding: 10))
}
Divider()
.foregroundColor(DamusColors.neutral3)
.padding(.top, 5)
}
.frame(height: 30)
.padding()
.padding(.top, 15)
HStack {
var attributedString: AttributedString {
var attributedString = AttributedString(selectedText)
if let range = attributedString.range(of: selectedText) {
attributedString[range].backgroundColor = DamusColors.highlight
}
return attributedString
}
Text(attributedString)
.lineSpacing(5)
.padding(10)
}
.overlay(
RoundedRectangle(cornerRadius: 25).fill(DamusColors.highlight).frame(width: 4),
alignment: .leading
)
.padding()
Spacer()
}
}
}
@@ -59,9 +59,9 @@ struct HighlightBodyView: View {
var body: some View {
Group {
if options.contains(.wide) {
Main.padding(.horizontal)
} else {
Main
} else {
Main.padding(.horizontal)
}
}
}
@@ -92,6 +92,18 @@ struct HighlightBodyView: View {
var Main: some View {
VStack(alignment: .leading, spacing: 0) {
if self.event.event.referenced_comment_items.first?.content != nil {
let all_options = options.union(.no_action_bar)
NoteContentView(
damus_state: self.state,
event: self.event.event,
blur_images: should_blur_images(damus_state: self.state, ev: self.event.event),
size: .normal,
options: all_options
).padding(.vertical, 10)
}
HStack {
var attributedString: AttributedString {
var attributedString: AttributedString = ""
@@ -119,14 +131,17 @@ struct HighlightBodyView: View {
RoundedRectangle(cornerRadius: 25).fill(DamusColors.highlight).frame(width: 4),
alignment: .leading
)
.padding(.horizontal)
.padding(.bottom, 10)
if let url = event.url_ref {
HighlightLink(state: state, url: url, content: event.event.content)
.padding(.horizontal)
} else {
if let evRef = event.event_ref {
if let eventHex = hex_decode_id(evRef) {
HighlightEventRef(damus_state: state, event_ref: NoteId(eventHex))
.padding(.horizontal)
.padding(.top, 5)
}
}
@@ -98,6 +98,7 @@ struct LongformPreviewBody: View {
}
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: header ? .infinity : 150)
.kfClickable()
.cornerRadius(1)
}
@@ -33,6 +33,7 @@ struct ImageContainerView: View {
view.framePreloadCount = 3
}
.imageModifier(ImageHandler(handler: $image))
.kfClickable()
.clipped()
.modifier(ImageContextMenuModifier(url: url, image: image, settings: settings, showShareSheet: $showShareSheet))
.sheet(isPresented: $showShareSheet) {
@@ -33,6 +33,7 @@ struct ProfileImageContainerView: View {
.imageModifier(ImageHandler(handler: $image))
.clipShape(Circle())
.modifier(ImageContextMenuModifier(url: url, image: image, settings: settings, showShareSheet: $showShareSheet))
.kfClickable()
.sheet(isPresented: $showShareSheet) {
ShareSheet(activityItems: [url])
}
+3 -3
View File
@@ -8,7 +8,7 @@ import SwiftUI
struct AddMuteItemView: View {
let state: DamusState
@State var new_text: String = ""
@Binding var new_text: String
@State var expiration: DamusDuration = .indefinite
@Environment(\.dismiss) var dismiss
@@ -87,7 +87,7 @@ struct AddMuteItemView: View {
new_text = ""
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
this_app.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
dismiss()
}) {
@@ -108,6 +108,6 @@ struct AddMuteItemView: View {
struct AddMuteItemView_Previews: PreviewProvider {
static var previews: some View {
AddMuteItemView(state: test_damus_state)
AddMuteItemView(state: test_damus_state, new_text: .constant(""))
}
}
+5 -7
View File
@@ -15,6 +15,8 @@ struct MutelistView: View {
@State var hashtags: [MuteItem] = []
@State var threads: [MuteItem] = []
@State var words: [MuteItem] = []
@State var new_text: String = ""
func RemoveAction(item: MuteItem) -> some View {
Button {
@@ -120,13 +122,9 @@ struct MutelistView: View {
}
}
.sheet(isPresented: $show_add_muteitem, onDismiss: { self.show_add_muteitem = false }) {
if #available(iOS 16.0, *) {
AddMuteItemView(state: damus_state)
.presentationDetents([.height(300)])
.presentationDragIndicator(.visible)
} else {
AddMuteItemView(state: damus_state)
}
AddMuteItemView(state: damus_state, new_text: $new_text)
.presentationDetents([.height(300)])
.presentationDragIndicator(.visible)
}
}
}
+26 -5
View File
@@ -9,6 +9,7 @@ import SwiftUI
import LinkPresentation
import NaturalLanguage
import MarkdownUI
import Translation
struct Blur: UIViewRepresentable {
var style: UIBlurEffect.Style = .systemUltraThinMaterial
@@ -32,6 +33,8 @@ struct NoteContentView: View {
let preview_height: CGFloat?
let options: EventViewOptions
@State var isAppleTranslationPopoverPresented: Bool = false
@ObservedObject var artifacts_model: NoteArtifactsModel
@ObservedObject var preview_model: PreviewModel
@ObservedObject var settings: UserSettingsStore
@@ -96,7 +99,17 @@ struct NoteContentView: View {
}
var translateView: some View {
TranslateView(damus_state: damus_state, event: event, size: self.size)
#if targetEnvironment(macCatalyst)
TranslateView(damus_state: damus_state, event: event, size: self.size, isAppleTranslationPopoverPresented: $isAppleTranslationPopoverPresented)
#else
if #available(iOS 18.0, macOS 15.0, *), damus_state.settings.translate_offline {
AnyView(OfflineTranslateView(damus_state: damus_state, event: event, size: self.size))
} else {
AnyView(
TranslateView(damus_state: damus_state, event: event, size: self.size, isAppleTranslationPopoverPresented: $isAppleTranslationPopoverPresented)
)
}
#endif
}
func previewView(links: [URL]) -> some View {
@@ -120,8 +133,7 @@ struct NoteContentView: View {
EventView(damus: damus_state, event: self.event, options: .embedded_text_only)
.padding(.top)
}
.background(.thinMaterial)
.preferredColorScheme(.dark)
.background(.thickMaterial)
.onTapGesture(perform: {
damus_state.nav.push(route: Route.Thread(thread: .init(event: self.event, damus_state: damus_state)))
dismiss()
@@ -146,7 +158,7 @@ struct NoteContentView: View {
}
}
if !options.contains(.no_translate) && (size == .selected || damus_state.settings.auto_translate) {
if !options.contains(.no_translate) && (size == .selected || TranslationService.isAppleTranslationSupported || damus_state.settings.auto_translate) {
if with_padding {
translateView
.padding(.horizontal)
@@ -299,7 +311,16 @@ struct NoteContentView: View {
Markdown(md.markdown)
.padding([.leading, .trailing, .top])
case .separated(let separated):
MainContent(artifacts: separated)
if #available(iOS 18.0, macOS 15.0, *), damus_state.settings.auto_translate {
MainContent(artifacts: separated)
} else if #available(iOS 17.4, macOS 14.4, *) {
MainContent(artifacts: separated)
#if !targetEnvironment(macCatalyst)
.translationPresentation(isPresented: $isAppleTranslationPopoverPresented, text: event.get_content(damus_state.keypair))
#endif
} else {
MainContent(artifacts: separated)
}
}
}
.fixedSize(horizontal: false, vertical: true)
@@ -96,7 +96,7 @@ struct DamusAppNotificationView: View {
@MainActor
func open_url(url: URL) {
UIApplication.shared.open(url)
this_app.open(url)
}
var body: some View {
@@ -56,6 +56,7 @@ struct NotificationsView: View {
@ObservedObject var notifications: NotificationsModel
@StateObject var filter = NotificationFilter()
@SceneStorage("NotificationsView.filter_state") var filter_state: NotificationFilterState = .all
@Binding var subtitle: String?
@Environment(\.colorScheme) var colorScheme
@@ -99,6 +100,15 @@ struct NotificationsView: View {
.tag(NotificationFilterState.replies)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(
action: { state.nav.push(route: Route.NotificationSettings(settings: state.settings)) },
label: {
Image("settings")
.foregroundColor(.gray)
}
)
}
ToolbarItem(placement: .navigationBarTrailing) {
if would_filter_non_friends_from_notifications(contacts: state.contacts, state: filter_state, items: self.notifications.notifications) {
FriendsButton(filter: $filter.fine_filter)
@@ -107,12 +117,14 @@ struct NotificationsView: View {
}
.onChange(of: filter.fine_filter) { val in
state.settings.friend_filter = val
self.subtitle = filter.fine_filter.description()
}
.onChange(of: filter_state) { val in
filter.state = val
}
.onAppear {
self.filter.fine_filter = state.settings.friend_filter
self.subtitle = filter.fine_filter.description()
filter.state = filter_state
}
.safeAreaInset(edge: .top, spacing: 0) {
@@ -163,7 +175,7 @@ struct NotificationsView: View {
struct NotificationsView_Previews: PreviewProvider {
static var previews: some View {
NotificationsView(state: test_damus_state, notifications: NotificationsModel(), filter: NotificationFilter())
NotificationsView(state: test_damus_state, notifications: NotificationsModel(), filter: NotificationFilter(), subtitle: .constant(nil))
}
}
@@ -174,7 +186,7 @@ func would_filter_non_friends_from_notifications(contacts: Contacts, state: Noti
continue
}
if item.would_filter({ ev in FriendFilter.friends.filter(contacts: contacts, pubkey: ev.pubkey) }) {
if item.would_filter({ ev in FriendFilter.friends_of_friends.filter(contacts: contacts, pubkey: ev.pubkey) }) {
return true
}
}
+61 -30
View File
@@ -30,15 +30,18 @@ enum PostAction {
case replying_to(NostrEvent)
case quoting(NostrEvent)
case posting(PostTarget)
case highlighting(HighlightContentDraft)
var ev: NostrEvent? {
switch self {
case .replying_to(let ev):
return ev
case .quoting(let ev):
return ev
case .posting:
return nil
case .replying_to(let ev):
return ev
case .quoting(let ev):
return ev
case .posting:
return nil
case .highlighting:
return nil
}
}
}
@@ -128,7 +131,12 @@ struct PostView: View {
}
var posting_disabled: Bool {
return is_post_empty || uploading_disabled
switch action {
case .highlighting(_):
return false
default:
return is_post_empty || uploading_disabled
}
}
// Returns a valid height for the text box, even when textHeight is not a number
@@ -204,6 +212,8 @@ struct PostView: View {
damus_state.drafts.quotes.removeValue(forKey: quoting)
case .posting:
damus_state.drafts.post = nil
case .highlighting(let draft):
damus_state.drafts.highlights.removeValue(forKey: draft.source)
}
}
@@ -371,6 +381,9 @@ struct PostView: View {
if case .quoting(let ev) = action {
BuilderEventView(damus: damus_state, event: ev)
}
else if case .highlighting(let draft) = action {
HighlightDraftContentView(draft: draft)
}
}
.padding(.horizontal)
}
@@ -454,14 +467,15 @@ struct PostView: View {
let loaded_draft = load_draft()
switch action {
case .replying_to(let replying_to):
references = gather_reply_ids(our_pubkey: damus_state.pubkey, from: replying_to)
case .quoting(let quoting):
references = gather_quote_ids(our_pubkey: damus_state.pubkey, from: quoting)
case .posting(let target):
guard !loaded_draft else { break }
fill_target_content(target: target)
case .replying_to(let replying_to):
references = gather_reply_ids(our_pubkey: damus_state.pubkey, from: replying_to)
case .quoting(let quoting):
references = gather_quote_ids(our_pubkey: damus_state.pubkey, from: quoting)
case .posting(let target):
guard !loaded_draft else { break }
fill_target_content(target: target)
case .highlighting(let draft):
references = [draft.source.ref()]
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
@@ -597,6 +611,8 @@ func set_draft_for_post(drafts: Drafts, action: PostAction, artifacts: DraftArti
drafts.quotes[ev] = artifacts
case .posting:
drafts.post = artifacts
case .highlighting(let draft):
drafts.highlights[draft.source] = artifacts
}
}
@@ -608,6 +624,8 @@ func load_draft_for_post(drafts: Drafts, action: PostAction) -> DraftArtifacts?
return drafts.quotes[ev]
case .posting:
return drafts.post
case .highlighting(let draft):
return drafts.highlights[draft.source]
}
}
@@ -669,27 +687,40 @@ func build_post(state: DamusState, post: NSMutableAttributedString, action: Post
var tags: [[String]] = []
switch action {
case .replying_to(let replying_to):
// start off with the reply tags
tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair)
case .replying_to(let replying_to):
// start off with the reply tags
tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair)
case .quoting(let ev):
content.append(" nostr:" + bech32_note_id(ev.id))
case .quoting(let ev):
content.append(" nostr:" + bech32_note_id(ev.id))
if let quoted_ev = state.events.lookup(ev.id) {
tags.append(["p", quoted_ev.pubkey.hex()])
}
case .posting(let postTarget):
break
}
// include pubkeys
tags += pubkeys.map { pk in
["p", pk.hex()]
if let quoted_ev = state.events.lookup(ev.id) {
tags.append(["p", quoted_ev.pubkey.hex()])
}
case .posting(let postTarget):
break
case .highlighting(let draft):
break
}
// append additional tags
tags += uploadedMedias.compactMap { $0.metadata?.to_tag() }
switch action {
case .highlighting(let draft):
tags.append(contentsOf: draft.source.tags())
if !(content.isEmpty || content.allSatisfy { $0.isWhitespace }) {
tags.append(["comment", content])
}
tags += pubkeys.map { pk in
["p", pk.hex(), "mention"]
}
return NostrPost(content: draft.selected_text, kind: .highlight, tags: tags)
default:
tags += pubkeys.map { pk in
["p", pk.hex()]
}
}
return NostrPost(content: content, kind: .text, tags: tags)
}
+105 -31
View File
@@ -21,13 +21,15 @@ struct EditMetadataView: View {
@State var ln: String
@State var website: String
@Environment(\.dismiss) var dismiss
@State var confirm_ln_address: Bool = false
@State var confirm_save_alert: Bool = false
@StateObject var profileUploadObserver = ImageUploadingObserver()
@StateObject var bannerUploadObserver = ImageUploadingObserver()
@Environment(\.dismiss) var dismiss
@Environment(\.presentationMode) var presentationMode
init(damus_state: DamusState) {
self.damus_state = damus_state
let profile_txn = damus_state.profiles.lookup(id: damus_state.pubkey)
@@ -77,7 +79,7 @@ struct EditMetadataView: View {
var TopSection: some View {
ZStack(alignment: .top) {
GeometryReader { geo in
EditBannerImageView(damus_state: damus_state, viewModel: bannerUploadObserver, callback: uploadedBanner(image_url:))
EditBannerImageView(damus_state: damus_state, viewModel: bannerUploadObserver, callback: uploadedBanner(image_url:), banner_image: URL(string: banner))
.aspectRatio(contentMode: .fill)
.frame(width: geo.size.width, height: BANNER_HEIGHT)
.clipped()
@@ -86,7 +88,7 @@ struct EditMetadataView: View {
let pfp_size: CGFloat = 90.0
HStack(alignment: .center) {
EditProfilePictureView(pubkey: damus_state.pubkey, damus_state: damus_state, size: pfp_size, uploadObserver: profileUploadObserver, callback: uploadedProfilePicture(image_url:))
EditProfilePictureView(profile_url: URL(string: picture), pubkey: damus_state.pubkey, damus_state: damus_state, size: pfp_size, uploadObserver: profileUploadObserver, callback: uploadedProfilePicture(image_url:))
.offset(y: -(pfp_size/2.0)) // Increase if set a frame
Spacer()
@@ -97,6 +99,28 @@ struct EditMetadataView: View {
}
}
func navImage(img: String) -> some View {
Image(img)
.frame(width: 33, height: 33)
.background(Color.black.opacity(0.6))
.clipShape(Circle())
}
var navBackButton: some View {
HStack {
Button {
if didChange() {
confirm_save_alert.toggle()
} else {
presentationMode.wrappedValue.dismiss()
}
} label: {
navImage(img: "chevron-left")
}
Spacer()
}
}
var body: some View {
VStack(alignment: .leading) {
TopSection
@@ -116,18 +140,6 @@ struct EditMetadataView: View {
}
Section (NSLocalizedString("Profile Picture", comment: "Label for Profile Picture section of user profile form.")) {
TextField(NSLocalizedString("https://example.com/pic.jpg", comment: "Placeholder example text for profile picture URL."), text: $picture)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
}
Section (NSLocalizedString("Banner Image", comment: "Label for Banner Image section of user profile form.")) {
TextField(NSLocalizedString("https://example.com/pic.jpg", comment: "Placeholder example text for profile picture URL."), text: $banner)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
}
Section(NSLocalizedString("Website", comment: "Label for Website section of user profile form.")) {
TextField(NSLocalizedString("https://jb55.com", comment: "Placeholder example text for website URL for user profile."), text: $website)
.autocorrectionDisabled(true)
@@ -139,10 +151,10 @@ struct EditMetadataView: View {
ZStack(alignment: .topLeading) {
TextEditor(text: $about)
.textInputAutocapitalization(.sentences)
.frame(minHeight: 20, alignment: .leading)
.frame(minHeight: 45, alignment: .leading)
.multilineTextAlignment(.leading)
Text(about.isEmpty ? placeholder : about)
.padding(.leading, 4)
.padding(4)
.opacity(about.isEmpty ? 1 : 0)
.foregroundColor(Color(uiColor: .placeholderText))
}
@@ -175,25 +187,48 @@ struct EditMetadataView: View {
}
})
Button(NSLocalizedString("Save", comment: "Button for saving profile.")) {
if !ln.isEmpty && !is_ln_valid(ln: ln) {
confirm_ln_address = true
} else {
save()
dismiss()
}
}
Button(action: {
if !ln.isEmpty && !is_ln_valid(ln: ln) {
confirm_ln_address = true
} else {
save()
dismiss()
}
.disabled(profileUploadObserver.isLoading || bannerUploadObserver.isLoading)
.alert(NSLocalizedString("Invalid Tip Address", comment: "Title of alerting as invalid tip address."), isPresented: $confirm_ln_address) {
Button(NSLocalizedString("Ok", comment: "Button to dismiss the alert.")) {
}
} message: {
Text("The address should either begin with LNURL or should look like an email address.", comment: "Giving the description of the alert message.")
}, label: {
Text(NSLocalizedString("Save", comment: "Button for saving profile."))
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
})
.buttonStyle(GradientButtonStyle(padding: 15))
.padding(.horizontal, 10)
.padding(.bottom, 10)
.disabled(!didChange())
.opacity(!didChange() ? 0.5 : 1)
.disabled(profileUploadObserver.isLoading || bannerUploadObserver.isLoading)
.alert(NSLocalizedString("Invalid Tip Address", comment: "Title of alerting as invalid tip address."), isPresented: $confirm_ln_address) {
Button(NSLocalizedString("Ok", comment: "Button to dismiss the alert.")) {
}
} message: {
Text("The address should either begin with LNURL or should look like an email address.", comment: "Giving the description of the alert message.")
}
}
.ignoresSafeArea(edges: .top)
.background(Color(.systemGroupedBackground))
.navigationBarBackButtonHidden()
.toolbar {
ToolbarItem(placement: .principal) {
navBackButton
}
}
.alert(NSLocalizedString("Discard changes?", comment: "Alert user that changes have been made."), isPresented: $confirm_save_alert) {
Button(NSLocalizedString("No", comment: "Do not discard changes."), role: .cancel) {
}
Button(NSLocalizedString("Yes", comment: "Agree to discard changes made to profile.")) {
dismiss()
}
}
}
func uploadedProfilePicture(image_url: URL?) {
@@ -203,6 +238,45 @@ struct EditMetadataView: View {
func uploadedBanner(image_url: URL?) {
banner = image_url?.absoluteString ?? ""
}
func didChange() -> Bool {
let profile_txn = damus_state.profiles.lookup(id: damus_state.pubkey)
let data = profile_txn?.unsafeUnownedValue
if data?.name ?? "" != name {
return true
}
if data?.display_name ?? "" != display_name {
return true
}
if data?.about ?? "" != about {
return true
}
if data?.website ?? "" != website {
return true
}
if data?.picture ?? "" != picture {
return true
}
if data?.banner ?? "" != banner {
return true
}
if data?.nip05 ?? "" != nip05 {
return true
}
if data?.lud16 ?? data?.lud06 ?? "" != ln {
return true
}
return false
}
}
struct EditMetadataView_Previews: PreviewProvider {
+76 -1
View File
@@ -18,6 +18,7 @@ struct EditPictureControl: View {
var size: CGFloat? = 25
var setup: Bool? = false
@Binding var image_url: URL?
@State var image_url_temp: URL?
@ObservedObject var uploadObserver: ImageUploadingObserver
let callback: (URL?) -> Void
@@ -25,12 +26,21 @@ struct EditPictureControl: View {
@State private var show_camera = false
@State private var show_library = false
@State private var show_url_sheet = false
@State var image_upload_confirm: Bool = false
@State var preUploadedMedia: PreUploadedMedia? = nil
@Environment(\.dismiss) var dismiss
var body: some View {
Menu {
Button(action: {
self.show_url_sheet = true
}) {
Text("Image URL", comment: "Option to enter a url")
}
Button(action: {
self.show_library = true
}) {
@@ -51,7 +61,7 @@ struct EditPictureControl: View {
.background(DamusColors.white.opacity(0.7))
.clipShape(Circle())
.shadow(color: DamusColors.purple, radius: 15, x: 0, y: 0)
} else if let url = image_url {
} else if let url = image_url, setup ?? false {
KFAnimatedImage(url)
.imageContext(.pfp, disable_animation: false)
.onFailure(fallbackUrl: URL(string: robohash(pubkey)), cacheKey: url.absoluteString)
@@ -61,6 +71,7 @@ struct EditPictureControl: View {
}
.scaledToFill()
.frame(width: (size ?? 25) + 10, height: (size ?? 25) + 10)
.kfClickable()
.foregroundColor(DamusColors.white)
.clipShape(Circle())
.overlay(Circle().stroke(.white, lineWidth: 4))
@@ -115,6 +126,70 @@ struct EditPictureControl: View {
Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {}
}
}
.sheet(isPresented: $show_url_sheet) {
ZStack {
DamusColors.adaptableWhite.edgesIgnoringSafeArea(.all)
VStack {
Text("Image URL")
.bold()
Divider()
.padding(.horizontal)
HStack {
Image(systemName: "doc.on.clipboard")
.foregroundColor(.gray)
.onTapGesture {
if let pastedURL = UIPasteboard.general.string {
image_url_temp = URL(string: pastedURL)
}
}
TextField(image_url_temp?.absoluteString ?? "", text: Binding(
get: { image_url_temp?.absoluteString ?? "" },
set: { image_url_temp = URL(string: $0) }
))
}
.padding(12)
.background {
RoundedRectangle(cornerRadius: 12)
.stroke(.gray.opacity(0.5), lineWidth: 1)
.background {
RoundedRectangle(cornerRadius: 12)
.foregroundColor(.damusAdaptableWhite)
}
}
.padding(10)
Button(action: {
show_url_sheet.toggle()
}, label: {
Text("Cancel", comment: "Cancel button text for dismissing updating image url.")
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
.padding(10)
})
.buttonStyle(NeutralButtonStyle())
.padding(10)
Button(action: {
image_url = image_url_temp
callback(image_url)
show_url_sheet.toggle()
}, label: {
Text("Update", comment: "Update button text for updating image url.")
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
})
.buttonStyle(GradientButtonStyle(padding: 10))
.padding(.horizontal, 10)
.disabled(image_url_temp == image_url)
.opacity(image_url_temp == image_url ? 0.5 : 1)
}
}
.onAppear {
image_url_temp = image_url
}
.presentationDetents([.height(300)])
.presentationDragIndicator(.visible)
}
}
private func handle_upload(media: MediaUpload) {
+1
View File
@@ -57,6 +57,7 @@ struct InnerProfilePicView: View {
}
.scaledToFill()
.frame(width: size, height: size)
.kfClickable()
.clipShape(Circle())
.overlay(Circle().stroke(highlight_color(highlight), lineWidth: pfp_line_width(highlight)))
}
@@ -31,6 +31,7 @@ struct EditProfilePictureView: View {
view.framePreloadCount = 3
}
.scaledToFill()
.kfClickable()
EditPictureControl(uploader: damus_state?.settings.default_media_uploader ?? .nostrBuild, pubkey: pubkey, image_url: $profile_url, uploadObserver: uploadObserver, callback: callback)
}
+18
View File
@@ -45,6 +45,21 @@ struct ProfileActionSheetView: View {
)
}
var muteButton: some View {
let target_pubkey = self.profile.pubkey
return VStack(alignment: .center, spacing: 10) {
MuteDurationMenu { duration in
notify(.mute(.user(target_pubkey, duration?.date_from_now)))
} label: {
Image("mute")
}
.buttonStyle(NeutralButtonShape.circle.style)
Text("Mute", comment: "Button label that allows the user to mute the user shown on-screen")
.foregroundStyle(.secondary)
.font(.caption)
}
}
var dmButton: some View {
let dm_model = damus_state.dms.lookup_or_create(profile.pubkey)
return VStack(alignment: .center, spacing: 10) {
@@ -103,6 +118,9 @@ struct ProfileActionSheetView: View {
self.followButton
self.zapButton
self.dmButton
if damus_state.keypair.pubkey != profile.pubkey && damus_state.keypair.privkey != nil {
self.muteButton
}
}
.padding()
@@ -64,7 +64,6 @@ struct DamusPurpleAccountView: View {
.padding(.bottom, 20)
}
.foregroundColor(.white.opacity(0.8))
.preferredColorScheme(.dark)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.padding()
}
@@ -81,7 +80,8 @@ struct DamusPurpleAccountView: View {
SupporterBadge(
percent: nil,
purple_account: account,
style: .full
style: .full,
text_color: .white
)
}
}
+10 -9
View File
@@ -126,11 +126,11 @@ struct QRCodeView: View {
if our_profile?.picture != nil {
ProfilePicView(pubkey: pubkey, size: 90.0, highlight: .custom(DamusColors.white, 3.0), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
.padding(.top, 50)
.padding(.top, 20)
} else {
Image(systemName: "person.fill")
.font(.system(size: 60))
.padding(.top, 50)
.padding(.top, 20)
}
if let display_name = profile?.display_name {
@@ -150,17 +150,18 @@ struct QRCodeView: View {
.interpolation(.none)
.resizable()
.scaledToFit()
.frame(width: 300, height: 300)
.frame(minWidth: 100, maxWidth: 300, minHeight: 100, maxHeight: 300)
.cornerRadius(10)
.overlay(RoundedRectangle(cornerRadius: 10)
.stroke(DamusColors.white, lineWidth: 5.0))
.stroke(DamusColors.white, lineWidth: 5.0)
.scaledToFit())
.shadow(radius: 10)
Spacer()
Text("Follow me on Nostr", comment: "Text on QR code view to prompt viewer looking at screen to follow the user.")
.font(.system(size: 24, weight: .heavy))
.padding(.top)
.padding(.top, 10)
.foregroundColor(.white)
Text("Scan the code", comment: "Text on QR code view to prompt viewer to scan the QR code on screen with their device camera.")
@@ -179,7 +180,7 @@ struct QRCodeView: View {
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: 12, alignment: .center)
}
.buttonStyle(GradientButtonStyle())
.padding(50)
.padding(20)
}
}
@@ -201,11 +202,11 @@ struct QRCodeView: View {
}
}
.scaledToFit()
.frame(width: 300, height: 300)
.frame(maxWidth: 300, maxHeight: 300)
.cornerRadius(10)
.overlay(RoundedRectangle(cornerRadius: 10).stroke(DamusColors.white, lineWidth: 5.0))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(DamusColors.white, lineWidth: 5.0).scaledToFit())
.overlay(RoundedRectangle(cornerRadius: 10).trim(from: 0.0, to: outerTrimEnd).stroke(DamusColors.black, lineWidth: 5.5)
.rotationEffect(.degrees(-90)))
.rotationEffect(.degrees(-90)).scaledToFit())
.shadow(radius: 10)
Spacer()
+1
View File
@@ -55,6 +55,7 @@ struct InnerRelayPicView: View {
Placeholder(url: url)
}
.scaledToFit()
.kfClickable()
} else {
FailedRelayImage(url: nil)
}
+7
View File
@@ -50,6 +50,13 @@ struct RelayView: View {
.padding(.bottom, 2)
.lineLimit(1)
RelayType(is_paid: state.relay_model_cache.model(with_relay_id: relay)?.metadata.is_paid ?? false)
if relay.absoluteString.hasSuffix(".onion") {
Image("tor")
.resizable()
.interpolation(.none)
.frame(width: 20, height: 20)
}
}
Text(relay.absoluteString)
.font(.subheadline)
@@ -0,0 +1,91 @@
//
// AppleTranslationSettingsView.swift
// damus
//
// Created by Terry Yiu on 9/22/24.
//
import SwiftUI
import Translation
@available(iOS 18.0, macOS 15.0, *)
@available(macCatalyst, unavailable)
struct AppleTranslationSettingsView: View {
@ObservedObject var settings: UserSettingsStore
@State private var supportedLanguages: [Locale.Language] = []
@State private var installedLanguages = Set<Locale.Language>()
@State private var installingLanguages = Set<Locale.Language>()
@State private var languageTranslationConfigurations = [Locale.Language: TranslationSession.Configuration]()
var body: some View {
if settings.translation_service == .none {
if !installedLanguages.isEmpty {
Section(NSLocalizedString("Available Offline", comment: "Section for downloaded languages for Apple offline translations.")) {
ForEach(installedLanguages.sorted(using: LanguageSortComparator(order: .forward)), id: \.self) { language in
Text(Locale.current.localizedString(forLanguage: language))
}
}
}
Section(NSLocalizedString("Languages Available for Download", comment: "Section for downloadable languages for Apple offline translations.")) {
ForEach(supportedLanguages.filter { !installedLanguages.contains($0) }, id: \.self) { language in
HStack {
Text(Locale.current.localizedString(forLanguage: language))
Button(
action: {
installingLanguages.insert(language)
languageTranslationConfigurations[language]?.invalidate()
},
label: {
Image(systemName: "arrow.down.circle")
}
)
}
.translationTask(languageTranslationConfigurations[language]) { session in
if installingLanguages.contains(language) {
do {
// Display a sheet asking the user's permission
// to start downloading the language pairing by
// translating a dummy string.
//
// We do not use `session.prepareTranslation()` because
// it does not throw errors as loudly as `session.translate` does,
// which helps us indicate when language download is complete.
_ = try await session.translate("A")
installedLanguages.insert(language)
installingLanguages.remove(language)
} catch {
// Handle any errors.
print("Error downloading language \(language): \(error)")
installingLanguages.remove(language)
}
}
}
}
}
.onAppear {
Task {
let languageAvailability = LanguageAvailability()
supportedLanguages = await languageAvailability.supportedLanguages
supportedLanguages.sort(using: LanguageSortComparator(order: .forward))
installedLanguages.removeAll()
for supportedLanguage in supportedLanguages {
let status = await languageAvailability.status(from: supportedLanguage, to: nil)
switch status {
case .installed:
installedLanguages.insert(supportedLanguage)
case .supported:
languageTranslationConfigurations[supportedLanguage] = TranslationSession.Configuration(
source: supportedLanguage
)
default:
break
}
}
}
}
}
}
}
@@ -18,12 +18,41 @@ struct DeveloperSettingsView: View {
.toggleStyle(.switch)
if settings.developer_mode {
Toggle(NSLocalizedString("Always show onboarding", comment: "Developer mode setting to always show onboarding suggestions."), isOn: $settings.always_show_onboarding_suggestions)
Toggle(NSLocalizedString("Enable experimental push notifications", comment: "Developer mode setting to enable experimental push notifications."), isOn: $settings.enable_experimental_push_notifications)
.toggleStyle(.switch)
Toggle(NSLocalizedString("Send device token to localhost", comment: "Developer mode setting to send device token metadata to a local server instead of the damus.io server."), isOn: $settings.send_device_token_to_localhost)
.toggleStyle(.switch)
Picker(NSLocalizedString("Push notification environment", comment: "Prompt selection of the Push notification environment (Developer feature to switch between real/production mode to test modes)."),
selection: Binding(
get: { () -> PushNotificationClient.Environment in
switch settings.push_notification_environment {
case .local_test(_):
return .local_test(host: nil) // Avoid errors related to a value which is not a valid picker option
default:
return settings.push_notification_environment
}
},
set: { new_value in
settings.push_notification_environment = new_value
}
)
) {
ForEach(PushNotificationClient.Environment.allCases, id: \.self) { push_notification_environment in
Text(push_notification_environment.text_description())
.tag(push_notification_environment.to_string())
}
}
if case .local_test(_) = settings.push_notification_environment {
TextField(
NSLocalizedString("URL", comment: "Custom URL host for Damus push notification testing"),
text: Binding.init(
get: {
return settings.push_notification_environment.custom_host() ?? ""
}, set: { new_host_value in
settings.push_notification_environment = .local_test(host: new_host_value)
}
)
)
.disableAutocorrection(true)
.autocapitalization(UITextAutocapitalizationType.none)
}
Toggle(NSLocalizedString("Enable experimental Purple API support", comment: "Developer mode setting to enable experimental Purple API support."), isOn: $settings.enable_experimental_purple_api)
.toggleStyle(.switch)
@@ -7,10 +7,13 @@
import SwiftUI
let MINIMUM_PUSH_NOTIFICATION_SYNC_DELAY_IN_SECONDS = 0.25
struct NotificationSettingsView: View {
let damus_state: DamusState
@ObservedObject var settings: UserSettingsStore
@State var notification_mode_setting_error: String? = nil
@State var notification_preferences_sync_state: PreferencesSyncState = .undefined
@Environment(\.dismiss) var dismiss
@@ -32,7 +35,8 @@ struct NotificationSettingsView: View {
Task {
do {
try await damus_state.push_notification_client.send_token()
settings.notifications_mode = new_value
await self.sync_up_remote_notification_settings()
settings.notification_mode = new_value
}
catch {
notification_mode_setting_error = String(format: NSLocalizedString("Error configuring push notifications with the server: %@", comment: "Error label shown when user tries to enable push notifications but something fails"), error.localizedDescription)
@@ -43,7 +47,8 @@ struct NotificationSettingsView: View {
Task {
do {
try await damus_state.push_notification_client.revoke_token()
settings.notifications_mode = new_value
settings.notification_mode = new_value
notification_preferences_sync_state = .not_applicable
}
catch {
notification_mode_setting_error = String(format: NSLocalizedString("Error disabling push notifications with the server: %@", comment: "Error label shown when user tries to disable push notifications but something fails"), error.localizedDescription)
@@ -52,9 +57,64 @@ struct NotificationSettingsView: View {
}
}
// MARK: - Push notification preference sync management
func notification_preference_binding<T>(_ raw_binding: Binding<T>) -> Binding<T> {
return Binding(
get: {
return raw_binding.wrappedValue
},
set: { new_value in
let old_value = raw_binding.wrappedValue
raw_binding.wrappedValue = new_value
if self.settings.notification_mode == .push {
Task {
await self.send_push_notification_preferences(on_failure: {
raw_binding.wrappedValue = old_value
})
}
}
}
)
}
func sync_up_remote_notification_settings() async {
do {
notification_preferences_sync_state = .syncing
let remote_settings = try await damus_state.push_notification_client.get_settings()
let local_settings = PushNotificationClient.NotificationSettings.from(settings: settings)
if remote_settings != local_settings {
await self.send_push_notification_preferences(local_settings)
}
else {
notification_preferences_sync_state = .success
}
}
catch {
notification_preferences_sync_state = .failure(error: String(format: NSLocalizedString("Failed to get push notification preferences from the server", comment: "Error label indicating about a failure in fetching notification preferences"), error.localizedDescription))
}
}
func send_push_notification_preferences(_ new_settings: PushNotificationClient.NotificationSettings? = nil, on_failure: (() -> Void)? = nil) async {
do {
notification_preferences_sync_state = .syncing
try await damus_state.push_notification_client.set_settings(new_settings)
// Make sync appear to take at least a few milliseconds or so to avoid issues with labor perception bias (https://growth.design/case-studies/labor-perception-bias)
DispatchQueue.main.asyncAfter(deadline: .now() + MINIMUM_PUSH_NOTIFICATION_SYNC_DELAY_IN_SECONDS) {
notification_preferences_sync_state = .success
}
}
catch {
notification_preferences_sync_state = .failure(error: String(format: NSLocalizedString("Error syncing up push notifications preferences with the server: %@", comment: "Error label shown when system tries to sync up notification preferences to the push notification server but something fails"), error.localizedDescription))
on_failure?()
}
}
// MARK: - View layout
var body: some View {
Form {
if settings.enable_experimental_push_notifications {
if settings.enable_push_notifications {
Section(
header: Text("General", comment: "Section header for general damus notifications user configuration"),
footer: VStack {
@@ -66,7 +126,7 @@ struct NotificationSettingsView: View {
) {
Picker(NSLocalizedString("Notifications mode", comment: "Prompt selection of the notification mode (Feature to switch between local notifications (generated from user's own phone) or push notifications (generated by Damus server)."),
selection: Binding(
get: { settings.notifications_mode },
get: { settings.notification_mode },
set: { newValue in
self.try_to_set_notifications_mode(new_value: newValue)
}
@@ -80,21 +140,40 @@ struct NotificationSettingsView: View {
}
}
Section(header: Text("Local Notifications", comment: "Section header for damus local notifications user configuration")) {
Toggle(NSLocalizedString("Zaps", comment: "Setting to enable Zap Local Notification"), isOn: $settings.zap_notification)
Section(
header: Text("Notification Preferences", comment: "Section header for Notification Preferences"),
footer: VStack {
switch notification_preferences_sync_state {
case .undefined, .not_applicable:
EmptyView()
case .success:
HStack {
Image("check-circle.fill")
.foregroundStyle(.damusGreen)
Text("Successfully synced", comment: "Label indicating success in syncing notification preferences")
}
case .syncing:
HStack(spacing: 10) {
ProgressView()
Text("Syncing", comment: "Label indicating success in syncing notification preferences")
}
case .failure(let error):
Text(error)
.foregroundStyle(.damusDangerPrimary)
}
}
) {
Toggle(NSLocalizedString("Zaps", comment: "Setting to enable Zap Local Notification"), isOn: self.notification_preference_binding($settings.zap_notification))
.toggleStyle(.switch)
Toggle(NSLocalizedString("Mentions", comment: "Setting to enable Mention Local Notification"), isOn: $settings.mention_notification)
Toggle(NSLocalizedString("Mentions", comment: "Setting to enable Mention Local Notification"), isOn: self.notification_preference_binding($settings.mention_notification))
.toggleStyle(.switch)
Toggle(NSLocalizedString("Reposts", comment: "Setting to enable Repost Local Notification"), isOn: $settings.repost_notification)
Toggle(NSLocalizedString("Reposts", comment: "Setting to enable Repost Local Notification"), isOn: self.notification_preference_binding($settings.repost_notification))
.toggleStyle(.switch)
Toggle(NSLocalizedString("Likes", comment: "Setting to enable Like Local Notification"), isOn: $settings.like_notification)
Toggle(NSLocalizedString("Likes", comment: "Setting to enable Like Local Notification"), isOn: self.notification_preference_binding($settings.like_notification))
.toggleStyle(.switch)
Toggle(NSLocalizedString("DMs", comment: "Setting to enable DM Local Notification"), isOn: $settings.dm_notification)
Toggle(NSLocalizedString("DMs", comment: "Setting to enable DM Local Notification"), isOn: self.notification_preference_binding($settings.dm_notification))
.toggleStyle(.switch)
}
Section(header: Text("Notification Preference", comment: "Section header for Notification Preferences")) {
Toggle(NSLocalizedString("Show only from users you follow", comment: "Setting to Show notifications only associated to users your follow"), isOn: $settings.notification_only_from_following)
Toggle(NSLocalizedString("Show only from users you follow", comment: "Setting to Show notifications only associated to users your follow"), isOn: self.notification_preference_binding($settings.notification_only_from_following))
.toggleStyle(.switch)
}
@@ -113,6 +192,28 @@ struct NotificationSettingsView: View {
.onReceive(handle_notify(.switched_timeline)) { _ in
dismiss()
}
.onAppear(perform: {
Task {
if self.settings.notification_mode == .push {
await self.sync_up_remote_notification_settings()
}
}
})
}
}
extension NotificationSettingsView {
enum PreferencesSyncState {
/// State is unknown
case undefined
/// State is not applicable (e.g. Notifications are set to local)
case not_applicable
/// Preferences are successfully synced
case success
/// Preferences are being synced
case syncing
/// There was a failure during syncing
case failure(error: String)
}
}
@@ -6,13 +6,14 @@
//
import SwiftUI
import Translation
struct TranslationSettingsView: View {
@ObservedObject var settings: UserSettingsStore
var damus_state: DamusState
@Environment(\.dismiss) var dismiss
var body: some View {
Form {
Section(NSLocalizedString("Translations", comment: "Section title for selecting the translation service.")) {
@@ -102,11 +103,48 @@ struct TranslationSettingsView: View {
*/
}
}
#if !targetEnvironment(macCatalyst)
if #available(iOS 18.0, macOS 15.0, *), settings.translation_service == .none {
Toggle(NSLocalizedString("Automatically translate notes", comment: "Toggle to automatically translate notes."), isOn: $settings.auto_translate)
.toggleStyle(.switch)
Section (
content: {
Toggle(NSLocalizedString("On-Device Mode", comment: "Toggle to always translate offline using downloaded languages."), isOn: $settings.translate_offline)
}, footer: {
Text("Always translate offline using downloaded languages. Offline translations may not be as accurate as online translations. Apple may collect usage metrics, but this data does not include the original or translated content.", comment: "Section footer explaining the implications of enabling offline translations.")
}
)
AppleTranslationSettingsView(settings: settings)
}
#endif
}
.navigationTitle(NSLocalizedString("Translation", comment: "Navigation title for translation settings."))
.onReceive(handle_notify(.switched_timeline)) { _ in
dismiss()
}
.onChange(of: settings.auto_translate) { newValue in
// Apple automatic translation can occur only if offline translations are enabled.
if settings.translation_service == .none && newValue && !settings.translate_offline {
settings.translate_offline = true
}
}
.onChange(of: settings.translate_offline) { newValue in
// Apple automatic translation can occur only if offline translations are enabled.
if settings.translation_service == .none && !newValue && settings.auto_translate {
settings.auto_translate = false
}
}
.onChange(of: settings.translation_service) { newValue in
// Apple automatic translation can occur only if offline translations are enabled.
// If automatic translations are enabled for a non-Apple translation service,
// and then the translation service is switched to Apple, offline translations need to be enabled.
if newValue == .none && settings.auto_translate && !settings.translate_offline {
settings.translate_offline = true
}
}
}
}
+19 -8
View File
@@ -90,6 +90,7 @@ struct TextViewWrapper: UIViewRepresentable {
let updateCursorPosition: ((Int) -> Void)
let initialTextSuffix: String?
var initialTextSuffixWasAdded: Bool = false
static let ESCAPE_SEQUENCES = ["\n", "@", " ", ", ", ". ", "! ", "? ", "; "]
init(attributedText: Binding<NSMutableAttributedString>,
getFocusWordForMention: ((String?, NSRange?) -> Void)?,
@@ -142,17 +143,27 @@ struct TextViewWrapper: UIViewRepresentable {
while startPosition != textView.beginningOfDocument {
guard let previousPosition = textView.position(from: startPosition, offset: -1),
let range = textView.textRange(from: previousPosition, to: startPosition),
let text = textView.text(in: range), !text.isEmpty,
let lastChar = text.last else {
let range = textView.textRange(from: previousPosition, to: position),
let text = textView.text(in: range), !text.isEmpty else {
break
}
if [" ", "\n", "@"].contains(lastChar) {
break
}
startPosition = previousPosition
if let styling = textView.textStyling(at: previousPosition, in: .backward),
styling[NSAttributedString.Key.link] != nil {
break
}
var found_escape_sequence = false
for escape_sequence in Self.ESCAPE_SEQUENCES {
if text.contains(escape_sequence) {
startPosition = textView.position(from: startPosition, offset: escape_sequence.count) ?? startPosition
found_escape_sequence = true
break
}
}
if found_escape_sequence { break }
}
return startPosition == position ? nil : textView.textRange(from: startPosition, to: position)
+23
View File
@@ -0,0 +1,23 @@
//
// ClickableOverlay.swift
// damus
//
// Created by Daniel DAquino on 2024-09-20.
//
import SwiftUI
/// Applies a property that makes `KFAnimatedImage` clickable again on iOS 18+
fileprivate struct KFClickable: ViewModifier {
func body(content: Content) -> some View {
content
.contentShape(Rectangle())
}
}
extension View {
/// Applies a property that makes `KFAnimatedImage` clickable again on iOS 18+
func kfClickable() -> some View {
return self.modifier(KFClickable())
}
}
+1 -1
View File
@@ -356,7 +356,7 @@ struct ZapSheetViewIfPossible: View {
extension View {
func hideKeyboard() {
let resign = #selector(UIResponder.resignFirstResponder)
UIApplication.shared.sendAction(resign, to: nil, from: nil, for: nil)
this_app.sendAction(resign, to: nil, from: nil, for: nil)
}
}
Binary file not shown.
+234 -97
View File
@@ -2,7 +2,7 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 http://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd">
<file original="damus/en-US.lproj/InfoPlist.strings" source-language="en-US" target-language="en-US" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.3" build-num="15E204a"/>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.4" build-num="15F31d"/>
</header>
<body>
<trans-unit id="CFBundleDisplayName" xml:space="preserve">
@@ -44,7 +44,7 @@
</file>
<file original="damus/en-US.lproj/Localizable.strings" source-language="en-US" target-language="en-US" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.3" build-num="15E204a"/>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.4" build-num="15F31d"/>
</header>
<body>
<trans-unit id="%@ %@" xml:space="preserve">
@@ -144,11 +144,6 @@ Sentence composed of 2 variables to describe how many people are following a use
<target>API Key (required)</target>
<note>Prompt for required entry of API Key to use translation server.</note>
</trans-unit>
<trans-unit id="About" xml:space="preserve">
<source>About</source>
<target>About</target>
<note>Label to prompt for about text entry for user to describe about themself.</note>
</trans-unit>
<trans-unit id="About Me" xml:space="preserve">
<source>About Me</source>
<target>About Me</target>
@@ -159,6 +154,11 @@ Sentence composed of 2 variables to describe how many people are following a use
<target>Absolute Boss</target>
<note>Placeholder text for About Me description.</note>
</trans-unit>
<trans-unit id="Absolute legend." xml:space="preserve">
<source>Absolute legend.</source>
<target>Absolute legend.</target>
<note>Example Bio</note>
</trans-unit>
<trans-unit id="Accessibility" xml:space="preserve">
<source>Accessibility</source>
<target>Accessibility</target>
@@ -189,6 +189,11 @@ Sentence composed of 2 variables to describe how many people are following a use
<target>Add Bookmark</target>
<note>Button text to add bookmark to a note.</note>
</trans-unit>
<trans-unit id="Add Photo" xml:space="preserve">
<source>Add Photo</source>
<target>Add Photo</target>
<note>Label to indicate user can add a photo.</note>
</trans-unit>
<trans-unit id="Add all" xml:space="preserve">
<source>Add all</source>
<target>Add all</target>
@@ -255,6 +260,11 @@ Button text to add a relay</note>
<target>An additional percentage of each zap will be sent to support Damus development</target>
<note>Text indicating that they can contribute zaps to support Damus development.</note>
</trans-unit>
<trans-unit id="An unexpected error happened while trying to create the new contact list. Please contact support." xml:space="preserve">
<source>An unexpected error happened while trying to create the new contact list. Please contact support.</source>
<target>An unexpected error happened while trying to create the new contact list. Please contact support.</target>
<note>Error message for a failed contact list reset operation</note>
</trans-unit>
<trans-unit id="Animations" xml:space="preserve">
<source>Animations</source>
<target>Animations</target>
@@ -351,10 +361,10 @@ Tip: You can always change this later in Settings → Translations</target>
<target>Be the first to access upcoming premium features: Automatic translations, longer note storage, and more</target>
<note>Description of new features to be expected</note>
</trans-unit>
<trans-unit id="Before we get started, you'll need to save your account info, otherwise you won't be able to login in the future if you ever uninstall Damus." xml:space="preserve">
<source>Before we get started, you'll need to save your account info, otherwise you won't be able to login in the future if you ever uninstall Damus.</source>
<target>Before we get started, you'll need to save your account info, otherwise you won't be able to login in the future if you ever uninstall Damus.</target>
<note>Reminder to user that they should save their account information.</note>
<trans-unit id="Bio" xml:space="preserve">
<source>Bio</source>
<target>Bio</target>
<note>Label to prompt bio entry for user to describe themself.</note>
</trans-unit>
<trans-unit id="Bitcoin Lightning Tips" xml:space="preserve">
<source>Bitcoin Lightning Tips</source>
@@ -383,10 +393,10 @@ Tip: You can always change this later in Settings → Translations</target>
<target>Broadcast music playing on Apple Music</target>
<note>Toggle to enable or disable broadcasting what music is being played on Apple Music in their profile status.</note>
</trans-unit>
<trans-unit id="By signing up, you agree to our " xml:space="preserve">
<source>By signing up, you agree to our </source>
<target>By signing up, you agree to our </target>
<note>Ask the user if they already have an account on Nostr</note>
<trans-unit id="By continuing you agree to our " xml:space="preserve">
<source>By continuing you agree to our </source>
<target>By continuing you agree to our </target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="By subscribing to Damus Purple, you are accepting our [privacy policy](https://damus.io/privacy-policy.txt) and Apple's Standard [EULA](https://www.apple.com/legal/internet-services/itunes/dev/stdeula/)" xml:space="preserve">
<source>By subscribing to Damus Purple, you are accepting our [privacy policy](https://damus.io/privacy-policy.txt) and Apple's Standard [EULA](https://www.apple.com/legal/internet-services/itunes/dev/stdeula/)</source>
@@ -413,7 +423,8 @@ Tip: You can always change this later in Settings → Translations</target>
Button to cancel the upload.
Cancel deleting bookmarks.
Cancel deleting the user.
Cancel out of logging out the user.</note>
Cancel out of logging out the user.
Cancel resetting the contact list.</note>
</trans-unit>
<trans-unit id="Changing this setting will cause the cache to be cleared. This will free space, but images may take longer to load again. Are you sure you want to proceed?" xml:space="preserve">
<source>Changing this setting will cause the cache to be cleared. This will free space, but images may take longer to load again. Are you sure you want to proceed?</source>
@@ -486,6 +497,16 @@ Button to connect to the relay.</note>
<target>Connecting</target>
<note>Relay status label that indicates a relay is connecting.</note>
</trans-unit>
<trans-unit id="Contact list (Follows + Relay list)" xml:space="preserve">
<source>Contact list (Follows + Relay list)</source>
<target>Contact list (Follows + Relay list)</target>
<note>Section title for Contact list first aid tools</note>
</trans-unit>
<trans-unit id="Contact list has been reset" xml:space="preserve">
<source>Contact list has been reset</source>
<target>Contact list has been reset</target>
<note>Message indicating that the contact list was successfully reset.</note>
</trans-unit>
<trans-unit id="Content filters" xml:space="preserve">
<source>Content filters</source>
<target>Content filters</target>
@@ -495,7 +516,8 @@ Button to connect to the relay.</note>
<source>Continue</source>
<target>Continue</target>
<note>Continue with bookmarks.
Continue with deleting the user.</note>
Continue with deleting the user.
Continue with resetting the contact list.</note>
</trans-unit>
<trans-unit id="Copied" xml:space="preserve">
<source>Copied</source>
@@ -569,6 +591,11 @@ Button to connect to the relay.</note>
<target>Copy user public key</target>
<note>Context menu option for copying the ID of the user who created the note.</note>
</trans-unit>
<trans-unit id="Could not create your initial contact list event. This is a software bug, please contact Damus support via support@damus.io or through our Nostr account for help." xml:space="preserve">
<source>Could not create your initial contact list event. This is a software bug, please contact Damus support via support@damus.io or through our Nostr account for help.</source>
<target>Could not create your initial contact list event. This is a software bug, please contact Damus support via support@damus.io or through our Nostr account for help.</target>
<note>Error message to the user indicating that the initial contact list failed to be created.</note>
</trans-unit>
<trans-unit id="Could not find the user you're looking for" xml:space="preserve">
<source>Could not find the user you're looking for</source>
<target>Could not find the user you're looking for</target>
@@ -579,26 +606,21 @@ Button to connect to the relay.</note>
<target>Could not find user to mute...</target>
<note>Alert message to indicate that the muted user could not be found.</note>
</trans-unit>
<trans-unit id="Create Account" xml:space="preserve">
<source>Create Account</source>
<target>Create Account</target>
<note>Button to continue to the create account page.</note>
</trans-unit>
<trans-unit id="Create account" xml:space="preserve">
<source>Create account</source>
<target>Create account</target>
<note>Button to navigate to create account view.</note>
</trans-unit>
<trans-unit id="Create account now" xml:space="preserve">
<source>Create account now</source>
<target>Create account now</target>
<note>Button to create account.</note>
</trans-unit>
<trans-unit id="Create new mutelist" xml:space="preserve">
<source>Create new mutelist</source>
<target>Create new mutelist</target>
<note>Title of alert prompting the user to create a new mutelist.</note>
</trans-unit>
<trans-unit id="Creator(s) of Bitcoin. Absolute legend." xml:space="preserve">
<source>Creator(s) of Bitcoin. Absolute legend.</source>
<target>Creator(s) of Bitcoin. Absolute legend.</target>
<note>Example description about Bitcoin creator(s), Satoshi Nakamoto.</note>
</trans-unit>
<trans-unit id="Custom" xml:space="preserve">
<source>Custom</source>
<target>Custom</target>
@@ -609,6 +631,7 @@ Button to connect to the relay.</note>
<target>DMs</target>
<note>Navigation title for DMs view, where DM is the English abbreviation for Direct Message.
Navigation title for view of DMs, where DM is an English abbreviation for Direct Message.
Picker option for DM selector for seeing only DMs that have been responded to. DM is the English abbreviation for Direct Message.
Setting to enable DM Local Notification
Toolbar label for DMs view, where DM is the English abbreviation for Direct Message.</note>
</trans-unit>
@@ -695,11 +718,6 @@ Button to disconnect from a relay server.</note>
<target>Dismiss</target>
<note>Button to dismiss alert</note>
</trans-unit>
<trans-unit id="Display name" xml:space="preserve">
<source>Display name</source>
<target>Display name</target>
<note>Label to prompt display name entry.</note>
</trans-unit>
<trans-unit id="Done" xml:space="preserve">
<source>Done</source>
<target>Done</target>
@@ -766,6 +784,16 @@ Button to disconnect from a relay server.</note>
<note>Label to display that authentication to a server has failed.
Relay status label that indicates a relay had an error when connecting</note>
</trans-unit>
<trans-unit id="Error configuring push notifications with the server: %@" xml:space="preserve">
<source>Error configuring push notifications with the server: %@</source>
<target>Error configuring push notifications with the server: %@</target>
<note>Error label shown when user tries to enable push notifications but something fails</note>
</trans-unit>
<trans-unit id="Error disabling push notifications with the server: %@" xml:space="preserve">
<source>Error disabling push notifications with the server: %@</source>
<target>Error disabling push notifications with the server: %@</target>
<note>Error label shown when user tries to disable push notifications but something fails</note>
</trans-unit>
<trans-unit id="Error fetching lightning invoice" xml:space="preserve">
<source>Error fetching lightning invoice</source>
<target>Error fetching lightning invoice</target>
@@ -816,6 +844,12 @@ Relay status label that indicates a relay had an error when connecting</note>
<target>Failed to parse</target>
<note>NostrScript error message when it fails to parse a script.</note>
</trans-unit>
<trans-unit id="First Aid" xml:space="preserve">
<source>First Aid</source>
<target>First Aid</target>
<note>Navigation title for first aid settings and tools
Section header for first aid tools and settings</note>
</trans-unit>
<trans-unit id="Follow" xml:space="preserve">
<source>Follow</source>
<target>Follow</target>
@@ -907,6 +941,11 @@ My side interests include languages and I am striving to be a #polyglot - I am a
<target>Free</target>
<note>Dropdown option for selecting Free plan for DeepL translation service.</note>
</trans-unit>
<trans-unit id="General" xml:space="preserve">
<source>General</source>
<target>General</target>
<note>Section header for general damus notifications user configuration</note>
</trans-unit>
<trans-unit id="Get API Key" xml:space="preserve">
<source>Get API Key</source>
<target>Get API Key</target>
@@ -926,7 +965,8 @@ My side interests include languages and I am striving to be a #polyglot - I am a
<trans-unit id="Hashtags" xml:space="preserve">
<source>Hashtags</source>
<target>Hashtags</target>
<note>Section header title for a list of hashtags that are muted.</note>
<note>Label for filter for seeing only hashtag follows.
Section header title for a list of hashtags that are muted.</note>
</trans-unit>
<trans-unit id="Hello everybody!&#10;&#10;This is my first post on Damus, I am happy to meet you all 🤙. Whats up?&#10;&#10;#introductions" xml:space="preserve">
<source>Hello everybody!
@@ -971,6 +1011,16 @@ This is my first post on Damus, I am happy to meet you all 🤙. Whats up?
<target>Hide notes with #nsfw tags</target>
<note>Setting to hide notes with the #nsfw (not safe for work) tags</note>
</trans-unit>
<trans-unit id="Highlighted" xml:space="preserve">
<source>Highlighted</source>
<target>Highlighted</target>
<note>Label to indicate that the user is highlighting their own post.</note>
</trans-unit>
<trans-unit id="Highlighted %@" xml:space="preserve">
<source>Highlighted %@</source>
<target>Highlighted %@</target>
<note>Label to indicate that the user is highlighting 1 user.</note>
</trans-unit>
<trans-unit id="Home" xml:space="preserve">
<source>Home</source>
<target>Home</target>
@@ -1005,6 +1055,11 @@ Hope to meet folks who are on their own journeys to a peaceful and free life!</t
<target>Impersonation</target>
<note>Description of report type for impersonation.</note>
</trans-unit>
<trans-unit id="In progress…" xml:space="preserve">
<source>In progress…</source>
<target>In progress…</target>
<note>Loading message indicating that a contact list reset operation is in progress.</note>
</trans-unit>
<trans-unit id="Indefinite" xml:space="preserve">
<source>Indefinite</source>
<target>Indefinite</target>
@@ -1051,11 +1106,6 @@ Hope to meet folks who are on their own journeys to a peaceful and free life!</t
<target>LIVE</target>
<note>Text indicator that the video is a livestream.</note>
</trans-unit>
<trans-unit id="Learn more about Nostr" xml:space="preserve">
<source>Learn more about Nostr</source>
<target>Learn more about Nostr</target>
<note>Button that opens up a webpage where the user can learn more about Nostr.</note>
</trans-unit>
<trans-unit id="Learn more about the features" xml:space="preserve">
<source>Learn more about the features</source>
<target>Learn more about the features</target>
@@ -1066,16 +1116,6 @@ Hope to meet folks who are on their own journeys to a peaceful and free life!</t
<target>Left Handed</target>
<note>Moves the post button to the left side of the screen</note>
</trans-unit>
<trans-unit id="Let's get started!" xml:space="preserve">
<source>Let's get started!</source>
<target>Let's get started!</target>
<note>Button to continue to login page.</note>
</trans-unit>
<trans-unit id="Let's go!" xml:space="preserve">
<source>Let's go!</source>
<target>Let's go!</target>
<note>Button to complete account creation and start using the app.</note>
</trans-unit>
<trans-unit id="LibreTranslate (Open Source)" xml:space="preserve">
<source>LibreTranslate (Open Source)</source>
<target>LibreTranslate (Open Source)</target>
@@ -1106,6 +1146,11 @@ Hope to meet folks who are on their own journeys to a peaceful and free life!</t
<target>Load media</target>
<note>Button to show media in note.</note>
</trans-unit>
<trans-unit id="Local" xml:space="preserve">
<source>Local</source>
<target>Local</target>
<note>Option for notification mode setting: Local notification mode</note>
</trans-unit>
<trans-unit id="Local Notifications" xml:space="preserve">
<source>Local Notifications</source>
<target>Local Notifications</target>
@@ -1175,7 +1220,8 @@ Hope to meet folks who are on their own journeys to a peaceful and free life!</t
<trans-unit id="Mentions" xml:space="preserve">
<source>Mentions</source>
<target>Mentions</target>
<note>Setting to enable Mention Local Notification</note>
<note>Label for filter for seeing mention notifications (replies, etc).
Setting to enable Mention Local Notification</note>
</trans-unit>
<trans-unit id="Merch" xml:space="preserve">
<source>Merch</source>
@@ -1195,7 +1241,9 @@ Hope to meet folks who are on their own journeys to a peaceful and free life!</t
<trans-unit id="Mute" xml:space="preserve">
<source>Mute</source>
<target>Mute</target>
<note>Alert button to mute a user.</note>
<note>Alert button to mute a user.
Button to mute a profile
Title for confirmation dialog to mute a profile.</note>
</trans-unit>
<trans-unit id="Mute %@?" xml:space="preserve">
<source>Mute %@?</source>
@@ -1235,6 +1283,11 @@ Hope to meet folks who are on their own journeys to a peaceful and free life!</t
Text label indicating that there is no NIP-11 relay description information found. In English, N/A stands for not applicable.
Text label indicating that there is no NIP-11 relay software information found. In English, N/A stands for not applicable.</note>
</trans-unit>
<trans-unit id="Name" xml:space="preserve">
<source>Name</source>
<target>Name</target>
<note>Label to prompt name entry.</note>
</trans-unit>
<trans-unit id="Never" xml:space="preserve">
<source>Never</source>
<target>Never</target>
@@ -1260,11 +1313,21 @@ Text label indicating that there is no NIP-11 relay software information found.
<target>New to Nostr?</target>
<note>Ask the user if they are new to Nostr</note>
</trans-unit>
<trans-unit id="Next" xml:space="preserve">
<source>Next</source>
<target>Next</target>
<note>Button to continue with account creation.</note>
</trans-unit>
<trans-unit id="No" xml:space="preserve">
<source>No</source>
<target>No</target>
<note>User confirm No</note>
</trans-unit>
<trans-unit id="No contact list was found. You might experience issues using the app. If you suspect you have permanently lost your contact list (or if you never had one), you can fix this by resetting it" xml:space="preserve">
<source>No contact list was found. You might experience issues using the app. If you suspect you have permanently lost your contact list (or if you never had one), you can fix this by resetting it</source>
<target>No contact list was found. You might experience issues using the app. If you suspect you have permanently lost your contact list (or if you never had one), you can fix this by resetting it</target>
<note>Section footer for Contact list first aid tools</note>
</trans-unit>
<trans-unit id="No logs to display" xml:space="preserve">
<source>No logs to display</source>
<target>No logs to display</target>
@@ -1310,11 +1373,6 @@ Text label indicating that there is no NIP-11 relay software information found.
<target>Nostr Address</target>
<note>Label for the Nostr Address section of user profile form.</note>
</trans-unit>
<trans-unit id="Nostr is a protocol, designed for simplicity, that aims to create a censorship-resistant global social network" xml:space="preserve">
<source>Nostr is a protocol, designed for simplicity, that aims to create a censorship-resistant global social network</source>
<target>Nostr is a protocol, designed for simplicity, that aims to create a censorship-resistant global social network</target>
<note>Description about what is Nostr.</note>
</trans-unit>
<trans-unit id="NostrScript" xml:space="preserve">
<source>NostrScript</source>
<target>NostrScript</target>
@@ -1325,6 +1383,11 @@ Text label indicating that there is no NIP-11 relay software information found.
<target>NostrScript Error</target>
<note>Text indicating that there was an error with loading NostrScript. There is a more descriptive error message shown separately underneath.</note>
</trans-unit>
<trans-unit id="Not now" xml:space="preserve">
<source>Not now</source>
<target>Not now</target>
<note>Button to not save key, complete account creation, and start using the app.</note>
</trans-unit>
<trans-unit id="Note from a %@ you've muted" xml:space="preserve">
<source>Note from a %@ you've muted</source>
<target>Note from a %@ you've muted</target>
@@ -1333,20 +1396,18 @@ Text label indicating that there is no NIP-11 relay software information found.
<trans-unit id="Note you've muted" xml:space="preserve">
<source>Note you've muted</source>
<target>Note you've muted</target>
<note>Text to indicate that what is being shown is a note which has been muted.</note>
<note>Text to indicate that what is being shown is a note which has been muted.
Label indicating note has been muted</note>
</trans-unit>
<trans-unit id="Notes" xml:space="preserve">
<source>Notes</source>
<target>Notes</target>
<note>Label for filter for seeing only notes (instead of notes and replies).
Label for filter for seeing only your notes (instead of notes and replies).
A label indicating that the notes being displayed below it are from a timeline, not search results</note>
<note>Label for filter for seeing only notes (instead of notes and replies).</note>
</trans-unit>
<trans-unit id="Notes &amp; Replies" xml:space="preserve">
<source>Notes &amp; Replies</source>
<target>Notes &amp; Replies</target>
<note>Label for filter for seeing notes and replies (instead of only notes).
Label for filter for seeing your notes and replies (instead of only your notes).</note>
<note>Label for filter for seeing notes and replies (instead of only notes).</note>
</trans-unit>
<trans-unit id="Notes with the #nsfw tag usually contains adult content or other &quot;Not safe for work&quot; content" xml:space="preserve">
<source>Notes with the #nsfw tag usually contains adult content or other "Not safe for work" content</source>
@@ -1374,6 +1435,11 @@ Label for filter for seeing your notes and replies (instead of only your notes).
<note>Section header for Damus notifications
Toolbar label for Notifications view.</note>
</trans-unit>
<trans-unit id="Notifications mode" xml:space="preserve">
<source>Notifications mode</source>
<target>Notifications mode</target>
<note>Prompt selection of the notification mode (Feature to switch between local notifications (generated from user's own phone) or push notifications (generated by Damus server).</note>
</trans-unit>
<trans-unit id="Nudity" xml:space="preserve">
<source>Nudity</source>
<target>Nudity</target>
@@ -1430,6 +1496,11 @@ Button label to dismiss an error dialog</note>
<target>Optional</target>
<note>Prompt to enter optional additional information when reporting an account or content.</note>
</trans-unit>
<trans-unit id="Orange-pill" xml:space="preserve">
<source>Orange-pill</source>
<target>Orange-pill</target>
<note>Button label that allows the user to start a direct message conversation with the user shown on-screen, to orange-pill them (i.e. help them to setup zaps)</note>
</trans-unit>
<trans-unit id="Paid Relay" xml:space="preserve">
<source>Paid Relay</source>
<target>Paid Relay</target>
@@ -1478,7 +1549,8 @@ Button label to dismiss an error dialog</note>
<trans-unit id="Post" xml:space="preserve">
<source>Post</source>
<target>Post</target>
<note>Button to post a note.</note>
<note>Button to post a highlight.
Button to post a note.</note>
</trans-unit>
<trans-unit id="Private" xml:space="preserve">
<source>Private</source>
@@ -1540,11 +1612,6 @@ Button label to dismiss an error dialog</note>
<target>Public Account ID</target>
<note>Section title for the user's public account ID.</note>
</trans-unit>
<trans-unit id="Public Key" xml:space="preserve">
<source>Public Key</source>
<target>Public Key</target>
<note>Label to indicate the public key of the account.</note>
</trans-unit>
<trans-unit id="Public key" xml:space="preserve">
<source>Public key</source>
<target>Public key</target>
@@ -1570,6 +1637,11 @@ Button label to dismiss an error dialog</note>
<target>Purple</target>
<note>Subscription service name</note>
</trans-unit>
<trans-unit id="Push" xml:space="preserve">
<source>Push</source>
<target>Push</target>
<note>Option for notification mode setting: Push notification mode</note>
</trans-unit>
<trans-unit id="QR Code" xml:space="preserve">
<source>QR Code</source>
<target>QR Code</target>
@@ -1590,6 +1662,11 @@ Button label to dismiss an error dialog</note>
<target>Ran to suspension.</target>
<note>Indication that a NostrScript was run until it reached a suspended state.</note>
</trans-unit>
<trans-unit id="React with default reaction emoji" xml:space="preserve">
<source>React with default reaction emoji</source>
<target>React with default reaction emoji</target>
<note>Accessibility label for react button</note>
</trans-unit>
<trans-unit id="Reactions" xml:space="preserve">
<source>Reactions</source>
<target>Reactions</target>
@@ -1700,6 +1777,11 @@ Button label to dismiss an error dialog</note>
<target>Repost</target>
<note>Button to repost a note</note>
</trans-unit>
<trans-unit id="Repost or quote this note" xml:space="preserve">
<source>Repost or quote this note</source>
<target>Repost or quote this note</target>
<note>Accessibility label for repost/quote button</note>
</trans-unit>
<trans-unit id="Reposted" xml:space="preserve">
<source>Reposted</source>
<target>Reposted</target>
@@ -1720,7 +1802,12 @@ Button label to dismiss an error dialog</note>
<trans-unit id="Requests" xml:space="preserve">
<source>Requests</source>
<target>Requests</target>
<note>Picker option for DM selector for seeing only message requests (DMs that someone else sent the user which has not been responded to yet). DM is the English abbreviation for Direct Message.</note>
<note>Picker option for DM selector for seeing only message requests (DMs that someone else sent the user which has not been responded to yet</note>
</trans-unit>
<trans-unit id="Reset contact list" xml:space="preserve">
<source>Reset contact list</source>
<target>Reset contact list</target>
<note>Button to reset contact list.</note>
</trans-unit>
<trans-unit id="Retry" xml:space="preserve">
<source>Retry</source>
@@ -1777,6 +1864,11 @@ Button label to dismiss an error dialog</note>
<target>Save Key in Secure Keychain</target>
<note>Toggle to save private key to the Apple secure keychain.</note>
</trans-unit>
<trans-unit id="Save your login info?" xml:space="preserve">
<source>Save your login info?</source>
<target>Save your login info?</target>
<note>Ask user if they want to save their account information.</note>
</trans-unit>
<trans-unit id="Scan Code" xml:space="preserve">
<source>Scan Code</source>
<target>Scan Code</target>
@@ -1907,6 +1999,11 @@ Button label to dismiss an error dialog</note>
<target>Share Via...</target>
<note>Button to present iOS share sheet</note>
</trans-unit>
<trans-unit id="Share externally" xml:space="preserve">
<source>Share externally</source>
<target>Share externally</target>
<note>Accessibility label for external share button</note>
</trans-unit>
<trans-unit id="Show" xml:space="preserve">
<source>Show</source>
<target>Show</target>
@@ -1954,6 +2051,11 @@ Button label to dismiss an error dialog</note>
<target>Show wallet selector</target>
<note>Toggle to show or hide selection of wallet.</note>
</trans-unit>
<trans-unit id="Sign In" xml:space="preserve">
<source>Sign In</source>
<target>Sign In</target>
<note>Button to continue to login page.</note>
</trans-unit>
<trans-unit id="Sign Out" xml:space="preserve">
<source>Sign Out</source>
<target>Sign Out</target>
@@ -1974,11 +2076,6 @@ Button label to dismiss an error dialog</note>
<target>Skip</target>
<note>Button to dismiss the suggested users screen</note>
</trans-unit>
<trans-unit id="Social media has developed into a key way information flows around the world. Unfortunately, our current social media systems are broken" xml:space="preserve">
<source>Social media has developed into a key way information flows around the world. Unfortunately, our current social media systems are broken</source>
<target>Social media has developed into a key way information flows around the world. Unfortunately, our current social media systems are broken</target>
<note>Description about why Nostr is needed.</note>
</trans-unit>
<trans-unit id="Someone posted a note" xml:space="preserve">
<source>Someone posted a note</source>
<target>Someone posted a note</target>
@@ -2087,11 +2184,6 @@ Enjoy!</target>
<target>The address should either begin with LNURL or should look like an email address.</target>
<note>Giving the description of the alert message.</note>
</trans-unit>
<trans-unit id="The go-to iOS Nostr client" xml:space="preserve">
<source>The go-to iOS Nostr client</source>
<target>The go-to iOS Nostr client</target>
<note>Quick description of what Damus is</note>
</trans-unit>
<trans-unit id="The relay you are trying to add is already added.&#10;You're all set!" xml:space="preserve">
<source>The relay you are trying to add is already added.
You're all set!</source>
@@ -2099,6 +2191,11 @@ You're all set!</source>
You're all set!</target>
<note>An error message that appears when the user attempts to add a relay that has already been added.</note>
</trans-unit>
<trans-unit id="The social network you control" xml:space="preserve">
<source>The social network you control</source>
<target>The social network you control</target>
<note>Quick description of what Damus is</note>
</trans-unit>
<trans-unit id="There has been an unexpected error with the in-app purchase. Please try again later or contact support@damus.io. Error: %@" xml:space="preserve">
<source>There has been an unexpected error with the in-app purchase. Please try again later or contact support@damus.io. Error: %@</source>
<target>There has been an unexpected error with the in-app purchase. Please try again later or contact support@damus.io. Error: %@</target>
@@ -2128,16 +2225,16 @@ Nice to meet you all! #introductions #plebchain </source>
Nice to meet you all! #introductions #plebchain </target>
<note>First post example given to the user during onboarding, as a suggestion as to what they could post first</note>
</trans-unit>
<trans-unit id="This is your secret account key. You need this to access your account. Don't share this with anyone! Save it in a password manager and keep it safe!" xml:space="preserve">
<source>This is your secret account key. You need this to access your account. Don't share this with anyone! Save it in a password manager and keep it safe!</source>
<target>This is your secret account key. You need this to access your account. Don't share this with anyone! Save it in a password manager and keep it safe!</target>
<note>Label to describe that a private key is the user's secret account key and what they should do with it.</note>
</trans-unit>
<trans-unit id="This note contains too many items and cannot be rendered" xml:space="preserve">
<source>This note contains too many items and cannot be rendered</source>
<target>This note contains too many items and cannot be rendered</target>
<note>Error message indicating that a note is too big and cannot be rendered</note>
</trans-unit>
<trans-unit id="This user cannot be zapped because they have not configured zaps on their account yet. Time to orange-pill?" xml:space="preserve">
<source>This user cannot be zapped because they have not configured zaps on their account yet. Time to orange-pill?</source>
<target>This user cannot be zapped because they have not configured zaps on their account yet. Time to orange-pill?</target>
<note>Comment explaining why a user cannot be zapped.</note>
</trans-unit>
<trans-unit id="Thread" xml:space="preserve">
<source>Thread</source>
<target>Thread</target>
@@ -2275,6 +2372,11 @@ Nice to meet you all! #introductions #plebchain </target>
<target>User muted</target>
<note>Alert message to indicate the user has been muted</note>
</trans-unit>
<trans-unit id="User not zappable" xml:space="preserve">
<source>User not zappable</source>
<target>User not zappable</target>
<note>Headline indicating a user cannot be zapped</note>
</trans-unit>
<trans-unit id="Username" xml:space="preserve">
<source>Username</source>
<target>Username</target>
@@ -2342,6 +2444,15 @@ YOU WILL NO LONGER BE ABLE TO LOG INTO DAMUS USING THIS ACCOUNT KEY.
ARE YOU SURE YOU WANT TO CONTINUE?</target>
<note>Alert for deleting the users account.</note>
</trans-unit>
<trans-unit id="WARNING:&#10;&#10;This will reset your contact list, including the list of everyone you follow and the list of all relays you usually connect to. ONLY PROCEED IF YOU ARE SURE YOU HAVE LOST YOUR CONTACT LIST BEYOND RECOVERABILITY." xml:space="preserve">
<source>WARNING:
This will reset your contact list, including the list of everyone you follow and the list of all relays you usually connect to. ONLY PROCEED IF YOU ARE SURE YOU HAVE LOST YOUR CONTACT LIST BEYOND RECOVERABILITY.</source>
<target>WARNING:
This will reset your contact list, including the list of everyone you follow and the list of all relays you usually connect to. ONLY PROCEED IF YOU ARE SURE YOU HAVE LOST YOUR CONTACT LIST BEYOND RECOVERABILITY.</target>
<note>Alert for resetting the user's contact list.</note>
</trans-unit>
<trans-unit id="Wake up, %@" xml:space="preserve">
<source>Wake up, %@</source>
<target>Wake up, %@</target>
@@ -2365,6 +2476,16 @@ YOU WILL NO LONGER BE ABLE TO LOG INTO DAMUS USING THIS ACCOUNT KEY.
<target>Wallet Relay</target>
<note>Label text indicating that below it is the information about the wallet relay.</note>
</trans-unit>
<trans-unit id="We did not detect any issues that we can automatically fix for you. If you are having issues, please contact Damus support: [support@damus.io](mailto:support@damus.io)" xml:space="preserve">
<source>We did not detect any issues that we can automatically fix for you. If you are having issues, please contact Damus support: [support@damus.io](mailto:support@damus.io)</source>
<target>We did not detect any issues that we can automatically fix for you. If you are having issues, please contact Damus support: [support@damus.io](mailto:support@damus.io)</target>
<note>Message indicating that no First Aid actions are available.</note>
</trans-unit>
<trans-unit id="We'll save your account key, so you won't need to enter it manually next time you log in." xml:space="preserve">
<source>We'll save your account key, so you won't need to enter it manually next time you log in.</source>
<target>We'll save your account key, so you won't need to enter it manually next time you log in.</target>
<note>Reminder to user that they should save their account information.</note>
</trans-unit>
<trans-unit id="Website" xml:space="preserve">
<source>Website</source>
<target>Website</target>
@@ -2405,21 +2526,11 @@ YOU WILL NO LONGER BE ABLE TO LOG INTO DAMUS USING THIS ACCOUNT KEY.
<target>What do you want to report?</target>
<note>Header text to prompt user what issue they want to report.</note>
</trans-unit>
<trans-unit id="What is Nostr?" xml:space="preserve">
<source>What is Nostr?</source>
<target>What is Nostr?</target>
<note>Heading text for section describing what is Nostr.</note>
</trans-unit>
<trans-unit id="Who to Follow" xml:space="preserve">
<source>Who to Follow</source>
<target>Who to Follow</target>
<note>Title for a screen displaying suggestions of who to follow</note>
</trans-unit>
<trans-unit id="Why we need Nostr?" xml:space="preserve">
<source>Why we need Nostr?</source>
<target>Why we need Nostr?</target>
<note>Heading text for section describing why Nostr is needed.</note>
</trans-unit>
<trans-unit id="Words" xml:space="preserve">
<source>Words</source>
<target>Words</target>
@@ -2530,7 +2641,8 @@ YOU WILL NO LONGER BE ABLE TO LOG INTO DAMUS USING THIS ACCOUNT KEY.
<trans-unit id="Zaps" xml:space="preserve">
<source>Zaps</source>
<target>Zaps</target>
<note>Navigation bar title for the Zaps view.
<note>Label for filter for zap notifications.
Navigation bar title for the Zaps view.
Navigation title for zap settings.
Section header for zap settings
Setting to enable Zap Local Notification
@@ -2647,6 +2759,11 @@ YOU WILL NO LONGER BE ABLE TO LOG INTO DAMUS USING THIS ACCOUNT KEY.
<target>%@ and %@ reposted your profile</target>
<note>Notification that 2 users reposted the current user's profile</note>
</trans-unit>
<trans-unit id="required" xml:space="preserve">
<source>required</source>
<target>required</target>
<note>Label indicating that a form input is required.</note>
</trans-unit>
<trans-unit id="self" xml:space="preserve">
<source>self</source>
<target>self</target>
@@ -2701,7 +2818,7 @@ YOU WILL NO LONGER BE ABLE TO LOG INTO DAMUS USING THIS ACCOUNT KEY.
</file>
<file original="damus/en-US.lproj/Localizable.stringsdict" source-language="en-US" target-language="en-US" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.3" build-num="15E204a"/>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.4" build-num="15F31d"/>
</header>
<body>
<trans-unit id="/followed_by_three_and_others:dict/NSStringLocalizedFormatKey:dict/:string" xml:space="preserve">
@@ -3083,7 +3200,7 @@ YOU WILL NO LONGER BE ABLE TO LOG INTO DAMUS USING THIS ACCOUNT KEY.
</file>
<file original="DamusNotificationService/InfoPlist.xcstrings" source-language="en-US" target-language="en-US" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.3" build-num="15E204a"/>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.4" build-num="15F31d"/>
</header>
<body>
<trans-unit id="CFBundleDisplayName" xml:space="preserve">
@@ -3105,7 +3222,7 @@ YOU WILL NO LONGER BE ABLE TO LOG INTO DAMUS USING THIS ACCOUNT KEY.
</file>
<file original="DamusNotificationService/Localizable.xcstrings" source-language="en-US" target-language="en-US" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.3" build-num="15E204a"/>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.4" build-num="15F31d"/>
</header>
<body>
<trans-unit id="" xml:space="preserve">
@@ -3158,6 +3275,11 @@ YOU WILL NO LONGER BE ABLE TO LOG INTO DAMUS USING THIS ACCOUNT KEY.
<target state="new">LibreTranslate (Open Source)</target>
<note>Dropdown option for selecting LibreTranslate as the translation service.</note>
</trans-unit>
<trans-unit id="Local" xml:space="preserve">
<source>Local</source>
<target state="new">Local</target>
<note>Option for notification mode setting: Local notification mode</note>
</trans-unit>
<trans-unit id="Local default" xml:space="preserve">
<source>Local default</source>
<target state="new">Local default</target>
@@ -3168,6 +3290,11 @@ YOU WILL NO LONGER BE ABLE TO LOG INTO DAMUS USING THIS ACCOUNT KEY.
<target state="new">Mentioned by %@</target>
<note>Mentioned by heading in local notification</note>
</trans-unit>
<trans-unit id="Muted event" xml:space="preserve">
<source>Muted event</source>
<target state="new">Muted event</target>
<note>Title for a push notification which has been muted</note>
</trans-unit>
<trans-unit id="New encrypted direct message" xml:space="preserve">
<source>New encrypted direct message</source>
<target state="new">New encrypted direct message</target>
@@ -3203,6 +3330,11 @@ YOU WILL NO LONGER BE ABLE TO LOG INTO DAMUS USING THIS ACCOUNT KEY.
<target state="new">Production</target>
<note>Label indicating the production environment for Damus Purple</note>
</trans-unit>
<trans-unit id="Push" xml:space="preserve">
<source>Push</source>
<target state="new">Push</target>
<note>Option for notification mode setting: Push notification mode</note>
</trans-unit>
<trans-unit id="Reposted by %@" xml:space="preserve">
<source>Reposted by %@</source>
<target state="new">Reposted by %@</target>
@@ -3238,6 +3370,11 @@ YOU WILL NO LONGER BE ABLE TO LOG INTO DAMUS USING THIS ACCOUNT KEY.
<target state="new">Test (local)</target>
<note>Label indicating a local test environment for Damus Purple functionality (Developer feature)</note>
</trans-unit>
<trans-unit id="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" xml:space="preserve">
<source>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</source>
<target state="new">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</target>
<note>Description for a push notification which has been muted, and explanation that we cannot suppress it</note>
</trans-unit>
<trans-unit id="This note contains too many items and cannot be rendered" xml:space="preserve">
<source>This note contains too many items and cannot be rendered</source>
<target state="new">This note contains too many items and cannot be rendered</target>
@@ -39,12 +39,18 @@
"LibreTranslate (Open Source)" : {
"comment" : "Dropdown option for selecting LibreTranslate as the translation service."
},
"Local" : {
"comment" : "Option for notification mode setting: Local notification mode"
},
"Local default" : {
"comment" : "Dropdown option label for system default for Lightning wallet."
},
"Mentioned by %@" : {
"comment" : "Mentioned by heading in local notification"
},
"Muted event" : {
"comment" : "Title for a push notification which has been muted"
},
"New encrypted direct message" : {
"comment" : "Notification that the user has received a new direct message"
},
@@ -78,6 +84,9 @@
"Production" : {
"comment" : "Label indicating the production environment for Damus Purple"
},
"Push" : {
"comment" : "Option for notification mode setting: Push notification mode"
},
"Reposted by %@" : {
"comment" : "Reposted by heading in local notification"
},
@@ -99,6 +108,9 @@
"Test (local)" : {
"comment" : "Label indicating a local test environment for Damus Purple functionality (Developer feature)"
},
"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"
},
"This note contains too many items and cannot be rendered" : {
"comment" : "Error message indicating that a note is too big and cannot be rendered"
},
+2 -2
View File
@@ -3,10 +3,10 @@
"project" : "damus.xcodeproj",
"targetLocale" : "en-US",
"toolInfo" : {
"toolBuildNumber" : "15E204a",
"toolBuildNumber" : "15F31d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.3"
"toolVersion" : "15.4"
},
"version" : "1.0"
}
Binary file not shown.
+16
View File
@@ -226,6 +226,22 @@
<string>Megosztások</string>
</dict>
</dict>
<key>quoted_reposts_count</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@QUOTE_REPOSTS@</string>
<key>QUOTE_REPOSTS</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>Idézet</string>
<key>other</key>
<string>Idézetek</string>
</dict>
</dict>
<key>sats</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
+14
View File
@@ -198,6 +198,20 @@
<string>リポスト</string>
</dict>
</dict>
<key>quoted_reposts_count</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@QUOTE_REPOSTS@</string>
<key>QUOTE_REPOSTS</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>other</key>
<string>引用</string>
</dict>
</dict>
<key>sats</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
Binary file not shown.
Binary file not shown.
+16
View File
@@ -226,6 +226,22 @@
<string>Delningar</string>
</dict>
</dict>
<key>quoted_reposts_count</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@QUOTE_REPOSTS@</string>
<key>QUOTE_REPOSTS</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>Citat</string>
<key>other</key>
<string>Citat</string>
</dict>
</dict>
<key>sats</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

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