Compare commits

...

287 Commits

Author SHA1 Message Date
tyiu 8ed2395865 Export strings for translation
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2024-12-10 21:06:16 -05:00
tyiu 274d1035e0 Fix suggested users category titles to be localizable
Changelog-Fixed: Fixed suggested users category titles to be localizable

Signed-off-by: Terry Yiu <git@tyiu.xyz>
2024-12-10 21:06:16 -05:00
tyiu b41205729e Fix GradientFollowButton to have consistent width and autoscale text limited to 1 line
Changelog-Fixed: Fixed GradientFollowButton to have consistent width and autoscale text limited to 1 line

Signed-off-by: Terry Yiu <git@tyiu.xyz>
2024-12-10 21:06:16 -05:00
tyiu 2d7b77a7e0 Fix right-to-left localization issues
Changelog-Fixed: Fixed right-to-left localization issues

Signed-off-by: Terry Yiu <git@tyiu.xyz>
2024-12-10 21:06:16 -05:00
tyiu 0ed2b4edec Fix AddMuteItemView to trim leading and trailing whitespaces from mute text and disallow adding text with only whitespaces
Changelog-Fixed: Fixed AddMuteItemView to trim leading and trailing whitespaces from mute text and disallow adding text with only whitespaces

Signed-off-by: Terry Yiu <git@tyiu.xyz>
2024-12-10 21:06:08 -05:00
tyiu 9a5fabfee5 Fix SideMenuView text to autoscale and limit to 1 line
Changelog-Fixed: Fixed SideMenuView text to autoscale and limit to 1 line

Signed-off-by: Terry Yiu <git@tyiu.xyz>
2024-12-09 18:36:43 -05:00
Daniel D’Aquino 1072c5a384 Version bump to 1.12
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-12-09 18:05:12 +09:00
transifex-integration[bot] 5ed6e85ad8 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] f948dd81ca Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] 391818f230 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] dc74ad37a1 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] e1c94b7ff9 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] ec933452d3 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] 977b268023 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] 0c778af833 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] e75e7950b5 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] 8d68297cce Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] e0fd24aff5 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] b5c3ff45e4 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] 786dbb21c4 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] 5a17c330da Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] 975be63ce1 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] d9796bd63c Translate Localizable.stringsdict in ar
100% translated source file: 'Localizable.stringsdict'
on 'ar'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] 25a835624a Translate Localizable.strings in ar
100% translated source file: 'Localizable.strings'
on 'ar'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] 1d06683bb3 Translate Localizable.stringsdict in pt_PT
100% translated source file: 'Localizable.stringsdict'
on 'pt_PT'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] 8e15a86c0a Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] 51a3008e5a Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] f71c1b9848 Translate InfoPlist.strings in pt_PT
100% translated source file: 'InfoPlist.strings'
on 'pt_PT'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] 151e23d524 Translate InfoPlist.strings in pt_PT
100% translated source file: 'InfoPlist.strings'
on 'pt_PT'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] 7619891c86 Translate InfoPlist.strings in pt_PT
100% translated source file: 'InfoPlist.strings'
on 'pt_PT'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] cc98525f59 Translate InfoPlist.strings in pt_PT
100% translated source file: 'InfoPlist.strings'
on 'pt_PT'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] 8cf9549981 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] 9ebf27cd37 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] 16a1a9f37f Translate Localizable.strings in ja
100% translated source file: 'Localizable.strings'
on 'ja'.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] 08d28b0f00 Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2024-12-09 18:00:42 +09:00
tyiu d0ae3ca08a Fix non-breaking spaces in localized strings
Changelog-Fixed: Fixed non-breaking spaces in localized strings

Signed-off-by: Terry Yiu <963907+tyiu@users.noreply.github.com>
2024-12-09 18:00:42 +09:00
tyiu 6e2f770876 Revert "Replace non-breaking spaces with regular spaces as Apple's NSLocalizedString macro does not seem to work with it"
This reverts commit 4adcb738a2.
2024-12-09 18:00:42 +09:00
transifex-integration[bot] 4079bea912 Translate Localizable.strings in ja
100% translated source file: 'Localizable.strings'
on 'ja'.
2024-12-09 18:00:42 +09:00
tyiu 4d01340b90 Fix localization issue on Add mute item button
Changelog-Fixed: Fixed localization issue on Add mute item button

Signed-off-by: Terry Yiu <963907+tyiu@users.noreply.github.com>
2024-12-09 18:00:42 +09:00
transifex-integration[bot] 61b89c2f54 Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2024-12-09 18:00:42 +09:00
Swift Coder cbdff4a5f8 Add profile info text in stretchable banner with follow button
Changelog-Added: Add profile info text in stretchable banner with follow button
Signed-off-by: Swift Coder <scoder1747@gmail.com>
2024-12-09 17:58:16 +09:00
Swift Coder 866afe970b Paste Gif image similar to jpeg and png files
This commit change will allow users to paste GIF file in the Post by copying from other apps (previously similar to pasting Jpeg and PNG image functionality)

Changelog-Added: Paste Gif image similar to jpeg and png files
Signed-off-by: Swift Coder <scoder1747@gmail.com>
2024-12-09 16:46:40 +09:00
transifex-integration[bot] 87efc91527 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-12-03 18:18:41 +09:00
tyiu 4121526588 Export strings for translation
Signed-off-by: Terry Yiu <963907+tyiu@users.noreply.github.com>
2024-12-03 18:18:41 +09:00
tyiu 4adcb738a2 Replace non-breaking spaces with regular spaces as Apple's NSLocalizedString macro does not seem to work with it
Changelog-Fixed: Replace non-breaking spaces with regular spaces as Apple's NSLocalizedString macro does not seem to work with it

Signed-off-by: Terry Yiu <963907+tyiu@users.noreply.github.com>
2024-12-03 18:18:41 +09:00
tyiu 1f17f19a6e Fix localization issues in RelayConfigView
Changelog-Fixed: Fixed localization issues in RelayConfigView

Signed-off-by: Terry Yiu <963907+tyiu@users.noreply.github.com>
2024-12-03 18:18:41 +09:00
transifex-integration[bot] fb54115286 Translate InfoPlist.strings in pt_PT
100% translated source file: 'InfoPlist.strings'
on 'pt_PT'.
2024-12-03 18:18:41 +09:00
transifex-integration[bot] 50dd35d089 Translate Localizable.strings in vi
100% translated source file: 'Localizable.strings'
on 'vi'.
2024-12-03 18:18:41 +09:00
transifex-integration[bot] 1205b2a0e2 Translate Localizable.strings in ar
100% translated source file: 'Localizable.strings'
on 'ar'.
2024-12-03 18:18:41 +09:00
transifex-integration[bot] 0a93e909ed Translate InfoPlist.strings in ar
100% translated source file: 'InfoPlist.strings'
on 'ar'.
2024-12-03 18:18:41 +09:00
transifex-integration[bot] 6f3f928ac3 Translate InfoPlist.strings in bg
100% translated source file: 'InfoPlist.strings'
on 'bg'.
2024-12-03 18:18:41 +09:00
transifex-integration[bot] 637ceabede Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2024-12-03 18:18:41 +09:00
transifex-integration[bot] c25f54f7e7 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2024-12-03 18:18:41 +09:00
transifex-integration[bot] d4ae0b1346 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2024-12-03 18:18:41 +09:00
transifex-integration[bot] 7773618547 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2024-12-03 18:18:41 +09:00
transifex-integration[bot] a4199fa299 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2024-12-03 18:18:41 +09:00
transifex-integration[bot] 5b9ccc4ee5 Translate Localizable.stringsdict in pl_PL
100% translated source file: 'Localizable.stringsdict'
on 'pl_PL'.
2024-12-03 18:18:41 +09:00
transifex-integration[bot] f538e03093 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-12-03 18:18:41 +09:00
transifex-integration[bot] e625297a2e Translate Localizable.strings in ja
100% translated source file: 'Localizable.strings'
on 'ja'.
2024-12-03 18:18:41 +09:00
transifex-integration[bot] e603678872 Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2024-12-03 18:18:41 +09:00
transifex-integration[bot] 9d77f1b2f7 Translate Localizable.strings in hu_HU
100% translated source file: 'Localizable.strings'
on 'hu_HU'.
2024-12-03 18:18:41 +09:00
tyiu c4f7d25793 Fix localization issues and export strings for translation 2024-12-03 18:18:41 +09:00
transifex-integration[bot] 100f195a03 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-12-03 18:18:41 +09:00
Swift Coder e599ef1ac9 Fix duplicate uploads
Reset orderIds and orderMap

Changelog-Fixed: Fix duplicate uploads
Signed-off-by: Swift Coder <scoder1747@gmail.com>
2024-12-03 17:31:55 +09:00
Swift Coder 033c69b92e Remove duplicate pubkey from Follow Suggestion list
Changelog-Fixed: Remove duplicate pubkey from Follow Suggestion list
Signed-off-by: Swift Coder <scoder1747@gmail.com>
2024-12-02 15:43:02 +09:00
Swift Coder 184e566b1b Fix Page control indicator for not reflecting current index of Image being previewed
Changelog-Fixed: Fix Page control indicator
Signed-off-by: Swift Coder <scoder1747@gmail.com>
2024-12-02 15:27:14 +09:00
Swift Coder 8c321e479b Fix Damus sharing issues
1. While sharing images/videos from Apple's Message app, the file will be treated as a Link with file-url. The if-else ordering will help to fix the issue.
2. While sharing image from Signal and Facebook app, the file being received is an UIImage and error is being sent. This PR fixes the issue.

Changelog-Fixed: Fix damus sharing issues
Signed-off-by: Swift Coder  <scoder1747@gmail.com>
2024-12-02 15:22:13 +09:00
Daniel D’Aquino 960c84d02e Fix CHANGELOG markdown syntax issue
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-11-22 11:49:19 -08:00
Daniel D’Aquino 02f1c2d342 Add script to help identify duplicate changelog entries
This commit adds a new script to devtools that can be used to help
identify duplicate changelog entries.

It works by identifying duplicate lines in CHANGELOG.md, and then
searching whether each one of those duplicate lines are present in a
separate text file (which can be a subset of the changelog that the user
is interested in analyzing)

No user-facing changes

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-11-22 11:42:35 -08:00
Daniel D’Aquino c8ca3c93f6 Changelog entry for 1.11(10)
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-11-22 11:42:35 -08:00
Daniel D’Aquino 5c6e5ca2de Add edit banner button UI automated test + accessibility improvements
This commit adds an automated UI test to check if the edit banner button
UI is clickable and not hidden behind a top bar or another invisible
element.

It also improves accessibility support for some elements on login and
top bar.

Changelog-Changed: Improved accessibility support on some elements
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-11-20 14:44:06 -08:00
Daniel D’Aquino e3105a90c5 Move edit banner button into safe area
In some conditions, it was found that the banner edit button was
obscured behind a top nav bar.

This commit fixes that by introspecting on the safe area margins and
applying them to the button

Closes: https://github.com/damus-io/damus/issues/2636
Changelog-Fixed: Fixed issue where banner edit button is unclickable
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-11-20 14:44:06 -08:00
Daniel D’Aquino 38dc7b046a Fix issues with new Share extension
This commit fixes the following issues with the new Share extension:
- Typo on user-facing label
- Misconfigured Info.plist that caused the build to be rejected by AppStore Connect

This extension was never shipped to users, so no changelog entry is needed.

Changelog-None
Fixes: eeb6547d3e
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-11-18 11:43:24 -08:00
Daniel D’Aquino da76ad9b66 Fix logical merge error
This commit fixes build errors caused by logical merge issues from
changes that worked in isolation but not when combined.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-11-18 10:32:09 -08:00
Swift Coder 177c55cf3d Fix missing tab bar while navigating
Set isSidebarVisible to false

Changelog-Fixed: missing tab bar on navigation
Signed-off-by: Swift Coder <scoder1747@gmail.com>
2024-11-15 17:17:10 -08:00
Swift eeb6547d3e Add Damus Share Feature
This PR change adds Damus Share feature to the app that allows the users to share Photos and URLs from foreign apps.

Changelog-Added: Add Damus Share Feature

Signed-off-by: Swift Coder  <scoder1747@gmail.com>
2024-11-15 17:08:44 -08:00
Daniel D’Aquino 50ef6600a8 Make QR code scanning more robust
1. Removed the dependency on finding the profile event for displaying actions to the user, even if the full profile couldn't be loaded. This allowed showing useful options such as the option to follow that pubkey.
2. Opened a profile preview sheet instead of navigating to the full profile page, enabling quick actions and saving bandwidth by not loading their timeline immediately.
3. Refactored most of that view to simplify state management and make it less prone to errors.
4. Improved error handling and management.
5. Ensured the view truly reflected the internal state of the scanner to the user.

Changelog-Fixed: Fixed some issues where QR code would not work, and improved UX
Closes: https://github.com/damus-io/damus/issues/2032
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-11-15 15:41:04 -08:00
Daniel D’Aquino be43819de2 Update and refactor ImageCarousel fill handling
Previously, the ImageCarousel needed to directly set a video size
binding to the video player view, in order to communicate and listen to
video size changes, and it used that to calculate the carousel image fill.

However, in the new video coordination architecture, the video size is
not owned by the ImageCarousel, but instead it is owned by the video
player itself, which in turn is owned by the video coordinator.
Therefore, this is incompatible with several logic elements of
ImageCarousel.

This commit updates the image carousel to integrate with the new video
coordinator architecture, and it also refactors the image fill logic
almost completely — with a focus on reducing stateful behavior by
carefully employing some state management patterns.

Furthermore, the new CarouselModel was heavily documented to explain its
design decisions and inner workings.

Note: There used to be some caching on the ImageFill calculations, but
after using this new refactored version without caching, I have not
noticed any noticeable performance regressions, so I have decided not to
add them back — applying Occam's razor

Changelog-Changed: Improved image carousel image fill behavior
Closes: https://github.com/damus-io/damus/issues/2458
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-11-11 15:30:17 -08:00
Daniel D’Aquino 58017952bc Video coordination improvements and new video controls view
This commit makes several improvements to video coordination,
and implements a new video control view.

The video support stack in Damus has been re-architected to achieve
this.

The new architecture can be summarized as follows:
1. `DamusVideoCoordinator` is a singleton object in `DamusState`, and it
   is responsible for deciding which video should have the "main stage"
   focus, based on main stage requests that video player views make when
   they become visible.

   Having "main stage" focus means that the coordinator will auto-play
   that video and pause others, and is used throughout the app to
   determine which video to talk to or control, in the case of app-wide
   controls (analogous to how Apple Music needs to know which song is
   playing for displaying playback controls on the iOS home screen)

   Having a singleton take care of this establishes
   clear ownership and prevents conflicts such as double-playing video.

   This coordinator also holds a pool of video media items (`DamusVideoPlayer`),
   with exactly ONE `DamusVideoPlayer` per URL, to reduce
   bandwidth and ensure perfect syncing of the same video in different
   contexts.

2. `DamusVideoPlayer` objects hold the actual media item (video data, playback state),
   much like `AVPlayer`.
   In fact, `DamusVideoPlayer` can be described as a wrapper for `AVPlayer`,
   except it has an interface that is much more SwiftUI friendly,
   enabling playback state syncing with minimal effort.

   `DamusVideoPlayer` is NOT a view. And there is only ONE `DamusVideoPlayer`
   per URL — held by the coordinator.
   However, when the app needs to display that same video in multiple
   places, the app can instantiate multiple video player VIEWS of the
   same `DamusVideoPlayer`

3. `DamusVideoPlayer.BaseView` is the most basic video player view for a
   `DamusVideoPlayer` item. It has basically no features other than
   showing the video itself.

4. `DamusVideoPlayerView` is the standard, batteries-included, video
   player view for `DamusVideoPlayer` items, that is used throughout the
   app.

   It also tries to detect its own visibility, and makes requests to
   `DamusVideoCoordinator` to take over the main stage when it becomes
   visible.

5. `DamusVideoControlsView` is a view that presents video playback
   controls (play/pause, mute, scrubbing) for a `DamusVideoPlayer`
   object.

How a `DamusVideoPlayerView` gains and loses main stage focus:
1. `DamusVideoPlayerView` uses `VisibilityTracker` to find out when it
   becomes visible or not
2. When it becomes visible, it makes a request to the video coordinator
   to take main stage focus. The request also specifies which layer the
   video view is in (Full screen layer? Normal app layer?), which the
   video player view gets from the `\.view_layer_context` environment
   variable set by `damus_full_screen_cover`
3. The coordinator (`DamusVideoCoordinator`) keeps all of these
   requests, and uses its own internal logic and info to determine which
   video should get the main stage.
   The logic also depends on whether or not the app finds itself in full
   screen mode.
   Once the main stage is given to a different video, the previous video
   is paused, the main-staged-video is played, and the requestor
   receives a callback.
4. Once the video disappears from view, it tells the coordinator that it
   is giving up the main stage, and the coordinator then picks another
   main stage request again.

On top of this, several of other small changes and improvements were made,
such as video gesture improvements

Note: This commit causes some breakage over the image carousel sizing
logic, which will be addressed separately in the next commit.

Changelog-Fixed: Fixed iOS 18 gesture issues that would take user to the thread view when clicking on a video or unmuting it
Changelog-Fixed: Fixed several issues that would cause video to automatically play or pause incorrectly
Changelog-Fixed: Fixed issue where full screen video would disappear when going to landscape mode
Changelog-Added: Added new easy to use video controls for full screen video
Changelog-Changed: Improved video syncing and bandwidth usage when switching between timeline video and full screen mode
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-11-11 15:30:17 -08:00
Daniel D’Aquino 409be7fc58 Refactor visibility tracker
This commit moves all logic related to visibility tracking into a single
view modifier for better code reusability.

Furthermore, the modified VisibilityTracker component was more
extensively documented, for better awareness of its limitations and
usage.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-11-11 15:30:17 -08:00
Daniel D’Aquino 1bc660c9cd Improve full screen support
This commit introduces new mechanisms to solve some issues with full screen support:
1. Full screen covers disappear when its caller disappears (e.g. when it
   is an event in a lazy stack, and an orientation change causes the
   view to disappear along with the full screen cover)
2. There are no mechanisms for views to determine whether they are being
   presented under a full screen cover or not, and whether the device is
   in full screen mode or not.

The commit overcomes the above limitations through the following:
1. A full screen cover on `ContentView` that can be accessed by any view
   when calling `present(fullScreenItem)`
2. A new `damus_full_screen_cover` view modifier that automatically
   tracks whether a device is in full screen mode or not, and allows
   any descendant view in its hierarchy to introspect on which view
   layer it is being presented in.

This commit lays a foundation that will later become important for
improving video coordination.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-11-11 15:30:17 -08:00
Daniel D’Aquino a56a59f81d Improve SwipeToDismiss modifier UX
This commit adds an opacity transition when swiping to dismiss an item,
to make it clear that the user is about to dismiss it.

Changelog-Changed: Swipe to dismiss on full screen carousel now shows an opacity effect for improved UX
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-11-11 15:30:17 -08:00
Daniel D’Aquino 1d5af6ca5c Remove event details from full screen carousel
Changelog-Changed: Removed event contents from full screen media carousel for cleaner view
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-11-11 15:30:17 -08:00
Daniel D’Aquino f81b2b677f Fix portrait video sizing on full screen carousel
Changelog-Fixed: Fixed portrait video size on full screen carousel
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-11-11 15:30:17 -08:00
Daniel D’Aquino 290152c859 Rename VideoController to DamusVideoCoordinator
This commit renames this class to better represent what it does.

This reduces some of the term overloading between this class and other video
controller classes/structs. (Such as AVPlayerController)

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-11-11 15:30:17 -08:00
Swift c4ee52fdac Add sharing option in image carousel view (#2629)
Add share button in Full screen image carousel view images

The ellipsis (share button) allows you to share the current image being displayed in the full screen carousel viewer.

Changelog-Changed: Add share button for images on full screen image carousel view
Signed-off-by: Swift Coder <scoder1747@gmail.com>
2024-11-11 14:36:27 -08:00
Swift Coder 6f04455350 Add Edit, Share, and Tap-gesture in Profile pic image viewer
Add Edit button that navigates onto Profile Page
Add ShareLink button that allows us to Share actual image
Add Tap gesture in profile pic image viewer

Changelog-Added: Add Edit, Share, and Tap-gesture in Profile pic image viewer
Signed-off-by: Swift Coder <scoder1747@gmail.com>
2024-11-04 13:22:57 -08:00
Daniel D’Aquino 3b62945e5b Revert "fix: regression that dropped q tags from quote reposts"
This reverts commit 5865b000c0.
2024-11-01 11:25:24 -07:00
Swift Coder b1b032d905 postview: add hashtag suggestions
Closes: #2604
Changelog-Changes: Add hashtag suggestions to post view
Signed-off-by: Swift Coder <scoder1747@gmail.com>
2024-10-28 11:49:47 -07:00
Swift Coder 7c805f7f23 fix: avatar image on qrcode view
Closes: #2612
Changelog-Fixed: Fix avator image on qrcode view
Signed-off-by: Swift Coder <scoder1747@gmail.com>
2024-10-28 11:45:45 -07:00
Swift Coder a2b0620175 fix: banner image upload
There was a nostr.build auth issue when uploading banner images. This
fixes that.

Closes: #2614
Changelog-Fixed: Fix banner image uploa
Signed-off-by: Swift Coder <scoder1747@gmail.com>
2024-10-28 11:45:45 -07:00
William Casarin 8c6bee3d90 Merge remote-tracking branch 'github/pr/2622' 2024-10-28 11:40:16 -07:00
ericholguin bba651b37c ui: reduce bold font in side menu
This PR simply reduces the bold font in the side menu labels.

Changelog-Changed: Changed boldness of font in side menu labels.

Signed-off-by: ericholguin <ericholguin@apache.org>
2024-10-26 21:55:28 -06:00
ericholguin f657af275a ui: replace search notes button with searched word
This PR replaces the search notes button with the searched word.
This also removes the magnifying glass image from the search buttons.

Closes: #2601

Changelog-Changed: Changed search notes button with searched keyword

Signed-off-by: ericholguin <ericholguin@apache.org>
2024-10-26 21:36:59 -06:00
William Casarin 03ded7d39f Merge Translations #2603
Terry Yiu (1):
      Fix localization issues and export strings

Transifex (5):
      Translate Localizable.strings in th
      Translate Localizable.strings in ja
      Translate Localizable.strings in ja
      Translate Localizable.strings in nl
      Translate Localizable.strings in hu_HU
2024-10-26 11:35:49 -07:00
William Casarin 53edc7eb0b Merge 'fix: Fix overlap in Universe view' #2607
ericholguin (1):
      fix: Fix overlap in Universe view
2024-10-26 11:34:43 -07:00
William Casarin 8b969021d5 Merge 'ux: increase opacity of tabbar and post button' #2608
ericholguin (1):
      ux: increase opacity of tabbar and post button
2024-10-26 11:33:46 -07:00
William Casarin 1e52982a5d Merge 'fix: regression that dropped q tags from quote reposts' #2617
William Casarin (2):
      fix: regression that dropped q tags from quote reposts
2024-10-26 11:30:50 -07:00
William Casarin 0038d42f71 Merge 'dismiss button in full screen carousel' #2611
Swift Coder (2):
      Fix: dismiss button in full screen carousel
      Address PR Feedback
2024-10-26 11:25:27 -07:00
Swift Coder d94b387fb9 Address PR Feedback
Use Switch statement to address all cases instead of if-else

Signed-off-by: Swift Coder <scoder1747@gmail.com>
2024-10-25 16:17:17 -04:00
Swift Coder 8a2dbc95ca Fix: dismiss button in full screen carousel
This updates the dismiss button on the fullscreen carousel
so that it is more visible in some scenarios.

Changelog-Fixed: Fix dismiss button visibility
Signed-off-by: Swift Coder <scoder1747@gmail.com>
2024-10-25 00:37:44 -04:00
William Casarin 5865b000c0 fix: regression that dropped q tags from quote reposts
It looks like some refactor broke q tags on quote reposts. This drops
the gather_quote_tags entirely and just relies on the logic in
build_post.

The references field wasn't being used for anything other than pubkeys,
so we switch to pubkeys directly.

Changelog-Fixed: Fix quote repost counting
Signed-off-by: William Casarin <jb55@jb55.com>
2024-10-24 15:31:02 -07:00
transifex-integration[bot] 6efb512a64 Translate Localizable.strings in hu_HU
100% translated source file: 'Localizable.strings'
on 'hu_HU'.
2024-10-22 11:14:57 +00:00
ericholguin b7b8c7f175 ux: increase opacity of tabbar and post button
This PR simply increases the opacity of the tabbar and post button.
The increase in opacity makes the post button active.

Closes: #2598
Closes: #2599

Changelog-Changed: Changed opacity of tabbar and post button

Signed-off-by: ericholguin <ericholguin@apache.org>
2024-10-21 19:25:40 -06:00
ericholguin a76e2aa677 fix: Fix overlap in Universe view
This PR fixes the overlapping text in the Universe View.

Changelog-Fixed: Fixed overlapping text in Universe View

Signed-off-by: ericholguin <ericholguin@apache.org>
2024-10-21 18:18:05 -06:00
William Casarin d9f2317728 Merge 'Maintain images preview as per the selection order' from 'github/pr/2595'
Swift Coder (2):
      Maintain images preview as per the selection order
      document
2024-10-21 09:44:19 -07:00
transifex-integration[bot] f1339e835b Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2024-10-21 08:32:58 +00:00
transifex-integration[bot] 64f2362be3 Translate Localizable.strings in ja
100% translated source file: 'Localizable.strings'
on 'ja'.
2024-10-21 01:49:49 +00:00
tyiu 5b184a40fd Fix localization issues and export strings
Changelog-Fixed: Fixed localization issues and exported strings

Signed-off-by: Terry Yiu <963907+tyiu@users.noreply.github.com>
2024-10-20 23:00:33 +02:00
transifex-integration[bot] 17e6191a92 Translate Localizable.strings in ja
100% translated source file: 'Localizable.strings'
on 'ja'.
2024-10-17 17:32:04 +02:00
transifex-integration[bot] 1ff065d4c7 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2024-10-17 17:32:03 +02:00
Swift Coder 94e2c76284 document 2024-10-17 11:27:03 -04:00
Swift Coder 1925af6897 Maintain images preview as per the selection order 2024-10-17 11:23:02 -04:00
William Casarin 4effaa4324 Merge 'Side menu redesign' from 'github/pr/2569' into master
ericholguin (1):
      ui: Side menu redesign
2024-10-16 09:14:42 -07:00
William Casarin e2a4443a9c Merge 'Fix padding for views for tabbar' from github/pr/2589 2024-10-16 09:09:24 -07:00
William Casarin 7c0e1c5ded Merge multiple image uploads
Changelog-Changed: Allow multiple images to be uploaded at the same time (swiftcoder)
2024-10-16 08:55:31 -07:00
William Casarin f6e34ad999 Merge 'Fix sensitive chat bubble on iOS 18' 2024-10-16 08:47:39 -07:00
Swift Coder ca5da7b5cd Ensures the order in which they were picked (with numbered badge selection) 2024-10-15 22:47:21 -04:00
Swift Coder c08e4a2fdd Documenting 2024-10-15 10:48:48 -04:00
Swift Coder 37a50f6087 addressed upload issue in pr feedback 2024-10-15 10:43:32 -04:00
ericholguin 2040e79165 ui: Side menu redesign
This PR redesigns the side menu to more closely match Roberto's design

Changelog-Changed: Changed side menu design

Signed-off-by: ericholguin <ericholguin@apache.org>
2024-10-14 19:21:57 -06:00
Daniel D’Aquino a6449020b6 Fix sensitive chat bubble on iOS 18
This commit fixes an issue with the chat bubble view where it would
unexpectedly trigger the reaction emoji keyboard when scrolling or
swiping, which became specially sensitive on iOS 18.

The fix consists of 2 parts:
- Changing the long press gesture logic to better adhere to Apple's API specs
- Modify the SwipeActions library to allow the gesture priority to be
  configurable, and demote the swiping gesture to have normal priority
  (it was found that having a high-priority drag gesture prevents
  long-presses from being activated)

Closes: https://github.com/damus-io/damus/issues/2577
Changelog-Fixed: Fix sensitive long-press gesture on event chat bubble in iOS 18
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-10-14 18:20:44 -07:00
ericholguin 75a46f4ab4 fixes: Fix padding for views for tabbar
This PR fixes the bottom padding on views to account for the bottom tabbar,
now that the tabbar is an overlay we must account for it.

Changelog-Fixed: Fixed bottom padding for tabbar

Signed-off-by: ericholguin <ericholguin@apache.org>
2024-10-14 19:07:01 -06:00
William Casarin c948c7e230 search: truncate content
sometimes its too big for search results

Changelog-Changed: Truncate fulltext search results
Signed-off-by: William Casarin <jb55@jb55.com>
2024-10-14 12:08:25 -07:00
William Casarin c83b0fba21 Merge 'paste images' support
Swift Coder (3):
      Allow-pasting-image-option
      Documenting
      Code optimization
2024-10-13 15:40:52 -07:00
William Casarin b7053e8680 Merge 'ux: Seamless Timeline'
ericholguin (1):
      ux: Seamless Timeline
2024-10-13 15:14:40 -07:00
Swift Coder 17183632c8 Multiple images upload 2024-10-11 12:50:26 -04:00
Swift Coder 686d6d6e92 Code optimization 2024-10-08 17:05:04 -04:00
Swift Coder 847ae7b396 Documenting 2024-10-08 14:45:34 -04:00
Swift Coder ba9780fb17 Allow-pasting-image-option 2024-10-08 13:52:30 -04:00
transifex-integration[bot] 42f5af0ffd Translate Localizable.strings in es_ES
100% translated source file: 'Localizable.strings'
on 'es_ES'.
2024-10-04 12:20:28 -07:00
transifex-integration[bot] 55f1330fc1 Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2024-10-04 12:20:28 -07:00
transifex-integration[bot] 4b326340a3 Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2024-10-04 12:20:28 -07:00
transifex-integration[bot] 83f7766833 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-10-04 12:20:28 -07:00
transifex-integration[bot] 1e3b20f5b3 Translate Localizable.strings in hu_HU
100% translated source file: 'Localizable.strings'
on 'hu_HU'.
2024-10-04 12:20:28 -07:00
transifex-integration[bot] e508f28f7d Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2024-10-04 12:20:28 -07:00
transifex-integration[bot] 2c139863b8 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2024-10-04 12:20:28 -07:00
transifex-integration[bot] c699409129 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2024-10-04 12:20:28 -07:00
transifex-integration[bot] 74e6d8781a Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2024-10-04 12:20:28 -07:00
transifex-integration[bot] 876f9c742f Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2024-10-04 12:20:28 -07:00
transifex-integration[bot] 1e7b57eaf3 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2024-10-04 12:20:28 -07:00
tyiu 5615b1e1ec Fix localization build failures
Changelog-Fixed: Fixed localization build failures
Signed-off-by: Terry Yiu <963907+tyiu@users.noreply.github.com>
2024-10-04 12:20:28 -07:00
transifex-integration[bot] 9d66a5ed4f Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-10-04 12:20:28 -07:00
transifex-integration[bot] 5555f1afec Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-10-04 12:20:28 -07:00
ericholguin 77b1b895a5 ux: fix navigation placement in profile edit view
This PR fixes the issue with the placement of the back navigation button in the profile edit view for iOS 18 devices:

Changelog-Fixed: Fixed back nav button placement in profile edit view

Signed-off-by: ericholguin <ericholguin@apache.org>
2024-10-04 10:29:31 -07:00
ericholguin 6751bc15cc ux: Seamless Timeline
This PR is a ux change to make the header, tabbar, and post button disappear when the user scrolls.
The main tabbar is now an overlay which means it will display over views, this was needed in
order to get the timeline to extend behind it. However, this mean we must add bottom padding to any
view where the main tabbar is present to account for the overlap.

Changelog-Added: Disappearing header, tabbar, and post button on scroll

Signed-off-by: ericholguin <ericholguin@apache.org>
2024-10-01 17:32:56 -06:00
William Casarin 735fa97089 Merge remote-tracking branch 'github/translations'
Transifex (11):
      Translate Localizable.strings in th
      Translate Localizable.strings in th
      Translate Localizable.strings in th
      Translate Localizable.strings in de
      Translate Localizable.strings in zh_CN
      Translate Localizable.strings in zh_HK
      Translate Localizable.strings in zh_HK
      Translate Localizable.strings in zh_TW
      Translate Localizable.strings in de
      Translate Localizable.strings in de
      Translate Localizable.strings in de
2024-09-27 13:55:17 -07:00
William Casarin 314774f032 fixup: 128 instead of 500
500 is excessive

Signed-off-by: William Casarin <jb55@jb55.com>
2024-09-27 13:52:58 -07:00
William Casarin 262bbf26ea profiles: expand search results to 128
This should be plenty

Fixes: https://github.com/damus-io/damus/issues/2547
Changelog-Changed: Expanded profile search results to 128
Changelog-Fixed: Friend profiles will now more likely show up in profile search
Signed-off-by: William Casarin <jb55@jb55.com>
2024-09-27 13:51:08 -07:00
transifex-integration[bot] cdf8d043c9 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-09-27 20:30:09 +00:00
transifex-integration[bot] 1eb7c94a5a Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-09-27 20:26:31 +00:00
William Casarin 5228d8cf4d Merge 'Add PR template with checklist #2548'
Daniel D’Aquino (1):
      Add PR template with checklist
2024-09-27 12:45:40 -07:00
William Casarin 782779f0d7 Merge 'v1.10.1 changelog #2494'
Daniel D’Aquino (1):
      v1.10.1 changelog
2024-09-27 12:44:21 -07:00
William Casarin 18ec8e6b6c Merge 'ui: add ndb search to universe view #2464'
William Casarin (3):
      search: use lazyvstack
      search: expand search results to 128

ericholguin (1):
      ui: add ndb search to universe view
2024-09-27 12:41:53 -07:00
William Casarin 7d82d8b76f search: expand search results to 128
This continues our hack due to the way the compiler bridges to static
sized arrays. yes its horrible. no i don't care.

Changelog-Changed: Expand nostrdb text search results to 128 items
Signed-off-by: William Casarin <jb55@jb55.com>
2024-09-27 12:37:53 -07:00
William Casarin f957756df7 search: use lazyvstack
we're gonna need this when expanding search results

Changelog-Changed: Use LazyVStack in text search results
Signed-off-by: William Casarin <jb55@jb55.com>
2024-09-27 12:37:21 -07:00
Daniel D’Aquino 2cb0553723 Add PR template with checklist
This adds a PR template that will be applied to every new pull request,
to help both contributors and reviewers make sure that the contribution
guidelines are being met.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-09-25 12:29:16 -07:00
Daniel D’Aquino 8464e151cc v1.10.1 changelog
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-09-25 11:48:33 -07:00
Daniel D’Aquino 2da444e7c2 Merge branch 'release_1.10' 2024-09-25 11:19:38 -07: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
Daniel D’Aquino f92509fddf Version bump to 1.10.1
This is needed because we already have a 1.10 build approved by Apple,
and they require a version number bump

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-09-22 13:32:33 -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
ericholguin 62772615b6 ui: add ndb search to universe view
This PR adds the NDB search functionality from the pull down search in the
posting timeline to the universe view.

Changelog-Added: Added NDB search functionality to the universe view

Signed-off-by: ericholguin <ericholguin@apache.org>
2024-09-11 20:34:24 -06: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
transifex-integration[bot] 8c5b0ed5c4 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-09-06 07:19:26 +00: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
transifex-integration[bot] 8481ab85de Translate Localizable.strings in zh_TW
100% translated source file: 'Localizable.strings'
on 'zh_TW'.
2024-09-01 08:22:33 +00:00
transifex-integration[bot] 881d3a3aa1 Translate Localizable.strings in zh_HK
100% translated source file: 'Localizable.strings'
on 'zh_HK'.
2024-09-01 08:17:37 +00:00
transifex-integration[bot] 878509090f Translate Localizable.strings in zh_HK
100% translated source file: 'Localizable.strings'
on 'zh_HK'.
2024-09-01 08:15:50 +00:00
transifex-integration[bot] 24657ecc75 Translate Localizable.strings in zh_CN
100% translated source file: 'Localizable.strings'
on 'zh_CN'.
2024-09-01 08:08:30 +00: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
transifex-integration[bot] 881ece214d Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-08-24 18:28:10 +00: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
transifex-integration[bot] 2519b0ee9f Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2024-08-08 02:50:07 +00:00
transifex-integration[bot] ba1589e2e2 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2024-08-08 02:49:58 +00:00
transifex-integration[bot] e9a2473bad Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2024-08-08 02:47:04 +00: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
tyiu 1767a677bb Fix profile view toolbar alignment bug in iOS 18
Changelog-Fixed: Fix profile view toolbar alignment bug in iOS 18
2024-07-29 10:21:06 -04:00
Daniel D’Aquino dba1799df0 Merge pull request #2338 from ericholguin/move-posting-timeline
refactor: move posting timeline
2024-07-24 10:53:00 -07:00
Daniel D’Aquino 2db3d7310f Add changelog for v1.8 and 1.9 (14) App Store releases
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-07-19 13:12:52 -07:00
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
Daniel D’Aquino 10b1cf64ae Merge pull request #2330 from ericholguin/highlight-fixes
highlight: fixes and improvements
2024-07-19 10:50:30 -07:00
ericholguin afdd3f1d43 refactor: move posting timeline
This patch simply moves the PostingTimelineView into its own file outside of
ContentView.

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

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

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

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

Testing
-------

PASS

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

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

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

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

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

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

Changelog-Fixed: Fix pretty bad crash when building flatbuffer profiles
Signed-off-by: William Casarin <jb55@jb55.com>
2024-07-14 21:26:03 -07:00
Daniel D’Aquino 164cea96f3 Merge pull request #2325 from tyiu/reactions-view-fix
Fix reactions view to not show reactions from replies on parent note
2024-07-13 11:23:05 -07:00
alltheseas fa70c376b1 Update README.md
Added NIP-70 to NIP list
2024-07-10 01:02:46 +03:00
214 changed files with 14462 additions and 2830 deletions
+36
View File
@@ -0,0 +1,36 @@
## Summary
_[Please provide a summary of the changes in this PR.]_
## Checklist
- [ ] I have read (or I am familiar with) the [Contribution Guidelines](../docs/CONTRIBUTING.md)
- [ ] I have tested the changes in this PR
- [ ] My PR is either small, or I have split it into smaller logical commits that are easier to review
- [ ] I have added the signoff line to all my commits. See [Signing off your work](../docs/CONTRIBUTING.md#sign-your-work---the-developers-certificate-of-origin)
- [ ] I have added appropriate changelog entries for the changes in this PR. See [Adding changelog entries](../docs/CONTRIBUTING.md#add-changelog-changed-changelog-fixed-etc)
- [ ] I do not need to add a changelog entry. Reason: _[Please provide a reason]_
- [ ] I have added appropriate `Closes:` or `Fixes:` tags in the commit messages wherever applicable, or made sure those are not needed. See [Submitting patches](https://github.com/damus-io/damus/blob/master/docs/CONTRIBUTING.md#submitting-patches)
## Test report
_Please provide a test report for the changes in this PR. You can use the template below, but feel free to modify it as needed._
**Device:** _[Please specify the device you used for testing]_
**iOS:** _[Please specify the iOS version you used for testing]_
**Damus:** _[Please specify the Damus version or commit hash you used for testing]_
**Setup:** _[Please provide a brief description of the setup you used for testing, if applicable]_
**Steps:** _[Please provide a list of steps you took to test the changes in this PR]_
**Results:**
- [ ] PASS
- [ ] Partial PASS
- Details: _[Please provide details of the partial pass]_
## Other notes
_[Please provide any other information that you think is relevant to this PR.]_
+180
View File
@@ -1,3 +1,183 @@
## [v1.11(10)](https://github.com/damus-io/damus/releases/tag/v1.11-10) - 2024-11-18
### Added
- Add Damus Share Feature (Swift)
- Added new easy to use video controls for full screen video (Daniel DAquino)
- Add Edit, Share, and Tap-gesture in Profile pic image viewer (Swift Coder)
- Disappearing header, tabbar, and post button on scroll (ericholguin)
- Add Apple translation popovers for notes for iOS 17.4+ and macOS 14.4+ (Terry Yiu)
- Added NDB search functionality to the universe view (ericholguin)
- Added mute button to ProfileActionSheet (chungwwei)
- Added mute action to selected text menu (ericholguin)
- Added support for pasting images from the clipboard to the post composer (Swift Coder)
### Changed
- Improved image carousel image fill behavior (Daniel DAquino)
- Improved video syncing and bandwidth usage when switching between timeline video and full screen mode (Daniel DAquino)
- Swipe to dismiss on full screen carousel now shows an opacity effect for improved UX (Daniel DAquino)
- Removed event contents from full screen media carousel for cleaner view (Daniel DAquino)
- Add share button for images on full screen image carousel view (Swift)
- Changed boldness of font in side menu labels. (ericholguin)
- Changed search notes button with searched keyword (ericholguin)
- Changed opacity of tabbar and post button (ericholguin)
- Allow multiple images to be uploaded at the same time (swiftcoder) (William Casarin)
- Changed side menu design (ericholguin)
- Truncate fulltext search results (William Casarin)
- Expanded profile search results to 128 (William Casarin)
- Expand nostrdb text search results to 128 items (William Casarin)
- Use LazyVStack in text search results (William Casarin)
### Fixed
- Fixed missing tab bar on navigation (Swift Coder)
- Fixed some issues where QR code would not work, and improved UX (Daniel DAquino)
- Fixed iOS 18 gesture issues that would take user to the thread view when clicking on a video or unmuting it (Daniel DAquino)
- Fixed several issues that would cause video to automatically play or pause incorrectly (Daniel DAquino)
- Fixed issue where full screen video would disappear when going to landscape mode (Daniel DAquino)
- Fixed portrait video size on full screen carousel (Daniel DAquino)
- Fix avatar image on qrcode view (Swift Coder)
- Fix banner image upload (Swift Coder)
- Fix dismiss button visibility (Swift Coder)
- Fix quote repost counting (William Casarin)
- Fixed overlapping text in Universe View (ericholguin)
- Fixed localization issues and exported strings (Terry Yiu)
- Fix sensitive long-press gesture on event chat bubble in iOS 18 (Daniel DAquino)
- Fixed bottom padding for tabbar (ericholguin)
- Fixed localization build failures (Terry Yiu)
- Fixed back nav button placement in profile edit view (ericholguin)
- Friend profiles will now more likely show up in profile search (William Casarin)
- Fix broken QR code scanner and fix landscape mode (Terry Yiu)
[1.11(10)](https://github.com/damus-io/damus/releases/tag/v1.11-10): https://github.com/damus-io/damus/releases/tag/v1.11-10
## [1.10.1] - 2024-09-22
### Added
- Push notification support (Daniel DAquino)
- Added profile edit safe guards (Eric Holguin)
- Tor relay icon (ericholguin)
- Add highlighter for web pages (Daniel DAquino)
- Add support for adding comments when creating a highlight (Daniel DAquino)
- Add support for rendering highlights with comments (Daniel DAquino)
- Ability to create highlights (ericholguin)
- Highlights (NIP-84) (ericholguin)
- Revamp emoji picker to be less error-prone and add search, frequently used, and multiple skin tone support capabilities (Terry Yiu)
### Changed
- Improve notification view filtering UX (Daniel DAquino)
- Improve visibility of friends filter button (Daniel DAquino)
- Changed the default banner from ostriches to damoose (Eric Holguin)
- Changed image and banner url text fields to new sheet view (Eric Holguin)
- Onboarding design (ericholguin)
### Fixed
- Fix items that became unclickable on iOS 18 (Daniel DAquino)
- Fix many reconnection issues (William Casarin)
- Fixed issue where theme would be changed to black and can't be switched back on iOS 18 (cr0bar)
- Fixed some scenarios where the contact list would never be saved locally and cause issues when switching relays. (Daniel DAquino)
- Fix albyhub zaps not appearing (William Casarin)
- Fix inadvertent escape from mention suggestion menu when typing a space character (Daniel DAquino)
- Fix profile view toolbar alignment bug in iOS 18 (Terry Yiu)
- Create Account model now uses correct metadata (ericholguin)
- Restore localization for custom tabs (William Casarin)
- Fix iOS 18 reflection runtime error for custom picker (William Casarin)
[1.10.1]: https://github.com/damus-io/damus/releases/tag/v1.10.1
## [1.9.1 (4)] - 2024-08-13
### Fixed
- Fix crash when viewing notes with invalid image dimension metadata (Daniel DAquino)
[1.9.1 (4)]: https://github.com/damus-io/damus/releases/tag/v1.9.1-4
## [1.9 (14)] - 2024-07-14
### Added
- Completely new threads experience that is easier and more pleasant to use (Daniel DAquino)
- Add emoji search to emoji picker (Terry Yiu)
### Changed
- Added first aid contact damus support email (alltheseas)
- Disable mutiny wallet button (William Casarin)
- Make friends show up first when searching for profiles (Terry Yiu)
### Fixed
- Fix crash on profile page when there are profile updates (William Casarin)
- Fix crash when adding duplicate mute items (William Casarin)
- Fix pretty bad crash when building flatbuffer profiles (William Casarin)
- Fix reactions view to not show reactions from replies on parent note (Terry Yiu)
- Fix missing Mute button in profile view menu (Terry Yiu)
- Fixed wallet not disconnecting when a user logs out (ericholguin)
- Fix stale feed issue when follow list is too big (Daniel DAquino)
[1.9 (14)]: https://github.com/damus-io/damus/releases/tag/v1.9-14
## [1.8] - 2024-05-11
### Added
- Added nip10 marker replies (William Casarin)
- Add marker nip10 support when reading notes (William Casarin)
- Added title image and tags to longform events (ericholguin)
- Add First Aid solution for users who do not have a contact list created for their account (Daniel DAquino)
- Relay fees metadata (ericholguin)
- Added callbackuri for a better ux when connecting mutiny wallet nwc (ericholguin)
- Add event content preview to the full screen carousel (Daniel DAquino)
- Show list of quoted reposts in threads (William Casarin)
- Proxy Tags are now viewable on Selected Events (ericholguin)
- Connect to Mutiny Wallet Button (ericholguin)
- Add ability to mute words, add new mutelist interface (Charlie) (William Casarin)
- Add ability to mute hashtag from SearchView (Charlie Fish)
### Changed
- Change reactions to use a native looking emoji picker (Terry Yiu)
- Relay detail design (ericholguin)
- Updated Zeus logo (ericholguin)
- Improve UX around video playback (Daniel DAquino)
- Moved paste nwc button to main wallet view (ericholguin)
- Errors with an NWC will show as an alert (ericholguin)
- Relay config view user interface (ericholguin)
- Always strip GPS data from images (kernelkind)
### Fixed
- Fix thread bug where a quote isn't picked up as a reply (William Casarin)
- Fixed threads not loading sometimes (William Casarin)
- Fixed issue where some replies were including the q tag (William Casarin)
- Fixed issue where timeline was scrolling when it isn't supposed to (William Casarin)
- Fix issue where bootstrap relays would inadvertently be added to the user's list on connectivity issues (Daniel DAquino)
- Fix broken GIF uploads (Daniel DAquino)
- Fix ghost notifications caused by Purple impending expiration notifications (Daniel DAquino)
- Improve reliability of contact list creation during onboarding (Daniel DAquino)
- Fix emoji reactions being cut off (ericholguin)
- Fix image indicators to limit number of dots to not spill screen beyond visible margins (ericholguin)
- Fix bug that would cause connection issues with relays defined with a trailing slash URL, and an inability to delete them. (Daniel DAquino)
- Issue where NWC Scanner view would not dismiss after a failed scan/paste (ericholguin)
[1.8]: https://github.com/damus-io/damus/releases/tag/v1.8
## [1.7-rc2] - 2024-02-28
### Added
@@ -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
@@ -1,6 +1,14 @@
{
"originHash" : "babaf4d5748afecf49bbb702530d8e9576460692f478b0a50ee43195dd4440e2",
"originHash" : "534c8e58993919d5ead25ceb4788c8e492c86bc2cf5833dc651ae60a0f30169c",
"pins" : [
{
"identity" : "codescanner",
"kind" : "remoteSourceControl",
"location" : "https://github.com/twostraws/CodeScanner.git",
"state" : {
"revision" : "9fa582f4b36c69c2a55bff5fb3377eb170ae273c"
}
},
{
"identity" : "emojikit",
"kind" : "remoteSourceControl",
@@ -92,10 +100,9 @@
{
"identity" : "swipeactions",
"kind" : "remoteSourceControl",
"location" : "https://github.com/aheze/SwipeActions",
"location" : "https://github.com/damus-io/SwipeActions.git",
"state" : {
"revision" : "41e6f6dce02d8cfa164f8c5461a41340850ca3ab",
"version" : "1.1.0"
"revision" : "33d99756c3112e1a07c1732e3cddc5ad5bd0c5f4"
}
}
],
@@ -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>
@@ -40,7 +40,7 @@
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "YES">
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEFC27F7A08200C66700"
+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

+7 -14
View File
@@ -12,31 +12,25 @@ let RECTANGLE_GRADIENT = LinearGradient(gradient: Gradient(colors: [
DamusColors.blue
]), startPoint: .leading, endPoint: .trailing)
struct CustomPicker<SelectionValue: Hashable, Content: View>: View {
struct CustomPicker<SelectionValue: Hashable>: View {
let tabs: [(String, SelectionValue)]
@Environment(\.colorScheme) var colorScheme
@Namespace var picker
@Binding var selection: SelectionValue
@ViewBuilder let content: Content
public var body: some View {
let contentMirror = Mirror(reflecting: content)
let blocksCount = Mirror(reflecting: contentMirror.descendant("value")!).children.count
HStack {
ForEach(0..<blocksCount, id: \.self) { index in
let tupleBlock = contentMirror.descendant("value", ".\(index)")
let text = Mirror(reflecting: tupleBlock!).descendant("content") as! Text
let tag = Mirror(reflecting: tupleBlock!).descendant("modifier", "value", "tagged") as! SelectionValue
ForEach(tabs, id: \.1) { (text, tag) in
Button {
withAnimation(.spring()) {
selection = tag
}
} label: {
text
.padding(EdgeInsets(top: 15, leading: 0, bottom: 10, trailing: 0))
Text(text).padding(EdgeInsets(top: 15, leading: 0, bottom: 10, trailing: 0))
.font(.system(size: 14, weight: .heavy))
.tag(tag)
}
.background(
Group {
@@ -52,7 +46,6 @@ struct CustomPicker<SelectionValue: Hashable, Content: View>: View {
.accentColor(tag == selection ? textColor() : .gray)
}
}
.background(Color(UIColor.systemBackground))
}
func textColor() -> Color {
@@ -20,6 +20,7 @@ struct DamusBackground: View {
.resizable()
.frame(maxWidth: .infinity, maxHeight: maxHeight, alignment: .center)
.ignoresSafeArea()
.accessibilityHidden(true)
}
}
+205 -112
View File
@@ -7,6 +7,7 @@
import SwiftUI
import Kingfisher
import Combine
// TODO: all this ShareSheet complexity can be replaced with ShareLink once we update to iOS 16
struct ShareSheet: UIViewControllerRepresentable {
@@ -95,64 +96,203 @@ enum ImageShape {
}
}
/// The `CarouselModel` helps `ImageCarousel` with some state management logic, keeping track of media sizes, and the ideal display size
///
/// This model is necessary because the state management logic required to keep track of media sizes for each one of the carousel items,
/// and the ideal display size at each moment is not a trivial task.
///
/// The rules for the media fill are as follows:
/// 1. The media item should generally have a width that completely fills the width of its parent view
/// 2. The height of the carousel should be adjusted accordingly
/// 3. The only exception to rules 1 and 2 is when the total height would be 20% larger than the height of the device
/// 4. If none of the above can be computed (e.g. due to missing information), default to a reasonable height, where the media item will fit into.
///
/// ## Usage notes
///
/// The view is has the following state management responsibilities:
/// 1. Watching the size of the images (via the `.observe_image_size` modifier)
/// 2. Notifying this class of geometry reader changes, by setting `geo_size`
///
/// ## Implementation notes
///
/// This class is organized in a way to reduce stateful behavior and the transiency bugs it can cause.
///
/// This is accomplished through the following pattern:
/// 1. The `current_item_fill` is a published property so that any updates instantly re-render the view
/// 2. However, `current_item_fill` has a mathematical dependency on other members of this class
/// 3. Therefore, the members on which the fill property depends on all have `didSet` observers that will cause the `current_item_fill` to be recalculated and published.
///
/// This pattern helps ensure that the state is always consistent and that the view is always up-to-date.
///
/// This class is marked as `@MainActor` since most of its properties are published and should be accessed from the main thread to avoid inconsistent SwiftUI state during renders
@MainActor
class CarouselModel: ObservableObject {
var current_url: URL?
var fillHeight: CGFloat
var maxHeight: CGFloat
var firstImageHeight: CGFloat?
// MARK: Immutable object attributes
// These are some attributes that are not expected to change throughout the lifecycle of this object
// These should not be modified after initialization to avoid state inconsistency
/// The state of the app
let damus_state: DamusState
/// All urls in the carousel
let urls: [MediaUrl]
/// The default fill height for the carousel, if we cannot calculate a more appropriate height
/// **Usage note:** Default to this when `current_item_fill` is nil
let default_fill_height: CGFloat
/// The maximum height for any carousel item
let max_height: CGFloat
// MARK: Miscellaneous
/// Holds items that allows us to cancel video size observers during de-initialization
private var all_cancellables: [AnyCancellable] = []
// MARK: State management properties
/// Properties relevant to state management.
/// These should be made into computed/functional properties when possible to avoid stateful behavior
/// When that is not possible (e.g. when dealing with an observed published property), establish its mathematical dependencies,
/// and use `didSet` observers to ensure that the state is always re-computed when necessary.
@Published var open_sheet: Bool
@Published var selectedIndex: Int
@Published var video_size: CGSize?
@Published var image_fill: ImageFill?
/// Stores information about the size of each media item in `urls`.
/// **Usage note:** The view is responsible for setting the size of image urls
var media_size_information: [URL: CGSize] {
didSet {
guard let current_url else { return }
// Upon updating information, update the carousel fill size if the size for the current url has changed
if oldValue[current_url] != media_size_information[current_url] {
self.refresh_current_item_fill()
}
}
}
/// Stores information about the geometry reader
/// **Usage note:** The view is responsible for setting this value
var geo_size: CGSize? {
didSet { self.refresh_current_item_fill() }
}
/// The index of the currently selected item
/// **Usage note:** The view is responsible for setting this value
@Published var selectedIndex: Int {
didSet { self.refresh_current_item_fill() }
}
/// The current fill for the media item.
/// **Usage note:** This property is read-only and should not be set directly. Update `selectedIndex` to update the current item being viewed.
var current_url: URL? {
return urls[safe: selectedIndex]?.url
}
/// Holds the ideal fill dimensions for the current item.
/// **Usage note:** This property is automatically updated when other properties are set, and should not be set directly
/// **Implementation note:** This property is mathematically dependent on geo_size, media_size_information, and `selectedIndex`,
/// and is automatically updated upon changes to these properties.
@Published private(set) var current_item_fill: ImageFill?
// MARK: Initialization and de-initialization
init(image_fill: ImageFill?) {
self.current_url = nil
self.fillHeight = 350
self.maxHeight = UIScreen.main.bounds.height * 1.2 // 1.2
self.firstImageHeight = nil
self.open_sheet = false
/// Initializes the `CarouselModel` with the given `DamusState` and `MediaUrl` array
init(damus_state: DamusState, urls: [MediaUrl]) {
// Immutable object attributes
self.damus_state = damus_state
self.urls = urls
self.default_fill_height = 350
self.max_height = UIScreen.main.bounds.height * 1.2 // 1.2
// State management properties
self.selectedIndex = 0
self.video_size = nil
self.image_fill = image_fill
self.current_item_fill = nil
self.geo_size = nil
self.media_size_information = [:]
// Setup the rest of the state management logic
self.observe_video_sizes()
Task {
self.refresh_current_item_fill()
}
}
/// This private function observes the video sizes for all videos
private func observe_video_sizes() {
for media_url in urls {
switch media_url {
case .video(let url):
let video_player = damus_state.video.get_player(for: url)
if let video_size = video_player.video_size {
self.media_size_information[url] = video_size // Set the initial size if available
}
let observer_cancellable = video_player.$video_size.sink(receiveValue: { new_size in
self.media_size_information[url] = new_size // Update the size when it changes
})
all_cancellables.append(observer_cancellable) // Store the cancellable to cancel it later
case .image(_):
break; // Observing an image size needs to be done on the view directly, through the `.observe_image_size` modifier
}
}
}
deinit {
for cancellable_item in all_cancellables {
cancellable_item.cancel()
}
}
// MARK: State management and logic
/// This function refreshes the current item fill based on the current state of the model
/// **Usage note:** This is private, do not call this directly from outside the class.
/// **Implementation note:** This should be called using `didSet` observers on properties that affect the fill
private func refresh_current_item_fill() {
if let current_url,
let item_size = self.media_size_information[current_url],
let geo_size {
self.current_item_fill = ImageFill.calculate_image_fill(
geo_size: geo_size,
img_size: item_size,
maxHeight: self.max_height,
fillHeight: self.default_fill_height
)
}
else {
self.current_item_fill = nil // Not enough information to compute the proper fill. Default to nil
}
}
}
// MARK: - Image Carousel
/// A carousel that displays images and videos
///
/// ## Implementation notes
///
/// - State management logic is mostly handled by `CarouselModel`, as it is complex, and becomes difficult to manage in a view
///
@MainActor
struct ImageCarousel<Content: View>: View {
var urls: [MediaUrl]
/// The event id of the note that this carousel is displaying
let evid: NoteId
let state: DamusState
/// The model that holds information and state of this carousel
/// This is observed to update the view when the model changes
@ObservedObject var model: CarouselModel
let content: ((_ dismiss: @escaping (() -> Void)) -> Content)?
init(state: DamusState, evid: NoteId, urls: [MediaUrl]) {
self.urls = urls
self.evid = evid
self.state = state
let media_model = state.events.get_cache_data(evid).media_metadata_model
self._model = ObservedObject(initialValue: CarouselModel(image_fill: media_model.fill))
self._model = ObservedObject(initialValue: CarouselModel(damus_state: state, urls: urls))
self.content = nil
}
init(state: DamusState, evid: NoteId, urls: [MediaUrl], @ViewBuilder content: @escaping (_ dismiss: @escaping (() -> Void)) -> Content) {
self.urls = urls
self.evid = evid
self.state = state
let media_model = state.events.get_cache_data(evid).media_metadata_model
self._model = ObservedObject(initialValue: CarouselModel(image_fill: media_model.fill))
self._model = ObservedObject(initialValue: CarouselModel(damus_state: state, urls: urls))
self.content = content
}
var filling: Bool {
model.image_fill?.filling == true
model.current_item_fill?.filling == true
}
var height: CGFloat {
model.firstImageHeight ?? model.image_fill?.height ?? model.fillHeight
// Use the calculated fill height if available, otherwise use the default fill height
model.current_item_fill?.height ?? model.default_fill_height
}
func Placeholder(url: URL, geo_size: CGSize, num_urls: Int) -> some View {
@@ -160,7 +300,7 @@ struct ImageCarousel<Content: View>: View {
if num_urls > 1 {
// jb55: quick hack since carousel with multiple images looks horrible with blurhash background
Color.clear
} else if let meta = state.events.lookup_img_metadata(url: url),
} else if let meta = model.damus_state.events.lookup_img_metadata(url: url),
case .processed(let blurhash) = meta.state {
Image(uiImage: blurhash)
.resizable()
@@ -169,12 +309,6 @@ struct ImageCarousel<Content: View>: View {
Color.clear
}
}
.onAppear {
if self.model.image_fill == nil, let size = state.video.size_for_url(url) {
let fill = ImageFill.calculate_image_fill(geo_size: geo_size, img_size: size, maxHeight: model.maxHeight, fillHeight: model.fillHeight)
self.model.image_fill = fill
}
}
}
func Media(geo: GeometryProxy, url: MediaUrl, index: Int) -> some View {
@@ -183,24 +317,17 @@ struct ImageCarousel<Content: View>: View {
case .image(let url):
Img(geo: geo, url: url, index: index)
.onTapGesture {
model.open_sheet = true
present(full_screen_item: .full_screen_carousel(urls: model.urls, selectedIndex: $model.selectedIndex))
}
case .video(let url):
DamusVideoPlayer(url: url, video_size: $model.video_size, controller: state.video, style: .preview(on_tap: { model.open_sheet = true }))
.onChange(of: model.video_size) { size in
guard let size else { return }
let fill = ImageFill.calculate_image_fill(geo_size: geo.size, img_size: size, maxHeight: model.maxHeight, fillHeight: model.fillHeight)
print("video_size changed \(size)")
if self.model.image_fill == nil {
print("video_size firstImageHeight \(fill.height)")
self.model.firstImageHeight = fill.height
state.events.get_cache_data(evid).media_metadata_model.fill = fill
}
self.model.image_fill = fill
}
let video_model = model.damus_state.video.get_player(for: url)
DamusVideoPlayerView(
model: video_model,
coordinator: model.damus_state.video,
style: .preview(on_tap: {
present(full_screen_item: .full_screen_carousel(urls: model.urls, selectedIndex: $model.selectedIndex))
})
)
}
}
}
@@ -209,33 +336,21 @@ struct ImageCarousel<Content: View>: View {
KFAnimatedImage(url)
.callbackQueue(.dispatch(.global(qos:.background)))
.backgroundDecode(true)
.imageContext(.note, disable_animation: state.settings.disable_animation)
.imageContext(.note, disable_animation: model.damus_state.settings.disable_animation)
.image_fade(duration: 0.25)
.cancelOnDisappear(true)
.configure { view in
view.framePreloadCount = 3
}
.imageFill(for: geo.size, max: model.maxHeight, fill: model.fillHeight) { fill in
state.events.get_cache_data(evid).media_metadata_model.fill = fill
// blur hash can be discarded when we have the url
// NOTE: this is the wrong place for this... we need to remove
// it when the image is loaded in memory. This may happen
// earlier than this (by the preloader, etc)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
state.events.lookup_img_metadata(url: url)?.state = .not_needed
}
self.model.image_fill = fill
if index == 0 {
self.model.firstImageHeight = fill.height
//maxHeight = firstImageHeight ?? maxHeight
} else {
//maxHeight = firstImageHeight ?? fill.height
}
}
.observe_image_size(size_changed: { size in
// Observe the image size to update the model when the size changes, so we can calculate the fill
model.media_size_information[url] = size
})
.background {
Placeholder(url: url, geo_size: geo.size, num_urls: urls.count)
Placeholder(url: url, geo_size: geo.size, num_urls: model.urls.count)
}
.aspectRatio(contentMode: filling ? .fill : .fit)
.kfClickable()
.position(x: geo.size.width / 2, y: geo.size.height / 2)
.tabItem {
Text(url.absoluteString)
@@ -247,25 +362,19 @@ struct ImageCarousel<Content: View>: View {
var Medias: some View {
TabView(selection: $model.selectedIndex) {
ForEach(urls.indices, id: \.self) { index in
ForEach(model.urls.indices, id: \.self) { index in
GeometryReader { geo in
Media(geo: geo, url: urls[index], index: index)
Media(geo: geo, url: model.urls[index], index: index)
.onChange(of: geo.size, perform: { new_size in
model.geo_size = new_size
})
.onAppear {
model.geo_size = geo.size
}
}
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.fullScreenCover(isPresented: $model.open_sheet) {
if let content {
FullScreenCarouselView<Content>(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex) {
content({ // Dismiss closure
model.open_sheet = false
})
}
}
else {
FullScreenCarouselView<AnyView>(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex)
}
}
.frame(height: height)
.onChange(of: model.selectedIndex) { value in
model.selectedIndex = value
@@ -274,11 +383,17 @@ 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)
if model.urls.count > 1 {
PageControlView(currentPage: $model.selectedIndex, numberOfPages: model.urls.count)
.frame(maxWidth: 0, maxHeight: 0)
.padding(.top, 5)
}
@@ -286,27 +401,6 @@ struct ImageCarousel<Content: View>: View {
}
}
// MARK: - Image Modifier
extension KFOptionSetter {
/// Sets a block to get image size
///
/// - Parameter block: The block which is used to read the image object.
/// - Returns: `Self` value after read size
public func imageFill(for size: CGSize, max: CGFloat, fill: CGFloat, block: @escaping (ImageFill) throws -> Void) -> Self {
let modifier = AnyImageModifier { image -> KFCrossPlatformImage in
let img_size = image.size
let geo_size = size
let fill = ImageFill.calculate_image_fill(geo_size: geo_size, img_size: img_size, maxHeight: max, fillHeight: fill)
DispatchQueue.main.async { [block, fill] in
try? block(fill)
}
return image
}
options.imageModifier = modifier
return self
}
}
public struct ImageFill {
let filling: Bool?
@@ -343,4 +437,3 @@ struct ImageCarousel_Previews: PreviewProvider {
.environmentObject(OrientationTracker())
}
}
+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))
}
+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.isAppleTranslationPopoverSupported {
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)
+120 -98
View File
@@ -57,6 +57,47 @@ enum Sheets: Identifiable {
}
}
/// An item to be presented full screen in a mechanism that is more robust for timeline views.
///
/// ## Implementation notes
///
/// This is part of the `present(full_screen_item: FullScreenItem)` interface that allows views in a timeline to show something full-screen without the lazy stack issues
/// Full screen cover modifiers are not suitable in those cases because device orientation changes or programmatic scroll commands will cause the view to be unloaded along with the cover,
/// causing the user to lose the full screen view randomly.
///
/// The `ContentView` is responsible for handling these objects
///
/// New items can be added as needed.
///
enum FullScreenItem: Identifiable, Equatable {
/// A full screen media carousel for images and videos.
case full_screen_carousel(urls: [MediaUrl], selectedIndex: Binding<Int>)
var id: String {
switch self {
case .full_screen_carousel(let urls, _): return "full_screen_carousel:\(urls.map(\.url))"
}
}
static func == (lhs: FullScreenItem, rhs: FullScreenItem) -> Bool {
return lhs.id == rhs.id
}
/// The view to display the item
func view(damus_state: DamusState) -> some View {
switch self {
case .full_screen_carousel(let urls, let selectedIndex):
return FullScreenCarouselView<AnyView>(video_coordinator: damus_state.video, urls: urls, settings: damus_state.settings, selectedIndex: selectedIndex)
}
}
}
func present_sheet(_ sheet: Sheets) {
notify(.present_sheet(sheet))
}
var tabHeight: CGFloat = 0.0
struct ContentView: View {
let keypair: Keypair
let appDelegate: AppDelegate?
@@ -72,78 +113,29 @@ struct ContentView: View {
@Environment(\.scenePhase) var scenePhase
@State var active_sheet: Sheets? = nil
@State var active_full_screen_item: FullScreenItem? = 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
@State var user_muted_confirm: Bool = false
@State var confirm_overwrite_mutelist: Bool = false
@SceneStorage("ContentView.filter_state") var filter_state : FilterState = .posts_and_replies
@State private var isSideBarOpened = false
@State var headerOffset: CGFloat = 0.0
var home: HomeModel = HomeModel()
@StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
@AppStorage("has_seen_suggested_users") private var hasSeenOnboardingSuggestions = false
let sub_id = UUID().description
@Environment(\.colorScheme) var colorScheme
// connect retry timer
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var mystery: some View {
Text("Are you lost?", comment: "Text asking the user if they are lost in the app.")
.id("what")
}
func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) {
var filters = ContentFilters.defaults(damus_state: damus_state!)
filters.append(fstate.filter)
return ContentFilters(filters: filters).filter
}
var PostingTimelineView: some View {
VStack {
ZStack {
TabView(selection: $filter_state) {
// This is needed or else there is a bug when switching from the 3rd or 2nd tab to first. no idea why.
mystery
contentTimelineView(filter: content_filter(.posts))
.tag(FilterState.posts)
.id(FilterState.posts)
contentTimelineView(filter: content_filter(.posts_and_replies))
.tag(FilterState.posts_and_replies)
.id(FilterState.posts_and_replies)
}
.tabViewStyle(.page(indexDisplayMode: .never))
if privkey != nil {
PostButtonContainer(is_left_handed: damus_state?.settings.left_handed ?? false) {
self.active_sheet = .post(.posting(.none))
}
}
}
}
.safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: 0) {
CustomPicker(selection: $filter_state, content: {
Text("Notes", comment: "Label for filter for seeing only notes (instead of notes and replies).").tag(FilterState.posts)
Text("Notes & Replies", comment: "Label for filter for seeing notes and replies (instead of only notes).").tag(FilterState.posts_and_replies)
})
Divider()
.frame(height: 1)
}
.background(colorScheme == .dark ? Color.black : Color.white)
}
}
func contentTimelineView(filter: (@escaping (NostrEvent) -> Bool)) -> some View {
TimelineView(events: home.events, loading: .constant(false), damus: damus_state, show_friend_icon: false, filter: filter) {
PullDownSearchView(state: damus_state, on_cancel: {})
}
}
func navIsAtRoot() -> Bool {
return navigationCoordinator.isAtRoot()
}
@@ -153,9 +145,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 {
@@ -171,34 +170,25 @@ struct ContentView: View {
}
case .home:
PostingTimelineView
PostingTimelineView(damus_state: damus_state!, home: home, isSideBarOpened: $isSideBarOpened, active_sheet: $active_sheet, headerOffset: $headerOffset)
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)
}
}
.background(DamusColors.adaptableWhite)
.edgesIgnoringSafeArea(selected_timeline != .home ? [] : [.top, .bottom])
.navigationBarTitle(timeline_name(selected_timeline), displayMode: .inline)
.toolbar(selected_timeline != .home ? .visible : .hidden)
.toolbar {
ToolbarItem(placement: .principal) {
VStack {
if selected_timeline == .home {
Image("damus-home")
.resizable()
.frame(width:30,height:30)
.shadow(color: DamusColors.purple, radius: 2)
.opacity(isSideBarOpened ? 0 : 1)
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
.onTapGesture {
isSideBarOpened.toggle()
}
} else {
timelineNavItem
.opacity(isSideBarOpened ? 0 : 1)
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
}
timelineNavItem
.opacity(isSideBarOpened ? 0 : 1)
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
}
}
}
@@ -249,14 +239,7 @@ struct ContentView: View {
MainContent(damus: damus)
.toolbar() {
ToolbarItem(placement: .navigationBarLeading) {
Button {
isSideBarOpened.toggle()
} label: {
ProfilePicView(pubkey: damus_state!.pubkey, size: 32, highlight: .none, profiles: damus_state!.profiles, disable_animation: damus_state!.settings.disable_animation)
.opacity(isSideBarOpened ? 0 : 1)
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
}
.disabled(isSideBarOpened)
TopbarSideMenuButton(damus_state: damus, isSideBarOpened: $isSideBarOpened)
}
ToolbarItem(placement: .navigationBarTrailing) {
@@ -277,9 +260,11 @@ struct ContentView: View {
}
}
}
.background(DamusColors.adaptableWhite)
.edgesIgnoringSafeArea(selected_timeline != .home ? [] : [.top, .bottom])
.tabViewStyle(.page(indexDisplayMode: .never))
.overlay(
SideMenuView(damus_state: damus_state!, isSidebarVisible: $isSideBarOpened.animation())
SideMenuView(damus_state: damus_state!, isSidebarVisible: $isSideBarOpened.animation(), selected: $selected_timeline)
)
.navigationDestination(for: Route.self) { route in
route.view(navigationCoordinator: navigationCoordinator, damusState: damus_state!)
@@ -289,13 +274,28 @@ struct ContentView: View {
}
}
.navigationViewStyle(.stack)
if !hide_bar {
TabBar(nstatus: home.notification_status, selected: $selected_timeline, settings: damus.settings, action: switch_timeline)
.padding([.bottom], 8)
.background(Color(uiColor: .systemBackground).ignoresSafeArea())
} else {
Text("")
.damus_full_screen_cover($active_full_screen_item, damus_state: damus, content: { item in
return item.view(damus_state: damus)
})
.overlay(alignment: .bottom) {
if !hide_bar {
if !isSideBarOpened {
TabBar(nstatus: home.notification_status, navIsAtRoot: navIsAtRoot(), selected: $selected_timeline, headerOffset: $headerOffset, settings: damus.settings, action: switch_timeline)
.padding([.bottom], 8)
.background(selected_timeline != .home || (selected_timeline == .home && !self.navIsAtRoot()) ? DamusColors.adaptableWhite : DamusColors.adaptableWhite.opacity(abs(1.25 - (abs(headerOffset/100.0)))))
.anchorPreference(key: HeaderBoundsKey.self, value: .bounds){$0}
.overlayPreferenceValue(HeaderBoundsKey.self) { value in
GeometryReader{ proxy in
if let anchor = value{
Color.clear
.onAppear {
tabHeight = proxy[anchor].height
}
}
}
}
}
}
}
}
}
@@ -453,6 +453,9 @@ struct ContentView: View {
.onReceive(handle_notify(.present_sheet)) { sheet in
self.active_sheet = sheet
}
.onReceive(handle_notify(.present_full_screen_item)) { item in
self.active_full_screen_item = item
}
.onReceive(handle_notify(.zapping)) { zap_ev in
guard !zap_ev.is_custom else {
return
@@ -718,7 +721,7 @@ struct ContentView: View {
wallet: WalletModel(settings: settings),
nav: self.navigationCoordinator,
music: MusicController(onChange: music_changed),
video: VideoController(),
video: DamusVideoCoordinator(),
ndb: ndb,
quote_reposts: .init(our_pubkey: pubkey),
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
@@ -774,7 +777,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
@@ -782,6 +785,25 @@ struct ContentView: View {
}
}
struct TopbarSideMenuButton: View {
let damus_state: DamusState
@Binding var isSideBarOpened: Bool
var body: some View {
Button {
isSideBarOpened.toggle()
} label: {
ProfilePicView(pubkey: damus_state.pubkey, size: 32, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
.opacity(isSideBarOpened ? 0 : 1)
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
.accessibilityHidden(true) // Knowing there is a profile picture here leads to no actionable outcome to VoiceOver users, so it is best not to show it
}
.accessibilityIdentifier(AppAccessibilityIdentifiers.main_side_menu_button.rawValue)
.accessibilityLabel(NSLocalizedString("Side menu", comment: "Accessibility label for the side menu button at the topbar"))
.disabled(isSideBarOpened)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(keypair: Keypair(pubkey: test_pubkey, privkey: nil), appDelegate: nil)
@@ -875,7 +897,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
@@ -1107,7 +1129,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)
@@ -1168,7 +1190,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
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ enum FilterState : Int {
func filter(ev: NostrEvent) -> Bool {
switch self {
case .posts:
return ev.known_kind == .boost || !ev.is_reply()
return ev.known_kind == .boost || ev.known_kind == .highlight || !ev.is_reply()
case .posts_and_replies:
return true
}
+76 -3
View File
@@ -34,13 +34,13 @@ class DamusState: HeadlessDamusState {
let wallet: WalletModel
let nav: NavigationCoordinator
let music: MusicController?
let video: VideoController
let video: DamusVideoCoordinator
let ndb: Ndb
var purple: DamusPurple
var push_notification_client: PushNotificationClient
let emoji_provider: EmojiProvider
init(pool: RelayPool, keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, postbox: PostBox, bootstrap_relays: [RelayURL], replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: VideoController, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider) {
init(pool: RelayPool, keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, postbox: PostBox, bootstrap_relays: [RelayURL], replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider) {
self.pool = pool
self.keypair = keypair
self.likes = likes
@@ -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: DamusVideoCoordinator(),
ndb: ndb,
quote_reposts: .init(our_pubkey: pubkey),
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
)
}
@discardableResult
func add_zap(zap: Zapping) -> Bool {
@@ -136,7 +209,7 @@ class DamusState: HeadlessDamusState {
wallet: WalletModel(settings: UserSettingsStore()),
nav: NavigationCoordinator(),
music: nil,
video: VideoController(),
video: DamusVideoCoordinator(),
ndb: .empty,
quote_reposts: .init(our_pubkey: empty_pub),
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
+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'")
}
}
}
+206 -1
View File
@@ -13,22 +13,227 @@ 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)
}
}
}
struct ShareContent {
let title: String
let content: ContentType
enum ContentType {
case link(URL)
case media([PreUploadedMedia])
}
func getLinkURL() -> URL? {
if case let .link(url) = content {
return url
}
return nil
}
func getMediaArray() -> [PreUploadedMedia] {
if case let .media(mediaArray) = content {
return mediaArray
}
return []
}
}
+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)
}
+4
View File
@@ -111,12 +111,16 @@ class MutelistManager {
private func add_mute_item(_ item: MuteItem) {
switch item {
case .user(_, _):
guard !users.contains(item) else { return }
users.insert(item)
case .hashtag(_, _):
guard !hashtags.contains(item) else { return }
hashtags.insert(item)
case .word(_, _):
guard !words.contains(item) else { return }
words.insert(item)
case .thread(_, _):
guard !threads.contains(item) else { return }
threads.insert(item)
}
}
+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"
}
}
}
}
+3 -1
View File
@@ -11,13 +11,15 @@ import Foundation
class ThreadModel: ObservableObject {
@Published var event: NostrEvent
let original_event: NostrEvent
let highlight: String?
var event_map: Set<NostrEvent>
init(event: NostrEvent, damus_state: DamusState) {
init(event: NostrEvent, damus_state: DamusState, highlight: String? = nil) {
self.damus_state = damus_state
self.event_map = Set()
self.event = event
self.original_event = event
self.highlight = highlight
add_event(event, keypair: damus_state.keypair)
}
+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.isAppleTranslationPopoverSupported {
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 isAppleTranslationPopoverSupported: Bool {
#if targetEnvironment(macCatalyst)
return false
#else
if #available(iOS 17.4, macOS 14.4, *) {
return true
} else {
return false
}
#endif
}
}
+7 -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
@@ -207,11 +207,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
+5 -1
View File
@@ -12,11 +12,15 @@ struct SwipeToDismissModifier: ViewModifier {
var onDismiss: () -> Void
@State private var offset: CGSize = .zero
@GestureState private var viewOffset: CGSize = .zero
let threshold_offset: CGFloat = 100.0
let minimum_opacity: CGFloat = 0.1
func body(content: Content) -> some View {
content
.offset(y: viewOffset.height)
.animation(.interactiveSpring(), value: viewOffset)
.opacity(max(min(1.0 - (abs(offset.height) / threshold_offset), 1.0), minimum_opacity))
.simultaneousGesture(
DragGesture(minimumDistance: minDistance ?? 10)
.updating($viewOffset, body: { value, gestureState, transaction in
@@ -28,7 +32,7 @@ struct SwipeToDismissModifier: ViewModifier {
}
}
.onEnded { _ in
if abs(offset.height) > 100 {
if abs(offset.height) > threshold_offset {
onDismiss()
} else {
offset = .zero
+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()
}
@@ -0,0 +1,42 @@
//
// PresentFullScreenItemNotify.swift
// damus
//
// Created by Daniel DAquino on 2024-11-01.
//
struct PresentFullScreenItemNotify: Notify {
typealias Payload = FullScreenItem
var payload: Payload
}
extension NotifyHandler {
static var present_full_screen_item: NotifyHandler<PresentFullScreenItemNotify> {
.init()
}
}
extension Notifications {
static func present_full_screen_item(_ item: FullScreenItem) -> Notifications<PresentFullScreenItemNotify> {
.init(.init(payload: item))
}
}
/// Tell the app to present an item in full screen. Use this when presenting items coming from a timeline or any lazy stack.
///
/// ## Usage notes
///
/// Use this instead of `.damus_full_screen_cover` when the source view is on a lazy stack or timeline.
///
/// The reason is that when using a full screen modifier in those scenarios, the full screen view may abruptly disappear.
/// One example is when showing videos from the timeline in full screen, where changing the orientation of the device (landscape/portrait)
/// can cause the source view to be unloaded by the lazy stack, making your full screen overlay to simply disappear, causing a feeling of flakiness to the app
///
/// ## Implementation notes
///
/// The requests from this function will be received and handled at the top level app view (`ContentView`), which contains a `.damus_full_screen_cover`.
///
func present(full_screen_item: FullScreenItem) {
notify(.present_full_screen_item(full_screen_item))
}
+7
View File
@@ -36,6 +36,13 @@ let test_short_note =
createdAt: UInt32(Date().timeIntervalSince1970 - 100)
)!
let test_super_short_note =
NostrEvent(
content: "A",
keypair: jack_keypair,
createdAt: UInt32(Date().timeIntervalSince1970 - 100)
)!
let test_note_json_with_escaped_slash = "{\"tags\":[],\"pubkey\":\"f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9\",\"content\":\"https:\\/\\/cdn.nostr.build\\/i\\/5c1d3296f66c2630131bf123106486aeaf051ed8466031c0e0532d70b33cddb2.jpg\",\"created_at\":1691864981,\"kind\":1,\"sig\":\"fc0033aa3d4df50b692a5b346fa816fdded698de2045e36e0642a021391468c44ca69c2471adc7e92088131872d4aaa1e90ea6e1ad97f3cc748f4aed96dfae18\",\"id\":\"e8f6eca3b161abba034dac9a02bb6930ecde9fd2fb5d6c5f22a05526e11382cb\"}"
let test_encoded_note_with_image = NostrEvent.owned_from_json(json: test_note_json_with_escaped_slash)
+8 -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")!
@@ -30,4 +31,7 @@ class Constants {
static let DAMUS_WEBSITE_LOCAL_TEST_URL: URL = URL(string: "http://localhost:3000")!
static let DAMUS_WEBSITE_STAGING_URL: URL = URL(string: "https://staging.damus.io")!
static let DAMUS_WEBSITE_PRODUCTION_URL: URL = URL(string: "https://damus.io")!
// MARK: General constants
static let GIF_IMAGE_TYPE: String = "com.compuserve.gif"
}
+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)
}
@@ -58,6 +58,22 @@ extension KFOptionSetter {
return self
}
/// This allows you to observe the size of the image, and get a callback when the size changes
/// This is useful for when you need to layout views based on the size of the image
/// - Parameter size_changed: A callback that will be called when the size of the image changes
/// - Returns: The same KFOptionSetter instance
func observe_image_size(size_changed: @escaping (CGSize) -> Void) -> Self {
let modifier = AnyImageModifier { image -> KFCrossPlatformImage in
let image_size = image.size
DispatchQueue.main.async { [size_changed, image_size] in
size_changed(image_size)
}
return image
}
options.imageModifier = modifier
return self
}
}
let MAX_FILE_SIZE = 20_971_520 // 20MiB
@@ -0,0 +1,78 @@
//
// OffsetExtension.swift
// damus
//
// Created by eric on 9/6/24.
//
import SwiftUI
enum SwipeDirection {
case up
case down
case none
}
extension View {
@ViewBuilder
func offsetY(completion: @escaping (CGFloat, CGFloat)->())->some View {
self
.modifier(OffsetHelper(onChange: completion))
}
func safeArea() -> UIEdgeInsets {
guard let scene = this_app.connectedScenes.first as? UIWindowScene else{return .zero}
guard let safeArea = scene.windows.first?.safeAreaInsets else{return .zero}
return safeArea
}
}
struct OffsetHelper: ViewModifier{
var onChange: (CGFloat,CGFloat)->()
@State var currentOffset: CGFloat = 0
@State var previousOffset: CGFloat = 0
func body(content: Content) -> some View {
content
.overlay {
GeometryReader{proxy in
let minY = proxy.frame(in: .named("scroll")).minY
Color.clear
.preference(key: OffsetKey.self, value: minY)
.onPreferenceChange(OffsetKey.self) { value in
previousOffset = currentOffset
currentOffset = value
onChange(previousOffset,currentOffset)
}
}
}
}
}
struct OffsetKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
struct HeaderBoundsKey: PreferenceKey{
static var defaultValue: Anchor<CGRect>?
static func reduce(value: inout Anchor<CGRect>?, nextValue: () -> Anchor<CGRect>?) {
value = nextValue()
}
}
func getSafeAreaTop()->CGFloat{
guard let scene = this_app.connectedScenes.first as? UIWindowScene else{return .zero}
guard let topSafeArea = scene.windows.first?.safeAreaInsets.top else{return .zero}
return topSafeArea
}
func getSafeAreaBottom()->CGFloat{
guard let scene = this_app.connectedScenes.first as? UIWindowScene else{return .zero}
guard let bottomSafeArea = scene.windows.first?.safeAreaInsets.bottom else{return .zero}
return bottomSafeArea
}
+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})
+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
+3 -7
View File
@@ -7,16 +7,12 @@
import Foundation
func bundleForLocale(locale: Locale?) -> Bundle {
if locale == nil {
return Bundle.main
}
let path = Bundle.main.path(forResource: locale!.identifier, ofType: "lproj")
func bundleForLocale(locale: Locale) -> Bundle {
let path = Bundle.main.path(forResource: locale.identifier, ofType: "lproj")
return path != nil ? (Bundle(path: path!) ?? Bundle.main) : Bundle.main
}
func localizedStringFormat(key: String, locale: Locale?) -> String {
func localizedStringFormat(key: String, locale: Locale) -> String {
let bundle = bundleForLocale(locale: locale)
let fallback = bundleForLocale(locale: Locale(identifier: "en-US")).localizedString(forKey: key, value: nil, table: nil)
return bundle.localizedString(forKey: key, value: fallback, table: nil)
+2
View File
@@ -13,9 +13,11 @@ enum LogCategory: String {
case nav
case render
case storage
case networking
case push_notifications
case damus_purple
case image_uploading
case video_coordination
}
/// Damus structured logger
+5
View File
@@ -37,6 +37,7 @@ enum Route: Hashable {
case Reactions(reactions: EventsModel)
case Zaps(target: ZapTarget)
case Search(search: SearchModel)
case NDBSearch(results: Binding<[NostrEvent]>)
case EULA
case Login
case CreateAccount
@@ -105,6 +106,8 @@ enum Route: Hashable {
ZapsView(state: damusState, target: target)
case .Search(let search):
SearchView(appstate: damusState, search: search)
case .NDBSearch(let results):
NDBSearchView(damus_state: damusState, results: results)
case .EULA:
EULAView(nav: navigationCoordinator)
case .Login:
@@ -200,6 +203,8 @@ enum Route: Hashable {
case .Search(let search):
hasher.combine("search")
hasher.combine(search.search)
case .NDBSearch(let results):
hasher.combine("results")
case .EULA:
hasher.combine("eula")
case .Login:
+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()
}) {
@@ -0,0 +1,66 @@
//
// AppAccessibilityIdentifiers.swift
// damus
//
// Created by Daniel DAquino on 2024-11-18.
//
import Foundation
/// A collection of app-wide identifier constants used to facilitate UI tests to find the element they are looking for.
///
/// ## Implementation notes
///
/// - This is not an exhaustive list. Add more identifiers as needed.
/// - Organize this by separating each category with `MARK` comment markers and a unique prefix, each category separated by 2 empty lines
enum AppAccessibilityIdentifiers: String {
// MARK: Login
// Prefix: `sign_in`
/// Sign in button at the very start of the app
case sign_in_option_button
/// A secure text entry field where the user can put their private key when logging in
case sign_in_nsec_key_entry_field
/// Button to sign in after entering private key
case sign_in_confirm_button
// MARK: Onboarding
// Prefix: `onboarding`
/// The skip button on the onboarding sheet
case onboarding_sheet_skip_button
// MARK: Post composer
// Prefix: `post_composer`
/// The cancel post button
case post_composer_cancel_button
// MARK: Main interface layout
// Prefix: `main`
/// Profile picture item on the top toolbar, used to open the side menu
case main_side_menu_button
// MARK: Side menu
// Prefix: `side_menu`
/// The profile option in the side menu
case side_menu_profile_button
// MARK: Items specific to the user's own profile
// Prefix: `own_profile`
/// The edit profile button
case own_profile_edit_button
/// The button to edit the banner image on the profile
case own_profile_banner_image_edit_button
/// The button to pick the new banner image from URL
case own_profile_banner_image_edit_from_url
}
+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) {
+20 -3
View File
@@ -13,7 +13,8 @@ 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()
let safeAreaInsets: EdgeInsets
@State var banner_image: URL? = nil
@@ -29,8 +30,23 @@ 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)
EditPictureControl(uploader: damus_state.settings.default_media_uploader, keypair: damus_state.keypair, pubkey: damus_state.pubkey, image_url: $banner_image, uploadObserver: viewModel, callback: callback)
.padding(10)
.backwardsCompatibleSafeAreaPadding(self.safeAreaInsets)
.accessibilityLabel(NSLocalizedString("Edit banner image", comment: "Accessibility label for edit banner image button"))
.accessibilityIdentifier(AppAccessibilityIdentifiers.own_profile_banner_image_edit_button.rawValue)
}
}
}
extension View {
fileprivate func backwardsCompatibleSafeAreaPadding(_ insets: EdgeInsets) -> some View {
if #available(iOS 17.0, *) {
return self.safeAreaPadding(insets)
} else {
return self.padding(.top, insets.top)
}
}
}
@@ -38,7 +54,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 +70,7 @@ struct InnerBannerImageView: View {
Color(uiColor: .secondarySystemBackground)
}
.onFailureImage(defaultImage)
.kfClickable()
} else {
Image(uiImage: defaultImage).resizable()
}
+1
View File
@@ -39,6 +39,7 @@ struct BookmarksView: View {
ScrollView {
InnerTimelineView(events: EventHolder(events: bookmarks, incoming: []), damus: state, filter: noneFilter)
}
.padding(.bottom, 10 + tabHeight + getSafeAreaBottom())
}
}
.onReceive(handle_notify(.switched_timeline)) { _ in
+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)
@@ -29,13 +29,18 @@ struct GradientFollowButton: View {
.fontWeight(.medium)
.padding([.top, .bottom], 10)
.padding([.leading, .trailing], 12)
.background(follow_state == .unfollows ? PinkGradient : GrayGradient)
.cornerRadius(12)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(grayBorder, lineWidth: follow_state == .unfollows ? 0 : 1)
.frame(width: 100)
)
.frame(width: 100)
.minimumScaleFactor(0.5)
.lineLimit(1)
}
.background(follow_state == .unfollows ? PinkGradient : GrayGradient)
.cornerRadius(12)
.frame(width: 100)
.onReceive(handle_notify(.followed)) { ref in
guard target.follow_ref == ref else { return }
self.follow_state = .follows
+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)
+73 -33
View File
@@ -27,11 +27,9 @@ struct ChatEventView: View {
// MARK: long-press reaction control objects
/// Whether the user is actively pressing the view
@State var is_pressing = false
/// The dispatched work item scheduled by a timer to bounce the event bubble and show the emoji selector
@State var long_press_bounce_work_item: DispatchWorkItem?
@State var popover_state: PopoverState = .closed {
didSet {
let generator = UIImpactFeedbackGenerator(style: popover_state == .open_emoji_selector ? .heavy : .light)
let generator = UIImpactFeedbackGenerator(style: popover_state.some_sheet_open() ? .heavy : .light)
generator.impactOccurred()
}
}
@@ -39,10 +37,16 @@ struct ChatEventView: View {
@State private var isOnTopHalfOfScreen: Bool = false
@ObservedObject var bar: ActionBarModel
@Environment(\.swipeViewGroupSelection) var swipeViewGroupSelection
enum PopoverState: String {
case closed
case open_emoji_selector
case open_zap_sheet
func some_sheet_open() -> Bool {
return self == .open_zap_sheet || self == .open_emoji_selector
}
}
var just_started: Bool {
@@ -90,9 +94,22 @@ struct ChatEventView: View {
var by_other_user: Bool {
return event.pubkey != damus_state.pubkey
}
var is_ours: Bool { return !by_other_user }
// MARK: Zapping properties
var lnurl: String? {
damus_state.profiles.lookup_with_timestamp(event.pubkey)?.map({ pr in
pr?.lnurl
}).value
}
var zap_target: ZapTarget {
ZapTarget.note(id: event.id, author: event.pubkey)
}
// MARK: Views
var event_bubble: some View {
ChatBubble(
direction: is_ours ? .right : .left,
@@ -107,6 +124,7 @@ struct ChatEventView: View {
.onTapGesture {
show_profile_action_sheet_if_enabled(damus_state: damus_state, pubkey: event.pubkey)
}
.lineLimit(1)
Text(verbatim: "\(format_relative_time(event.created_at))")
.foregroundColor(.gray)
}
@@ -124,13 +142,18 @@ struct ChatEventView: View {
}
let blur_images = should_blur_images(settings: damus_state.settings, contacts: damus_state.contacts, ev: event, our_pubkey: damus_state.pubkey)
NoteContentView(damus_state: damus_state, event: event, blur_images: blur_images, size: .normal, options: [])
NoteContentView(damus_state: damus_state, event: event, blur_images: blur_images, size: .normal, options: [.truncate_content])
.padding(2)
if let mention = first_eref_mention(ev: event, keypair: damus_state.keypair) {
MentionView(damus_state: damus_state, mention: mention)
.background(DamusColors.adaptableWhite)
.clipShape(RoundedRectangle(cornerSize: CGSize(width: 10, height: 10)))
}
}
.frame(minWidth: 150, alignment: is_ours ? .trailing : .leading)
.frame(minWidth: 5, alignment: is_ours ? .trailing : .leading)
.padding(10)
}
.tint(is_ours ? Color.white : Color.accentColor)
.tint(Color.accentColor)
.overlay(
ZStack(alignment: is_ours ? .bottomLeading : .bottomTrailing) {
VStack {
@@ -164,6 +187,14 @@ struct ChatEventView: View {
EmojiPickerView(selectedEmoji: $selected_emoji, emojiProvider: damus_state.emoji_provider)
}.presentationDetents([.medium, .large])
}
.sheet(isPresented: Binding(get: { popover_state == .open_zap_sheet }, set: { new_state in
withAnimation(new_state == true ? .easeIn(duration: 0.5) : .easeOut(duration: 0.1)) {
popover_state = new_state == true ? .open_zap_sheet : .closed
}
})) {
ZapSheetViewIfPossible(damus_state: damus_state, target: zap_target, lnurl: lnurl)
.presentationDetents([.medium, .large])
}
.onChange(of: selected_emoji) { newSelectedEmoji in
if let newSelectedEmoji {
send_like(emoji: newSelectedEmoji.value)
@@ -171,30 +202,21 @@ struct ChatEventView: View {
}
}
}
.scaleEffect(self.popover_state == .open_emoji_selector ? 1.08 : is_pressing ? 1.02 : 1)
.shadow(color: (is_pressing || self.popover_state == .open_emoji_selector) ? .black.opacity(0.1) : .black.opacity(0.3), radius: (is_pressing || self.popover_state == .open_emoji_selector) ? 8 : 0, y: (is_pressing || self.popover_state == .open_emoji_selector) ? 15 : 0)
.scaleEffect(self.popover_state.some_sheet_open() ? 1.08 : is_pressing ? 1.02 : 1)
.shadow(color: (is_pressing || self.popover_state.some_sheet_open()) ? .black.opacity(0.1) : .black.opacity(0.3), radius: (is_pressing || self.popover_state.some_sheet_open()) ? 8 : 0, y: (is_pressing || self.popover_state.some_sheet_open()) ? 15 : 0)
.onLongPressGesture(minimumDuration: 0.5, maximumDistance: 10, perform: {
long_press_bounce_work_item?.cancel()
withAnimation(.bouncy(duration: 0.2, extraBounce: 0.35)) {
let should_show_zap_sheet = !damus_state.settings.nozaps && damus_state.settings.onlyzaps_mode
popover_state = should_show_zap_sheet ? .open_zap_sheet : .open_emoji_selector
}
}, onPressingChanged: { is_pressing in
withAnimation(is_pressing ? .easeIn(duration: 0.5) : .easeOut(duration: 0.1)) {
self.is_pressing = is_pressing
if popover_state != .closed {
return
}
if self.is_pressing {
let item = DispatchWorkItem {
// Ensure the action is performed only if the condition is still valid
if self.is_pressing {
withAnimation(.bouncy(duration: 0.2, extraBounce: 0.35)) {
popover_state = .open_emoji_selector
}
}
}
long_press_bounce_work_item = item
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: item)
}
}
})
.onChange(of: swipeViewGroupSelection.wrappedValue) { newValue in
self.is_pressing = false
}
.background(
GeometryReader { geometry in
EmptyView()
@@ -254,17 +276,30 @@ struct ChatEventView: View {
SwipeView {
self.event_bubble_with_long_press_interaction
} leadingActions: { context in
EventActionBar(
damus_state: damus_state,
event: event,
bar: bar,
options: is_ours ? [.swipe_action_menu_reverse] : [.swipe_action_menu],
swipe_context: context
)
if !is_ours {
EventActionBar(
damus_state: damus_state,
event: event,
bar: bar,
options: is_ours ? [.swipe_action_menu_reverse] : [.swipe_action_menu],
swipe_context: context
)
}
} trailingActions: { context in
if is_ours {
EventActionBar(
damus_state: damus_state,
event: event,
bar: bar,
options: is_ours ? [.swipe_action_menu_reverse] : [.swipe_action_menu],
swipe_context: context
)
}
}
.swipeSpacing(-20)
.swipeActionsStyle(.mask)
.swipeMinimumDistance(20)
.swipeDragGesturePriority(.normal)
}
}
@@ -322,3 +357,8 @@ extension Notification.Name {
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
return ChatEventView(event: test_short_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: true, bar: bar)
}
#Preview {
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
return ChatEventView(event: test_super_short_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false, bar: bar)
}
@@ -135,6 +135,9 @@ struct ChatroomThreadView: View {
}
.padding(.top)
EndBlock()
HStack {}
.frame(height: tabHeight + getSafeAreaBottom())
}
.onReceive(handle_notify(.post), perform: { notify in
switch notify {
-114
View File
@@ -1,114 +0,0 @@
//
// CodeScanner.swift
// https://github.com/twostraws/CodeScanner
//
// Created by Paul Hudson on 14/12/2021.
// Copyright © 2021 Paul Hudson. All rights reserved.
//
import AVFoundation
import SwiftUI
/// An enum describing the ways CodeScannerView can hit scanning problems.
public enum ScanError: Error {
/// The camera could not be accessed.
case badInput
/// The camera was not capable of scanning the requested codes.
case badOutput
/// Initialization failed.
case initError(_ error: Error)
}
/// The result from a successful scan: the string that was scanned, and also the type of data that was found.
/// The type is useful for times when you've asked to scan several different code types at the same time, because
/// it will report the exact code type that was found.
public struct ScanResult {
/// The contents of the code.
public let string: String
/// The type of code that was matched.
public let type: AVMetadataObject.ObjectType
}
/// The operating mode for CodeScannerView.
public enum ScanMode {
/// Scan exactly one code, then stop.
case once
/// Scan each code no more than once.
case oncePerCode
/// Keep scanning all codes until dismissed.
case continuous
}
/// A SwiftUI view that is able to scan barcodes, QR codes, and more, and send back what was found.
/// To use, set `codeTypes` to be an array of things to scan for, e.g. `[.qr]`, and set `completion` to
/// a closure that will be called when scanning has finished. This will be sent the string that was detected or a `ScanError`.
/// For testing inside the simulator, set the `simulatedData` property to some test data you want to send back.
public struct CodeScannerView: UIViewControllerRepresentable {
public let codeTypes: [AVMetadataObject.ObjectType]
public let scanMode: ScanMode
public let scanInterval: Double
public let showViewfinder: Bool
public var simulatedData = ""
public var shouldVibrateOnSuccess: Bool
public var isTorchOn: Bool
public var isGalleryPresented: Binding<Bool>
public var videoCaptureDevice: AVCaptureDevice?
public var completion: (Result<ScanResult, ScanError>) -> Void
public init(
codeTypes: [AVMetadataObject.ObjectType],
scanMode: ScanMode = .once,
scanInterval: Double = 2.0,
showViewfinder: Bool = false,
simulatedData: String = "",
shouldVibrateOnSuccess: Bool = true,
isTorchOn: Bool = false,
isGalleryPresented: Binding<Bool> = .constant(false),
videoCaptureDevice: AVCaptureDevice? = AVCaptureDevice.default(for: .video),
completion: @escaping (Result<ScanResult, ScanError>) -> Void
) {
self.codeTypes = codeTypes
self.scanMode = scanMode
self.showViewfinder = showViewfinder
self.scanInterval = scanInterval
self.simulatedData = simulatedData
self.shouldVibrateOnSuccess = shouldVibrateOnSuccess
self.isTorchOn = isTorchOn
self.isGalleryPresented = isGalleryPresented
self.videoCaptureDevice = videoCaptureDevice
self.completion = completion
}
public func makeCoordinator() -> ScannerCoordinator {
ScannerCoordinator(parent: self)
}
public func makeUIViewController(context: Context) -> ScannerViewController {
let viewController = ScannerViewController(showViewfinder: showViewfinder)
viewController.delegate = context.coordinator
return viewController
}
public func updateUIViewController(_ uiViewController: ScannerViewController, context: Context) {
uiViewController.updateViewController(
isTorchOn: isTorchOn,
isGalleryPresented: isGalleryPresented.wrappedValue
)
}
}
@available(macCatalyst 14.0, *)
struct CodeScannerView_Previews: PreviewProvider {
static var previews: some View {
CodeScannerView(codeTypes: [.qr]) { result in
// do nothing
}
}
}
@@ -1,75 +0,0 @@
//
// CodeScanner.swift
// https://github.com/twostraws/CodeScanner
//
// Created by Paul Hudson on 14/12/2021.
// Copyright © 2021 Paul Hudson. All rights reserved.
//
import AVFoundation
import SwiftUI
extension CodeScannerView {
@available(macCatalyst 14.0, *)
public class ScannerCoordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
var parent: CodeScannerView
var codesFound = Set<String>()
var didFinishScanning = false
var lastTime = Date(timeIntervalSince1970: 0)
init(parent: CodeScannerView) {
self.parent = parent
}
public func reset() {
codesFound.removeAll()
didFinishScanning = false
lastTime = Date(timeIntervalSince1970: 0)
}
public func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
if let metadataObject = metadataObjects.first {
guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject else { return }
guard let stringValue = readableObject.stringValue else { return }
guard didFinishScanning == false else { return }
let result = ScanResult(string: stringValue, type: readableObject.type)
switch parent.scanMode {
case .once:
found(result)
// make sure we only trigger scan once per use
didFinishScanning = true
case .oncePerCode:
if !codesFound.contains(stringValue) {
codesFound.insert(stringValue)
found(result)
}
case .continuous:
if isPastScanInterval() {
found(result)
}
}
}
}
func isPastScanInterval() -> Bool {
Date().timeIntervalSince(lastTime) >= parent.scanInterval
}
func found(_ result: ScanResult) {
lastTime = Date()
if parent.shouldVibrateOnSuccess {
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
}
parent.completion(.success(result))
}
func didFail(reason: ScanError) {
parent.completion(.failure(reason))
}
}
}
@@ -1,300 +0,0 @@
//
// CodeScanner.swift
// https://github.com/twostraws/CodeScanner
//
// Created by Paul Hudson on 14/12/2021.
// Copyright © 2021 Paul Hudson. All rights reserved.
//
import AVFoundation
import UIKit
extension CodeScannerView {
@available(macCatalyst 14.0, *)
public class ScannerViewController: UIViewController, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
var delegate: ScannerCoordinator?
private let showViewfinder: Bool
private var isGalleryShowing: Bool = false {
didSet {
// Update binding
if delegate?.parent.isGalleryPresented.wrappedValue != isGalleryShowing {
delegate?.parent.isGalleryPresented.wrappedValue = isGalleryShowing
}
}
}
public init(showViewfinder: Bool = false) {
self.showViewfinder = showViewfinder
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
self.showViewfinder = false
super.init(coder: coder)
}
func openGallery() {
isGalleryShowing = true
let imagePicker = UIImagePickerController()
imagePicker.delegate = self
present(imagePicker, animated: true, completion: nil)
}
@objc func openGalleryFromButton(_ sender: UIButton) {
openGallery()
}
public func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
isGalleryShowing = false
if let qrcodeImg = info[.originalImage] as? UIImage {
let detector = CIDetector(ofType: CIDetectorTypeQRCode, context: nil, options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])!
let ciImage = CIImage(image:qrcodeImg)!
var qrCodeLink = ""
let features = detector.features(in: ciImage)
for feature in features as! [CIQRCodeFeature] {
qrCodeLink += feature.messageString!
}
if qrCodeLink == "" {
delegate?.didFail(reason: .badOutput)
} else {
let result = ScanResult(string: qrCodeLink, type: .qr)
delegate?.found(result)
}
} else {
print("Something went wrong")
}
dismiss(animated: true, completion: nil)
}
public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
isGalleryShowing = false
}
#if targetEnvironment(simulator)
override public func loadView() {
view = UIView()
view.isUserInteractionEnabled = true
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.numberOfLines = 0
label.text = "You're running in the simulator, which means the camera isn't available. Tap anywhere to send back some simulated data."
label.textAlignment = .center
let button = UIButton()
button.translatesAutoresizingMaskIntoConstraints = false
button.setTitle("Select a custom image", for: .normal)
button.setTitleColor(UIColor.systemBlue, for: .normal)
button.setTitleColor(UIColor.gray, for: .highlighted)
button.addTarget(self, action: #selector(openGalleryFromButton), for: .touchUpInside)
let stackView = UIStackView()
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.spacing = 50
stackView.addArrangedSubview(label)
stackView.addArrangedSubview(button)
view.addSubview(stackView)
NSLayoutConstraint.activate([
button.heightAnchor.constraint(equalToConstant: 50),
stackView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
override public func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let simulatedData = delegate?.parent.simulatedData else {
print("Simulated Data Not Provided!")
return
}
// Send back their simulated data, as if it was one of the types they were scanning for
let result = ScanResult(string: simulatedData, type: delegate?.parent.codeTypes.first ?? .qr)
delegate?.found(result)
}
#else
var captureSession: AVCaptureSession!
var previewLayer: AVCaptureVideoPreviewLayer!
let fallbackVideoCaptureDevice = AVCaptureDevice.default(for: .video)
private lazy var viewFinder: UIImageView? = {
guard let image = UIImage(named: "viewfinder", in: .main, with: nil) else {
return nil
}
let imageView = UIImageView(image: image)
imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
}()
override public func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self,
selector: #selector(updateOrientation),
name: Notification.Name("UIDeviceOrientationDidChangeNotification"),
object: nil)
view.backgroundColor = UIColor.black
captureSession = AVCaptureSession()
guard let videoCaptureDevice = delegate?.parent.videoCaptureDevice ?? fallbackVideoCaptureDevice else {
return
}
let videoInput: AVCaptureDeviceInput
do {
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
} catch {
delegate?.didFail(reason: .initError(error))
return
}
if (captureSession.canAddInput(videoInput)) {
captureSession.addInput(videoInput)
} else {
delegate?.didFail(reason: .badInput)
return
}
let metadataOutput = AVCaptureMetadataOutput()
if (captureSession.canAddOutput(metadataOutput)) {
captureSession.addOutput(metadataOutput)
metadataOutput.setMetadataObjectsDelegate(delegate, queue: DispatchQueue.main)
metadataOutput.metadataObjectTypes = delegate?.parent.codeTypes
} else {
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)
}
previewLayer.frame = view.layer.bounds
previewLayer.videoGravity = .resizeAspectFill
view.layer.addSublayer(previewLayer)
addviewfinder()
delegate?.reset()
if (captureSession?.isRunning == false) {
DispatchQueue.global(qos: .userInitiated).async {
self.captureSession.startRunning()
}
}
}
private func addviewfinder() {
guard showViewfinder, let imageView = viewFinder else { return }
view.addSubview(imageView)
NSLayoutConstraint.activate([
imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
imageView.widthAnchor.constraint(equalToConstant: 200),
imageView.heightAnchor.constraint(equalToConstant: 200),
])
}
override public func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if (captureSession?.isRunning == true) {
DispatchQueue.global(qos: .userInitiated).async {
self.captureSession.stopRunning()
}
}
NotificationCenter.default.removeObserver(self)
}
override public var prefersStatusBarHidden: Bool {
true
}
override public var supportedInterfaceOrientations: UIInterfaceOrientationMask {
.all
}
/** Touch the screen for autofocus */
public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard touches.first?.view == view,
let touchPoint = touches.first,
let device = delegate?.parent.videoCaptureDevice ?? fallbackVideoCaptureDevice
else { return }
let videoView = view
let screenSize = videoView!.bounds.size
let xPoint = touchPoint.location(in: videoView).y / screenSize.height
let yPoint = 1.0 - touchPoint.location(in: videoView).x / screenSize.width
let focusPoint = CGPoint(x: xPoint, y: yPoint)
do {
try device.lockForConfiguration()
} catch {
return
}
// Focus to the correct point, make continiuous focus and exposure so the point stays sharp when moving the device closer
device.focusPointOfInterest = focusPoint
device.focusMode = .continuousAutoFocus
device.exposurePointOfInterest = focusPoint
device.exposureMode = AVCaptureDevice.ExposureMode.continuousAutoExposure
device.unlockForConfiguration()
}
#endif
func updateViewController(isTorchOn: Bool, isGalleryPresented: Bool) {
if let backCamera = AVCaptureDevice.default(for: AVMediaType.video),
backCamera.hasTorch
{
try? backCamera.lockForConfiguration()
backCamera.torchMode = isTorchOn ? .on : .off
backCamera.unlockForConfiguration()
}
if isGalleryPresented && !isGalleryShowing {
openGallery()
}
}
}
}
+4 -1
View File
@@ -99,7 +99,10 @@ struct ConfigView: View {
}
}
Section(NSLocalizedString("Version", comment: "Section title for displaying the version number of the Damus app.")) {
Section(
header: Text(NSLocalizedString("Version", comment: "Section title for displaying the version number of the Damus app.")),
footer: Text("").padding(.bottom, tabHeight + getSafeAreaBottom())
) {
Text(verbatim: VersionInfo.version)
.contextMenu {
Button {
+1 -1
View File
@@ -28,7 +28,7 @@ struct CreateAccountView: View {
Spacer()
VStack(alignment: .center) {
EditPictureControl(uploader: .nostrBuild, pubkey: account.pubkey, size: 75, setup: true, image_url: $account.profile_image , uploadObserver: profileUploadObserver, callback: uploadedProfilePicture)
EditPictureControl(uploader: .nostrBuild, keypair: account.keypair, pubkey: account.pubkey, size: 75, setup: true, image_url: $account.profile_image , uploadObserver: profileUploadObserver, callback: uploadedProfilePicture)
.shadow(radius: 2)
.padding(.top, 100)
+3
View File
@@ -10,6 +10,7 @@ import Combine
struct DMChatView: View, KeyboardReadable {
let damus_state: DamusState
@FocusState private var isTextFieldFocused: Bool
@ObservedObject var dms: DirectMessageModel
var pubkey: Pubkey {
@@ -46,6 +47,7 @@ struct DMChatView: View, KeyboardReadable {
}
}
}
.padding(.bottom, isTextFieldFocused ? 0 : tabHeight)
}
func scroll_to_end(_ scroller: ScrollViewProxy, animated: Bool = false) {
@@ -74,6 +76,7 @@ struct DMChatView: View, KeyboardReadable {
.textEditorBackground {
InputBackground()
}
.focused($isTextFieldFocused)
.cornerRadius(8)
.background(
RoundedRectangle(cornerRadius: 8)
+7 -8
View File
@@ -35,6 +35,7 @@ struct DirectMessagesView: View {
}
.padding(.horizontal)
}
.padding(.bottom, tabHeight)
}
func filter_dms(dms: [DirectMessageModel]) -> [DirectMessageModel] {
@@ -72,13 +73,11 @@ struct DirectMessagesView: View {
var body: some View {
VStack(spacing: 0) {
CustomPicker(selection: $dm_type, content: {
Text("DMs", comment: "Picker option for DM selector for seeing only DMs that have been responded to. DM is the English abbreviation for Direct Message.")
.tag(DMType.friend)
Text("Requests", comment: "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.")
.tag(DMType.rando)
})
CustomPicker(tabs: [
(NSLocalizedString("DMs", comment: "Picker option for DM selector for seeing only DMs that have been responded to. DM is the English abbreviation for Direct Message."), DMType.friend),
(NSLocalizedString("Requests", comment: "Picker option for DM selector for seeing only message requests (DMs that someone else sent the user which has not been responded to yet"), DMType.rando),
], selection: $dm_type)
Divider()
.frame(height: 1)
@@ -105,7 +104,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)))
+6 -11
View File
@@ -15,18 +15,13 @@ struct ReplyPart: View {
var body: some View {
Group {
if let reply_ref = event.thread_reply()?.reply {
if event.known_kind == .highlight {
let highlighted_note = event.highlighted_note_id().flatMap { events.lookup($0) }
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)
if event.known_kind != .highlight {
ReplyDescription(event: event, replying_to: replying_to, ndb: ndb)
} else if event.known_kind == .highlight {
HighlightDescription(event: event, highlighted_event: replying_to, ndb: ndb)
}
else {
EmptyView()
}
} else {
EmptyView()
ReplyDescription(event: event, replying_to: replying_to, ndb: ndb)
}
}
}
+6
View File
@@ -37,6 +37,12 @@ struct EventBody: View {
}
} else if event.known_kind == .highlight {
HighlightBodyView(state: damus_state, ev: event, options: options)
.onTapGesture {
if let highlighted_note = event.highlighted_note_id().flatMap({ damus_state.events.lookup($0) }) {
let thread = ThreadModel(event: highlighted_note, damus_state: damus_state, highlight: event.content)
damus_state.nav.push(route: Route.Thread(thread: thread))
}
}
} else {
note_content
}
@@ -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()
@@ -58,7 +59,7 @@ struct HighlightEventRef: View {
}
VStack(alignment: .leading, spacing: 5) {
Text(longform_event.title ?? "Untitled")
Text(longform_event.title ?? NSLocalizedString("Untitled", comment: "Title of longform event if it is untitled."))
.font(.system(size: 14, weight: .bold))
.lineLimit(1)
@@ -61,6 +61,7 @@ struct HighlightLink: View {
.background(DamusColors.adaptableWhite)
}
.frame(width: 35, height: 35)
.kfClickable()
.clipShape(RoundedRectangle(cornerRadius: 10))
.scaledToFit()
} else {
@@ -1,78 +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.")) {
var tags: [[String]] = [ ["e", "\(self.event.id)"] ]
tags.append(["context", self.event.content])
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(self.event.content)
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)
}
@@ -129,7 +130,7 @@ struct LongformPreviewBody: View {
}
}
Text(event.title ?? "Untitled")
Text(event.title ?? NSLocalizedString("Untitled", comment: "Title of longform event if it is untitled."))
.font(header ? .title : .headline)
.padding(.horizontal, 10)
.padding(.top, 5)
@@ -24,7 +24,7 @@ struct LongformView: View {
var body: some View {
EventShell(state: state, event: event.event, options: options) {
SelectableText(damus_state: state, event: event.event, attributedString: AttributedString(stringLiteral: event.title ?? "Untitled"), size: .title)
SelectableText(damus_state: state, event: event.event, attributedString: AttributedString(stringLiteral: event.title ?? NSLocalizedString("Untitled", comment: "Title of longform event if it is untitled.")), size: .title)
NoteContentView(damus_state: state, event: event.event, blur_images: false, size: .selected, options: options)
}
+4 -15
View File
@@ -39,21 +39,10 @@ struct SelectedEventView: View {
.padding(.horizontal)
.minimumScaleFactor(0.75)
.lineLimit(1)
if let reply_ref = event.thread_reply()?.reply {
let replying_to = damus.events.lookup(reply_ref.note_id)
if event.known_kind == .highlight {
HighlightDescription(event: event, highlighted_event: replying_to, ndb: damus.ndb)
.padding(.horizontal)
} else {
ReplyDescription(event: event, replying_to: replying_to, ndb: damus.ndb)
.padding(.horizontal)
}
} else if event.known_kind == .highlight {
HighlightDescription(event: event, highlighted_event: nil, ndb: damus.ndb)
.padding(.horizontal)
}
ReplyPart(events: damus.events, event: event, keypair: damus.keypair, ndb: damus.ndb)
.padding(.horizontal)
ProxyView(event: event)
.padding(.top, 5)
.padding(.horizontal)
@@ -0,0 +1,140 @@
//
// DamusFullScreenCover.swift
// damus
//
// Created by Daniel DAquino on 2024-10-25.
//
import SwiftUI
// MARK: - Private view modifier implementations of DamusFullScreenCover
/// This implements a full screen cover made for use in Damus.
/// This was created as a way to facilitate video coordination throughout the app, by handling the necessary logic without requiring any special handling in the usages of video player views.
///
/// In the future this could be used to faciliate other full screen logic as well.
///
/// # Features
///
/// This has the following features:
/// 1. It automatically tells the video coordinator about full screen mode changes, so that the video coordinator always knows if the app is in normal mode or in full screen mode for video coordination
/// 2. It automatically sets the `view_layer_context`, which is consumed by video player views, allowing those views to communicate about their layer position to the video coordinator
fileprivate struct DamusFullScreenCover<FullScreenContent: View, T: Identifiable & Equatable>: ViewModifier {
/// The `damus_state`, where we can access the video coordinator
let damus_state: DamusState
/// The item to be presented full screen
@Binding var item: T?
/// The view to be presented full screen
let full_screen_content: (T) -> FullScreenContent
func body(content: Content) -> some View {
content
.environment(\.view_layer_context, .normal_layer) // Let the views under content know they are NOT in a full screen environment
.onChange(of: item, perform: { newValue in
// Inform the video coordinator whether we are in full screen mode or not.
damus_state.video.set_full_screen_mode(newValue != nil)
})
.fullScreenCover(item: $item, content: { item in
full_screen_content(item)
.environment(\.view_layer_context, .full_screen_layer) // Let the views under full screen content know they are in a full screen environment
// Another observer for full screen presentation is needed here because in some cases the underlying view (`body::content`) may have been deinitialized and no longer listen to changes
// One such example is when the underlying navigation stack navigates away from a source view at the same time it opens the full screen view
// Therefore, when the full screen view is dismissed, this content will disappear, and we should notify the video coordinator.
.onDisappear {
damus_state.video.set_full_screen_mode(false)
}
})
}
}
/// A convenience view modifier that provides a different interface than `DamusFullScreenCover`, but is otherwise identical to it.
fileprivate struct DamusFullScreenCoverWithoutItem<FullScreenContent: View>: ViewModifier {
let damus_state: DamusState
@Binding var is_presented: Bool
let full_screen_content: () -> FullScreenContent
private let fake_item: FakeItem = FakeItem()
private var binding_item: Binding<FakeItem?> {
return Binding(
get: { is_presented ? self.fake_item : nil },
set: { is_presented = $0 != nil ? true : false }
)
}
func body(content: Content) -> some View {
content
.damus_full_screen_cover(self.binding_item, damus_state: damus_state, content: { _ in full_screen_content() })
}
private struct FakeItem: Identifiable, Equatable {
let id: Int = 1
}
}
// MARK: - Environment variable definitions
extension EnvironmentValues {
@Entry var view_layer_context: ViewLayerContext? = nil
}
/// Context about the layer a view finds itself in
/// This communicates to a view (e.g. a video player) context about whether it is being displayed inside a full screen layer, or a normal layer
enum ViewLayerContext {
/// This is used for items placed in a scroll view, such as on a timeline or a thread view.
case normal_layer
/// This is used for video players being displayed full screen
case full_screen_layer
}
// MARK: - View extension interfaces to access Damus' full screen cover
extension View {
/// A full screen cover to be used throughout Damus, containing extra functionality that helps with app coordination, and is meant to replace `.fullScreenCover`
///
/// ## Usage notes
///
/// This is the preferred method of doing a full screen cover. This is preferred over `.fullScreenCover` because it helps with certain coordination elements:
///
/// 1. It automatically informs the video coordinator if the app is in full screen or not
/// 2. It provides contextual information that any child view can pickup to introspect whether or not they are in a full screen layer. This can be picked up via the `\.view_layer_context` environment variable
///
/// **CAUTION:**
/// If you are planning to use this from a view that is presented on a timeline or lazy stack, please use `present(full_screen_item: FullScreenItem)` instead to avoid your full screen view to abruptly disappear.
/// Please read the documentation for `present(full_screen_item: FullScreenItem)` for more details.
///
/// - Parameters:
/// - is_presented: whether to show the full screen cover
/// - damus_state: The state of the app
/// - content: The view to show full screen
/// - Returns: the modified view
func damus_full_screen_cover<Content: View>(_ is_presented: Binding<Bool>, damus_state: DamusState, @ViewBuilder content: @escaping () -> Content) -> some View {
return self.modifier(DamusFullScreenCoverWithoutItem(damus_state: damus_state, is_presented: is_presented, full_screen_content: content))
}
/// A full screen cover to be used throughout Damus, containing extra functionality that helps with app coordination, and is meant to replace `.fullScreenCover`
///
/// ## Usage notes
///
/// This is the preferred method of doing a full screen cover. This is preferred over `.fullScreenCover` because it helps with certain coordination elements:
///
/// 1. It automatically informs the video coordinator if the app is in full screen or not
/// 2. It provides contextual information that any child view can pickup to introspect whether or not they are in a full screen layer. This can be picked up via the `\.view_layer_context` environment variable
///
/// **CAUTION:**
/// If you are planning to use this from a view that is presented on a timeline or lazy stack, please use `present(full_screen_item: FullScreenItem)` instead to avoid your full screen view to abruptly disappear.
/// Please read the documentation for `present(full_screen_item: FullScreenItem)` for more details.
///
///
/// - Parameters:
/// - item: The item to be displayed full screen, or `nil` if full screen should be dismissed.
/// - damus_state: The state of the app
/// - content: The view to render `item`
/// - Returns: the modified view
func damus_full_screen_cover<Content: View, T: Identifiable & Equatable>(_ item: Binding<T?>, damus_state: DamusState, @ViewBuilder content: @escaping (T) -> Content) -> some View {
return self.modifier(DamusFullScreenCover(damus_state: damus_state, item: item, full_screen_content: content))
}
}
+106 -14
View File
@@ -10,27 +10,119 @@ import Foundation
import SwiftUI
extension View {
func on_visibility_change(perform visibility_change_notifier: @escaping (Bool) -> Void, edge: Alignment = .center) -> some View {
self.modifier(VisibilityTracker(visibility_change_notifier: visibility_change_notifier, edge: edge))
/// Watches for visibility changes. Does not detect occlusion
///
/// ## Usage notes
///
/// 1. Detection mechanisms are not perfect, parameters may need fine tuning. Please refer to `VisibilityTracker` documentation for more details.
/// 2. This does **not** detect if the view has been occluded. There are currently no known mechanisms to do that.
/// If occlusion tracking is needed for your usage, consider using layout knowledge/introspection of the different layers that make up the view, and using that information for your logic.
/// For example, when dealing with items on a normal view, and a full screen cover, write your logic based on explicit information about which views are in the full screen layer.
/// Read about `present(full_screen_item: FullScreenItem)`, `damus_full_screen_cover`, and the `.view_layer_context` environment variable.
///
/// - Parameters:
/// - visibility_change_notifier: Function to call once visibility changes
/// - edge: Edge for the visibility overlay sensor
/// - method: The method to use for visibility tracking. Refer to `VisibilityTracker` documentation for more details.
/// - Returns: A modified view.
func on_visibility_change(perform visibility_change_notifier: @escaping (Bool) -> Void, edge: Alignment = .center, method: VisibilityTracker.Method = .standard) -> some View {
self.modifier(VisibilityTracker(visibility_change_notifier: visibility_change_notifier, edge: edge, method: method))
}
}
/// Tracks visibility of a SwiftUI view.
/// Built mostly to track visibility states of video players around the app and help the video coordinator pick a video to focus on, but can be used for basically any other view
/// **Caution:** This is not a perfect tracker, please read and fine-tune parameters for your use case, especially `method`
struct VisibilityTracker: ViewModifier {
let visibility_window: CGFloat = 0.8
let visibility_change_notifier: (Bool) -> Void
let edge: Alignment
let method: Method
init(visibility_change_notifier: @escaping (Bool) -> Void, edge: Alignment, method: Method) {
self.visibility_change_notifier = visibility_change_notifier
self.edge = edge
self.method = method
}
@EnvironmentObject private var orientationTracker: OrientationTracker
/// Holds information about whether the view is "generically" visible, meaning whether it would have been loaded on a lazy stack.
@State private var generic_visible: Bool = false {
didSet {
if oldValue == generic_visible { return } // Save up computing resources if there were no changes
self.visibility_change_notifier(self.is_visible)
}
}
/// Whether the view is visible by checking if its Y position is within a range of the user's screen
@State private var y_scroll_visible: Bool = false {
didSet {
switch self.method {
case .standard:
if oldValue == y_scroll_visible { return } // Save up computing resources if there were no changes
self.visibility_change_notifier(self.is_visible)
case .no_y_scroll_detection:
return // Don't cause re-renders if the visibility method does not use this
}
}
}
/// Whether view is "visible"
var is_visible: Bool {
switch method {
case .standard:
return generic_visible && y_scroll_visible
case .no_y_scroll_detection:
return generic_visible
}
}
func body(content: Content) -> some View {
content
.overlay(
LazyVStack {
Color.clear
.onAppear {
visibility_change_notifier(true)
}
.onDisappear {
visibility_change_notifier(false)
}
},
alignment: edge)
content
.overlay(
GeometryReader { geo in
let localFrame = geo.frame(in: .local)
let centerY = globalCoordinate(localX: 0, localY: localFrame.midY, localGeometry: geo).y
LazyVStack {
Color.clear
// MARK: Detection triggers
.onAppear {
self.generic_visible = true
self.y_scroll_visible = self.compute_y_scroll_visible(centerY: centerY)
}
.onDisappear {
self.generic_visible = false
}
.onChange(of: centerY) { new_center_y in
if generic_visible == false { return } // Don't bother calculating anything if this is not visible generically, to save up computing resources
self.y_scroll_visible = self.compute_y_scroll_visible(
centerY: new_center_y // Compute the new Y scroll visibility using the newest value to avoid transient issues on device orientation changes
)
}
}
},
alignment: edge)
}
/// Computes whether the view is "visible" in a range of the screen given its Y position
private func compute_y_scroll_visible(centerY: CGFloat) -> Bool {
let screen_center_y = orientationTracker.deviceMajorAxis / 2
let screen_visibility_window_margin = orientationTracker.deviceMajorAxis * visibility_window / 2
let isBelowTop = centerY > screen_center_y - screen_visibility_window_margin,
isAboveBottom = centerY < screen_center_y + screen_visibility_window_margin
return (isBelowTop && isAboveBottom)
}
/// The methods available for visibility detection.
/// Unfortunately, there is currently no perfect visibility detection mechanism, so callers of `VisibilityTracker` should select a method that best suits the context of the view.
enum Method: Equatable {
/// Includes both a generic and Y coordinate based visibility detection.
/// When this option is selected, the view is only deemed visible if both lazy view evaluators load it (when close enough to viewport), and the center Y coordinate is sufficiently in the center
/// This is best for most view presentations, specially for scroll views.
case standard
/// Includes only a generic visibility detection based on a lazy view loader
/// When this option is selected, the view is only deemed visible if the lazy view evaluators load it (which SwiftUI does when it is close enough to viewport), regardless of Y coordinate
/// This is not suitable for scroll views or most presentations because it may trigger too early, leading to false positives. This is more suitable when the standard detection mechanism is triggering too many false negatives, and this is a more "static" view
/// For example: when displaying an item in full screen mode where it is visible in a more stable, static form, and device orientation changes may cause transient visibility triggers
case no_y_scroll_detection
}
}
+4 -4
View File
@@ -160,10 +160,10 @@ struct FollowingView: View {
.navigationBarTitle(NSLocalizedString("Following", comment: "Navigation bar title for view that shows who a user is following."))
.safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: 0) {
CustomPicker(selection: $tab_selection, content: {
Text("People", comment: "Label for filter for seeing only people follows.").tag(FollowingViewTabSelection.people)
Text("Hashtags", comment: "Label for filter for seeing only hashtag follows.").tag(FollowingViewTabSelection.hashtags)
})
CustomPicker(tabs: [
(NSLocalizedString("People", comment: "Label for filter for seeing only people follows."), FollowingViewTabSelection.people),
(NSLocalizedString("Hashtags", comment: "Label for filter for seeing only hashtag follows."), FollowingViewTabSelection.hashtags)
], selection: $tab_selection)
Divider()
.frame(height: 1)
}
-20
View File
@@ -1,20 +0,0 @@
//
// ImageView.swift
// damus
//
// Created by user232838 on 1/5/23.
//
import SwiftUI
struct ImageView: View {
var body: some View {
Text(verbatim: /*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
}
}
struct ImageView_Previews: PreviewProvider {
static var previews: some View {
ImageView()
}
}
+71 -30
View File
@@ -8,37 +8,37 @@
import SwiftUI
struct FullScreenCarouselView<Content: View>: View {
let video_controller: VideoController
@ObservedObject var video_coordinator: DamusVideoCoordinator
let urls: [MediaUrl]
@Environment(\.presentationMode) var presentationMode
@State var showMenu = true
@State private var imageDict: [URL: UIImage] = [:]
let settings: UserSettingsStore
@Binding var selectedIndex: Int
@ObservedObject var carouselSelection: CarouselSelection
let content: (() -> Content)?
init(video_controller: VideoController, urls: [MediaUrl], showMenu: Bool = true, settings: UserSettingsStore, selectedIndex: Binding<Int>, @ViewBuilder content: @escaping () -> Content) {
self.video_controller = video_controller
init(video_coordinator: DamusVideoCoordinator, urls: [MediaUrl], showMenu: Bool = true, settings: UserSettingsStore, selectedIndex: Binding<Int>, @ViewBuilder content: @escaping () -> Content) {
self.video_coordinator = video_coordinator
self.urls = urls
self._showMenu = State(initialValue: showMenu)
self.settings = settings
_selectedIndex = selectedIndex
self._carouselSelection = ObservedObject(initialValue: CarouselSelection(index: selectedIndex.wrappedValue))
self.content = content
}
init(video_controller: VideoController, urls: [MediaUrl], showMenu: Bool = true, settings: UserSettingsStore, selectedIndex: Binding<Int>) {
self.video_controller = video_controller
init(video_coordinator: DamusVideoCoordinator, urls: [MediaUrl], showMenu: Bool = true, settings: UserSettingsStore, selectedIndex: Binding<Int>) {
self.video_coordinator = video_coordinator
self.urls = urls
self._showMenu = State(initialValue: showMenu)
self.settings = settings
_selectedIndex = selectedIndex
self._carouselSelection = ObservedObject(initialValue: CarouselSelection(index: selectedIndex.wrappedValue))
self.content = nil
}
var background: some ShapeStyle {
if case .video = urls[safe: selectedIndex] {
if case .video = urls[safe: carouselSelection.index] {
return AnyShapeStyle(Color.black)
}
else {
@@ -55,23 +55,24 @@ struct FullScreenCarouselView<Content: View>: View {
Color(self.background_color)
.ignoresSafeArea()
TabView(selection: $selectedIndex) {
TabView(selection: $carouselSelection.index) {
ForEach(urls.indices, id: \.self) { index in
VStack {
if case .video = urls[safe: index] {
ImageContainerView(video_controller: video_controller, url: urls[index], settings: settings)
.clipped() // SwiftUI hack from https://stackoverflow.com/a/74401288 to make playback controls show up within the TabView
.aspectRatio(contentMode: .fit)
.padding(.top, Theme.safeAreaInsets?.top)
.padding(.bottom, Theme.safeAreaInsets?.bottom)
.modifier(SwipeToDismissModifier(minDistance: 50, onDismiss: {
presentationMode.wrappedValue.dismiss()
}))
.ignoresSafeArea()
ImageContainerView(
video_coordinator: video_coordinator,
url: urls[index],
settings: settings,
imageDict: $imageDict
)
.modifier(SwipeToDismissModifier(minDistance: 50, onDismiss: {
presentationMode.wrappedValue.dismiss()
}))
.ignoresSafeArea()
}
else {
ZoomableScrollView {
ImageContainerView(video_controller: video_controller, url: urls[index], settings: settings)
ImageContainerView(video_coordinator: video_coordinator, url: urls[index], settings: settings, imageDict: $imageDict)
.aspectRatio(contentMode: .fit)
.padding(.top, Theme.safeAreaInsets?.top)
.padding(.bottom, Theme.safeAreaInsets?.bottom)
@@ -96,17 +97,49 @@ struct FullScreenCarouselView<Content: View>: View {
GeometryReader { geo in
VStack {
if showMenu {
NavDismissBarView(showBackgroundCircle: false)
.foregroundColor(.white)
HStack {
Button(action: {
presentationMode.wrappedValue.dismiss()
}, label: {
Image(systemName: "xmark")
.frame(width: 30, height: 30)
})
.buttonStyle(PlayerCircleButtonStyle())
Spacer()
if let url = urls[safe: carouselSelection.index],
let image = imageDict[url.url] {
ShareLink(item: Image(uiImage: image),
preview: SharePreview(NSLocalizedString("Shared Picture",
comment: "Label for the preview of the image being picture"),
image: Image(uiImage: image))) {
Image(systemName: "ellipsis")
.frame(width: 30, height: 30)
}
.buttonStyle(PlayerCircleButtonStyle())
}
}
.padding()
Spacer()
if urls.count > 1 {
PageControlView(currentPage: $selectedIndex, numberOfPages: urls.count)
.frame(maxWidth: 0, maxHeight: 0)
.padding(.top, 5)
VStack {
if urls.count > 1 {
PageControlView(currentPage: $carouselSelection.index, numberOfPages: urls.count)
.frame(maxWidth: 0, maxHeight: 0)
.padding(.top, 5)
}
if let focused_video = video_coordinator.focused_video {
DamusVideoControlsView(video: focused_video)
}
self.content?()
}
self.content?()
.padding(.top, 5)
.background(Color.black.opacity(0.7))
}
}
.animation(.easeInOut, value: showMenu)
@@ -128,7 +161,7 @@ fileprivate struct FullScreenCarouselPreviewView<Content: View>: View {
}
var body: some View {
FullScreenCarouselView(video_controller: test_damus_state.video, urls: [test_video_url, url], settings: test_damus_state.settings, selectedIndex: $selectedIndex) {
FullScreenCarouselView(video_coordinator: test_damus_state.video, urls: [test_video_url, url], settings: test_damus_state.settings, selectedIndex: $selectedIndex) {
self.custom_content?()
}
.environmentObject(OrientationTracker())
@@ -156,3 +189,11 @@ struct FullScreenCarouselView_Previews: PreviewProvider {
}
}
}
/// Class to define object for monitoring selectedIndex and updating mutlples views
final class CarouselSelection: ObservableObject {
@Published var index: Int
init(index: Int) {
self.index = index
}
}
+18 -5
View File
@@ -10,18 +10,29 @@ import Kingfisher
struct ImageContainerView: View {
let video_controller: VideoController
let video_coordinator: DamusVideoCoordinator
let url: MediaUrl
let settings: UserSettingsStore
@Binding var imageDict: [URL: UIImage]
@State private var image: UIImage?
@State private var showShareSheet = false
init(video_coordinator: DamusVideoCoordinator, url: MediaUrl, settings: UserSettingsStore, imageDict: Binding<[URL: UIImage]>) {
self.video_coordinator = video_coordinator
self.url = url
self.settings = settings
self._imageDict = imageDict
}
private struct ImageHandler: ImageModifier {
@Binding var handler: UIImage?
@Binding var imageDict: [URL: UIImage]
let url: URL
func modify(_ image: UIImage) -> UIImage {
handler = image
imageDict[url] = image
return image
}
}
@@ -32,7 +43,8 @@ struct ImageContainerView: View {
.configure { view in
view.framePreloadCount = 3
}
.imageModifier(ImageHandler(handler: $image))
.imageModifier(ImageHandler(handler: $image, imageDict: $imageDict, url: url))
.kfClickable()
.clipped()
.modifier(ImageContextMenuModifier(url: url, image: image, settings: settings, showShareSheet: $showShareSheet))
.sheet(isPresented: $showShareSheet) {
@@ -46,7 +58,7 @@ struct ImageContainerView: View {
case .image(let url):
Img(url: url)
case .video(let url):
DamusVideoPlayer(url: url, video_size: .constant(nil), controller: video_controller, style: .full, visibility_tracking_method: .generic)
DamusVideoPlayerView(url: url, coordinator: video_coordinator, style: .no_controls(on_tap: nil))
}
}
}
@@ -57,10 +69,11 @@ fileprivate let test_video_url = URL(string: "http://cdn.jb55.com/s/zaps-build.m
struct ImageContainerView_Previews: PreviewProvider {
static var previews: some View {
@State var imageDict: [URL: UIImage] = [:]
Group {
ImageContainerView(video_controller: test_damus_state.video, url: .image(test_image_url), settings: test_damus_state.settings)
ImageContainerView(video_coordinator: test_damus_state.video, url: .image(test_image_url), settings: test_damus_state.settings, imageDict: $imageDict)
.previewDisplayName("Image")
ImageContainerView(video_controller: test_damus_state.video, url: .video(test_video_url), settings: test_damus_state.settings)
ImageContainerView(video_coordinator: test_damus_state.video, url: .video(test_video_url), settings: test_damus_state.settings, imageDict: $imageDict)
.previewDisplayName("Video")
}
.environmentObject(OrientationTracker())
@@ -78,7 +78,7 @@ struct ImageContextMenuModifier: ViewModifier {
Label(NSLocalizedString("Share", comment: "Button to share an image."), image: "upload")
}
}
.alert(NSLocalizedString("Found\n \(qrCodeValue)", comment: "Alert message asking if the user wants to open the link.").truncate(maxLength: 50), isPresented: $open_link_confirm) {
.alert(String(format: NSLocalizedString("Found\n %@", comment: "Alert message asking if the user wants to open the link."), qrCodeValue).truncate(maxLength: 50), isPresented: $open_link_confirm) {
if open_wallet_confirm {
Button(NSLocalizedString("Open in wallet", comment: "Button to open the value found in browser."), role: .none) {
do {
+62 -12
View File
@@ -10,8 +10,7 @@ import Kingfisher
struct ProfileImageContainerView: View {
let url: URL?
let settings: UserSettingsStore
@State private var image: UIImage?
@Binding var image: UIImage?
@State private var showShareSheet = false
private struct ImageHandler: ImageModifier {
@@ -33,19 +32,25 @@ 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])
}
}
}
enum NavDismissBarContainer {
case fullScreenCarousel
case profilePicImageView
}
struct NavDismissBarView: View {
@Environment(\.presentationMode) var presentationMode
let showBackgroundCircle: Bool
let navDismissBarContainer: NavDismissBarContainer
init(showBackgroundCircle: Bool = true) {
self.showBackgroundCircle = showBackgroundCircle
init(navDismissBarContainer: NavDismissBarContainer) {
self.navDismissBarContainer = navDismissBarContainer
}
var body: some View {
@@ -53,15 +58,18 @@ struct NavDismissBarView: View {
Button(action: {
presentationMode.wrappedValue.dismiss()
}, label: {
if showBackgroundCircle {
switch navDismissBarContainer {
case .profilePicImageView:
Image("close")
.frame(width: 33, height: 33)
.background(.regularMaterial)
.clipShape(Circle())
}
else {
case .fullScreenCarousel:
Image("close")
.frame(width: 33, height: 33)
.background(.damusBlack)
.clipShape(Circle())
}
})
@@ -75,6 +83,10 @@ struct ProfilePicImageView: View {
let pubkey: Pubkey
let profiles: Profiles
let settings: UserSettingsStore
let nav: NavigationCoordinator
let shouldShowEditButton: Bool
@State var image: UIImage?
@State var showMenu = true
@Environment(\.presentationMode) var presentationMode
@@ -84,18 +96,57 @@ struct ProfilePicImageView: View {
.ignoresSafeArea()
ZoomableScrollView {
ProfileImageContainerView(url: get_profile_url(picture: nil, pubkey: pubkey, profiles: profiles), settings: settings)
ProfileImageContainerView(url: get_profile_url(picture: nil, pubkey: pubkey, profiles: profiles), settings: settings, image: $image)
.aspectRatio(contentMode: .fit)
.padding(.top, Theme.safeAreaInsets?.top)
.padding(.bottom, Theme.safeAreaInsets?.bottom)
.padding(.horizontal)
.allowsHitTesting(false)
}
.ignoresSafeArea()
.modifier(SwipeToDismissModifier(minDistance: 50, onDismiss: {
presentationMode.wrappedValue.dismiss()
}))
}
.overlay(NavDismissBarView(), alignment: .top)
.overlay(
Group {
if showMenu {
HStack {
NavDismissBarView(navDismissBarContainer: .profilePicImageView)
if let image = image {
ShareLink(item: Image(uiImage: image),
preview: SharePreview(NSLocalizedString("Damus Profile", comment: "Label for the preview of the profile picture"), image: Image(uiImage: image))) {
Image(systemName: "ellipsis")
.frame(width: 33, height: 33)
.background(.regularMaterial)
.clipShape(Circle())
}
.padding(20)
}
}
}
},
alignment: .top
)
.overlay(
shouldShowEditButton && showMenu ?
Button(action: {
presentationMode.wrappedValue.dismiss()
nav.push(route: Route.EditMetadata)
}) {
Text("Edit", comment: "Edit Button for editing profile")
.font(.system(size: 16, weight: .bold))
.foregroundColor(Color("DamusPurple"))
Spacer()
}
.padding([.vertical, .leading], 20)
: nil,
alignment: .bottomLeading
)
.gesture(TapGesture(count: 1).onEnded {
showMenu.toggle()
})
.animation(.easeInOut, value: showMenu)
}
}
@@ -104,7 +155,6 @@ struct ProfileZoomView_Previews: PreviewProvider {
ProfilePicImageView(
pubkey: test_pubkey,
profiles: make_preview_profiles(test_pubkey),
settings: test_damus_state.settings
)
settings: test_damus_state.settings, nav: test_damus_state.nav, shouldShowEditButton: true)
}
}
+32 -19
View File
@@ -5,6 +5,7 @@
// Created by William Casarin on 2022-05-22.
//
import CodeScanner
import SwiftUI
enum ParsedKey {
@@ -103,6 +104,7 @@ struct LoginView: View {
}
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: 12, alignment: .center)
}
.accessibilityIdentifier(AppAccessibilityIdentifiers.sign_in_confirm_button.rawValue)
.buttonStyle(GradientButtonStyle())
.padding(.top, 10)
}
@@ -298,27 +300,35 @@ struct KeyInput: View {
var body: some View {
HStack {
Image(systemName: "doc.on.clipboard")
.foregroundColor(.gray)
.onTapGesture {
if let pastedkey = UIPasteboard.general.string {
self.key.wrappedValue = pastedkey
}
Button(action: {
if let pastedkey = UIPasteboard.general.string {
self.key.wrappedValue = pastedkey
}
}, label: {
Image(systemName: "doc.on.clipboard")
})
.foregroundColor(.gray)
.accessibilityLabel(NSLocalizedString("Paste private key", comment: "Accessibility label for the private key paste button"))
SignInScan(shouldSaveKey: shouldSaveKey, loginKey: key, privKeyFound: privKeyFound)
if is_secured {
SecureField("", text: key)
.nsecLoginStyle(key: key.wrappedValue, title: title)
} else {
TextField("", text: key)
.nsecLoginStyle(key: key.wrappedValue, title: title)
}
Image(systemName: "eye.slash")
.foregroundColor(.gray)
.onTapGesture {
is_secured.toggle()
}
SecureField("", text: key)
.nsecLoginStyle(key: key.wrappedValue, title: title)
.accessibilityLabel(NSLocalizedString("Account private key", comment: "Accessibility label for the private key input field"))
} else {
TextField("", text: key)
.nsecLoginStyle(key: key.wrappedValue, title: title)
.accessibilityLabel(NSLocalizedString("Account private key", comment: "Accessibility label for the private key input field"))
}
Button(action: {
is_secured.toggle()
}, label: {
Image(systemName: "eye.slash")
})
.foregroundColor(.gray)
.accessibilityLabel(NSLocalizedString("Toggle key visibility", comment: "Accessibility label for toggling the visibility of the private key input field"))
}
.padding(.vertical, 2)
.padding(.horizontal, 10)
@@ -341,6 +351,7 @@ struct SignInHeader: View {
.frame(width: 56, height: 56, alignment: .center)
.shadow(color: DamusColors.purple, radius: 2)
.padding(.bottom)
.accessibilityLabel(NSLocalizedString("Damus logo", comment: "Accessibility label for damus logo"))
Text("Sign in", comment: "Title of view to log into an account.")
.foregroundColor(DamusColors.neutral6)
@@ -364,10 +375,12 @@ struct SignInEntry: View {
.fontWeight(.medium)
.padding(.top, 30)
KeyInput(NSLocalizedString("nsec1...", comment: "Prompt for user to enter in an account key to login. This text shows the characters the key could start with if it was a private key."),
KeyInput(NSLocalizedString("nsec1…", comment: "Prompt for user to enter in an account key to login. This text shows the characters the key could start with if it was a private key."),
key: key,
shouldSaveKey: shouldSaveKey,
privKeyFound: $privKeyFound)
.accessibilityIdentifier(AppAccessibilityIdentifiers.sign_in_nsec_key_entry_field.rawValue)
if privKeyFound {
Toggle(NSLocalizedString("Save Key in Secure Keychain", comment: "Toggle to save private key to the Apple secure keychain."), isOn: shouldSaveKey)
}
@@ -388,7 +401,7 @@ struct SignInScan: View {
Button(action: { showQR.toggle() }, label: {
Image(systemName: "qrcode.viewfinder")})
.foregroundColor(.gray)
.accessibilityLabel(NSLocalizedString("Scan QR code", comment: "Accessibility label for a button that scans a private key QR code"))
}
.sheet(isPresented: $showQR, onDismiss: {
if qrkey == nil { resetView() }}
+3
View File
@@ -66,7 +66,9 @@ struct TabButton: View {
struct TabBar: View {
var nstatus: NotificationStatusModel
var navIsAtRoot: Bool
@Binding var selected: Timeline
@Binding var headerOffset: CGFloat
let settings: UserSettingsStore
let action: (Timeline) -> ()
@@ -81,5 +83,6 @@ struct TabBar: View {
TabButton(timeline: .notifications, img: "notification-bell", selected: $selected, nstatus: nstatus, settings: settings, action: action).keyboardShortcut("4")
}
}
.opacity(selected != .home || (selected == .home && !navIsAtRoot) ? 1.0 : 0.35 + abs(1.25 - (abs(headerOffset/100.0))))
}
}
+55 -18
View File
@@ -9,18 +9,27 @@ import UIKit
import SwiftUI
import PhotosUI
enum MediaPickerEntry {
case editPictureControl
case postView
}
struct MediaPicker: UIViewControllerRepresentable {
@Environment(\.presentationMode)
@Binding private var presentationMode
let mediaPickerEntry: MediaPickerEntry
@Binding var image_upload_confirm: Bool
var imagesOnly: Bool = false
let onMediaPicked: (PreUploadedMedia) -> Void
final class Coordinator: NSObject, PHPickerViewControllerDelegate {
let parent: MediaPicker
var parent: MediaPicker
// properties used for returning medias in the same order as picking
let dispatchGroup: DispatchGroup = DispatchGroup()
var orderIds: [String] = []
var orderMap: [String: PreUploadedMedia] = [:]
init(_ parent: MediaPicker) {
self.parent = parent
@@ -31,7 +40,16 @@ struct MediaPicker: UIViewControllerRepresentable {
self.parent.presentationMode.dismiss()
}
// When user dismiss the upload confirmation and re-adds again, reset orderIds and orderMap
orderIds.removeAll()
orderMap.removeAll()
for result in results {
let orderId = result.assetIdentifier ?? UUID().uuidString
orderIds.append(orderId)
dispatchGroup.enter()
if result.itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
result.itemProvider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { (item, error) in
guard let url = item as? URL else { return }
@@ -50,7 +68,7 @@ struct MediaPicker: UIViewControllerRepresentable {
do {
try imageData.write(to: destinationURL)
Task {
await self.chooseMedia(.processed_image(destinationURL))
await self.chooseMedia(.processed_image(destinationURL), orderId: orderId)
}
}
catch {
@@ -64,13 +82,13 @@ struct MediaPicker: UIViewControllerRepresentable {
url: url,
fallback: processImage,
unprocessedEnum: {.unprocessed_image($0)},
processedEnum: {.processed_image($0)}
)
processedEnum: {.processed_image($0)},
orderId: orderId)
} else {
// Media was taken from camera
result.itemProvider.loadObject(ofClass: UIImage.self) { (image, error) in
if let image = image as? UIImage, error == nil {
self.chooseMedia(.uiimage(image))
self.chooseMedia(.uiimage(image), orderId: orderId)
}
}
}
@@ -83,41 +101,60 @@ struct MediaPicker: UIViewControllerRepresentable {
url: url,
fallback: processVideo,
unprocessedEnum: {.unprocessed_video($0)},
processedEnum: {.processed_video($0)}
processedEnum: {.processed_video($0)}, orderId: orderId
)
}
}
}
dispatchGroup.notify(queue: .main) { [weak self] in
guard let self = self else { return }
var arrMedia: [PreUploadedMedia] = []
for id in self.orderIds {
if let media = self.orderMap[id] {
arrMedia.append(media)
self.parent.onMediaPicked(media)
}
}
}
}
private func chooseMedia(_ media: PreUploadedMedia) {
self.parent.onMediaPicked(media)
private func chooseMedia(_ media: PreUploadedMedia, orderId: String) {
self.parent.image_upload_confirm = true
self.orderMap[orderId] = media
self.dispatchGroup.leave()
}
private func attemptAcquireResourceAndChooseMedia(url: URL, fallback: (URL) -> URL?, unprocessedEnum: (URL) -> PreUploadedMedia, processedEnum: (URL) -> PreUploadedMedia) {
private func attemptAcquireResourceAndChooseMedia(url: URL, fallback: (URL) -> URL?, unprocessedEnum: (URL) -> PreUploadedMedia, processedEnum: (URL) -> PreUploadedMedia, orderId: String) {
if url.startAccessingSecurityScopedResource() {
// Have permission from system to use url out of scope
print("Acquired permission to security scoped resource")
self.chooseMedia(unprocessedEnum(url))
self.chooseMedia(unprocessedEnum(url), orderId: orderId)
} else {
// Need to copy URL to non-security scoped location
guard let newUrl = fallback(url) else { return }
self.chooseMedia(processedEnum(newUrl))
self.chooseMedia(processedEnum(newUrl), orderId: orderId)
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
Coordinator(self)
}
func makeUIViewController(context: Context) -> PHPickerViewController {
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.selectionLimit = 1
configuration.filter = imagesOnly ? .images : .any(of: [.images, .videos])
switch mediaPickerEntry {
case .postView:
configuration.selectionLimit = 0 // allows multiple media selection
configuration.filter = .any(of: [.images, .videos])
configuration.selection = .ordered // images are returned in the order they were selected + numbered badge displayed
case .editPictureControl:
configuration.selectionLimit = 1 // allows one media selection
configuration.filter = .images // allows image only
}
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = context.coordinator as any PHPickerViewControllerDelegate
return picker
+18 -11
View File
@@ -8,11 +8,15 @@ 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
var trimmedText: String {
new_text.trimmingCharacters(in: .whitespaces)
}
var body: some View {
VStack {
Text("Add mute item", comment: "Title text to indicate user to an add an item to their mutelist.")
@@ -30,12 +34,13 @@ struct AddMuteItemView: View {
Text("Duration", comment: "The duration in which to mute the given item.")
}
let trimmedText = self.trimmedText
HStack {
Label("", image: "copy2")
.onTapGesture {
if let pasted_text = UIPasteboard.general.string {
self.new_text = pasted_text
self.new_text = pasted_text.trimmingCharacters(in: .whitespaces)
}
}
TextField(NSLocalizedString("npub, #hashtag, phrase", comment: "Placeholder example for relay server address."), text: $new_text)
@@ -44,7 +49,7 @@ struct AddMuteItemView: View {
Label("", image: "close-circle")
.foregroundColor(.accentColor)
.opacity((new_text == "") ? 0.0 : 1.0)
.opacity(trimmedText.isEmpty ? 0.0 : 1.0)
.onTapGesture {
self.new_text = ""
}
@@ -56,17 +61,17 @@ struct AddMuteItemView: View {
Button(action: {
let expiration_date: Date? = self.expiration.date_from_now
let mute_item: MuteItem? = {
if new_text.starts(with: "npub") {
if let pubkey: Pubkey = bech32_pubkey_decode(new_text) {
if trimmedText.starts(with: "npub") {
if let pubkey: Pubkey = bech32_pubkey_decode(trimmedText) {
return .user(pubkey, expiration_date)
} else {
return nil
}
} else if new_text.starts(with: "#") {
} else if trimmedText.starts(with: "#") {
// Remove the starting `#` character
return .hashtag(Hashtag(hashtag: String("\(new_text)".dropFirst())), expiration_date)
return .hashtag(Hashtag(hashtag: String("\(trimmedText)".dropFirst())), expiration_date)
} else {
return .word(new_text, expiration_date)
return .word(trimmedText, expiration_date)
}
}()
@@ -87,18 +92,20 @@ 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()
}) {
HStack {
Text(verbatim: "Add mute item")
Text("Add mute item", comment: "Button to an add an item to the user's mutelist.")
.bold()
}
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
}
.buttonStyle(GradientButtonStyle(padding: 10))
.padding(.vertical)
.opacity(trimmedText.isEmpty ? 0.5 : 1.0)
.disabled(trimmedText.isEmpty)
Spacer()
}
@@ -108,6 +115,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(""))
}
}

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