Compare commits

..

58 Commits

Author SHA1 Message Date
8e852ed742 Remove language filtering from Universe feed because language detection can be inaccurate
Changelog-Removed: Removed language filtering from Universe feed because language detection can be inaccurate

Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-01-19 13:21:31 -05:00
7b4fc79030 Translate notes even if they are in a preferred language but not the current language as that is what users expect
Changelog-Fixed: Translate notes even if they are in a preferred language but not the current language as that is what users expect

Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-01-19 13:07:51 -05:00
ericholguin
7a4af31859 nwc: Coinos
This PR adds a button to allow users to easily connect to Coinos
Also cleans up and organizes assets.

Changelog-Added: Coinos connection button in Wallet view

Signed-off-by: ericholguin <ericholguin@apache.org>
2025-01-12 15:20:06 +09:00
Tomek ⚡ K
e106be1412 Add Alby Go to mobile wallets selection
Changelog-Added: Added Alby Go to mobile wallets selection menu
Signed-off-by: Tomek K <itstomekk@getalby.com>
2025-01-12 11:33:20 +09:00
Swift Coder
282bf80daa Cancel ongoing uploading operations after cancelling post
1] Cancel ongoing uploading operations after the user has pressed "cancel post"
2] Don't generate haptic feedback in this error case because user has already dismissed the Post View

Changelog-Fixed: Cancel ongoing uploading operations after the user cancels the post
Signed-off-by: Swift Coder <scoder1747@gmail.com>
2025-01-06 15:55:53 +09:00
Daniel D’Aquino
bcb861a61b Improve accessibility of EditPictureControl
This commit improves accessibility of EditPictureControl, by adding
better accessibility labels and hints.

Changelog-Added: Minor accessibility improvements around picture editing and onboarding
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-01-06 15:45:58 +09:00
Daniel D’Aquino
bb0ad18913 Implement profile image cropping and optimization
This commit implements profile image cropping and optimization, as well
as a major refactor on EditPictureControl.

It now employs the following techniques:
- Users can now crop their profile pictures to fit a square aspect
  ratio nicely and avoid issues with automatic resizing/cropping
- Profile images are resized to a 400px by 400px image before sending it
  over the wire for better bandwidth usage
- Profile pictures are now tagged as such to the media uploaders, to
  enable media optimization or special care on their end.

Integrating the cropping step was very difficult with the previous
structures, so `EditPictureControl` was heavily refactored to have
improved state handling and better testability:

1. Enums with associated values are being used to capture all of the
   state in the picture selection process, as that helps ensure the
   needed info in each step is there and more clearly delianeate
   different steps — all at compile-time
2. The view was split into a view-model architecture, with almost all of
   the view logic ported to the new view-model class, making the view
   and the logic more clear to read as concerns are separated. This also
   enables better testabilty

Several automated tests were added to cover EditPictureControl logic and
looks.

Closes: https://github.com/damus-io/damus/issues/2643
Changelog-Added: Profile image cropping tools
Changelog-Changed: Improved profile image bandwidth optimization
Changelog-Changed: Improved reliability of picture selector
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-01-06 15:45:57 +09:00
Daniel D’Aquino
81830c7540 Add SwiftyCrop dependency
This commit adds the SwiftyCrop dependency, to provide users with a way
to crop their profile images prior to upload

- Dependency version is commit-hash-locked for extra security and
  reproducibility
- Reviewed code contents of the library to check for any user tracking
  code. None was found

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-01-06 15:45:57 +09:00
transifex-integration[bot]
68128b5ff1 Translate Localizable.strings in hu_HU
100% translated source file: 'Localizable.strings'
on 'hu_HU'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
aebeb26bc6 Translate Localizable.strings in ja
100% translated source file: 'Localizable.strings'
on 'ja'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
79cf3db279 Translate Localizable.strings in ja
100% translated source file: 'Localizable.strings'
on 'ja'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
dcae0d2cc7 Translate Localizable.strings in ja
100% translated source file: 'Localizable.strings'
on 'ja'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
2b12dc5920 Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
51930e7a12 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
b04e09d2e0 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-12-27 20:36:57 +09:00
b6c4213515 Export strings for translation 2024-12-27 20:36:57 +09:00
transifex-integration[bot]
8230c6eded Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
e79590f795 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
79bced1246 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
896f4b55e3 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
52e65f9429 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
a22cc532e2 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
823227920c Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
3e2bbce25e Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
e05b2d9ecf Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
d7b31a1cd8 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
70f01c0880 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
2cf5f21f78 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
96e8f8b6b2 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
370cfd1b08 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
046af15734 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
9e4ab2d54c Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
7cf12e2e0d Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
a63a81b387 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
d994cd13dc Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
95e985cfce Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
3a69de9274 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
64f5acf98c Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
5167ab264d Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
e02895b29f Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
0009d11025 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
afc317bb52 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
629212ea23 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
transifex-integration[bot]
ec1252200f Translate InfoPlist.strings in pt_PT
100% translated source file: 'InfoPlist.strings'
on 'pt_PT'.
2024-12-27 20:36:57 +09:00
Swift Coder
54ea1ab803 MacOS Damus Support allowing link and photo sharing option
Changelog-Fixed: Fixed link and photo sharing support on macOS
Signed-off-by: Swift Coder <scoder1747@gmail.com>
2024-12-27 19:13:45 +09:00
Swift Coder
4cf8097de4 Displaying suitable text instead of Empty Notification View
Changelog-Fixed:Handle empty notification pages by displaying suitable text
Signed-off-by: Swift Coder <scoder1747@gmail.com>
2024-12-20 11:01:02 +09:00
Daniel D’Aquino
2c7384b0a9 Fix button hidden behind software keyboard in create account view
This commit fixes an issue where the "next" button is hidden behind the
software keyboard in the account creation view, where it is very hard to
press.

The fix was done by dynamically shrinking the profile picture size when
keyboard appears in smartphone screens, so that there is enough space
for all content to appear.

Changelog-Fixed: Fixed issue where the "next" button would appear hidden and hard to click on the create account view
Closes: https://github.com/damus-io/damus/issues/2771
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-12-20 10:38:20 +09:00
Swift Coder
19e312a8fb Fix non scrollable wallet screen
Changelog-Fixed: Fix non scrollable wallet screen
Signed-off-by: Swift Coder <scoder1747@gmail.com>
2024-12-18 11:27:51 +09:00
Swift Coder
3986308638 Render Gif and video files while composing posts
Changelog-Added: Render Gif and video files while composing posts
Signed-off-by: Swift Coder <scoder1747@gmail.com>
2024-12-16 17:22:12 +09:00
fa7740948b Export strings for translation
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2024-12-16 17:04:20 +09:00
892a1420f3 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-16 17:04:20 +09:00
ee4cbf7363 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-16 17:04:20 +09:00
a1b1ce949b Fix right-to-left localization issues
Changelog-Fixed: Fixed right-to-left localization issues

Signed-off-by: Terry Yiu <git@tyiu.xyz>
2024-12-16 17:04:20 +09:00
902e8c3950 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-16 17:04:20 +09:00
b776788b38 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-16 17:04:20 +09:00
Daniel D’Aquino
78066773f4 Improve clarity of word search label
Changelog-Changed: Improved UX around the label for searching words
Closes: https://github.com/damus-io/damus/issues/2733
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-12-13 14:00:00 +09:00
Daniel D’Aquino
0bac284eee Fix issues with inputting a profile twice to the search bar
This fixes an issue where a user would have to input a profile npub
twice in order to get a result.

The fix is composed of the following constituents:
1. The removal of the dependency on NostrDB having profile information.
   Previously the function relied on NostrDB having profile information
   about a freshly downloaded profile, which it sometimes does not. The
   function does not require the profile to be on NostrDB at the time
   the profile is found on the relay.
2. The increase in allowed relay attempts to all relays. Previously it
   would only look for about half of the relays, which could cause
   certain events to not be found
3. The closing of relay subscription on EOSE. Previously, the
   subscription would only be closed if an event was found, which could
   lead to a "leak" of open subscriptions if an event is not found.

Closes: https://github.com/damus-io/damus/issues/2635
Changelog-Fixed: Fixed an issue where a profile would need to be input twice in the search to be found
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-12-13 14:00:00 +09:00
Daniel D’Aquino
07c95d1003 Turn on strict concurrency checks
Turn on strict concurrency checks on the compiler to make potential
concurrency issues more visible and aid during debugging, as well as to
start preparing us for Swift 6.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2024-12-13 14:00:00 +09:00
118 changed files with 1681 additions and 554 deletions

View File

@@ -406,9 +406,10 @@
5C6E1DAD2A193EC2008FC15A /* GradientButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6E1DAC2A193EC2008FC15A /* GradientButtonStyle.swift */; };
5C6E1DAF2A194075008FC15A /* PinkGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6E1DAE2A194075008FC15A /* PinkGradient.swift */; };
5C7389B12B6EFA7100781E0A /* ProxyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7389B02B6EFA7100781E0A /* ProxyView.swift */; };
5C7389B72B9E692E00781E0A /* MutinyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7389B62B9E692E00781E0A /* MutinyButton.swift */; };
5C7389B92B9E69ED00781E0A /* MutinyGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7389B82B9E69ED00781E0A /* MutinyGradient.swift */; };
5C8711DE2C460C06007879C2 /* PostingTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8711DD2C460C06007879C2 /* PostingTimelineView.swift */; };
5CB017212D2D985E00A9ED05 /* CoinosButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017202D2D985800A9ED05 /* CoinosButton.swift */; };
5CB017222D2D985E00A9ED05 /* CoinosButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017202D2D985800A9ED05 /* CoinosButton.swift */; };
5CB017232D2D985E00A9ED05 /* CoinosButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017202D2D985800A9ED05 /* CoinosButton.swift */; };
5CC8529D2BD741CD0039FFC5 /* HighlightEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC8529C2BD741CD0039FFC5 /* HighlightEvent.swift */; };
5CC8529F2BD744F60039FFC5 /* HighlightView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC8529E2BD744F60039FFC5 /* HighlightView.swift */; };
5CC852A22BDED9B90039FFC5 /* HighlightDescription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC852A12BDED9B90039FFC5 /* HighlightDescription.swift */; };
@@ -535,7 +536,6 @@
82D6FB0F2CD99F7900C925F4 /* DamusLogoGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0707D02A1ECB38004E7B51 /* DamusLogoGradient.swift */; };
82D6FB102CD99F7900C925F4 /* DamusBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C687C202A5F7ED00092C550 /* DamusBackground.swift */; };
82D6FB112CD99F7900C925F4 /* DamusLightGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF2DCCD2AABE1A500984B8D /* DamusLightGradient.swift */; };
82D6FB122CD99F7900C925F4 /* MutinyGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7389B82B9E69ED00781E0A /* MutinyGradient.swift */; };
82D6FB132CD99F7900C925F4 /* Shimmer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31D2E846295218AF006D67F8 /* Shimmer.swift */; };
82D6FB142CD99F7900C925F4 /* EndBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CD7641A28A1641400B6928F /* EndBlock.swift */; };
82D6FB152CD99F7900C925F4 /* ImageCarousel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C06670528FCB08600038D2A /* ImageCarousel.swift */; };
@@ -733,7 +733,6 @@
82D6FBD82CD99F7900C925F4 /* FriendsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C8D1A6E29F31E5000ACDF75 /* FriendsButton.swift */; };
82D6FBD92CD99F7900C925F4 /* GradientFollowButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694F32A6732B7001F4053 /* GradientFollowButton.swift */; };
82D6FBDA2CD99F7900C925F4 /* AlbyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09652A0AE62100943473 /* AlbyButton.swift */; };
82D6FBDB2CD99F7900C925F4 /* MutinyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7389B62B9E692E00781E0A /* MutinyButton.swift */; };
82D6FBDC2CD99F7900C925F4 /* DamusVideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1A9A2929DDF54400516EAC /* DamusVideoPlayerView.swift */; };
82D6FBDD2CD99F7900C925F4 /* DamusVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A16FFC2AA7525700DFEC1F /* DamusVideoPlayer.swift */; };
82D6FBDE2CD99F7900C925F4 /* DamusVideoCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A16FFE2AA76A0900DFEC1F /* DamusVideoCoordinator.swift */; };
@@ -1127,7 +1126,6 @@
D73E5E442C6A97F4007EB227 /* DamusLogoGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0707D02A1ECB38004E7B51 /* DamusLogoGradient.swift */; };
D73E5E452C6A97F4007EB227 /* DamusBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C687C202A5F7ED00092C550 /* DamusBackground.swift */; };
D73E5E462C6A97F4007EB227 /* DamusLightGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF2DCCD2AABE1A500984B8D /* DamusLightGradient.swift */; };
D73E5E472C6A97F4007EB227 /* MutinyGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7389B82B9E69ED00781E0A /* MutinyGradient.swift */; };
D73E5E482C6A97F4007EB227 /* Shimmer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31D2E846295218AF006D67F8 /* Shimmer.swift */; };
D73E5E492C6A97F4007EB227 /* EndBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CD7641A28A1641400B6928F /* EndBlock.swift */; };
D73E5E4D2C6A97F4007EB227 /* NIP05Badge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB8838A296F6E1E00DC99E7 /* NIP05Badge.swift */; };
@@ -1255,7 +1253,6 @@
D73E5ED42C6A97F4007EB227 /* FriendsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C8D1A6E29F31E5000ACDF75 /* FriendsButton.swift */; };
D73E5ED52C6A97F4007EB227 /* GradientFollowButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694F32A6732B7001F4053 /* GradientFollowButton.swift */; };
D73E5ED62C6A97F4007EB227 /* AlbyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09652A0AE62100943473 /* AlbyButton.swift */; };
D73E5ED72C6A97F4007EB227 /* MutinyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7389B62B9E692E00781E0A /* MutinyButton.swift */; };
D73E5ED82C6A97F4007EB227 /* DamusVideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1A9A2929DDF54400516EAC /* DamusVideoPlayerView.swift */; };
D73E5ED92C6A97F4007EB227 /* DamusVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A16FFC2AA7525700DFEC1F /* DamusVideoPlayer.swift */; };
D73E5EDA2C6A97F4007EB227 /* DamusVideoCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A16FFE2AA76A0900DFEC1F /* DamusVideoCoordinator.swift */; };
@@ -1485,6 +1482,7 @@
D798D22E2B086E4800234419 /* NostrResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFB028049D510006080F /* NostrResponse.swift */; };
D79C4C172AFEB061003A41B4 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79C4C162AFEB061003A41B4 /* NotificationService.swift */; };
D79C4C1B2AFEB061003A41B4 /* DamusNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D79C4C142AFEB061003A41B4 /* DamusNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D7A0D8752D1FE67900DCBE59 /* EditPictureControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A0D8742D1FE66A00DCBE59 /* EditPictureControlTests.swift */; };
D7A343EE2AD0D77C00CED48B /* InlineSnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = D7A343ED2AD0D77C00CED48B /* InlineSnapshotTesting */; };
D7A343F02AD0D77C00CED48B /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = D7A343EF2AD0D77C00CED48B /* SnapshotTesting */; };
D7ADD3DE2B53854300F104C4 /* DamusPurpleURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */; };
@@ -1492,6 +1490,9 @@
D7ADD3E22B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */; };
D7B76C902C825042003A16CB /* PushNotificationClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D2A3802BF815D000E4B42B /* PushNotificationClient.swift */; };
D7B76C912C82507F003A16CB /* NIP98AuthenticatedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift */; };
D7C48C0B2D12DE0C00A3BACF /* SwiftyCrop in Frameworks */ = {isa = PBXBuildFile; productRef = D7C48C0A2D12DE0C00A3BACF /* SwiftyCrop */; };
D7C48C0D2D12E34900A3BACF /* SwiftyCrop in Frameworks */ = {isa = PBXBuildFile; productRef = D7C48C0C2D12E34900A3BACF /* SwiftyCrop */; };
D7C48C0F2D12E35600A3BACF /* SwiftyCrop in Frameworks */ = {isa = PBXBuildFile; productRef = D7C48C0E2D12E35600A3BACF /* SwiftyCrop */; };
D7C6787E2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift */; };
D7C9701E2C890FC500C56602 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3ACB685D297633BC00C46468 /* Localizable.strings */; };
D7C9701F2C890FEB00C56602 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 3A4325AA2961E11400BFCD9D /* Localizable.stringsdict */; };
@@ -2335,9 +2336,8 @@
5C6E1DAC2A193EC2008FC15A /* GradientButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientButtonStyle.swift; sourceTree = "<group>"; };
5C6E1DAE2A194075008FC15A /* PinkGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinkGradient.swift; sourceTree = "<group>"; };
5C7389B02B6EFA7100781E0A /* ProxyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyView.swift; sourceTree = "<group>"; };
5C7389B62B9E692E00781E0A /* MutinyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutinyButton.swift; sourceTree = "<group>"; };
5C7389B82B9E69ED00781E0A /* MutinyGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutinyGradient.swift; sourceTree = "<group>"; };
5C8711DD2C460C06007879C2 /* PostingTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostingTimelineView.swift; sourceTree = "<group>"; };
5CB017202D2D985800A9ED05 /* CoinosButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinosButton.swift; sourceTree = "<group>"; };
5CC8529C2BD741CD0039FFC5 /* HighlightEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightEvent.swift; sourceTree = "<group>"; };
5CC8529E2BD744F60039FFC5 /* HighlightView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightView.swift; sourceTree = "<group>"; };
5CC852A12BDED9B90039FFC5 /* HighlightDescription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDescription.swift; sourceTree = "<group>"; };
@@ -2446,6 +2446,7 @@
D79C4C162AFEB061003A41B4 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
D79C4C182AFEB061003A41B4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D79C4C1C2AFEB061003A41B4 /* DamusNotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DamusNotificationService.entitlements; sourceTree = "<group>"; };
D7A0D8742D1FE66A00DCBE59 /* EditPictureControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPictureControlTests.swift; sourceTree = "<group>"; };
D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURL.swift; sourceTree = "<group>"; };
D7ADD3DF2B538D4200F104C4 /* DamusPurpleURLSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURLSheetView.swift; sourceTree = "<group>"; };
D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleVerifyNpubView.swift; sourceTree = "<group>"; };
@@ -2508,6 +2509,7 @@
4C06670428FC7EC500038D2A /* Kingfisher in Frameworks */,
3A0A30BB2C21397A00F8C9BC /* EmojiPicker in Frameworks */,
D70D90982CDED61800CD0534 /* CodeScanner in Frameworks */,
D7C48C0B2D12DE0C00A3BACF /* SwiftyCrop in Frameworks */,
D78DB8592C1CE9CA00F0AB12 /* SwipeActions in Frameworks */,
4C649881286E0EE300EAE2B3 /* secp256k1 in Frameworks */,
4C27C9322A64766F007DBC75 /* MarkdownUI in Frameworks */,
@@ -2537,6 +2539,7 @@
82D6FC862CD9A4A600C925F4 /* MarkdownUI in Frameworks */,
82D6FC8A2CD9A54600C925F4 /* SwipeActions in Frameworks */,
D7F360292CEBBE34009D34DA /* CodeScanner in Frameworks */,
D7C48C0D2D12E34900A3BACF /* SwiftyCrop in Frameworks */,
82D6FC882CD9A4DE00C925F4 /* EmojiPicker in Frameworks */,
82D6FC842CD9A48500C925F4 /* Kingfisher in Frameworks */,
82D6FC812CD99FC500C925F4 /* secp256k1 in Frameworks */,
@@ -2551,6 +2554,7 @@
D73E5F9D2C6AA8E3007EB227 /* SwipeActions in Frameworks */,
D73E5F762C6A997E007EB227 /* EmojiPicker in Frameworks */,
D703D7192C66E47100A400EA /* UniformTypeIdentifiers.framework in Frameworks */,
D7C48C0F2D12E35600A3BACF /* SwiftyCrop in Frameworks */,
D703D7492C6709B100A400EA /* secp256k1 in Frameworks */,
D70D909C2CDED7B200CD0534 /* CodeScanner in Frameworks */,
D73E5F9B2C6AA8B0007EB227 /* Kingfisher in Frameworks */,
@@ -3184,7 +3188,6 @@
5C0707D02A1ECB38004E7B51 /* DamusLogoGradient.swift */,
4C687C202A5F7ED00092C550 /* DamusBackground.swift */,
5CF2DCCD2AABE1A500984B8D /* DamusLightGradient.swift */,
5C7389B82B9E69ED00781E0A /* MutinyGradient.swift */,
);
path = Gradients;
sourceTree = "<group>";
@@ -3253,10 +3256,10 @@
4C8D1A6D29F31E4100ACDF75 /* Buttons */ = {
isa = PBXGroup;
children = (
5CB017202D2D985800A9ED05 /* CoinosButton.swift */,
4C8D1A6E29F31E5000ACDF75 /* FriendsButton.swift */,
F71694F32A6732B7001F4053 /* GradientFollowButton.swift */,
4C7D09652A0AE62100943473 /* AlbyButton.swift */,
5C7389B62B9E692E00781E0A /* MutinyButton.swift */,
);
path = Buttons;
sourceTree = "<group>";
@@ -3590,6 +3593,7 @@
4CE6DEF627F7A08200C66700 /* damusTests */ = {
isa = PBXGroup;
children = (
D7A0D8742D1FE66A00DCBE59 /* EditPictureControlTests.swift */,
E06336A72B7582D600A88E6B /* Assets */,
D72A2D032AD9C165002AFF62 /* Mocking */,
4C9B0DEC2A65A74000CBDA21 /* Util */,
@@ -3967,6 +3971,7 @@
3A0A30BA2C21397A00F8C9BC /* EmojiPicker */,
D78DB8582C1CE9CA00F0AB12 /* SwipeActions */,
D70D90972CDED61800CD0534 /* CodeScanner */,
D7C48C0A2D12DE0C00A3BACF /* SwiftyCrop */,
);
productName = damus;
productReference = 4CE6DEE327F7A08100C66700 /* damus.app */;
@@ -4032,6 +4037,7 @@
82D6FC872CD9A4DE00C925F4 /* EmojiPicker */,
82D6FC892CD9A54600C925F4 /* SwipeActions */,
D7F360282CEBBE34009D34DA /* CodeScanner */,
D7C48C0C2D12E34900A3BACF /* SwiftyCrop */,
);
productName = "share extension";
productReference = 82D6FA972CD9820500C925F4 /* ShareExtension.appex */;
@@ -4059,6 +4065,7 @@
D73E5F9A2C6AA8B0007EB227 /* Kingfisher */,
D73E5F9C2C6AA8E3007EB227 /* SwipeActions */,
D70D909B2CDED7B200CD0534 /* CodeScanner */,
D7C48C0E2D12E35600A3BACF /* SwiftyCrop */,
);
productName = "highlighter action extension";
productReference = D703D7172C66E47100A400EA /* HighlighterActionExtension.appex */;
@@ -4167,6 +4174,7 @@
3A0A30B92C21397A00F8C9BC /* XCRemoteSwiftPackageReference "EmojiPicker" */,
D78DB8572C1CE9CA00F0AB12 /* XCRemoteSwiftPackageReference "SwipeActions" */,
D70D90962CDED61800CD0534 /* XCRemoteSwiftPackageReference "CodeScanner" */,
D7C48C092D12DE0C00A3BACF /* XCRemoteSwiftPackageReference "SwiftyCrop" */,
);
productRefGroup = 4CE6DEE427F7A08100C66700 /* Products */;
projectDirPath = "";
@@ -4299,7 +4307,6 @@
4C30AC7829A577AB00E2BD5A /* EventCache.swift in Sources */,
4C285C8428385690008A31F1 /* CreateAccountView.swift in Sources */,
4CDD1AE22A6B3074001CD4DF /* NdbTagsIterator.swift in Sources */,
5C7389B72B9E692E00781E0A /* MutinyButton.swift in Sources */,
4C216F34286F5ACD00040376 /* DMView.swift in Sources */,
D7CB5D512B1174D100AD4105 /* FriendFilter.swift in Sources */,
D7CBD1D42B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift in Sources */,
@@ -4604,7 +4611,6 @@
4CF0ABD82981980C00D66079 /* Lists.swift in Sources */,
F71694EA2A662232001F4053 /* OnboardingSuggestionsView.swift in Sources */,
4C12536A2A76D3850004F4B8 /* RelaysChangedNotify.swift in Sources */,
5C7389B92B9E69ED00781E0A /* MutinyGradient.swift in Sources */,
4C30AC8029A6A53F00E2BD5A /* ProfilePicturesView.swift in Sources */,
D7373BAA2B68A65A00F7783D /* PurpleAccountUpdateNotify.swift in Sources */,
5C6E1DAD2A193EC2008FC15A /* GradientButtonStyle.swift in Sources */,
@@ -4702,6 +4708,7 @@
5C513FBA297F72980072348F /* CustomPicker.swift in Sources */,
4C1253622A76D00B0004F4B8 /* PostNotify.swift in Sources */,
4CACA9D5280C31E100D9BBE8 /* ReplyView.swift in Sources */,
5CB017232D2D985E00A9ED05 /* CoinosButton.swift in Sources */,
F7908E92298B0F0700AB113A /* RelayDetailView.swift in Sources */,
4C9147002A2A891E00DDEA40 /* error.c in Sources */,
4CE879552996BAB900F758CC /* RelayPaidDetail.swift in Sources */,
@@ -4795,6 +4802,7 @@
D72A2D052AD9C1B5002AFF62 /* MockDamusState.swift in Sources */,
E06336AA2B75832100A88E6B /* ImageMetadataTest.swift in Sources */,
4C363AA02828A8DD006E126D /* LikeTests.swift in Sources */,
D7A0D8752D1FE67900DCBE59 /* EditPictureControlTests.swift in Sources */,
4C90BD1C283AC38E008EE7EF /* Bech32Tests.swift in Sources */,
50A50A8D29A09E1C00C01BE7 /* RequestTests.swift in Sources */,
4CE6DEF827F7A08200C66700 /* damusTests.swift in Sources */,
@@ -4926,7 +4934,6 @@
82D6FB0F2CD99F7900C925F4 /* DamusLogoGradient.swift in Sources */,
82D6FB102CD99F7900C925F4 /* DamusBackground.swift in Sources */,
82D6FB112CD99F7900C925F4 /* DamusLightGradient.swift in Sources */,
82D6FB122CD99F7900C925F4 /* MutinyGradient.swift in Sources */,
82D6FB132CD99F7900C925F4 /* Shimmer.swift in Sources */,
82D6FB142CD99F7900C925F4 /* EndBlock.swift in Sources */,
82D6FB152CD99F7900C925F4 /* ImageCarousel.swift in Sources */,
@@ -5124,7 +5131,6 @@
82D6FBD82CD99F7900C925F4 /* FriendsButton.swift in Sources */,
82D6FBD92CD99F7900C925F4 /* GradientFollowButton.swift in Sources */,
82D6FBDA2CD99F7900C925F4 /* AlbyButton.swift in Sources */,
82D6FBDB2CD99F7900C925F4 /* MutinyButton.swift in Sources */,
82D6FBDC2CD99F7900C925F4 /* DamusVideoPlayerView.swift in Sources */,
82D6FBDD2CD99F7900C925F4 /* DamusVideoPlayer.swift in Sources */,
82D6FBDE2CD99F7900C925F4 /* DamusVideoCoordinator.swift in Sources */,
@@ -5179,6 +5185,7 @@
82D6FC0E2CD99F7900C925F4 /* ProfilePicView.swift in Sources */,
82D6FC0F2CD99F7900C925F4 /* ProfileView.swift in Sources */,
82D6FC102CD99F7900C925F4 /* ProfileNameView.swift in Sources */,
5CB017212D2D985E00A9ED05 /* CoinosButton.swift in Sources */,
82D6FC112CD99F7900C925F4 /* MaybeAnonPfpView.swift in Sources */,
82D6FC122CD99F7900C925F4 /* EventProfileName.swift in Sources */,
82D6FC132CD99F7900C925F4 /* FriendIcon.swift in Sources */,
@@ -5337,7 +5344,6 @@
D73E5E442C6A97F4007EB227 /* DamusLogoGradient.swift in Sources */,
D73E5E452C6A97F4007EB227 /* DamusBackground.swift in Sources */,
D73E5E462C6A97F4007EB227 /* DamusLightGradient.swift in Sources */,
D73E5E472C6A97F4007EB227 /* MutinyGradient.swift in Sources */,
D73E5E482C6A97F4007EB227 /* Shimmer.swift in Sources */,
D73E5E492C6A97F4007EB227 /* EndBlock.swift in Sources */,
D73E5E4D2C6A97F4007EB227 /* NIP05Badge.swift in Sources */,
@@ -5457,6 +5463,7 @@
D73E5EBB2C6A97F4007EB227 /* Nip98HTTPAuth.swift in Sources */,
D73E5EBC2C6A97F4007EB227 /* Relay.swift in Sources */,
D73E5EBD2C6A97F4007EB227 /* NostrRequest.swift in Sources */,
5CB017222D2D985E00A9ED05 /* CoinosButton.swift in Sources */,
D73E5EBE2C6A97F4007EB227 /* NostrLink.swift in Sources */,
D73E5EBF2C6A97F4007EB227 /* WebSocket.swift in Sources */,
D73E5F812C6AA07A007EB227 /* HighlighterExtensionAliases.swift in Sources */,
@@ -5476,7 +5483,6 @@
D73E5ED42C6A97F4007EB227 /* FriendsButton.swift in Sources */,
D73E5ED52C6A97F4007EB227 /* GradientFollowButton.swift in Sources */,
D73E5ED62C6A97F4007EB227 /* AlbyButton.swift in Sources */,
D73E5ED72C6A97F4007EB227 /* MutinyButton.swift in Sources */,
D73E5ED82C6A97F4007EB227 /* DamusVideoPlayerView.swift in Sources */,
D73E5ED92C6A97F4007EB227 /* DamusVideoPlayer.swift in Sources */,
D73E5EDA2C6A97F4007EB227 /* DamusVideoCoordinator.swift in Sources */,
@@ -6251,6 +6257,7 @@
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "damus-c/damus-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_STRICT_CONCURRENCY = complete;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
@@ -6300,6 +6307,7 @@
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "damus-c/damus-Bridging-Header.h";
SWIFT_STRICT_CONCURRENCY = complete;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
@@ -6711,6 +6719,14 @@
minimumVersion = 1.14.1;
};
};
D7C48C092D12DE0C00A3BACF /* XCRemoteSwiftPackageReference "SwiftyCrop" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/benedom/SwiftyCrop";
requirement = {
kind = revision;
revision = 454d0a0d4faf6f3a19c8d817ab9d7d27524bd79f;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@@ -6824,6 +6840,21 @@
package = D7A343EC2AD0D77C00CED48B /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */;
productName = SnapshotTesting;
};
D7C48C0A2D12DE0C00A3BACF /* SwiftyCrop */ = {
isa = XCSwiftPackageProductDependency;
package = D7C48C092D12DE0C00A3BACF /* XCRemoteSwiftPackageReference "SwiftyCrop" */;
productName = SwiftyCrop;
};
D7C48C0C2D12E34900A3BACF /* SwiftyCrop */ = {
isa = XCSwiftPackageProductDependency;
package = D7C48C092D12DE0C00A3BACF /* XCRemoteSwiftPackageReference "SwiftyCrop" */;
productName = SwiftyCrop;
};
D7C48C0E2D12E35600A3BACF /* SwiftyCrop */ = {
isa = XCSwiftPackageProductDependency;
package = D7C48C092D12DE0C00A3BACF /* XCRemoteSwiftPackageReference "SwiftyCrop" */;
productName = SwiftyCrop;
};
D7EDED242B117F7C0018B19C /* MarkdownUI */ = {
isa = XCSwiftPackageProductDependency;
package = 4C27C9302A64766F007DBC75 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;

View File

@@ -1,5 +1,5 @@
{
"originHash" : "534c8e58993919d5ead25ceb4788c8e492c86bc2cf5833dc651ae60a0f30169c",
"originHash" : "0d806129a33991730dd1aa3d38c47a745f9e9e6ff44999f4a7f28b695f024832",
"pins" : [
{
"identity" : "codescanner",
@@ -97,6 +97,14 @@
"version" : "0.1.2"
}
},
{
"identity" : "swiftycrop",
"kind" : "remoteSourceControl",
"location" : "https://github.com/benedom/SwiftyCrop",
"state" : {
"revision" : "454d0a0d4faf6f3a19c8d817ab9d7d27524bd79f"
}
},
{
"identity" : "swipeactions",
"kind" : "remoteSourceControl",

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 KiB

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

Before

Width:  |  Height:  |  Size: 511 KiB

After

Width:  |  Height:  |  Size: 511 KiB

View File

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 187 KiB

View File

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

View File

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 146 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 547 KiB

After

Width:  |  Height:  |  Size: 547 KiB

View File

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 124 KiB

View File

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

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

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

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 300 KiB

After

Width:  |  Height:  |  Size: 300 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 215 KiB

View File

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "profile-banner.jpeg",
"filename" : "coinos.png",
"idiom" : "universal"
}
],

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 176 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

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

View File

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

View File

@@ -931,7 +931,6 @@ enum FindEventType {
enum FoundEvent {
case profile(Pubkey)
case invalid_profile(NostrEvent)
case event(NostrEvent)
}
@@ -988,10 +987,6 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
switch query {
case .profile:
if ev.known_kind == .metadata {
guard state.ndb.lookup_profile_key(ev.pubkey) != nil else {
callback(.invalid_profile(ev))
return
}
callback(.profile(ev.pubkey))
}
case .event:
@@ -1000,17 +995,16 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
case .eose:
if !has_event {
attempts += 1
if attempts == state.pool.our_descriptors.count / 2 {
callback(nil)
if attempts >= state.pool.our_descriptors.count {
callback(nil) // If we could not find any events in any of the relays we are connected to, send back nil
}
state.pool.unsubscribe(sub_id: subid, to: [relay_id])
}
state.pool.unsubscribe(sub_id: subid, to: [relay_id]) // We are only finding an event once, so close subscription on eose
case .notice:
break
case .auth:
break
}
}
}

View File

@@ -48,6 +48,8 @@
<key>LSApplicationQueriesSchemes</key>
<array>
<string>river</string>
<string>alby</string>
<string>albygo</string>
<string>bitcoinbeach</string>
<string>breez</string>
<string>muun</string>

View File

@@ -77,11 +77,19 @@ enum MediaUpload {
}
}
class ImageUploadModel: NSObject, URLSessionTaskDelegate, ObservableObject {
protocol ImageUploadModelProtocol {
init()
func start(media: MediaUpload, uploader: any MediaUploaderProtocol, mediaType: ImageUploadMediaType, keypair: Keypair?) async -> ImageUploadResult
}
class ImageUploadModel: NSObject, URLSessionTaskDelegate, ObservableObject, ImageUploadModelProtocol {
@Published var progress: Double? = nil
func start(media: MediaUpload, uploader: MediaUploader, keypair: Keypair? = nil) async -> ImageUploadResult {
let res = await create_upload_request(mediaToUpload: media, mediaUploader: uploader, progress: self, keypair: keypair)
override required init() { }
func start(media: MediaUpload, uploader: any MediaUploaderProtocol, mediaType: ImageUploadMediaType, keypair: Keypair? = nil) async -> ImageUploadResult {
let res = await AttachMediaUtility.create_upload_request(mediaToUpload: media, mediaUploader: uploader, mediaType: mediaType, progress: self, keypair: keypair)
switch res {
case .success(_):
@@ -89,10 +97,17 @@ class ImageUploadModel: NSObject, URLSessionTaskDelegate, ObservableObject {
self.progress = nil
UINotificationFeedbackGenerator().notificationOccurred(.success)
}
case .failed(_):
case .failed(let error):
DispatchQueue.main.async {
self.progress = nil
UINotificationFeedbackGenerator().notificationOccurred(.error)
if let nsError = error as NSError?,
nsError.domain == NSURLErrorDomain,
nsError.code == NSURLErrorCancelled {
print("Upload forced cancelled by user after Cancelling the Post, no feedback triggered.")
} else {
// Trigger feedback for all other errors
UINotificationFeedbackGenerator().notificationOccurred(.error)
}
}
}

View File

@@ -7,7 +7,18 @@
import Foundation
enum MediaUploader: String, CaseIterable, Identifiable, StringCodable {
protocol MediaUploaderProtocol: Identifiable {
var nameParam: String { get }
var mediaTypeParam: String { get }
var supportsVideo: Bool { get }
var requiresNip98: Bool { get }
var postAPI: String { get }
func getMediaURL(from data: Data) -> String?
func mediaTypeValue(for mediaType: ImageUploadMediaType) -> String?
}
enum MediaUploader: String, CaseIterable, MediaUploaderProtocol, StringCodable {
var id: String { self.rawValue }
case nostrBuild
case nostrcheck
@@ -33,6 +44,19 @@ enum MediaUploader: String, CaseIterable, Identifiable, StringCodable {
}
}
var mediaTypeParam: String {
return "media_type"
}
func mediaTypeValue(for mediaType: ImageUploadMediaType) -> String? {
switch mediaType {
case .normal:
return nil
case .profile_picture:
return "avatar"
}
}
var supportsVideo: Bool {
switch self {
case .nostrBuild:
@@ -42,6 +66,15 @@ enum MediaUploader: String, CaseIterable, Identifiable, StringCodable {
}
}
var requiresNip98: Bool {
switch self {
case .nostrBuild:
return true
case .nostrcheck:
return true
}
}
struct Model: Identifiable, Hashable {
var id: String { self.tag }
var index: Int

View File

@@ -185,9 +185,6 @@ class UserSettingsStore: ObservableObject {
@Setting(key: "show_music_statuses", default_value: true)
var show_music_statuses: Bool
@Setting(key: "show_only_preferred_languages", default_value: false)
var show_only_preferred_languages: Bool
@Setting(key: "multiple_events_per_pubkey", default_value: false)
var multiple_events_per_pubkey: Bool

View File

@@ -46,6 +46,7 @@ enum Wallet: String, CaseIterable, Identifiable, StringCodable {
case bitcoinbeach
case blixtwallet
case river
case albygo
var model: Model {
switch self {
@@ -90,6 +91,9 @@ enum Wallet: String, CaseIterable, Identifiable, StringCodable {
case .river:
return .init(index: 12, tag: "river", displayName: "River", link: "river://",
appStoreLink: "https://apps.apple.com/us/app/river-buy-mine-bitcoin/id1536176542", image: "river")
case .albygo:
return .init(index: 13, tag: "albygo", displayName: "Alby Go", link: "alby:",
appStoreLink: "https://apps.apple.com/us/app/alby-go/id6471335774", image: "alby-go")
}
}

View File

@@ -259,11 +259,10 @@ func should_translate(event: NostrEvent, our_keypair: Keypair, note_lang: String
}
if let note_lang {
let preferredLanguages = Set(Locale.preferredLanguages.map { localeToLanguage($0) })
let currentLanguage = localeToLanguage(Locale.current.identifier)
// 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
// Don't translate if the note is in our current language
guard currentLanguage != note_lang else {
return false
}
}

View File

@@ -15,12 +15,30 @@ enum ImageUploadResult {
case failed(Error?)
}
fileprivate func create_upload_body(mediaData: Data, boundary: String, mediaUploader: MediaUploader, mediaToUpload: MediaUpload) -> Data {
enum ImageUploadMediaType {
case normal
case profile_picture
}
protocol AttachMediaUtilityProtocol {
static func create_upload_request(mediaToUpload: MediaUpload, mediaUploader: any MediaUploaderProtocol, mediaType: ImageUploadMediaType, progress: URLSessionTaskDelegate, keypair: Keypair?) async -> ImageUploadResult
}
class AttachMediaUtility {
fileprivate static func create_upload_body(mediaData: Data, boundary: String, mediaUploader: any MediaUploaderProtocol, mediaToUpload: MediaUpload, mediaType: ImageUploadMediaType) -> Data {
let mediaTypeFieldValue = mediaUploader.mediaTypeValue(for: mediaType)
let mediaTypeFieldEntry: String?
if let mediaTypeFieldValue {
mediaTypeFieldEntry = "; \(mediaUploader.mediaTypeParam)=\(mediaTypeFieldValue)"
}
else {
mediaTypeFieldEntry = nil
}
let body = NSMutableData();
let contentType = mediaToUpload.mime_type
body.appendString(string: "Content-Type: multipart/form-data; boundary=\(boundary)\r\n\r\n")
body.appendString(string: "--\(boundary)\r\n")
body.appendString(string: "Content-Disposition: form-data; name=\(mediaUploader.nameParam); filename=\(mediaToUpload.genericFileName)\r\n")
body.appendString(string: "Content-Disposition: form-data; name=\(mediaUploader.nameParam); filename=\(mediaToUpload.genericFileName)\(mediaTypeFieldEntry ?? "")\r\n")
body.appendString(string: "Content-Type: \(contentType)\r\n\r\n")
body.append(mediaData as Data)
body.appendString(string: "\r\n")
@@ -28,59 +46,60 @@ fileprivate func create_upload_body(mediaData: Data, boundary: String, mediaUplo
return body as Data
}
func create_upload_request(mediaToUpload: MediaUpload, mediaUploader: MediaUploader, progress: URLSessionTaskDelegate, keypair: Keypair? = nil) async -> ImageUploadResult {
var mediaData: Data?
guard let url = URL(string: mediaUploader.postAPI) else {
return .failed(nil)
}
var request = URLRequest(url: url)
request.httpMethod = "POST";
let boundary = "Boundary-\(UUID().description)"
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 || mediaUploader == .nostrcheck,
let keypair,
let method = request.httpMethod,
let signature = create_nip98_signature(keypair: keypair, method: method, url: url) {
request.setValue(signature, forHTTPHeaderField: "Authorization")
}
switch mediaToUpload {
case .image(let url):
do {
mediaData = try Data(contentsOf: url)
} catch {
return .failed(error)
}
case .video(let url):
do {
mediaData = try Data(contentsOf: url)
} catch {
return .failed(error)
}
}
guard let mediaData else {
return .failed(nil)
}
request.httpBody = create_upload_body(mediaData: mediaData, boundary: boundary, mediaUploader: mediaUploader, mediaToUpload: mediaToUpload)
do {
let (data, _) = try await URLSession.shared.data(for: request, delegate: progress)
guard let url = mediaUploader.getMediaURL(from: data) else {
print("Upload failed getting media url")
static func create_upload_request(mediaToUpload: MediaUpload, mediaUploader: any MediaUploaderProtocol, mediaType: ImageUploadMediaType, progress: URLSessionTaskDelegate, keypair: Keypair? = nil) async -> ImageUploadResult {
var mediaData: Data?
guard let url = URL(string: mediaUploader.postAPI) else {
return .failed(nil)
}
return .success(url)
var request = URLRequest(url: url)
request.httpMethod = "POST";
let boundary = "Boundary-\(UUID().description)"
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
} catch {
return .failed(error)
// If uploading to a media host that support NIP-98 authorization, add the header
if mediaUploader.requiresNip98,
let keypair,
let method = request.httpMethod,
let signature = create_nip98_signature(keypair: keypair, method: method, url: url) {
request.setValue(signature, forHTTPHeaderField: "Authorization")
}
switch mediaToUpload {
case .image(let url):
do {
mediaData = try Data(contentsOf: url)
} catch {
return .failed(error)
}
case .video(let url):
do {
mediaData = try Data(contentsOf: url)
} catch {
return .failed(error)
}
}
guard let mediaData else {
return .failed(nil)
}
request.httpBody = create_upload_body(mediaData: mediaData, boundary: boundary, mediaUploader: mediaUploader, mediaToUpload: mediaToUpload, mediaType: mediaType)
do {
let (data, _) = try await URLSession.shared.data(for: request, delegate: progress)
guard let url = mediaUploader.getMediaURL(from: data) else {
print("Upload failed getting media url")
return .failed(nil)
}
return .success(url)
} catch {
return .failed(error)
}
}
}

View File

@@ -32,7 +32,15 @@ struct EditBannerImageView: View {
.onFailureImage(defaultImage)
.kfClickable()
EditPictureControl(uploader: damus_state.settings.default_media_uploader, keypair: damus_state.keypair, pubkey: damus_state.pubkey, image_url: $banner_image, uploadObserver: viewModel, callback: callback)
EditPictureControl(
uploader: damus_state.settings.default_media_uploader,
context: .normal,
keypair: damus_state.keypair,
pubkey: damus_state.pubkey,
current_image_url: $banner_image,
upload_observer: viewModel,
callback: callback
)
.padding(10)
.backwardsCompatibleSafeAreaPadding(self.safeAreaInsets)
.accessibilityLabel(NSLocalizedString("Edit banner image", comment: "Accessibility label for edit banner image button"))

View File

@@ -0,0 +1,48 @@
//
// CoinosButton.swift
// damus
//
// Created by eric on 1/7/25.
//
import SwiftUI
struct CoinosButton: View {
let action: () -> ()
@Environment(\.colorScheme) var colorScheme
init(action: @escaping () -> ()) {
self.action = action
}
var body: some View {
Button(action: {
action()
}) {
HStack {
Image("coinos")
.resizable()
.frame(width: 35, height: 35)
Text("Connect to Coinos", comment: "Button to attach a Coinos Wallet, a service that provides a Lightning wallet for zapping sats. Coinos is the name of the service and should not be translated.")
.padding()
.bold()
}
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
.foregroundColor(DamusColors.black)
.background {
RoundedRectangle(cornerRadius: 12)
.fill(GrayGradient, strokeBorder: colorScheme == .light ? DamusColors.black.opacity(0.2) : DamusColors.white.opacity(0.2), lineWidth: 1)
}
}
}
}
struct CoinosButton_Previews: PreviewProvider {
static var previews: some View {
CoinosButton(action: {
print("mutiny button")
})
}
}

View File

@@ -1,47 +0,0 @@
//
// MutinyButton.swift
// damus
//
// Created by eric on 3/9/24.
//
import SwiftUI
struct MutinyButton: View {
let action: () -> ()
@Environment(\.colorScheme) var colorScheme
init(action: @escaping () -> ()) {
self.action = action
}
var body: some View {
Button(action: {
action()
}) {
HStack {
Image("mutiny")
.resizable()
.frame(width: 45, height: 45)
Text("Connect to Mutiny Wallet", comment: "Button to attach an Mutiny Wallet, a service that provides a Lightning wallet for zapping sats. Mutiny is the name of the service and should not be translated.")
.padding()
}
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
.foregroundColor(DamusColors.white)
.background {
RoundedRectangle(cornerRadius: 12)
.fill(MutinyGradient, strokeBorder: colorScheme == .light ? DamusColors.black.opacity(0.2) : DamusColors.white.opacity(0.2), lineWidth: 1)
}
}
}
}
struct MutinyButton_Previews: PreviewProvider {
static var previews: some View {
MutinyButton(action: {
print("mutiny button")
})
}
}

View File

@@ -13,9 +13,14 @@ struct CameraController: UIViewControllerRepresentable {
@Environment(\.presentationMode)
@Binding private var presentationMode
let uploader: MediaUploader
let done: () -> Void
let uploader: any MediaUploaderProtocol
var imagesOnly: Bool = false
var mode: Mode
enum Mode {
case save_to_library(when_done: () -> Void)
case handle_image(handler: (UIImage) -> Void)
}
final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
let parent: CameraController
@@ -25,18 +30,29 @@ struct CameraController: UIViewControllerRepresentable {
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
if !parent.imagesOnly, let videoURL = info[UIImagePickerController.InfoKey.mediaURL] as? URL {
// Handle the selected video
UISaveVideoAtPathToSavedPhotosAlbum(videoURL.relativePath, nil, nil, nil)
} else if let cameraImage = info[UIImagePickerController.InfoKey.originalImage] as? UIImage {
let orientedImage = cameraImage.fixOrientation()
UIImageWriteToSavedPhotosAlbum(orientedImage, nil, nil, nil)
} else if let editedImage = info[UIImagePickerController.InfoKey.editedImage] as? UIImage {
let orientedImage = editedImage.fixOrientation()
UIImageWriteToSavedPhotosAlbum(orientedImage, nil, nil, nil)
switch parent.mode {
case .save_to_library(when_done: let done):
if !parent.imagesOnly, let videoURL = info[UIImagePickerController.InfoKey.mediaURL] as? URL {
// Handle the selected video
UISaveVideoAtPathToSavedPhotosAlbum(videoURL.relativePath, nil, nil, nil)
} else if let cameraImage = info[UIImagePickerController.InfoKey.originalImage] as? UIImage {
let orientedImage = cameraImage.fixOrientation()
UIImageWriteToSavedPhotosAlbum(orientedImage, nil, nil, nil)
} else if let editedImage = info[UIImagePickerController.InfoKey.editedImage] as? UIImage {
let orientedImage = editedImage.fixOrientation()
UIImageWriteToSavedPhotosAlbum(orientedImage, nil, nil, nil)
}
done()
case .handle_image(handler: let handler):
if let cameraImage = info[UIImagePickerController.InfoKey.originalImage] as? UIImage {
let orientedImage = cameraImage.fixOrientation()
handler(orientedImage)
} else if let editedImage = info[UIImagePickerController.InfoKey.editedImage] as? UIImage {
let orientedImage = editedImage.fixOrientation()
handler(orientedImage)
}
}
parent.done()
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {

View File

@@ -6,11 +6,14 @@
//
import SwiftUI
import Combine
struct CreateAccountView: View {
struct CreateAccountView: View, KeyboardReadable {
@StateObject var account: CreateAccountModel = CreateAccountModel()
@StateObject var profileUploadObserver = ImageUploadingObserver()
var nav: NavigationCoordinator
@State var keyboardVisible: Bool = false
let maxViewportHeightForAdaptiveContentSize: CGFloat = 975 // 956px height = iPhone 16 Pro Max
func SignupForm<FormContent: View>(@ViewBuilder content: () -> FormContent) -> some View {
return VStack(alignment: .leading, spacing: 10.0, content: content)
@@ -26,15 +29,25 @@ struct CreateAccountView: View {
ZStack(alignment: .top) {
VStack {
Spacer()
VStack(alignment: .center) {
EditPictureControl(uploader: .nostrBuild, keypair: account.keypair, pubkey: account.pubkey, size: 75, setup: true, image_url: $account.profile_image , uploadObserver: profileUploadObserver, callback: uploadedProfilePicture)
let screenHeight = UIScreen.main.bounds.height
let style = EditPictureControl.Style(
size: keyboardVisible && screenHeight < maxViewportHeightForAdaptiveContentSize ? 25 : 75,
first_time_setup: true
)
EditPictureControl(
uploader: MediaUploader.nostrBuild,
context: .profile_picture,
keypair: account.keypair,
pubkey: account.pubkey,
style: style,
current_image_url: $account.profile_image,
upload_observer: profileUploadObserver,
callback: uploadedProfilePicture
)
.shadow(radius: 2)
.padding(.top, 100)
Text("Add Photo", comment: "Label to indicate user can add a photo.")
.bold()
.foregroundColor(DamusColors.neutral6)
}
SignupForm {
@@ -42,13 +55,13 @@ struct CreateAccountView: View {
.foregroundColor(DamusColors.neutral6)
FormTextInput(NSLocalizedString("Satoshi Nakamoto", comment: "Name of Bitcoin creator(s)."), text: $account.name)
.textInputAutocapitalization(.words)
FormLabel(NSLocalizedString("Bio", comment: "Label to prompt bio entry for user to describe themself."), optional: true)
.foregroundColor(DamusColors.neutral6)
FormTextInput(NSLocalizedString("Absolute legend.", comment: "Example Bio"), text: $account.about)
}
.padding(.top, 25)
Button(action: {
nav.push(route: Route.SaveKeys(account: account))
}) {
@@ -72,6 +85,11 @@ struct CreateAccountView: View {
}
.background(DamusBackground(maxHeight: UIScreen.main.bounds.size.height/2), alignment: .top)
.dismissKeyboardOnTap()
.onReceive(keyboardPublisher) { visible in
withAnimation {
self.keyboardVisible = visible
}
}
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.navigationBarItems(leading: BackNav())

View File

@@ -20,8 +20,14 @@ struct MediaPicker: UIViewControllerRepresentable {
@Binding private var presentationMode
let mediaPickerEntry: MediaPickerEntry
@Binding var image_upload_confirm: Bool
let onMediaSelected: (() -> Void)?
let onMediaPicked: (PreUploadedMedia) -> Void
init(mediaPickerEntry: MediaPickerEntry, onMediaSelected: (() -> Void)? = nil, onMediaPicked: @escaping (PreUploadedMedia) -> Void) {
self.mediaPickerEntry = mediaPickerEntry
self.onMediaSelected = onMediaSelected
self.onMediaPicked = onMediaPicked
}
final class Coordinator: NSObject, PHPickerViewControllerDelegate {
var parent: MediaPicker
@@ -121,7 +127,7 @@ struct MediaPicker: UIViewControllerRepresentable {
private func chooseMedia(_ media: PreUploadedMedia, orderId: String) {
self.parent.image_upload_confirm = true
self.parent.onMediaSelected?()
self.orderMap[orderId] = media
self.dispatchGroup.leave()
}

View File

@@ -144,21 +144,25 @@ struct NotificationsView: View {
func NotificationTab(_ filter: NotificationFilter) -> some View {
ScrollViewReader { scroller in
ScrollView {
LazyVStack(alignment: .leading) {
Color.white.opacity(0)
.id("startblock")
.frame(height: 5)
let notifs = Array(zip(1..., filter.filter(contacts: state.contacts, items: notifications.notifications)))
ForEach(notifs, id: \.0) { zip in
NotificationItemView(state: state, item: zip.1)
let notifs = Array(zip(1..., filter.filter(contacts: state.contacts, items: notifications.notifications)))
if notifs.isEmpty {
EmptyTimelineView()
} else {
LazyVStack(alignment: .leading) {
Color.white.opacity(0)
.id("startblock")
.frame(height: 5)
ForEach(notifs, id: \.0) { zip in
NotificationItemView(state: state, item: zip.1)
}
}
.background(GeometryReader { proxy -> Color in
DispatchQueue.main.async {
handle_scroll_queue(proxy, queue: self.notifications)
}
return Color.clear
})
}
.background(GeometryReader { proxy -> Color in
DispatchQueue.main.async {
handle_scroll_queue(proxy, queue: self.notifications)
}
return Color.clear
})
}
.coordinateSpace(name: "scroll")
.onReceive(handle_notify(.scroll_to_top)) { notif in

View File

@@ -6,7 +6,8 @@
//
import SwiftUI
import AVFoundation
import AVKit
import Kingfisher
enum NostrPostResult {
case post(NostrPost)
@@ -72,6 +73,7 @@ struct PostView: View {
@StateObject var tagModel: TagModel = TagModel()
@State private var current_placeholder_index = 0
@State private var uploadTasks: [Task<Void, Never>] = []
let action: PostAction
let damus_state: DamusState
@@ -97,9 +99,15 @@ struct PostView: View {
func cancel() {
notify(.post(.cancel))
cancelUploadTasks()
dismiss()
}
func cancelUploadTasks() {
uploadTasks.forEach { $0.cancel() }
uploadTasks.removeAll()
}
func send_post() {
// don't add duplicate pubkeys but retain order
var pkset = Set<Pubkey>()
@@ -338,7 +346,7 @@ struct PostView: View {
print("img size w:\(img.size.width) h:\(img.size.height)")
async let blurhash = calculate_blurhash(img: img)
let res = await image_upload.start(media: media, uploader: uploader, keypair: damus_state.keypair)
let res = await image_upload.start(media: media, uploader: uploader, mediaType: .normal, keypair: damus_state.keypair)
switch res {
case .success(let url):
@@ -473,19 +481,20 @@ struct PostView: View {
}
.background(DamusColors.adaptableWhite.edgesIgnoringSafeArea(.all))
.sheet(isPresented: $attach_media) {
MediaPicker(mediaPickerEntry: .postView, image_upload_confirm: $image_upload_confirm){ media in
MediaPicker(mediaPickerEntry: .postView, onMediaSelected: { image_upload_confirm = true }) { media in
self.preUploadedMedia.append(media)
}
.alert(NSLocalizedString("Are you sure you want to upload the selected media?", comment: "Alert message asking if the user wants to upload media."), isPresented: $image_upload_confirm) {
Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) {
// initiate asynchronous uploading Task for multiple-images
Task {
// initiate asynchronous uploading Task for multiple-images
let task = Task {
for media in preUploadedMedia {
if let mediaToUpload = generateMediaUpload(media) {
await self.handle_upload(media: mediaToUpload)
}
}
}
uploadTasks.append(task)
self.attach_media = false
}
Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {
@@ -494,19 +503,20 @@ struct PostView: View {
}
}
.sheet(isPresented: $attach_camera) {
CameraController(uploader: damus_state.settings.default_media_uploader) {
CameraController(uploader: damus_state.settings.default_media_uploader, mode: .save_to_library(when_done: {
self.attach_camera = false
self.attach_media = true
}
}))
}
// This alert seeks confirmation about Image-upload when user taps Paste option
.alert(NSLocalizedString("Are you sure you want to upload this media?", comment: "Alert message asking if the user wants to upload media."), isPresented: $imageUploadConfirmPasteboard) {
Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) {
if let image = imagePastedFromPasteboard,
let mediaToUpload = generateMediaUpload(image) {
Task {
await self.handle_upload(media: mediaToUpload)
let task = Task {
_ = await self.handle_upload(media: mediaToUpload)
}
uploadTasks.append(task)
}
}
Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {}
@@ -514,13 +524,14 @@ struct PostView: View {
// This alert seeks confirmation about media-upload from Damus Share Extension
.alert(NSLocalizedString("Are you sure you want to upload the selected media?", comment: "Alert message asking if the user wants to upload media."), isPresented: $imageUploadConfirmDamusShare) {
Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) {
Task {
let task = Task {
for media in preUploadedMedia {
if let mediaToUpload = generateMediaUpload(media) {
await self.handle_upload(media: mediaToUpload)
}
}
}
uploadTasks.append(task)
}
Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {}
}
@@ -609,38 +620,79 @@ struct PVImageCarouselView: View {
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(media.map({$0.representingImage}), id: \.self) { image in
ZStack(alignment: .topTrailing) {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: media.count == 1 ? deviceWidth*0.8 : 250, height: media.count == 1 ? 400 : 250)
.cornerRadius(10)
.padding()
.contextMenu {
if let uploadedURL = media.first(where: { $0.representingImage == image })?.uploadedURL {
Button(action: {
UIPasteboard.general.string = uploadedURL.absoluteString
}) {
Label(NSLocalizedString("Copy URL", comment: "Label for button in context menu to copy URL of the selected uploaded media asset."), image: "copy")
}
ForEach(media.indices, id: \.self) { index in
ZStack(alignment: .topLeading) {
if isSupportedVideo(url: media[index].uploadedURL) {
VideoPlayer(player: configurePlayer(with: media[index].localURL))
.frame(width: media.count == 1 ? deviceWidth * 0.8 : 250, height: media.count == 1 ? 400 : 250)
.cornerRadius(10)
.padding()
.contextMenu { contextMenuContent(for: media[index]) }
} else if is_animated_image(url: media[index].uploadedURL) {
KFAnimatedImage(media[index].uploadedURL)
.imageContext(.note, disable_animation: false)
.configure { view in
view.framePreloadCount = 3
}
}
Image("close-circle")
.foregroundColor(.white)
.padding(20)
.shadow(radius: 5)
.onTapGesture {
if let index = media.map({$0.representingImage}).firstIndex(of: image) {
media.remove(at: index)
.frame(width: media.count == 1 ? deviceWidth * 0.8 : 250, height: media.count == 1 ? 400 : 250)
.cornerRadius(10)
.padding()
.contextMenu { contextMenuContent(for: media[index]) }
} else {
Image(uiImage: media[index].representingImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: media.count == 1 ? deviceWidth * 0.8 : 250, height: media.count == 1 ? 400 : 250)
.cornerRadius(10)
.padding()
.contextMenu { contextMenuContent(for: media[index]) }
}
VStack { // Set spacing to 0 to remove the gap between items
Image("close-circle")
.foregroundColor(.white)
.padding(20)
.shadow(radius: 5)
.onTapGesture {
media.remove(at: index) // Direct removal using index
}
}
if isSupportedVideo(url: media[index].uploadedURL) {
Spacer()
Image(systemName: "video")
.foregroundColor(.white)
.padding(10)
.shadow(radius: 5)
.opacity(0.6)
}
}
.padding(.bottom, 35)
}
}
}
.padding()
}
}
// Helper Function for Context Menu
@ViewBuilder
private func contextMenuContent(for mediaItem: UploadedMedia) -> some View {
Button(action: {
UIPasteboard.general.string = mediaItem.uploadedURL.absoluteString
}) {
Label(
NSLocalizedString("Copy URL", comment: "Copy URL of the selected uploaded media asset."),
systemImage: "doc.on.doc"
)
}
}
private func configurePlayer(with url: URL) -> AVPlayer {
let player = AVPlayer(url: url)
player.allowsExternalPlayback = false
player.usesExternalPlaybackWhileExternalScreenIsActive = false
return player
}
}
fileprivate func getImage(media: MediaUpload) -> UIImage {
@@ -813,3 +865,14 @@ func build_post(state: DamusState, post: NSMutableAttributedString, action: Post
return NostrPost(content: content, kind: .text, tags: tags)
}
func isSupportedVideo(url: URL?) -> Bool {
guard let url = url else { return false }
let fileExtension = url.pathExtension.lowercased()
let supportedUTIs = AVURLAsset.audiovisualTypes().map { $0.rawValue }
return supportedUTIs.contains { utiString in
if let utType = UTType(utiString), let fileUTType = UTType(filenameExtension: fileExtension) {
return fileUTType.conforms(to: utType)
}
return false
}
}

View File

@@ -7,223 +7,751 @@
import SwiftUI
import Kingfisher
import SwiftyCrop
class ImageUploadingObserver: ObservableObject {
@Published var isLoading: Bool = false
}
// MARK: - Main view
/// A view that shows an existing picture, and allows a user to upload a new one.
struct EditPictureControl: View {
let uploader: MediaUploader
let keypair: Keypair?
let pubkey: Pubkey
var size: CGFloat? = 25
var setup: Bool? = false
@Binding var image_url: URL?
@State var image_url_temp: URL?
@ObservedObject var uploadObserver: ImageUploadingObserver
// MARK: Type aliases
typealias T = ImageUploadModel
typealias Model = EditPictureControlViewModel<T>
// MARK: Properties and state
@StateObject var model: Model
@Binding var current_image_url: URL?
let style: Style
let callback: (URL?) -> Void
@StateObject var image_upload: ImageUploadModel = ImageUploadModel()
@State private var show_camera = false
@State private var show_library = false
@State private var show_url_sheet = false
@State var image_upload_confirm: Bool = false
@State var preUploadedMedia: PreUploadedMedia? = nil
@Environment(\.dismiss) var dismiss
// MARK: Initializers
init(model: Model, style: Style? = nil, callback: @escaping (URL?) -> Void) {
self._model = StateObject.init(wrappedValue: model)
self.style = style ?? Style(size: nil, first_time_setup: false)
self.callback = callback
self._current_image_url = model.$current_image_url
}
init(
uploader: any MediaUploaderProtocol,
context: Model.Context,
keypair: Keypair?,
pubkey: Pubkey,
style: Style? = nil,
current_image_url: Binding<URL?>,
upload_observer: ImageUploadingObserver? = nil,
callback: @escaping (URL?) -> Void
) {
let model = EditPictureControlViewModel(
context: context,
pubkey: pubkey,
current_image_url: current_image_url,
keypair: keypair,
uploader: uploader,
callback: callback
)
self.init(model: model, style: style, callback: callback)
}
// MARK: View definitions
var body: some View {
Menu {
Button(action: {
self.show_url_sheet = true
}) {
self.menu_options
} label: {
if self.style.first_time_setup {
self.first_time_setup_view
}
else {
self.default_view
}
}
.accessibilityLabel(self.accessibility_label)
.accessibilityHint(self.accessibility_hint)
.maybeAccessibilityValue(self.accessibility_value)
.sheet(isPresented: self.model.show_camera) {
CameraController(uploader: model.uploader, mode: .handle_image(handler: { image in
self.model.request_upload_authorization(PreUploadedMedia.uiimage(image))
}))
}
.sheet(isPresented: self.model.show_library) {
MediaPicker(mediaPickerEntry: .editPictureControl) { media in
self.model.request_upload_authorization(media)
}
}
.alert(
NSLocalizedString("Are you sure you want to upload this image?", comment: "Alert message asking if the user wants to upload an image."),
isPresented: Binding.constant(self.model.state.is_confirming_upload)
) {
Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) {
self.model.confirm_upload_authorization()
}
Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {}
}
.fullScreenCover(isPresented: self.model.show_image_cropper) {
self.image_cropper
}
.sheet(isPresented: self.model.show_url_sheet) {
ImageURLSelector(callback: { url in
self.model.choose_url(url)
}, cancel: { self.model.cancel() })
.presentationDetents([.height(300)])
.presentationDragIndicator(.visible)
}
.sheet(item: self.model.error_message, onDismiss: { self.model.cancel() }, content: { error in
Text(error.rawValue)
})
}
var progress_view: some View {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: DamusColors.purple))
.frame(width: style.size, height: style.size)
.padding(10)
.background(DamusColors.white.opacity(0.7))
.clipShape(Circle())
.shadow(color: DamusColors.purple, radius: 15, x: 0, y: 0)
}
var menu_options: some View {
Group {
Button(action: { self.model.select_image_from_url() }) {
Text("Image URL", comment: "Option to enter a url")
}
.accessibilityIdentifier(AppAccessibilityIdentifiers.own_profile_banner_image_edit_from_url.rawValue)
Button(action: {
self.show_library = true
}) {
Button(action: { self.model.select_image_from_library() }) {
Text("Choose from Library", comment: "Option to select photo from library")
}
Button(action: {
self.show_camera = true
}) {
Button(action: { self.model.select_image_from_camera() }) {
Text("Take Photo", comment: "Option to take a photo with the camera")
}
} label: {
if uploadObserver.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: DamusColors.purple))
.frame(width: size, height: size)
.padding(10)
.background(DamusColors.white.opacity(0.7))
.clipShape(Circle())
.shadow(color: DamusColors.purple, radius: 15, x: 0, y: 0)
} else if let url = image_url, setup ?? false {
KFAnimatedImage(url)
.imageContext(.pfp, disable_animation: false)
.onFailure(fallbackUrl: URL(string: robohash(pubkey)), cacheKey: url.absoluteString)
.cancelOnDisappear(true)
.configure { view in
view.framePreloadCount = 3
}
.scaledToFill()
.frame(width: (size ?? 25) + 10, height: (size ?? 25) + 10)
.kfClickable()
.foregroundColor(DamusColors.white)
.clipShape(Circle())
.overlay(Circle().stroke(.white, lineWidth: 4))
} else {
if setup ?? false {
Image(systemName: "person")
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.foregroundColor(DamusColors.white)
.padding(20)
.clipShape(Circle())
.background {
Circle()
.fill(PinkGradient, strokeBorder: .white, lineWidth: 4)
}
} else {
Image("camera")
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.foregroundColor(DamusColors.purple)
.padding(10)
.background(DamusColors.white.opacity(0.7))
.clipShape(Circle())
.background {
Circle()
.fill(DamusColors.purple, strokeBorder: .white, lineWidth: 2)
}
}
}
}
.sheet(isPresented: $show_camera) {
CameraController(uploader: uploader) {
self.show_camera = false
self.show_library = true
}
}
.sheet(isPresented: $show_library) {
MediaPicker(mediaPickerEntry: .editPictureControl, image_upload_confirm: $image_upload_confirm) { media in
self.preUploadedMedia = media
}
.alert(NSLocalizedString("Are you sure you want to upload this image?", comment: "Alert message asking if the user wants to upload an image."), isPresented: $image_upload_confirm) {
Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) {
if let mediaToUpload = generateMediaUpload(preUploadedMedia) {
self.handle_upload(media: mediaToUpload)
self.show_library = false
}
}
Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {}
}
}
.sheet(isPresented: $show_url_sheet) {
ZStack {
DamusColors.adaptableWhite.edgesIgnoringSafeArea(.all)
VStack {
Text("Image URL")
.bold()
Divider()
.padding(.horizontal)
HStack {
Image(systemName: "doc.on.clipboard")
.foregroundColor(.gray)
.onTapGesture {
if let pastedURL = UIPasteboard.general.string {
image_url_temp = URL(string: pastedURL)
}
}
TextField(image_url_temp?.absoluteString ?? "", text: Binding(
get: { image_url_temp?.absoluteString ?? "" },
set: { image_url_temp = URL(string: $0) }
))
}
.padding(12)
.background {
RoundedRectangle(cornerRadius: 12)
.stroke(.gray.opacity(0.5), lineWidth: 1)
.background {
RoundedRectangle(cornerRadius: 12)
.foregroundColor(.damusAdaptableWhite)
}
}
.padding(10)
Button(action: {
show_url_sheet.toggle()
}, label: {
Text("Cancel", comment: "Cancel button text for dismissing updating image url.")
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
.padding(10)
})
.buttonStyle(NeutralButtonStyle())
.padding(10)
Button(action: {
image_url = image_url_temp
callback(image_url)
show_url_sheet.toggle()
}, label: {
Text("Update", comment: "Update button text for updating image url.")
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
})
.buttonStyle(GradientButtonStyle(padding: 10))
.padding(.horizontal, 10)
.disabled(image_url_temp == image_url)
.opacity(image_url_temp == image_url ? 0.5 : 1)
}
}
.onAppear {
image_url_temp = image_url
}
.presentationDetents([.height(300)])
.presentationDragIndicator(.visible)
}
}
private func handle_upload(media: MediaUpload) {
uploadObserver.isLoading = true
Task {
let res = await image_upload.start(media: media, uploader: uploader, keypair: keypair)
switch res {
case .success(let urlString):
let url = URL(string: urlString)
image_url = url
callback(url)
case .failed(let error):
if let error {
print("Error uploading profile image \(error.localizedDescription)")
} else {
print("Error uploading image :(")
}
callback(nil)
/// We show this on non-onboarding places such as profile edit page
var default_view: some View {
Group {
switch self.model.state {
case .uploading:
self.progress_view
default:
Image("camera")
.resizable()
.scaledToFit()
.frame(width: style.size ?? 25, height: style.size ?? 25)
.foregroundColor(DamusColors.purple)
.padding(10)
.background(DamusColors.white.opacity(0.7))
.clipShape(Circle())
.background {
Circle()
.fill(DamusColors.purple, strokeBorder: .white, lineWidth: 2)
}
.shadow(radius: 3)
}
uploadObserver.isLoading = false
}
}
/// We show this on onboarding
var first_time_setup_view: some View {
Group {
switch self.model.state {
case .uploading:
self.progress_view
default:
if let url = current_image_url {
KFAnimatedImage(url)
.imageContext(.pfp, disable_animation: false)
.onFailure(fallbackUrl: URL(string: robohash(model.pubkey)), cacheKey: url.absoluteString)
.cancelOnDisappear(true)
.configure { view in
view.framePreloadCount = 3
}
.scaledToFill()
.frame(width: (style.size ?? 25) + 30, height: (style.size ?? 25) + 30)
.kfClickable()
.foregroundColor(DamusColors.white)
.clipShape(Circle())
.overlay(Circle().stroke(.white, lineWidth: 4))
}
else {
self.first_time_setup_no_image_view
}
}
}
}
/// We show this on onboarding before the user enters any image
var first_time_setup_no_image_view: some View {
Image(systemName: "person.fill")
.resizable()
.scaledToFit()
.frame(width: style.size, height: style.size)
.foregroundColor(DamusColors.white)
.padding(20)
.clipShape(Circle())
.background {
Circle()
.fill(PinkGradient, strokeBorder: .white, lineWidth: 4)
}
.overlay(
Image(systemName: "plus.circle.fill")
.resizable()
.frame(
width: max((style.size ?? 30)/3, 20),
height: max((style.size ?? 30)/3, 20)
)
.background(.damusDeepPurple)
.clipShape(Circle())
.padding(.leading, -10)
.padding(.top, -10)
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.2), radius: 4)
, alignment: .bottomTrailing
)
}
var crop_configuration: SwiftyCropConfiguration = SwiftyCropConfiguration(rotateImage: false, zoomSensitivity: 5)
var image_cropper: some View {
Group {
if case .cropping(let preUploadedMedia) = model.state {
switch preUploadedMedia {
case .uiimage(let image):
SwiftyCropView(
imageToCrop: image,
maskShape: .circle
) { croppedImage in
self.model.finished_cropping(croppedImage: croppedImage)
}
case .unprocessed_image(let url), .processed_image(let url):
if let image = try? UIImage.from(url: url) {
SwiftyCropView(
imageToCrop: image,
maskShape: .circle,
configuration: crop_configuration
) { croppedImage in
self.model.finished_cropping(croppedImage: croppedImage)
}
}
else {
self.cropping_error_screen // Cannot load image
}
case .unprocessed_video(_), .processed_video(_):
self.cropping_error_screen // No support for video profile pictures
}
}
else {
self.cropping_error_screen // Some form of internal logical inconsistency
}
}
}
var cropping_error_screen: some View {
VStack(spacing: 5) {
Text("Error while cropping image", comment: "Heading on cropping error page")
.font(.headline)
Text("Sorry, but for some reason there has been an issue while trying to crop this image. Please try again later. If the error persists, please contact [Damus support](mailto:support@damus.io)", comment: "Cropping error message")
Button(action: { self.model.cancel() }, label: {
Text("Dismiss", comment: "Button to dismiss error")
})
}
}
// MARK: Accesibility helpers
var accessibility_label: String {
switch self.model.context {
case .normal:
return NSLocalizedString("Edit Image", comment: "Accessibility label for a button that edits an image")
case .profile_picture:
return NSLocalizedString("Edit profile picture", comment: "Accessibility label for a button that edits a profile picture")
}
}
var accessibility_hint: String {
return NSLocalizedString("Shows options to edit the image", comment: "Accessibility hint for a button that edits an image")
}
var accessibility_value: String? {
if style.first_time_setup {
if let current_image_url = model.current_image_url {
switch self.model.context {
case .normal:
return NSLocalizedString("Image is setup", comment: "Accessibility value on image control")
case .profile_picture:
return NSLocalizedString("Profile picture is setup", comment: "Accessibility value on profile picture image control")
}
}
else {
switch self.model.context {
case .normal:
return NSLocalizedString("No image is currently setup", comment: "Accessibility value on image control")
case .profile_picture:
return NSLocalizedString("No profile picture is currently setup", comment: "Accessibility value on profile picture image control")
}
}
}
else {
return nil // Image is shown outside this control and will have its accessibility defined outside this view.
}
}
}
// MARK: - View model
/// Tracks the state, and provides the logic needed for the EditPictureControl view
///
/// ## Implementation notes
///
/// - This makes it easier to test the logic as well as the view, and makes the view easier to work with by separating concerns.
@MainActor
class EditPictureControlViewModel<T: ImageUploadModelProtocol>: ObservableObject {
// MARK: Properties
// Properties are designed to reduce statefulness and hopefully increase predictability.
/// The context of the upload. Is it a profile picture? A regular picture?
let context: Context
/// Pubkey of the user
let pubkey: Pubkey
/// The currently loaded image URL
@Binding var current_image_url: URL?
/// The state of the picture selection process
@Published private(set) var state: PictureSelectionState
/// User's keypair
let keypair: Keypair?
/// The uploader service to be used when uploading
let uploader: any MediaUploaderProtocol
/// An image upload observer, that can be set when the parent view wants to keep track of the upload process
let image_upload_observer: ImageUploadingObserver?
/// A callback to receive new image urls once the picture selection and upload is complete.
let callback: (URL?) -> Void
// MARK: Constants
/// The desired profile image size
var profile_image_size: CGSize = CGSize(width: 400, height: 400)
// MARK: Initializers
init(
context: Context,
pubkey: Pubkey,
setup: Bool? = nil,
current_image_url: Binding<URL?>,
state: PictureSelectionState = .ready,
keypair: Keypair?,
uploader: any MediaUploaderProtocol,
image_upload_observer: ImageUploadingObserver? = nil,
callback: @escaping (URL?) -> Void
) {
self.context = context
self.pubkey = pubkey
self._current_image_url = current_image_url
self.state = state
self.keypair = keypair
self.uploader = uploader
self.image_upload_observer = image_upload_observer
self.callback = callback
}
// MARK: Convenience bindings to be used in views
var show_camera: Binding<Bool> {
Binding(
get: { self.state.show_camera },
set: { newShowCamera in
switch self.state {
case .selecting_picture_from_camera:
self.state = newShowCamera ? .selecting_picture_from_camera : .ready
default:
if newShowCamera == true { self.state = .selecting_picture_from_camera }
else { return } // Leave state as-is
}
}
)
}
var show_library: Binding<Bool> {
Binding(
get: { self.state.show_library },
set: { newValue in
switch self.state {
case .selecting_picture_from_library:
self.state = newValue ? .selecting_picture_from_library : .ready
default:
if newValue == true { self.state = .selecting_picture_from_library }
else { return } // Leave state as-is
}
}
)
}
var show_url_sheet: Binding<Bool> {
Binding(
get: { self.state.show_url_sheet },
set: { newValue in self.state = newValue ? .selecting_picture_from_url : .ready }
)
}
var show_image_cropper: Binding<Bool> {
Binding(
get: { self.state.show_image_cropper },
set: { newValue in
switch self.state {
case .cropping(let media):
self.state = newValue ? .cropping(media) : .ready
default:
return // Leave state as-is
}
}
)
}
fileprivate var error_message: Binding<IdentifiableString?> {
Binding(
get: { IdentifiableString(text: self.state.error_message) },
set: { newValue in
if let newValue {
self.state = .failed(message: newValue.rawValue)
}
else {
self.state = .ready
}
}
)
}
// MARK: Control methods
// These are methods to be used by the view or a test program to represent user actions.
/// Ask user if they are sure they want to upload an image
func request_upload_authorization(_ media: PreUploadedMedia) {
self.state = .confirming_upload(media)
}
/// Confirm on behalf of the user that we have their permission to upload image
func confirm_upload_authorization() {
guard case .confirming_upload(let preUploadedMedia) = state else {
return
}
switch self.context {
case .normal:
self.upload(media: preUploadedMedia)
case .profile_picture:
self.state = .cropping(preUploadedMedia)
}
}
/// Indicate the image has finished being cropped. This will resize the image and upload it
func finished_cropping(croppedImage: UIImage?) {
guard let croppedImage else { return }
let resizedCroppedImage = croppedImage.resized(to: profile_image_size)
let newPreUploadedMedia: PreUploadedMedia = .uiimage(resizedCroppedImage)
self.upload(media: newPreUploadedMedia)
}
/// Upload the media
func upload(media: PreUploadedMedia) {
if let mediaToUpload = generateMediaUpload(media) {
self.handle_upload(media: mediaToUpload)
}
else {
self.state = .failed(message: NSLocalizedString("Failed to generate media for upload. Please try again. If error persists, please contact Damus support at support@damus.io", comment: "Error label forming media for upload after user crops the image."))
}
}
/// Cancel the picture selection process
func cancel() {
self.state = .ready
}
/// Mark the picture selection process as failed
func failed(message: String) {
self.state = .failed(message: message)
}
/// Choose an image based on a URL
func choose_url(_ url: URL?) {
self.current_image_url = url
callback(url)
self.state = .ready
}
/// Select an image from the gallery
func select_image_from_library() {
self.state = .selecting_picture_from_library
}
/// Select an image by taking a photo
func select_image_from_camera() {
self.state = .selecting_picture_from_camera
}
/// Select an image by specifying a URL
func select_image_from_url() {
self.state = .selecting_picture_from_url
}
// MARK: Internal logic
/// Handles the upload process
private func handle_upload(media: MediaUpload) {
let image_upload = T()
let upload_observer = ImageUploadingObserver()
self.state = .uploading(media: media, upload: image_upload, uploadObserver: upload_observer)
upload_observer.isLoading = true
Task {
let res = await image_upload.start(media: media, uploader: uploader, mediaType: self.context.mediaType, keypair: keypair)
switch res {
case .success(let urlString):
let url = URL(string: urlString)
current_image_url = url
self.state = .ready
callback(url)
case .failed(let error):
if let error {
Log.info("Error uploading profile image with error: %@", for: .image_uploading, error.localizedDescription)
} else {
Log.info("Failed to upload profile image without error", for: .image_uploading)
}
self.state = .failed(message: NSLocalizedString("Error uploading profile image. Please check your internet connection and try again. If error persists, please contact Damus support (support@damus.io).", comment: "Error label when uploading profile image"))
}
upload_observer.isLoading = false
}
}
}
// MARK: - Helper views
/// A view that can be used for inputting a URL.
struct ImageURLSelector: View {
@State var image_url_temp: String = ""
@State var error: String? = nil
@State var image_url: URL? = nil
let callback: (URL?) -> Void
let cancel: () -> Void
var body: some View {
ZStack {
DamusColors.adaptableWhite.edgesIgnoringSafeArea(.all)
VStack {
Text("Image URL", comment: "Label for image url text field")
.bold()
Divider()
.padding(.horizontal)
HStack {
Image(systemName: "doc.on.clipboard")
.foregroundColor(.gray)
.onTapGesture {
if let pastedURL = UIPasteboard.general.string {
image_url_temp = URL(string: pastedURL)?.absoluteString ?? ""
}
}
TextField(image_url_temp, text: $image_url_temp)
}
.padding(12)
.background {
RoundedRectangle(cornerRadius: 12)
.stroke(.gray.opacity(0.5), lineWidth: 1)
.background {
RoundedRectangle(cornerRadius: 12)
.foregroundColor(.damusAdaptableWhite)
}
}
.padding(10)
if let error {
Text(error)
.foregroundStyle(.red)
}
Button(action: {
self.cancel()
}, label: {
Text("Cancel", comment: "Cancel button text for dismissing updating image url.")
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
.padding(10)
})
.buttonStyle(NeutralButtonStyle())
.padding(10)
Button(action: {
guard let the_url = URL(string: image_url_temp) else {
error = NSLocalizedString("Invalid URL", comment: "Error label when user enters an invalid URL")
return
}
image_url = the_url
callback(the_url)
}, label: {
Text("Update", comment: "Update button text for updating image url.")
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
})
.buttonStyle(GradientButtonStyle(padding: 10))
.padding(.horizontal, 10)
.disabled(image_url_temp == image_url?.absoluteString)
.opacity(image_url_temp == image_url?.absoluteString ? 0.5 : 1)
}
}
.onAppear {
image_url_temp = image_url?.absoluteString ?? ""
}
}
}
// MARK: - Helper structures
extension EditPictureControlViewModel {
/// Tracks the state of the picture selection process in the picture control view and provides convenient computed properties for the view
///
/// ## Implementation notes
///
/// Made as an enum with associated values to reduce the amount of independent variables in the view model, and enforce the presence of certain values in certain steps of the process.
enum PictureSelectionState {
case ready
case selecting_picture_from_library
case selecting_picture_from_url
case selecting_picture_from_camera
case confirming_upload(PreUploadedMedia)
case cropping(PreUploadedMedia)
case uploading(media: MediaUpload, upload: any ImageUploadModelProtocol, uploadObserver: ImageUploadingObserver)
case failed(message: String)
// MARK: Convenience computed properties
// Translates the information in the state, in a way that does not introduce further statefulness
var is_confirming_upload: Bool { self.step == .confirming_upload }
var show_image_cropper: Bool { self.step == .cropping }
var show_library: Bool { self.step == .selecting_picture_from_library }
var show_camera: Bool { self.step == .selecting_picture_from_camera }
var show_url_sheet: Bool { self.step == .selecting_picture_from_url }
var is_uploading: Bool { self.step == .uploading }
var error_message: String? { if case .failed(let message) = self { return message } else { return nil } }
var step: Step {
switch self {
case .ready: .ready
case .selecting_picture_from_library: .selecting_picture_from_library
case .selecting_picture_from_url: .selecting_picture_from_url
case .selecting_picture_from_camera: .selecting_picture_from_camera
case .confirming_upload(_): .confirming_upload
case .cropping(_): .cropping
case .uploading(_,_,_): .uploading
case .failed(_): .failed
}
}
/// Tracks the specific step of the picture selection state, without any associated values, to make easy comparisons on where in the process we are
enum Step: String, RawRepresentable, Equatable {
case ready
case selecting_picture_from_library
case selecting_picture_from_url
case selecting_picture_from_camera
case confirming_upload
case cropping
case uploading
case failed
}
}
}
extension EditPictureControlViewModel {
/// Defines the context of this picture. Is it a profile picture? A normal picture?
enum Context {
case normal
case profile_picture
var mediaType: ImageUploadMediaType {
switch self {
case .normal: .normal
case .profile_picture: .profile_picture
}
}
}
}
/// An object that can be used for tracking the status of an upload across the view hierarchy.
/// For example, a parent view can instantiate this object and pass it to a child view that handles uploads,
/// and that parent view can change its own style accordingly
///
/// ## Implementation note:
///
/// It would be correct to put this entire class in the MainActor, but for some reason adding `@MainActor` crashes the Swift compiler with no helpful messages (on Xcode 16.2 (16C5032a)), so individual members of this class need to be manually put into the main actor.
//@MainActor
class ImageUploadingObserver: ObservableObject {
@MainActor @Published var isLoading: Bool = false
}
fileprivate struct IdentifiableString: Identifiable, RawRepresentable {
var id: String { return rawValue }
typealias RawValue = String
var rawValue: String
init?(rawValue: String) {
self.rawValue = rawValue
}
init?(text: String?) {
guard let text else { return nil }
self.rawValue = text
}
}
extension EditPictureControl {
struct Style {
let size: CGFloat?
let first_time_setup: Bool
}
}
// MARK: - Convenience extensions
fileprivate extension UIImage {
/// Convenience function to easily get an UIImage from a URL
static func from(url: URL) throws -> UIImage? {
let data = try Data(contentsOf: url)
return UIImage(data: data)
}
}
fileprivate extension View {
func maybeAccessibilityValue(_ value: String?) -> some View {
Group {
if let value { self.accessibilityValue(value) } else { self }
}
}
}
// MARK: - Previews
struct EditPictureControl_Previews: PreviewProvider {
static var previews: some View {
let url = Binding<URL?>.constant(URL(string: "https://damus.io")!)
let observer = ImageUploadingObserver()
ZStack {
Color.gray
EditPictureControl(uploader: .nostrBuild, keypair: test_keypair, pubkey: test_pubkey, size: 100, setup: false, image_url: url, uploadObserver: observer) { _ in
EditPictureControl(uploader: MediaUploader.nostrBuild, context: .profile_picture, keypair: test_keypair, pubkey: test_pubkey, style: .init(size: 100, first_time_setup: false), current_image_url: url) { _ in
//
}
}

View File

@@ -33,7 +33,15 @@ struct EditProfilePictureView: View {
.scaledToFill()
.kfClickable()
EditPictureControl(uploader: damus_state?.settings.default_media_uploader ?? .nostrBuild, keypair: damus_state?.keypair, pubkey: pubkey, image_url: $profile_url, uploadObserver: uploadObserver, callback: callback)
EditPictureControl(
uploader: damus_state?.settings.default_media_uploader ?? .nostrBuild,
context: .profile_picture,
keypair: damus_state?.keypair,
pubkey: pubkey,
current_image_url: $profile_url,
upload_observer: uploadObserver,
callback: callback
)
}
.frame(width: size, height: size)
.clipShape(Circle())

View File

@@ -20,8 +20,6 @@ struct SearchHomeView: View {
return ContentFilters(filters: filters).filter
}
let preferredLanguages = Set(Locale.preferredLanguages.map { localeToLanguage($0) })
var SearchInput: some View {
HStack {
HStack{
@@ -64,17 +62,7 @@ struct SearchHomeView: View {
return false
}
if damus_state.settings.show_only_preferred_languages == false {
return true
}
// If we can't determine the note's language with 50%+ confidence, lean on the side of caution and show it anyway.
let note_lang = damus_state.events.get_cache_data(ev.id).translations_model.note_language
guard let note_lang else {
return true
}
return preferredLanguages.contains(note_lang)
return true
},
content: {
AnyView(VStack {

View File

@@ -70,7 +70,7 @@ struct InnerSearchResults: View {
func TextSearch(_ txt: String) -> some View {
return NavigationLink(value: Route.NDBSearch(results: $results)) {
HStack {
Text(txt)
Text("Search word: \(txt)", comment: "Navigation link to search for a word.")
}
.padding(.horizontal, 15)
.padding(.vertical, 5)

View File

@@ -16,9 +16,6 @@ struct TranslationSettingsView: View {
var body: some View {
Form {
Section(NSLocalizedString("Translations", comment: "Section title for selecting the translation service.")) {
Toggle(NSLocalizedString("Show only preferred languages on Universe feed", comment: "Toggle to show notes that are only in the device's preferred languages on the Universe feed and hide notes that are in other languages."), isOn: $settings.show_only_preferred_languages)
.toggleStyle(.switch)
Picker(NSLocalizedString("Service", comment: "Prompt selection of translation service provider."), selection: $settings.translation_service) {
ForEach(TranslationService.allCases.filter({ damus_state.purple.enable_purple ? true : $0 != .purple }), id: \.self) { server in
Text(server.model.displayName)

View File

@@ -96,16 +96,9 @@ struct ConnectWalletView: View {
openURL(URL(string:"https://nwc.getalby.com/apps/new?c=Damus")!)
}
//
// Mutiny Wallet NWC is way too advanced to recommend for normal
// users until they have a way to do async receive.
//
/*
MutinyButton() {
openURL(URL(string:"https://app.mutinywallet.com/settings/connections?callbackUri=nostr%2bwalletconnect&name=Damus")!)
CoinosButton() {
openURL(URL(string:"https://coinos.io/settings/nostr")!)
}
*/
Button(action: {
if let pasted_nwc = UIPasteboard.general.string {

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