Compare commits

..

6 Commits

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

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

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

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

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

Signed-off-by: Terry Yiu <git@tyiu.xyz>
2024-12-09 18:36:43 -05:00
118 changed files with 533 additions and 1660 deletions

View File

@@ -406,10 +406,9 @@
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 */; };
@@ -536,6 +535,7 @@
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,6 +733,7 @@
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 */; };
@@ -1126,6 +1127,7 @@
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 */; };
@@ -1253,6 +1255,7 @@
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 */; };
@@ -1482,7 +1485,6 @@
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 */; };
@@ -1490,9 +1492,6 @@
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 */; };
@@ -2336,8 +2335,9 @@
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,7 +2446,6 @@
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>"; };
@@ -2509,7 +2508,6 @@
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 */,
@@ -2539,7 +2537,6 @@
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 */,
@@ -2554,7 +2551,6 @@
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 */,
@@ -3188,6 +3184,7 @@
5C0707D02A1ECB38004E7B51 /* DamusLogoGradient.swift */,
4C687C202A5F7ED00092C550 /* DamusBackground.swift */,
5CF2DCCD2AABE1A500984B8D /* DamusLightGradient.swift */,
5C7389B82B9E69ED00781E0A /* MutinyGradient.swift */,
);
path = Gradients;
sourceTree = "<group>";
@@ -3256,10 +3253,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>";
@@ -3593,7 +3590,6 @@
4CE6DEF627F7A08200C66700 /* damusTests */ = {
isa = PBXGroup;
children = (
D7A0D8742D1FE66A00DCBE59 /* EditPictureControlTests.swift */,
E06336A72B7582D600A88E6B /* Assets */,
D72A2D032AD9C165002AFF62 /* Mocking */,
4C9B0DEC2A65A74000CBDA21 /* Util */,
@@ -3971,7 +3967,6 @@
3A0A30BA2C21397A00F8C9BC /* EmojiPicker */,
D78DB8582C1CE9CA00F0AB12 /* SwipeActions */,
D70D90972CDED61800CD0534 /* CodeScanner */,
D7C48C0A2D12DE0C00A3BACF /* SwiftyCrop */,
);
productName = damus;
productReference = 4CE6DEE327F7A08100C66700 /* damus.app */;
@@ -4037,7 +4032,6 @@
82D6FC872CD9A4DE00C925F4 /* EmojiPicker */,
82D6FC892CD9A54600C925F4 /* SwipeActions */,
D7F360282CEBBE34009D34DA /* CodeScanner */,
D7C48C0C2D12E34900A3BACF /* SwiftyCrop */,
);
productName = "share extension";
productReference = 82D6FA972CD9820500C925F4 /* ShareExtension.appex */;
@@ -4065,7 +4059,6 @@
D73E5F9A2C6AA8B0007EB227 /* Kingfisher */,
D73E5F9C2C6AA8E3007EB227 /* SwipeActions */,
D70D909B2CDED7B200CD0534 /* CodeScanner */,
D7C48C0E2D12E35600A3BACF /* SwiftyCrop */,
);
productName = "highlighter action extension";
productReference = D703D7172C66E47100A400EA /* HighlighterActionExtension.appex */;
@@ -4174,7 +4167,6 @@
3A0A30B92C21397A00F8C9BC /* XCRemoteSwiftPackageReference "EmojiPicker" */,
D78DB8572C1CE9CA00F0AB12 /* XCRemoteSwiftPackageReference "SwipeActions" */,
D70D90962CDED61800CD0534 /* XCRemoteSwiftPackageReference "CodeScanner" */,
D7C48C092D12DE0C00A3BACF /* XCRemoteSwiftPackageReference "SwiftyCrop" */,
);
productRefGroup = 4CE6DEE427F7A08100C66700 /* Products */;
projectDirPath = "";
@@ -4307,6 +4299,7 @@
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 */,
@@ -4611,6 +4604,7 @@
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 */,
@@ -4708,7 +4702,6 @@
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 */,
@@ -4802,7 +4795,6 @@
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 */,
@@ -4934,6 +4926,7 @@
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 */,
@@ -5131,6 +5124,7 @@
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 */,
@@ -5185,7 +5179,6 @@
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 */,
@@ -5344,6 +5337,7 @@
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 */,
@@ -5463,7 +5457,6 @@
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 */,
@@ -5483,6 +5476,7 @@
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 */,
@@ -6257,7 +6251,6 @@
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";
};
@@ -6307,7 +6300,6 @@
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";
};
@@ -6719,14 +6711,6 @@
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 */
@@ -6840,21 +6824,6 @@
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" : "0d806129a33991730dd1aa3d38c47a745f9e9e6ff44999f4a7f28b695f024832",
"originHash" : "534c8e58993919d5ead25ceb4788c8e492c86bc2cf5833dc651ae60a0f30169c",
"pins" : [
{
"identity" : "codescanner",
@@ -97,14 +97,6 @@
"version" : "0.1.2"
}
},
{
"identity" : "swiftycrop",
"kind" : "remoteSourceControl",
"location" : "https://github.com/benedom/SwiftyCrop",
"state" : {
"revision" : "454d0a0d4faf6f3a19c8d817ab9d7d27524bd79f"
}
},
{
"identity" : "swipeactions",
"kind" : "remoteSourceControl",

View File

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

View File

@@ -1,23 +0,0 @@
{
"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
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -0,0 +1,23 @@
{
"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

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: 187 KiB

After

Width:  |  Height:  |  Size: 187 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

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.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 511 KiB

After

Width:  |  Height:  |  Size: 511 KiB

View File

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 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: 34 KiB

After

Width:  |  Height:  |  Size: 34 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: 124 KiB

After

Width:  |  Height:  |  Size: 124 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

View File

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

View File

@@ -931,6 +931,7 @@ enum FindEventType {
enum FoundEvent {
case profile(Pubkey)
case invalid_profile(NostrEvent)
case event(NostrEvent)
}
@@ -987,6 +988,10 @@ 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:
@@ -995,16 +1000,17 @@ 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 {
callback(nil) // If we could not find any events in any of the relays we are connected to, send back nil
if attempts == state.pool.our_descriptors.count / 2 {
callback(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,8 +48,6 @@
<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,19 +77,11 @@ enum MediaUpload {
}
}
protocol ImageUploadModelProtocol {
init()
func start(media: MediaUpload, uploader: any MediaUploaderProtocol, mediaType: ImageUploadMediaType, keypair: Keypair?) async -> ImageUploadResult
}
class ImageUploadModel: NSObject, URLSessionTaskDelegate, ObservableObject, ImageUploadModelProtocol {
class ImageUploadModel: NSObject, URLSessionTaskDelegate, ObservableObject {
@Published var progress: Double? = nil
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)
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)
switch res {
case .success(_):
@@ -97,17 +89,10 @@ class ImageUploadModel: NSObject, URLSessionTaskDelegate, ObservableObject, Imag
self.progress = nil
UINotificationFeedbackGenerator().notificationOccurred(.success)
}
case .failed(let error):
case .failed(_):
DispatchQueue.main.async {
self.progress = nil
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)
}
UINotificationFeedbackGenerator().notificationOccurred(.error)
}
}

View File

@@ -7,18 +7,7 @@
import Foundation
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 {
enum MediaUploader: String, CaseIterable, Identifiable, StringCodable {
var id: String { self.rawValue }
case nostrBuild
case nostrcheck
@@ -44,19 +33,6 @@ enum MediaUploader: String, CaseIterable, MediaUploaderProtocol, 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:
@@ -66,15 +42,6 @@ enum MediaUploader: String, CaseIterable, MediaUploaderProtocol, 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,6 +185,9 @@ 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,7 +46,6 @@ enum Wallet: String, CaseIterable, Identifiable, StringCodable {
case bitcoinbeach
case blixtwallet
case river
case albygo
var model: Model {
switch self {
@@ -91,9 +90,6 @@ 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,10 +259,11 @@ func should_translate(event: NostrEvent, our_keypair: Keypair, note_lang: String
}
if let note_lang {
let currentLanguage = localeToLanguage(Locale.current.identifier)
let preferredLanguages = Set(Locale.preferredLanguages.map { localeToLanguage($0) })
// Don't translate if the note is in our current language
guard currentLanguage != note_lang else {
// Don't translate if its in our preferred languages
guard !preferredLanguages.contains(note_lang) else {
// if its the same, give up and don't retry
return false
}
}

View File

@@ -15,30 +15,12 @@ enum ImageUploadResult {
case failed(Error?)
}
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
}
fileprivate func create_upload_body(mediaData: Data, boundary: String, mediaUploader: MediaUploader, mediaToUpload: MediaUpload) -> Data {
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)\(mediaTypeFieldEntry ?? "")\r\n")
body.appendString(string: "Content-Disposition: form-data; name=\(mediaUploader.nameParam); filename=\(mediaToUpload.genericFileName)\r\n")
body.appendString(string: "Content-Type: \(contentType)\r\n\r\n")
body.append(mediaData as Data)
body.appendString(string: "\r\n")
@@ -46,60 +28,59 @@ class AttachMediaUtility {
return body as Data
}
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)
}
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.requiresNip98,
let keypair,
let method = request.httpMethod,
let signature = create_nip98_signature(keypair: keypair, method: method, url: url) {
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, mediaType: mediaType)
request.setValue(signature, forHTTPHeaderField: "Authorization")
}
switch mediaToUpload {
case .image(let url):
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)
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")
return .failed(nil)
}
return .success(url)
} catch {
return .failed(error)
}
}

View File

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

View File

@@ -1,48 +0,0 @@
//
// 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

@@ -0,0 +1,47 @@
//
// 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,14 +13,9 @@ struct CameraController: UIViewControllerRepresentable {
@Environment(\.presentationMode)
@Binding private var presentationMode
let uploader: any MediaUploaderProtocol
let uploader: MediaUploader
let done: () -> Void
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
@@ -30,29 +25,18 @@ struct CameraController: UIViewControllerRepresentable {
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
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)
}
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)
}
parent.done()
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {

View File

@@ -6,14 +6,11 @@
//
import SwiftUI
import Combine
struct CreateAccountView: View, KeyboardReadable {
struct CreateAccountView: View {
@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)
@@ -29,25 +26,15 @@ struct CreateAccountView: View, KeyboardReadable {
ZStack(alignment: .top) {
VStack {
Spacer()
VStack(alignment: .center) {
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
)
EditPictureControl(uploader: .nostrBuild, keypair: account.keypair, pubkey: account.pubkey, size: 75, setup: true, image_url: $account.profile_image , uploadObserver: profileUploadObserver, callback: uploadedProfilePicture)
.shadow(radius: 2)
.padding(.top, 100)
Text("Add Photo", comment: "Label to indicate user can add a photo.")
.bold()
.foregroundColor(DamusColors.neutral6)
}
SignupForm {
@@ -55,13 +42,13 @@ struct CreateAccountView: View, KeyboardReadable {
.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))
}) {
@@ -85,11 +72,6 @@ struct CreateAccountView: View, KeyboardReadable {
}
.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,14 +20,8 @@ struct MediaPicker: UIViewControllerRepresentable {
@Binding private var presentationMode
let mediaPickerEntry: MediaPickerEntry
let onMediaSelected: (() -> Void)?
@Binding var image_upload_confirm: Bool
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
@@ -127,7 +121,7 @@ struct MediaPicker: UIViewControllerRepresentable {
private func chooseMedia(_ media: PreUploadedMedia, orderId: String) {
self.parent.onMediaSelected?()
self.parent.image_upload_confirm = true
self.orderMap[orderId] = media
self.dispatchGroup.leave()
}

View File

@@ -144,25 +144,21 @@ struct NotificationsView: View {
func NotificationTab(_ filter: NotificationFilter) -> some View {
ScrollViewReader { scroller in
ScrollView {
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)
}
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)
}
.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,8 +6,7 @@
//
import SwiftUI
import AVKit
import Kingfisher
import AVFoundation
enum NostrPostResult {
case post(NostrPost)
@@ -73,7 +72,6 @@ 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
@@ -99,15 +97,9 @@ 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>()
@@ -346,7 +338,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, mediaType: .normal, keypair: damus_state.keypair)
let res = await image_upload.start(media: media, uploader: uploader, keypair: damus_state.keypair)
switch res {
case .success(let url):
@@ -481,20 +473,19 @@ struct PostView: View {
}
.background(DamusColors.adaptableWhite.edgesIgnoringSafeArea(.all))
.sheet(isPresented: $attach_media) {
MediaPicker(mediaPickerEntry: .postView, onMediaSelected: { image_upload_confirm = true }) { media in
MediaPicker(mediaPickerEntry: .postView, image_upload_confirm: $image_upload_confirm){ 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
let task = Task {
// initiate asynchronous uploading Task for multiple-images
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) {
@@ -503,20 +494,19 @@ struct PostView: View {
}
}
.sheet(isPresented: $attach_camera) {
CameraController(uploader: damus_state.settings.default_media_uploader, mode: .save_to_library(when_done: {
CameraController(uploader: damus_state.settings.default_media_uploader) {
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) {
let task = Task {
_ = await self.handle_upload(media: mediaToUpload)
Task {
await self.handle_upload(media: mediaToUpload)
}
uploadTasks.append(task)
}
}
Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {}
@@ -524,14 +514,13 @@ 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) {
let task = 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) {}
}
@@ -620,79 +609,38 @@ struct PVImageCarouselView: View {
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
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
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")
}
}
.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
}
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)
}
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 {
@@ -865,14 +813,3 @@ 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,751 +7,223 @@
import SwiftUI
import Kingfisher
import SwiftyCrop
// MARK: - Main view
class ImageUploadingObserver: ObservableObject {
@Published var isLoading: Bool = false
}
/// A view that shows an existing picture, and allows a user to upload a new one.
struct EditPictureControl: View {
// 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 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
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 {
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() }) {
Button(action: {
self.show_url_sheet = true
}) {
Text("Image URL", comment: "Option to enter a url")
}
.accessibilityIdentifier(AppAccessibilityIdentifiers.own_profile_banner_image_edit_from_url.rawValue)
Button(action: { self.model.select_image_from_library() }) {
Button(action: {
self.show_library = true
}) {
Text("Choose from Library", comment: "Option to select photo from library")
}
Button(action: { self.model.select_image_from_camera() }) {
Button(action: {
self.show_camera = true
}) {
Text("Take Photo", comment: "Option to take a photo with the camera")
}
}
}
/// 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)
} 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())
.background {
Circle()
.fill(DamusColors.purple, strokeBorder: .white, lineWidth: 2)
.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
}
.shadow(radius: 3)
}
}
}
/// 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)
.scaledToFill()
.frame(width: (size ?? 25) + 10, height: (size ?? 25) + 10)
.kfClickable()
.foregroundColor(DamusColors.white)
.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)
.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
}
else {
self.cropping_error_screen // Cannot load image
}
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) }
))
}
case .unprocessed_video(_), .processed_video(_):
self.cropping_error_screen // No support for video profile pictures
.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)
}
}
else {
self.cropping_error_screen // Some form of internal logical inconsistency
.onAppear {
image_url_temp = image_url
}
.presentationDetents([.height(300)])
.presentationDragIndicator(.visible)
}
}
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
uploadObserver.isLoading = true
Task {
let res = await image_upload.start(media: media, uploader: uploader, mediaType: self.context.mediaType, keypair: keypair)
let res = await image_upload.start(media: media, uploader: uploader, keypair: keypair)
switch res {
case .success(let urlString):
let url = URL(string: urlString)
current_image_url = url
self.state = .ready
image_url = url
callback(url)
case .failed(let error):
if let error {
Log.info("Error uploading profile image with error: %@", for: .image_uploading, error.localizedDescription)
print("Error uploading profile image \(error.localizedDescription)")
} else {
Log.info("Failed to upload profile image without error", for: .image_uploading)
print("Error uploading image :(")
}
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"))
callback(nil)
}
upload_observer.isLoading = false
uploadObserver.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: MediaUploader.nostrBuild, context: .profile_picture, keypair: test_keypair, pubkey: test_pubkey, style: .init(size: 100, first_time_setup: false), current_image_url: url) { _ in
EditPictureControl(uploader: .nostrBuild, keypair: test_keypair, pubkey: test_pubkey, size: 100, setup: false, image_url: url, uploadObserver: observer) { _ in
//
}
}

View File

@@ -33,15 +33,7 @@ struct EditProfilePictureView: View {
.scaledToFill()
.kfClickable()
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
)
EditPictureControl(uploader: damus_state?.settings.default_media_uploader ?? .nostrBuild, keypair: damus_state?.keypair, pubkey: pubkey, image_url: $profile_url, uploadObserver: uploadObserver, callback: callback)
}
.frame(width: size, height: size)
.clipShape(Circle())

View File

@@ -20,6 +20,8 @@ struct SearchHomeView: View {
return ContentFilters(filters: filters).filter
}
let preferredLanguages = Set(Locale.preferredLanguages.map { localeToLanguage($0) })
var SearchInput: some View {
HStack {
HStack{
@@ -62,7 +64,17 @@ struct SearchHomeView: View {
return false
}
return true
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)
},
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("Search word: \(txt)", comment: "Navigation link to search for a word.")
Text(txt)
}
.padding(.horizontal, 15)
.padding(.vertical, 5)

View File

@@ -16,6 +16,9 @@ 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,9 +96,16 @@ struct ConnectWalletView: View {
openURL(URL(string:"https://nwc.getalby.com/apps/new?c=Damus")!)
}
CoinosButton() {
openURL(URL(string:"https://coinos.io/settings/nostr")!)
//
// 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")!)
}
*/
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