From f8e7dd78d9dae323cb8281d0e10693d64b0e74e3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 6 Jan 2024 11:32:41 -0500 Subject: [PATCH] Reverts to the non-Google kotlin style. --- .../amethyst/ImageUploadTesting.kt | 182 +- .../amethyst/RichTextParserTest.kt | 7010 ++++++++--------- .../amethyst/ThreadAssemblerTest.kt | 149 +- .../amethyst/UrlUserTagTransformationTest.kt | 150 +- .../amethyst/TranslationsTest.kt | 238 +- .../service/lang/LanguageTranslatorService.kt | 2 +- .../notifications/PushDistributorHandler.kt | 118 +- .../notifications/PushMessageReceiver.kt | 154 +- .../notifications/PushNotificationUtils.kt | 26 +- .../components/SelectNotificationProvider.kt | 243 +- .../components/TranslatableRichTextViewer.kt | 19 +- .../material3/pullrefresh/PullRefresh.kt | 96 +- .../pullrefresh/PullRefreshIndicator.kt | 222 +- .../PullRefreshIndicatorTransform.kt | 67 +- .../material3/pullrefresh/PullRefreshState.kt | 268 +- .../com/vitorpamplona/amethyst/Amethyst.kt | 102 +- .../amethyst/EncryptedStorage.kt | 40 +- .../amethyst/LocalPreferences.kt | 1069 ++- .../vitorpamplona/amethyst/ServiceManager.kt | 348 +- .../vitorpamplona/amethyst/model/Account.kt | 4382 ++++++----- .../amethyst/model/AntiSpamFilter.kt | 136 +- .../vitorpamplona/amethyst/model/Channel.kt | 234 +- .../vitorpamplona/amethyst/model/Chatroom.kt | 96 +- .../amethyst/model/HashtagIcon.kt | 280 +- .../amethyst/model/LocalCache.kt | 3300 ++++---- .../com/vitorpamplona/amethyst/model/Note.kt | 1794 ++--- .../amethyst/model/ParticipantListBuilder.kt | 150 +- .../amethyst/model/RelayInformation.kt | 81 +- .../amethyst/model/RelaySetupInfo.kt | 20 +- .../vitorpamplona/amethyst/model/Settings.kt | 100 +- .../amethyst/model/ThreadAssembler.kt | 144 +- .../amethyst/model/UrlCachedPreviewer.kt | 85 +- .../com/vitorpamplona/amethyst/model/User.kt | 946 +-- .../amethyst/service/BlurHashDecoder.kt | 536 +- .../amethyst/service/BlurHashImage.kt | 82 +- .../amethyst/service/CachedRichTextParser.kt | 472 +- .../amethyst/service/CashuProcessor.kt | 312 +- .../amethyst/service/EmojiUtils.kt | 161 +- .../amethyst/service/FileHeader.kt | 385 +- .../amethyst/service/HttpClient.kt | 142 +- .../amethyst/service/LocationUtil.kt | 132 +- .../amethyst/service/MainThreadChecker.kt | 6 +- .../service/Nip05NostrAddressVerifier.kt | 219 +- .../service/Nip11RelayInfoRetriever.kt | 176 +- .../amethyst/service/Nip30CustomEmoji.kt | 50 +- .../amethyst/service/Nip44UrlParser.kt | 28 +- .../service/Nip47WalletConnectParser.kt | 40 +- .../amethyst/service/Nip96MediaServers.kt | 138 +- .../amethyst/service/Nip96Uploader.kt | 458 +- .../service/NostrAccountDataSource.kt | 559 +- .../service/NostrChannelDataSource.kt | 158 +- .../service/NostrChatroomDataSource.kt | 135 +- .../service/NostrChatroomListDataSource.kt | 235 +- .../service/NostrCommunityDataSource.kt | 60 +- .../amethyst/service/NostrDataSource.kt | 544 +- .../service/NostrDiscoveryDataSource.kt | 751 +- .../service/NostrGeohashDataSource.kt | 74 +- .../service/NostrHashtagDataSource.kt | 80 +- .../amethyst/service/NostrHomeDataSource.kt | 359 +- .../NostrLnZapPaymentResponseDataSource.kt | 86 +- .../NostrSearchEventOrUserDataSource.kt | 214 +- .../service/NostrSingleChannelDataSource.kt | 158 +- .../service/NostrSingleEventDataSource.kt | 393 +- .../service/NostrSingleUserDataSource.kt | 167 +- .../amethyst/service/NostrThreadDataSource.kt | 77 +- .../service/NostrUserProfileDataSource.kt | 264 +- .../amethyst/service/NostrVideoDataSource.kt | 219 +- .../amethyst/service/OnlineCheck.kt | 78 +- .../amethyst/service/PackageUtils.kt | 28 +- .../amethyst/service/ZapPaymentHandler.kt | 403 +- .../service/lnurl/LightningAddressResolver.kt | 462 +- .../amethyst/service/model/zaps/UserZaps.kt | 16 +- .../EventNotificationConsumer.kt | 386 +- .../notifications/NotificationUtils.kt | 390 +- .../service/notifications/RegisterAccounts.kt | 154 +- .../playback/MultiPlayerPlaybackManager.kt | 238 +- .../playback/PlaybackClientController.kt | 70 +- .../service/playback/PlaybackService.kt | 276 +- .../amethyst/service/playback/VideoCache.kt | 62 +- .../playback/VideoViewedPositionCache.kt | 20 +- .../service/previews/BahaUrlPreview.kt | 28 +- .../service/previews/IUrlPreviewCallback.kt | 4 +- .../amethyst/service/previews/UrlInfoItem.kt | 38 +- .../service/previews/UrlPreviewUtils.kt | 240 +- .../amethyst/service/relays/Client.kt | 488 +- .../amethyst/service/relays/Constants.kt | 298 +- .../amethyst/service/relays/EOSE.kt | 86 +- .../amethyst/service/relays/JsonFilter.kt | 118 +- .../amethyst/service/relays/Relay.kt | 898 +-- .../amethyst/service/relays/RelayPool.kt | 370 +- .../amethyst/service/relays/Subscription.kt | 56 +- .../amethyst/service/relays/TypedFilter.kt | 98 +- .../service/tts/TextToSpeechEngine.kt | 265 +- .../service/tts/TextToSpeechHelper.kt | 270 +- .../vitorpamplona/amethyst/ui/MainActivity.kt | 482 +- .../amethyst/ui/actions/ImageDownloader.kt | 68 +- .../amethyst/ui/actions/ImageSaver.kt | 296 +- .../amethyst/ui/actions/InformationDialog.kt | 54 +- .../ui/actions/JoinUserOrChannelView.kt | 540 +- .../amethyst/ui/actions/NewChannelView.kt | 184 +- .../ui/actions/NewChannelViewModel.kt | 86 +- .../amethyst/ui/actions/NewMediaModel.kt | 518 +- .../amethyst/ui/actions/NewMediaView.kt | 409 +- .../amethyst/ui/actions/NewMessageTagger.kt | 310 +- .../amethyst/ui/actions/NewPollClosing.kt | 94 +- .../ui/actions/NewPollConsensusThreshold.kt | 94 +- .../amethyst/ui/actions/NewPollOption.kt | 76 +- .../ui/actions/NewPollPrimaryDescription.kt | 92 +- .../ui/actions/NewPollRecipientsField.kt | 50 +- .../ui/actions/NewPollVoteValueRange.kt | 136 +- .../amethyst/ui/actions/NewPostView.kt | 2873 +++---- .../amethyst/ui/actions/NewPostViewModel.kt | 1716 ++-- .../amethyst/ui/actions/NewRelayListView.kt | 1484 ++-- .../ui/actions/NewRelayListViewModel.kt | 318 +- .../ui/actions/NewUserMetadataView.kt | 490 +- .../ui/actions/NewUserMetadataViewModel.kt | 398 +- .../ui/actions/NotifyRequestDialog.kt | 82 +- .../ui/actions/RelayInformationDialog.kt | 421 +- .../ui/actions/RelaySelectionDialog.kt | 354 +- .../amethyst/ui/actions/SaveToGallery.kt | 210 +- .../amethyst/ui/actions/UploadFromGallery.kt | 218 +- .../ui/actions/UrlUserTagTransformation.kt | 263 +- .../amethyst/ui/buttons/ChannelFabColumn.kt | 144 +- .../amethyst/ui/buttons/NewChannelButton.kt | 38 +- .../ui/buttons/NewCommunityNoteButton.kt | 52 +- .../amethyst/ui/buttons/NewImageButton.kt | 160 +- .../amethyst/ui/buttons/NewNoteButton.kt | 38 +- .../ui/components/AudioWaveformReadOnly.kt | 234 +- .../amethyst/ui/components/BundledUpdate.kt | 134 +- .../amethyst/ui/components/CashuRedeem.kt | 488 +- .../amethyst/ui/components/ClickableEmail.kt | 42 +- .../ui/components/ClickableNoteTag.kt | 14 +- .../amethyst/ui/components/ClickablePhone.kt | 24 +- .../amethyst/ui/components/ClickableRoute.kt | 1008 +-- .../amethyst/ui/components/ClickableUrl.kt | 28 +- .../ui/components/ClickableUserTag.kt | 22 +- .../ui/components/ClickableWithdrawal.kt | 58 +- .../ui/components/ExpandableRichTextViewer.kt | 146 +- .../amethyst/ui/components/GenericLoadable.kt | 8 +- .../amethyst/ui/components/InvoicePreview.kt | 186 +- .../amethyst/ui/components/InvoiceRequest.kt | 294 +- .../amethyst/ui/components/LoadUrlPreview.kt | 102 +- .../amethyst/ui/components/MarkdownParser.kt | 296 +- .../amethyst/ui/components/MediaCompressor.kt | 226 +- .../amethyst/ui/components/RichTextViewer.kt | 974 +-- .../amethyst/ui/components/Robohash.kt | 93 +- .../ui/components/RobohashAsyncImage.kt | 224 +- .../ui/components/SelectTextDialog.kt | 68 +- .../ui/components/SensitivityWarning.kt | 168 +- .../amethyst/ui/components/SlidingCarousel.kt | 92 +- .../amethyst/ui/components/SplitItem.kt | 174 +- .../amethyst/ui/components/TextSpinner.kt | 232 +- .../ui/components/TranslationConfig.kt | 8 +- .../amethyst/ui/components/UrlPreviewCard.kt | 78 +- .../amethyst/ui/components/UrlPreviewState.kt | 8 +- .../amethyst/ui/components/VideoView.kt | 1506 ++-- .../ui/components/ZapRaiserRequest.kt | 116 +- .../ui/components/ZoomableContentView.kt | 1539 ++-- .../ui/dal/BookmarkPrivateFeedFilter.kt | 34 +- .../ui/dal/BookmarkPublicFeedFilter.kt | 30 +- .../amethyst/ui/dal/ChannelFeedFilter.kt | 36 +- .../amethyst/ui/dal/ChatroomFeedFilter.kt | 38 +- .../ui/dal/ChatroomListKnownFeedFilter.kt | 314 +- .../ui/dal/ChatroomListNewFeedFilter.kt | 212 +- .../amethyst/ui/dal/CommunityFeedFilter.kt | 70 +- .../amethyst/ui/dal/DiscoverChatFeedFilter.kt | 128 +- .../ui/dal/DiscoverCommunityFeedFilter.kt | 134 +- .../amethyst/ui/dal/DiscoverLiveFeedFilter.kt | 178 +- .../ui/dal/DiscoverLiveNowFeedFilter.kt | 36 +- .../ui/dal/DiscoverMarketplaceFeedFilter.kt | 100 +- .../amethyst/ui/dal/FeedFilter.kt | 64 +- .../amethyst/ui/dal/GeoHashFeedFilter.kt | 56 +- .../amethyst/ui/dal/HashtagFeedFilter.kt | 56 +- .../ui/dal/HiddenAccountsFeedFilter.kt | 54 +- .../ui/dal/HomeConversationsFeedFilter.kt | 92 +- .../ui/dal/HomeNewThreadFeedFilter.kt | 145 +- .../amethyst/ui/dal/NotificationFeedFilter.kt | 126 +- .../amethyst/ui/dal/ThreadFeedFilter.kt | 48 +- ...UserProfileAppRecommendationsFeedFilter.kt | 60 +- .../ui/dal/UserProfileBookmarksFeedFilter.kt | 42 +- .../dal/UserProfileConversationsFeedFilter.kt | 56 +- .../ui/dal/UserProfileFollowersFeedFilter.kt | 14 +- .../ui/dal/UserProfileFollowsFeedFilter.kt | 36 +- .../ui/dal/UserProfileNewThreadFeedFilter.kt | 70 +- .../ui/dal/UserProfileReportsFeedFilter.kt | 36 +- .../ui/dal/UserProfileZapsFeedFilter.kt | 14 +- .../amethyst/ui/dal/VideoFeedFilter.kt | 92 +- .../amethyst/ui/elements/AddRemoveButtons.kt | 116 +- .../amethyst/ui/elements/DisplayCommunity.kt | 62 +- .../amethyst/ui/elements/DisplayHashtags.kt | 68 +- .../amethyst/ui/elements/DisplayPoW.kt | 24 +- .../amethyst/ui/elements/DisplayReward.kt | 332 +- .../ui/elements/DisplayUncitedHashtags.kt | 40 +- .../amethyst/ui/elements/DisplayZapSplits.kt | 84 +- .../amethyst/ui/layouts/ChatHeaderLayout.kt | 146 +- .../amethyst/ui/layouts/LeftPictureLayout.kt | 158 +- .../amethyst/ui/layouts/RepostLayout.kt | 38 +- .../ui/navigation/AccountSwitchBottomSheet.kt | 363 +- .../amethyst/ui/navigation/AppBottomBar.kt | 212 +- .../amethyst/ui/navigation/AppNavigation.kt | 633 +- .../amethyst/ui/navigation/AppTopBar.kt | 1393 ++-- .../amethyst/ui/navigation/DrawerContent.kt | 1053 +-- .../amethyst/ui/navigation/RouteMaker.kt | 84 +- .../amethyst/ui/navigation/Routes.kt | 650 +- .../amethyst/ui/note/BadgeCompose.kt | 232 +- .../amethyst/ui/note/BlankNote.kt | 174 +- .../amethyst/ui/note/ChannelCardCompose.kt | 1563 ++-- .../amethyst/ui/note/ChatroomHeaderCompose.kt | 685 +- .../ui/note/ChatroomMessageCompose.kt | 1105 +-- .../vitorpamplona/amethyst/ui/note/Icons.kt | 490 +- .../amethyst/ui/note/MessageSetCompose.kt | 148 +- .../amethyst/ui/note/MultiSetCompose.kt | 682 +- .../ui/note/NIP05VerificationDisplay.kt | 504 +- .../amethyst/ui/note/NoteCompose.kt | 5794 +++++++------- .../amethyst/ui/note/NoteQuickActionMenu.kt | 775 +- .../amethyst/ui/note/PollNote.kt | 826 +- .../amethyst/ui/note/PollNoteViewModel.kt | 435 +- .../amethyst/ui/note/PubKeyFormatter.kt | 8 +- .../amethyst/ui/note/ReactionsRow.kt | 2006 ++--- .../amethyst/ui/note/RelayCompose.kt | 166 +- .../amethyst/ui/note/RelayListBox.kt | 66 +- .../amethyst/ui/note/RelayListRow.kt | 255 +- .../amethyst/ui/note/ReplyInformation.kt | 290 +- .../amethyst/ui/note/TimeAgoFormatter.kt | 94 +- .../ui/note/UpdateReactionTypeDialog.kt | 530 +- .../amethyst/ui/note/UpdateZapAmountDialog.kt | 1045 +-- .../amethyst/ui/note/UserCompose.kt | 58 +- .../amethyst/ui/note/UserProfilePicture.kt | 1004 +-- .../amethyst/ui/note/UserReactionsRow.kt | 644 +- .../amethyst/ui/note/UsernameDisplay.kt | 244 +- .../amethyst/ui/note/ZapCustomDialog.kt | 663 +- .../amethyst/ui/note/ZapNoteCompose.kt | 250 +- .../amethyst/ui/note/ZapUserSetCompose.kt | 144 +- .../amethyst/ui/qrcode/QrCodeDrawer.kt | 370 +- .../amethyst/ui/qrcode/QrCodeScanner.kt | 82 +- .../amethyst/ui/qrcode/ShowQRDialog.kt | 194 +- .../amethyst/ui/screen/AccountScreen.kt | 188 +- .../amethyst/ui/screen/AccountState.kt | 16 +- .../ui/screen/AccountStateViewModel.kt | 327 +- .../amethyst/ui/screen/CardFeedState.kt | 110 +- .../amethyst/ui/screen/CardFeedView.kt | 356 +- .../amethyst/ui/screen/CardFeedViewModel.kt | 716 +- .../amethyst/ui/screen/ChatroomFeedView.kt | 182 +- .../ui/screen/ChatroomListFeedView.kt | 106 +- .../amethyst/ui/screen/FeedState.kt | 10 +- .../amethyst/ui/screen/FeedView.kt | 325 +- .../amethyst/ui/screen/FeedViewModel.kt | 542 +- .../amethyst/ui/screen/LnZapFeedState.kt | 8 +- .../amethyst/ui/screen/LnZapFeedView.kt | 60 +- .../amethyst/ui/screen/LnZapFeedViewModel.kt | 134 +- .../amethyst/ui/screen/RelayFeedView.kt | 190 +- .../ui/screen/RememberForeverStates.kt | 148 +- .../ui/screen/SharedPreferencesViewModel.kt | 288 +- .../amethyst/ui/screen/StringFeedState.kt | 8 +- .../amethyst/ui/screen/StringFeedView.kt | 104 +- .../amethyst/ui/screen/StringFeedViewModel.kt | 144 +- .../amethyst/ui/screen/ThreadFeedView.kt | 1119 +-- .../amethyst/ui/screen/UserFeedState.kt | 8 +- .../amethyst/ui/screen/UserFeedView.kt | 70 +- .../amethyst/ui/screen/UserFeedViewModel.kt | 182 +- .../ui/screen/loggedIn/AccountBackupDialog.kt | 194 +- .../ui/screen/loggedIn/AccountViewModel.kt | 2166 +++-- .../ui/screen/loggedIn/BookmarkListScreen.kt | 112 +- .../ui/screen/loggedIn/ChannelScreen.kt | 1708 ++-- .../ui/screen/loggedIn/ChatroomListScreen.kt | 438 +- .../ui/screen/loggedIn/ChatroomScreen.kt | 1110 +-- .../ui/screen/loggedIn/CommunityScreen.kt | 113 +- .../ui/screen/loggedIn/ConnectOrbotDialog.kt | 204 +- .../ui/screen/loggedIn/DiscoverScreen.kt | 447 +- .../ui/screen/loggedIn/GeoHashScreen.kt | 251 +- .../ui/screen/loggedIn/HashtagScreen.kt | 225 +- .../ui/screen/loggedIn/HiddenUsersScreen.kt | 481 +- .../amethyst/ui/screen/loggedIn/HomeScreen.kt | 277 +- .../ui/screen/loggedIn/LoadRedirectScreen.kt | 144 +- .../amethyst/ui/screen/loggedIn/MainScreen.kt | 833 +- .../ui/screen/loggedIn/NotificationScreen.kt | 327 +- .../ui/screen/loggedIn/ProfileScreen.kt | 2562 +++--- .../ui/screen/loggedIn/ReportNoteDialog.kt | 233 +- .../ui/screen/loggedIn/SearchScreen.kt | 614 +- .../ui/screen/loggedIn/SettingsScreen.kt | 366 +- .../ui/screen/loggedIn/ThreadScreen.kt | 75 +- .../ui/screen/loggedIn/VideoScreen.kt | 609 +- .../ui/screen/loggedOff/LoginScreen.kt | 741 +- .../vitorpamplona/amethyst/ui/theme/Shape.kt | 64 +- .../vitorpamplona/amethyst/ui/theme/Theme.kt | 404 +- .../vitorpamplona/amethyst/ui/theme/Type.kt | 76 +- .../service/lang/LanguageTranslatorService.kt | 310 +- .../PushNotificationReceiverService.kt | 90 +- .../notifications/PushNotificationUtils.kt | 26 +- .../components/SelectNotificationProvider.kt | 2 +- .../components/TranslatableRichTextViewer.kt | 543 +- .../com/vitorpamplona/amethyst/CharsetTest.kt | 128 +- .../amethyst/NewMessageTaggerKeyParseTest.kt | 282 +- .../vitorpamplona/amethyst/SplitterTest.kt | 133 +- .../service/Nip05NostrAddressVerifierTest.kt | 170 +- .../amethyst/service/Nip30Test.kt | 82 +- .../amethyst/service/Nip96Test.kt | 42 +- .../amethyst/service/zaps/UserZapsTest.kt | 74 +- .../amethyst/benchmark/BechBenchmark.kt | 102 +- .../amethyst/benchmark/ContainsBenchmark.kt | 114 +- .../amethyst/benchmark/CryptoBenchmark.kt | 116 +- .../amethyst/benchmark/EventBenchmark.kt | 112 +- .../amethyst/benchmark/GiftWrapBenchmark.kt | 244 +- .../benchmark/GiftWrapReceivingBenchmark.kt | 308 +- .../benchmark/GiftWrapSigningBenchmark.kt | 298 +- .../amethyst/benchmark/HexBenchmark.kt | 46 +- .../amethyst/benchmark/RobohashBenchmark.kt | 28 +- build.gradle | 2 +- .../vitorpamplona/quartz/ChatroomKeyTest.kt | 14 +- .../com/vitorpamplona/quartz/CitationTests.kt | 28 +- .../vitorpamplona/quartz/CryptoUtilsTest.kt | 134 +- .../com/vitorpamplona/quartz/EventSigCheck.kt | 18 +- .../vitorpamplona/quartz/GiftWrapEventTest.kt | 1124 +-- .../vitorpamplona/quartz/HexEncodingTest.kt | 70 +- .../quartz/LargeDBSignatureCheck.kt | 31 +- .../vitorpamplona/quartz/LnInvoiceUtilTest.kt | 28 +- .../com/vitorpamplona/quartz/NIP44v2Test.kt | 274 +- .../vitorpamplona/quartz/PrivateZapTests.kt | 172 +- .../quartz/crypto/CryptoUtils.kt | 597 +- .../com/vitorpamplona/quartz/crypto/Hkdf.kt | 70 +- .../vitorpamplona/quartz/crypto/KeyPair.kt | 48 +- .../com/vitorpamplona/quartz/crypto/Nip04.kt | 248 +- .../vitorpamplona/quartz/crypto/Nip44v1.kt | 254 +- .../vitorpamplona/quartz/crypto/Nip44v2.kt | 460 +- .../quartz/crypto/SharedKeyCache.kt | 52 +- .../quartz/crypto/SodiumUtils.kt | 118 +- .../com/vitorpamplona/quartz/encoders/ATag.kt | 124 +- .../quartz/encoders/Bech32Util.kt | 434 +- .../vitorpamplona/quartz/encoders/HexUtils.kt | 143 +- .../quartz/encoders/LnInvoiceUtil.kt | 574 +- .../quartz/encoders/LnWithdrawalUtil.kt | 44 +- .../vitorpamplona/quartz/encoders/Lud06.kt | 42 +- .../vitorpamplona/quartz/encoders/Nip19.kt | 298 +- .../com/vitorpamplona/quartz/encoders/Tlv.kt | 124 +- .../quartz/events/AdvertisedRelayListEvent.kt | 106 +- .../quartz/events/AppDefinitionEvent.kt | 96 +- .../quartz/events/AppRecommendationEvent.kt | 43 +- .../quartz/events/AudioHeaderEvent.kt | 92 +- .../quartz/events/AudioTrackEvent.kt | 85 +- .../quartz/events/BadgeAwardEvent.kt | 24 +- .../quartz/events/BadgeDefinitionEvent.kt | 28 +- .../quartz/events/BadgeProfilesEvent.kt | 40 +- .../quartz/events/BaseTextNoteEvent.kt | 240 +- .../quartz/events/BookmarkListEvent.kt | 384 +- .../quartz/events/CalendarDateSlotEvent.kt | 44 +- .../quartz/events/CalendarEvent.kt | 34 +- .../quartz/events/CalendarRSVPEvent.kt | 48 +- .../quartz/events/CalendarTimeSlotEvent.kt | 52 +- .../quartz/events/ChannelCreateEvent.kt | 118 +- .../quartz/events/ChannelHideMessageEvent.kt | 52 +- .../quartz/events/ChannelMessageEvent.kt | 110 +- .../quartz/events/ChannelMetadataEvent.kt | 120 +- .../quartz/events/ChannelMuteUserEvent.kt | 54 +- .../quartz/events/ChatMessageEvent.kt | 128 +- .../quartz/events/ClassifiedsEvent.kt | 264 +- .../quartz/events/CommunityDefinitionEvent.kt | 45 +- .../events/CommunityPostApprovalEvent.kt | 114 +- .../quartz/events/ContactListEvent.kt | 741 +- .../quartz/events/DeletionEvent.kt | 42 +- .../quartz/events/EmojiPackEvent.kt | 68 +- .../quartz/events/EmojiPackSelectionEvent.kt | 46 +- .../com/vitorpamplona/quartz/events/Event.kt | 846 +- .../quartz/events/EventFactory.kt | 203 +- .../quartz/events/EventInterface.kt | 118 +- .../quartz/events/FileHeaderEvent.kt | 151 +- .../quartz/events/FileServersEvent.kt | 46 +- .../quartz/events/FileStorageEvent.kt | 83 +- .../quartz/events/FileStorageHeaderEvent.kt | 135 +- .../quartz/events/GeneralListEvent.kt | 302 +- .../quartz/events/GenericRepostEvent.kt | 84 +- .../quartz/events/GiftWrapEvent.kt | 126 +- .../vitorpamplona/quartz/events/GoalEvent.kt | 86 +- .../quartz/events/HTTPAuthorizationEvent.kt | 52 +- .../quartz/events/HighlightEvent.kt | 42 +- .../events/LiveActivitiesChatMessageEvent.kt | 126 +- .../quartz/events/LiveActivitiesEvent.kt | 95 +- .../vitorpamplona/quartz/events/LnZapEvent.kt | 106 +- .../quartz/events/LnZapEventInterface.kt | 12 +- .../quartz/events/LnZapPaymentRequestEvent.kt | 132 +- .../events/LnZapPaymentResponseEvent.kt | 194 +- .../quartz/events/LnZapPrivateEvent.kt | 36 +- .../quartz/events/LnZapRequestEvent.kt | 362 +- .../quartz/events/LongTextNoteEvent.kt | 80 +- .../quartz/events/MetadataEvent.kt | 228 +- .../quartz/events/MuteListEvent.kt | 536 +- .../quartz/events/NIP24Factory.kt | 314 +- .../vitorpamplona/quartz/events/NNSEvent.kt | 40 +- .../quartz/events/PeopleListEvent.kt | 620 +- .../quartz/events/PinListEvent.kt | 42 +- .../quartz/events/PollNoteEvent.kt | 145 +- .../quartz/events/PrivateDmEvent.kt | 224 +- .../quartz/events/ReactionEvent.kt | 140 +- .../quartz/events/RecommendRelayEvent.kt | 36 +- .../quartz/events/RelayAuthEvent.kt | 50 +- .../quartz/events/RelaySetEvent.kt | 44 +- .../quartz/events/ReportEvent.kt | 180 +- .../quartz/events/RepostEvent.kt | 80 +- .../quartz/events/SealedGossipEvent.kt | 198 +- .../quartz/events/StatusEvent.kt | 86 +- .../quartz/events/TextNoteEvent.kt | 227 +- .../vitorpamplona/quartz/events/VideoEvent.kt | 170 +- .../quartz/events/VideoHorizontalEvent.kt | 96 +- .../quartz/events/VideoVerticalEvent.kt | 96 +- .../quartz/events/VideoViewEvent.kt | 90 +- .../quartz/signers/ExternalSignerLauncher.kt | 620 +- .../quartz/signers/NostrSigner.kt | 62 +- .../quartz/signers/NostrSignerExternal.kt | 246 +- .../quartz/signers/NostrSignerInternal.kt | 406 +- .../vitorpamplona/quartz/utils/Robohash.kt | 652 +- .../vitorpamplona/quartz/utils/StringUtils.kt | 58 +- .../vitorpamplona/quartz/utils/TimeUtils.kt | 34 +- .../quartz/encoders/Lud06Test.kt | 14 +- .../quartz/encoders/NIP19ParserTest.kt | 478 +- .../quartz/encoders/Nip19Test.kt | 116 +- .../quartz/encoders/TlvIntegerTest.kt | 46 +- 415 files changed, 64333 insertions(+), 64218 deletions(-) diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt index a871ac250..fa6e9dcae 100644 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.service.Nip96Retriever import com.vitorpamplona.amethyst.service.Nip96Uploader import com.vitorpamplona.amethyst.ui.actions.ImageDownloader import com.vitorpamplona.quartz.crypto.KeyPair -import java.util.Base64 import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertTrue import junit.framework.TestCase.fail @@ -36,107 +35,114 @@ import kotlinx.coroutines.runBlocking import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith +import java.util.Base64 @RunWith(AndroidJUnit4::class) class ImageUploadTesting { - val contentType = "image/gif" - val image = - "R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw==" + val contentType = "image/gif" + val image = + "R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw==" - val contentTypePng = "image/png" - val imagePng = - "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=" + val contentTypePng = "image/png" + val imagePng = + "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=" - private suspend fun testBase(server: Nip96MediaServers.ServerName) { - val serverInfo = - Nip96Retriever() - .loadInfo( - server.baseUrl, + private suspend fun testBase(server: Nip96MediaServers.ServerName) { + val serverInfo = + Nip96Retriever() + .loadInfo( + server.baseUrl, + ) + + val bytes = Base64.getDecoder().decode(imagePng) + val inputStream = bytes.inputStream() + + val account = Account(KeyPair()) + + val result = + Nip96Uploader(account) + .uploadImage( + inputStream, + bytes.size.toLong(), + contentTypePng, + alt = null, + sensitiveContent = null, + serverInfo, + onProgress = {}, + ) + + val url = result.tags!!.first { it[0] == "url" }.get(1) + val size = result.tags!!.firstOrNull { it[0] == "size" }?.get(1)?.ifBlank { null } + val dim = result.tags!!.firstOrNull { it[0] == "dim" }?.get(1)?.ifBlank { null } + val hash = result.tags!!.firstOrNull { it[0] == "x" }?.get(1)?.ifBlank { null } + val contentType = result.tags!!.first { it[0] == "m" }.get(1) + val ox = result.tags!!.first { it[0] == "ox" }.get(1) + + Assert.assertTrue(url.startsWith("http")) + + val imageData: ByteArray = + ImageDownloader().waitAndGetImage(url) + ?: run { + fail("Should not be null") + return + } + + FileHeader.prepare( + imageData, + contentTypePng, + null, + onReady = { + if (dim != null) { + assertEquals(dim, it.dim) + } + if (size != null) { + assertEquals(size, it.size.toString()) + } + if (hash != null) { + assertEquals(hash, it.hash) + } + }, + onError = { fail("It should not fail") }, ) - val bytes = Base64.getDecoder().decode(imagePng) - val inputStream = bytes.inputStream() + // delay(1000) - val account = Account(KeyPair()) + // assertTrue(Nip96Uploader(account).delete(ox, contentType, serverInfo)) + } - val result = - Nip96Uploader(account) - .uploadImage( - inputStream, - bytes.size.toLong(), - contentTypePng, - alt = null, - sensitiveContent = null, - serverInfo, - onProgress = {}, - ) - - val url = result.tags!!.first { it[0] == "url" }.get(1) - val size = result.tags!!.firstOrNull { it[0] == "size" }?.get(1)?.ifBlank { null } - val dim = result.tags!!.firstOrNull { it[0] == "dim" }?.get(1)?.ifBlank { null } - val hash = result.tags!!.firstOrNull { it[0] == "x" }?.get(1)?.ifBlank { null } - val contentType = result.tags!!.first { it[0] == "m" }.get(1) - val ox = result.tags!!.first { it[0] == "ox" }.get(1) - - Assert.assertTrue(url.startsWith("http")) - - val imageData: ByteArray = - ImageDownloader().waitAndGetImage(url) - ?: run { - fail("Should not be null") - return + @Test() + fun testNostrCheck() = + runBlocking { + testBase(Nip96MediaServers.ServerName("nostrcheck.me", "https://nostrcheck.me")) } - FileHeader.prepare( - imageData, - contentTypePng, - null, - onReady = { - if (dim != null) { - assertEquals(dim, it.dim) + @Test() + fun testNostrage() = + runBlocking { + testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com")) } - if (size != null) { - assertEquals(size, it.size.toString()) + + @Test() + fun testSove() = + runBlocking { + testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent")) } - if (hash != null) { - assertEquals(hash, it.hash) + + @Test() + fun testNostrBuild() = + runBlocking { + testBase(Nip96MediaServers.ServerName("nostr.build", "https://nostr.build")) } - }, - onError = { fail("It should not fail") }, - ) - // delay(1000) + @Test() + fun testSovbit() = + runBlocking { + testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host")) + } - // assertTrue(Nip96Uploader(account).delete(ox, contentType, serverInfo)) - } - - @Test() - fun testNostrCheck() = runBlocking { - testBase(Nip96MediaServers.ServerName("nostrcheck.me", "https://nostrcheck.me")) - } - - @Test() - fun testNostrage() = runBlocking { - testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com")) - } - - @Test() - fun testSove() = runBlocking { - testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent")) - } - - @Test() - fun testNostrBuild() = runBlocking { - testBase(Nip96MediaServers.ServerName("nostr.build", "https://nostr.build")) - } - - @Test() - fun testSovbit() = runBlocking { - testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host")) - } - - @Test() - fun testVoidCat() = runBlocking { - testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat")) - } + @Test() + fun testVoidCat() = + runBlocking { + testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat")) + } } diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/RichTextParserTest.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/RichTextParserTest.kt index b7b0318f8..6ce7ea618 100644 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/RichTextParserTest.kt +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/RichTextParserTest.kt @@ -30,8 +30,8 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class RichTextParserTest { - private val textToParse = - """ + private val textToParse = + """ ๐Ÿ“ฐ 24 Hour NostrInspector Report ๐Ÿ•ต (TEXT ONLY VERSION) Generated Friday June 30 2023 03:59:01 UTC-6 (CST) @@ -684,3542 +684,3542 @@ class RichTextParserTest { ๐Ÿ•ต๏ธ @nostrin "The Nostr Inspector" npub17m7f7q08k4x746s2v45eyvwppck32dcahw7uj2mu5txuswldgqkqw9zms7 """ - .trimIndent() + .trimIndent() - @Test - fun testTextToParse() { - val state = RichTextParser().parseText(textToParse, EmptyTagList) - Assert.assertEquals( - "relay.shitforce.one, relayable.org, universe.nostrich.land, nos.lol, universe.nostrich.land?lang=zh, universe.nostrich.land?lang=en, relay.damus.io, relay.nostr.wirednet.jp, offchain.pub, nostr.rocks, relay.wellorder.net, nostr.oxtr.dev, universe.nostrich.land?lang=ja, relay.mostr.pub, nostr.bitcoiner.social, Nostr-Check.com, MR.Rabbit, Ancap.su, zapper.lol, smies.me, baller.hodl", - state.urlSet.joinToString(", "), - ) - - printStateForDebug(state) - - val expectedResult = - listOf( - "RegularText(๐Ÿ“ฐ 24 Hour NostrInspector Report ๐Ÿ•ต (TEXT ONLY VERSION))", - "RegularText()", - "RegularText(Generated Friday June 30 2023 03:59:01 UTC-6 (CST))", - "RegularText()", - "RegularText(Network statistics)", - "RegularText()", - "RegularText(New events witnessed (top 110 relays) )", - "RegularText()", - "RegularText(Kind, count, (% count), size, (% size))", - "RegularText(1, 207.9K, (28.8%), 458.02MB, (9.2%))", - "RegularText(7, 158.3K, (22%), 280.83MB, (5.7%))", - "RegularText(0, 84.1K, (11.7%), 192.89MB, (3.9%))", - "RegularText(9735, 57.2K, (7.9%), 353.16MB, (7.1%))", - "RegularText(3, 54.7K, (7.6%), 2.75GB, (56.7%))", - "RegularText(6, 31.6K, (4.4%), 111.27MB, (2.2%))", - "RegularText(4, 30.8K, (4.3%), 89.79MB, (1.8%))", - "RegularText(30000, 29.1K, (4%), 115.33MB, (2.3%))", - "RegularText(30078, 12.1K, (1.7%), 317.25MB, (6.4%))", - "RegularText(5, 11K, (1.5%), 16.86MB, (0.3%))", - "RegularText(10002, 8.6K, (1.2%), 16.59MB, (0.3%))", - "RegularText(1311, 7.7K, (1.1%), 12.71MB, (0.3%))", - "RegularText(1984, 6.3K, (0.9%), 10.93MB, (0.2%))", - "RegularText(9734, 3.7K, (0.5%), 10.88MB, (0.2%))", - "RegularText(30001, 3.1K, (0.4%), 66.91MB, (1.3%))", - "RegularText(1000, 2.8K, (0.4%), 13.43MB, (0.3%))", - "RegularText(20100, 1.4K, (0.2%), 2.32MB, (0%))", - "RegularText(42, 1.1K, (0.2%), 2.30MB, (0%))", - "RegularText(13194, 1K, (0.1%), 1.22MB, (0%))", - "RegularText(1063, 875, (0.1%), 1.96MB, (0%))", - "RegularText()", - "RegularText(New events by relay (top 50%))", - "RegularText()", - "RegularText(Events (%) Relay)", - "RegularText(33.4K)", - "RegularText((4.6%))", - "Link(relay.shitforce.one)", - "RegularText(32.9K)", - "RegularText((4.6%))", - "Link(relayable.org)", - "RegularText(26.6K)", - "RegularText((3.7%))", - "Link(universe.nostrich.land)", - "RegularText(22.8K)", - "RegularText((3.2%))", - "Link(nos.lol)", - "RegularText(22.7K)", - "RegularText((3.1%))", - "Link(universe.nostrich.land?lang=zh)", - "RegularText(22.5K)", - "RegularText((3.1%))", - "Link(universe.nostrich.land?lang=en)", - "RegularText(21.2K)", - "RegularText((2.9%))", - "Link(relay.damus.io)", - "RegularText(20.6K)", - "RegularText((2.9%))", - "Link(relay.nostr.wirednet.jp)", - "RegularText(20.1K)", - "RegularText((2.8%))", - "Link(offchain.pub)", - "RegularText(19.9K)", - "RegularText((2.8%))", - "Link(nostr.rocks)", - "RegularText(19.5K)", - "RegularText((2.7%))", - "Link(relay.wellorder.net)", - "RegularText(19.4K)", - "RegularText((2.7%))", - "Link(nostr.oxtr.dev)", - "RegularText(19K)", - "RegularText((2.6%))", - "Link(universe.nostrich.land?lang=ja)", - "RegularText(18.4K)", - "RegularText((2.6%))", - "Link(relay.mostr.pub)", - "RegularText(17.5K)", - "RegularText((2.4%))", - "Link(universe.nostrich.land?lang=zh)", - "RegularText(16.3K)", - "RegularText((2.3%))", - "Link(nostr.bitcoiner.social)", - "RegularText()", - "RegularText(30 day global new events)", - "RegularText()", - "RegularText(23-05-29 1M)", - "RegularText(23-05-30 861.9K)", - "RegularText(23-05-31 752.5K)", - "RegularText(23-06-01 0.9M)", - "RegularText(23-06-02 808.9K)", - "RegularText(23-06-03 683.8K)", - "RegularText(23-06-04 0.9M)", - "RegularText(23-06-05 890.6K)", - "RegularText(23-06-06 839.4K)", - "RegularText(23-06-07 827K)", - "RegularText(23-06-08 804.8K)", - "RegularText(23-06-09 736.7K)", - "RegularText(23-06-10 709.7K)", - "RegularText(23-06-11 772.2K)", - "RegularText(23-06-12 882K)", - "RegularText(23-06-13 794.9K)", - "RegularText(23-06-14 842.2K)", - "RegularText(23-06-15 812.1K)", - "RegularText(23-06-16 839.6K)", - "RegularText(23-06-17 730.2K)", - "RegularText(23-06-18 811.9K)", - "RegularText(23-06-19 721.9K)", - "RegularText(23-06-20 786.2K)", - "RegularText(23-06-21 756.6K)", - "RegularText(23-06-22 736K)", - "RegularText(23-06-23 723.5K)", - "RegularText(23-06-24 703.9K)", - "RegularText(23-06-25 734.9K)", - "RegularText(23-06-26 742.4K)", - "RegularText(23-06-27 707.8K)", - "RegularText(23-06-28 747.7K)", - "RegularText()", - "RegularText(Social Network Statistics)", - "RegularText()", - "RegularText(Top 30 hashtags found today)", - "RegularText()", - "HashTag(#hashtag,)", - "RegularText(mentions)", - "RegularText(today,)", - "RegularText(days)", - "RegularText(in)", - "RegularText(top)", - "RegularText(30)", - "RegularText()", - "HashTag(#bitcoin,)", - "RegularText(1.7K,)", - "RegularText(109)", - "HashTag(#concussion,)", - "RegularText(1.1K,)", - "RegularText(25)", - "HashTag(#press,)", - "RegularText(0.9K,)", - "RegularText(65)", - "HashTag(#france,)", - "RegularText(492,)", - "RegularText(46)", - "HashTag(#presse,)", - "RegularText(480,)", - "RegularText(42)", - "HashTag(#covid19,)", - "RegularText(465,)", - "RegularText(65)", - "HashTag(#nostr,)", - "RegularText(414,)", - "RegularText(109)", - "HashTag(#zapathon,)", - "RegularText(386,)", - "RegularText(76)", - "HashTag(#rssfeed,)", - "RegularText(309,)", - "RegularText(53)", - "HashTag(#btc,)", - "RegularText(299,)", - "RegularText(109)", - "HashTag(#news,)", - "RegularText(294,)", - "RegularText(91)", - "HashTag(#zap,)", - "RegularText(283,)", - "RegularText(109)", - "HashTag(#linux,)", - "RegularText(253,)", - "RegularText(88)", - "HashTag(#respond,)", - "RegularText(246,)", - "RegularText(90)", - "HashTag(#kompost,)", - "RegularText(240,)", - "RegularText(31)", - "HashTag(#plebchain,)", - "RegularText(236,)", - "RegularText(109)", - "HashTag(#gardenaward,)", - "RegularText(236,)", - "RegularText(31)", - "HashTag(#start,)", - "RegularText(236,)", - "RegularText(31)", - "HashTag(#unicef,)", - "RegularText(233,)", - "RegularText(32)", - "HashTag(#coronavirus,)", - "RegularText(233,)", - "RegularText(33)", - "HashTag(#bew,)", - "RegularText(229,)", - "RegularText(31)", - "HashTag(#balkon,)", - "RegularText(229,)", - "RegularText(31)", - "HashTag(#terrasse,)", - "RegularText(229,)", - "RegularText(31)", - "HashTag(#braininjuryawareness,)", - "RegularText(229,)", - "RegularText(24)", - "HashTag(#garten,)", - "RegularText(220,)", - "RegularText(21)", - "HashTag(#smart,)", - "RegularText(220,)", - "RegularText(21)", - "HashTag(#nsfw,)", - "RegularText(211,)", - "RegularText(85)", - "HashTag(#protoncalendar,)", - "RegularText(206,)", - "RegularText(31)", - "HashTag(#stacksats,)", - "RegularText(195,)", - "RegularText(99)", - "HashTag(#nokyc,)", - "RegularText(179,)", - "RegularText(98)", - "RegularText()", - "RegularText(Emoji sentiment today)", - "RegularText()", - "RegularText(โšก (1.6K) ๐Ÿ‘‰ (1.4K) ๐Ÿ‡ช๐Ÿ‡บ (1.2K) ๐Ÿซ‚ (1.2K) ๐Ÿ‡บ๐Ÿ‡ธ (1.1K) ๐Ÿ’œ (875) ๐Ÿง  (858) ๐Ÿ˜‚ (830) ๐Ÿ”ฅ (690) ๐Ÿคฃ (566) ๐Ÿค™ (525) โ˜• (444) ๐Ÿ‘‡ (443) ๐Ÿ™Œ๐Ÿป (425) โ˜€ (307) ๐Ÿ˜Ž (305) ๐Ÿฅณ (301) ๐Ÿค” (276) ๐ŸŒป (270) ๐Ÿงก (270) ๐Ÿฅ‡ (269) ๐Ÿ—“ (269) ๐Ÿ™ (268) ๐Ÿ† (267) ๐ŸŒฑ (264) ๐Ÿ“ฐ (230) ๐Ÿ‰ (221) ๐Ÿ˜ญ (220) ๐Ÿ’ฐ (219) ๐Ÿ”— (209) ๐Ÿ‘€ (201) ๐Ÿ˜… (199) โœจ (193) ๐Ÿ‡ท๐Ÿ‡บ (182) ๐Ÿ’ช (167) โœ… (164) ๐Ÿ’ค (163) ๐Ÿถ (151) ๐Ÿ‡จ๐Ÿ‡ญ (141) ๐Ÿ“ (137) ๐Ÿ˜ (136) ๐ŸŒž (136) ๐Ÿพ (136) โค (132) ๐Ÿ’ป (126) ๐Ÿš€ (125) ๐Ÿ‘ (125) ๐Ÿ‡ง๐Ÿ‡ท (125) ๐Ÿ˜Š (121) ๐Ÿ“š (120) โžก (120) ๐Ÿ‘ (118) ๐ŸŽ‰ (117) ๐ŸŽฎ (115) ๐Ÿคท (113) ๐Ÿ‘‹ (112) ๐Ÿ’ƒ (108) ๐Ÿ•บ๐Ÿป (106) ๐Ÿ’ก (104) ๐Ÿšจ (99) ๐Ÿ˜† (97) ๐Ÿ’ฏ (95) โš  (92) ๐Ÿ“ข (92) ๐Ÿค— (89) ๐Ÿ˜ด (87) ๐Ÿ” (83) ๐Ÿฐ (81) ๐Ÿ˜€ (79) ๐ŸŽŸ (78) โ› (78) ๐Ÿฆ (76) ๐Ÿ’ธ (76) โœŒ๐Ÿป (75) ๐Ÿค (73) ๐Ÿ‡ฌ๐Ÿ‡ง (73) ๐ŸŒฝ (70) ๐Ÿคก (69) ๐Ÿคฎ (69) โ— (66) ๐Ÿค (65) ๐Ÿ˜‰ (65) ๐Ÿ™‡ (65) ๐Ÿป (64) ๐ŸŒ (64) ๐Ÿ’• (63) ๐ŸŒธ (62) ๐Ÿ’ฌ (61) โ˜บ (61) ๐Ÿ‡ฆ๐Ÿ‡ท (59) ๐Ÿ‡ฎ๐Ÿ‡ฉ (57) ๐Ÿ˜ณ (57) ๐Ÿ˜„ (57) ๐ŸŽถ (57) ๐Ÿฅท๐Ÿป (56) ๐ŸŽต (56) ๐Ÿ˜ƒ (56) ๐Ÿ” (55) ๐Ÿ’ฅ (55) ๐ŸŽฒ (54) โœ (54) ๐Ÿ•’ (53) โฌ‡ (53) ๐Ÿ’™ (51) ๐Ÿ”’ (50) ๐Ÿ“ˆ (50) ๐Ÿช™ (50) ๐ŸŒง (50) ๐Ÿฅฐ (50) ๐Ÿ•ธ (50) ๐ŸŒ (50) ๐Ÿ’ญ (49) ๐ŸŒ™ (49) ๐Ÿ˜ (49) ๐Ÿ“ฑ (48) ๐ŸŒŸ (48) ๐Ÿคฉ (48) ๐Ÿ’” (47) ๐Ÿ”Œ (47) ๐Ÿ˜‹ (47) ๐ŸŽ– (47) ๐Ÿฃ (46) ๐Ÿ“ท (46) ๐Ÿ’ผ (45) โญ (45) ๐Ÿฅ” (45) ๐Ÿฅบ (45) ๐Ÿ‘Œ (44) ๐Ÿ‘ท๐Ÿผ (43) ๐Ÿ˜ฑ (43) ๐Ÿ“… (43) ๐Ÿค– (43) ๐Ÿ“ธ (42) ๐Ÿ“Š (42) ๐Ÿฆ‘ (40) ๐Ÿ’ต (40) ๐Ÿคฆ (39) โฃ (38) ๐Ÿ’Ž (38) ๐Ÿ–ค (38) ๐Ÿ“บ (37) ๐Ÿ‡ต๐Ÿ‡ฑ (37) ๐Ÿ‡ฏ๐Ÿ‡ต (36) ๐Ÿ”ง (36) ๐Ÿค˜ (36) ๐Ÿ’– (36) โ€ผ (35) ๐Ÿ˜ข (35) ๐Ÿ˜บ (34) ๐Ÿ”Š (34) ๐Ÿ˜ (34) ๐Ÿ‡ธ๐Ÿ‡ฐ (34) ๐Ÿƒ (34) ๐Ÿ‘ฉโ€๐Ÿ‘ง (34) โฐ (33) ๐Ÿ‘จโ€๐Ÿ’ป (33) ๐Ÿ‘‘ (33) ๐Ÿ‘ฅ (32) ๐Ÿ–ฅ (32) ๐Ÿ’จ (32) ๐Ÿ’— (31) ๐Ÿ‡ฒ๐Ÿ‡ฝ (31) ๐Ÿ“– (31) ๐Ÿšซ (31) ๐Ÿ‘Š๐Ÿป (31) ๐Ÿ˜ก (31) ๐ŸŒŽ (31) ๐Ÿ‘ (30) ๐Ÿ—ž (30) ๐Ÿ€ (30) ๐Ÿฝ (29) ๐Ÿธ (29) ๐Ÿฅš (29) ๐Ÿ’ฉ (29) โœŠ๐Ÿพ (29) ๐Ÿ˜ฎ (29) ๐ŸŒก (29) ๐Ÿ™ƒ (28) ๐Ÿ”” (28) ๐Ÿ‡ป๐Ÿ‡ช (28) ๐Ÿ’ฆ (28) ๐ŸŽฏ (28) ๐ŸŽจ (28) ๐Ÿ› (28) ๐Ÿ–ผ (27) โ˜๐Ÿป (27) ๐Ÿ›‘ (27) ๐Ÿ™„ (27) ๐Ÿง‘๐Ÿปโ€๐Ÿคโ€๐Ÿง‘๐Ÿฝ (27) ๐ŸŒˆ (27) ๐Ÿฅ‚ (26) ๐Ÿ‡ซ๐Ÿ‡ฎ (26) ๐ŸŽฅ (26) ๐Ÿ˜ฌ (26) ๐Ÿฅฒ (25) ๐Ÿฆพ (24) ๐Ÿคœ (24) ๐Ÿ™‚ (24) ๐Ÿ–• (24) ๐Ÿ˜ฉ (24) )", - "RegularText()", - "RegularText(Zap economy)", - "RegularText()", - "RegularText(โšก41.7M sats (โ‚ฟ0.417) )", - "RegularText(1,816 zappers & 920 zapped (unique pubkeys))", - "RegularText(๐ŸŒฉ๏ธ 33,248 zaps, 1,253 sats per zap (avg))", - "RegularText()", - "RegularText(Most followed )", - "RegularText()", - "HashTag(#1)", - "RegularText(30%)", - "RegularText(jb55,)", - "Email(jb55@jb55.com)", - "RegularText(-)", - "RegularText(32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245)", - "HashTag(#2)", - "RegularText(19%)", - "RegularText(Snowden,)", - "Email(Snowden@Nostr-Check.com)", - "RegularText(-)", - "RegularText(84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240)", - "HashTag(#3)", - "RegularText(18%)", - "RegularText(cameri,)", - "Email(cameri@elder.nostr.land)", - "RegularText(-)", - "RegularText(00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700)", - "HashTag(#4)", - "RegularText(11%)", - "RegularText(Natalie,)", - "Email(natalie@NostrVerified.com)", - "RegularText(-)", - "RegularText(edcd20558f17d99327d841e4582f9b006331ac4010806efa020ef0d40078e6da)", - "HashTag(#5)", - "RegularText(11%)", - "RegularText(saifedean,)", - "RegularText()", - "RegularText(-)", - "RegularText(4379e76bfa76a80b8db9ea759211d90bb3e67b2202f8880cc4f5ffe2065061ad)", - "HashTag(#6)", - "RegularText(11%)", - "RegularText(alanbwt,)", - "Email(alanbwt@nostrplebs.com)", - "RegularText(-)", - "RegularText(1bd32a386a7be6f688b3dc7c480efc21cd946b43eac14ba4ba7834ac77a23e69)", - "HashTag(#7)", - "RegularText(10%)", - "RegularText(rick,)", - "Email(rick@no.str.cr)", - "RegularText(-)", - "RegularText(978c8f26ea9b3c58bfd4c8ddfde83741a6c2496fab72774109fe46819ca49708)", - "HashTag(#8)", - "RegularText(9%)", - "RegularText(shawn,)", - "Email(shawn@shawnyeager.com)", - "RegularText(-)", - "RegularText(c7eda660a6bc8270530e82b4a7712acdea2e31dc0a56f8dc955ac009efd97c86)", - "HashTag(#9)", - "RegularText(9%)", - "RegularText(0xtr,)", - "Email(0xtr@oxtr.dev)", - "RegularText(-)", - "RegularText(b2d670de53b27691c0c3400225b65c35a26d06093bcc41f48ffc71e0907f9d4a)", - "HashTag(#10)", - "RegularText(9%)", - "RegularText(stick,)", - "Email(pavol@rusnak.io)", - "RegularText(-)", - "RegularText(d7f0e3917c466f1e2233e9624fbd6d4bd1392dbcfcaf3574f457569d496cb731)", - "HashTag(#11)", - "RegularText(9%)", - "RegularText(caitlinlong,)", - "Email(caitlin@nostrverified.com)", - "RegularText(-)", - "RegularText(e1055729d51e037b3c14e8c56e2c79c22183385d94aadb32e5dc88092cd0fef4)", - "HashTag(#12)", - "RegularText(9%)", - "RegularText(ralf,)", - "Email(ralf@snort.social)", - "RegularText(-)", - "RegularText(c89cf36deea286da912d4145f7140c73495d77e2cfedfb652158daa7c771f2f8)", - "HashTag(#13)", - "RegularText(9%)", - "RegularText(StackSats,)", - "Email(stacksats@nostrplebs.com)", - "RegularText(-)", - "RegularText(b93049a6e2547a36a7692d90e4baa809012526175546a17337454def9ab69d30)", - "HashTag(#14)", - "RegularText(9%)", - "RegularText(MrHodl,)", - "Email(MrHodl@nostrpurple.com)", - "RegularText(-)", - "RegularText(29fbc05acee671fb579182ca33b0e41b455bb1f9564b90a3d8f2f39dee3f2779)", - "HashTag(#15)", - "RegularText(9%)", - "RegularText(mikedilger,)", - "Email(_@mikedilger.com)", - "RegularText(-)", - "RegularText(ee11a5dff40c19a555f41fe42b48f00e618c91225622ae37b6c2bb67b76c4e49)", - "HashTag(#16)", - "RegularText(9%)", - "RegularText(jascha,)", - "Email(jascha@relayable.org)", - "RegularText(-)", - "RegularText(2479739594ed5802a96703e5a870b515d986982474a71feae180e8ecffa302c6)", - "HashTag(#17)", - "RegularText(8%)", - "RegularText(Nakadaimon,)", - "Email(Nakadaimon@nostrplebs.com)", - "RegularText(-)", - "RegularText(803a613997a26e8714116f99aa1f98e8589cb6116e1aaa1fc9c389984fcd9bb8)", - "HashTag(#18)", - "RegularText(8%)", - "RegularText(KeithMukai,)", - "Email(KeithMukai@nostr.seedsigner.com)", - "RegularText(-)", - "RegularText(5b0e8da6fdfba663038690b37d216d8345a623cc33e111afd0f738ed7792bc54)", - "HashTag(#19)", - "RegularText(8%)", - "RegularText(TheGuySwann,)", - "Email(theguyswann@NostrVerified.com)", - "RegularText(-)", - "RegularText(b0b8fbd9578ac23e782d97a32b7b3a72cda0760761359bd65661d42752b4090a)", - "HashTag(#20)", - "RegularText(8%)", - "RegularText(dk,)", - "Email(dk@stacker.news)", - "RegularText(-)", - "RegularText(b708f7392f588406212c3882e7b3bc0d9b08d62f95fa170d099127ece2770e5e)", - "HashTag(#21)", - "RegularText(7%)", - "RegularText(zerohedge,)", - "Email(npub1z7eqn5603ltuxr77w70t3sasep8hyngzr6lxqpa9hfcqjwe9wmdqhw0qhv@nost.vip)", - "RegularText(-)", - "RegularText(17b209d34f8fd7c30fde779eb8c3b0c84f724d021ebe6007a5ba70093b2576da)", - "HashTag(#22)", - "RegularText(7%)", - "RegularText(miljan,)", - "Email(miljan@primal.net)", - "RegularText(-)", - "RegularText(d61f3bc5b3eb4400efdae6169a5c17cabf3246b514361de939ce4a1a0da6ef4a)", - "HashTag(#23)", - "RegularText(7%)", - "RegularText(jared,)", - "Email(jared@nostrplebs.com)", - "RegularText(-)", - "RegularText(92e3aac668edb25319edd1d87cadef0b189557fdd13b123d82a19d67fd211909)", - "HashTag(#24)", - "RegularText(7%)", - "RegularText(radii,)", - "Email(radii@orangepill.dev)", - "RegularText(-)", - "RegularText(acedd3597025cb13b84f9a89643645aeb61a3b4a3af8d7ac01f8553171bf17c5)", - "HashTag(#25)", - "RegularText(7%)", - "RegularText(katie,)", - "Email(_@katieannbaker.com)", - "RegularText(-)", - "RegularText(07eced8b63b883cedbd8520bdb3303bf9c2b37c2c7921ca5c59f64e0f79ad2a6)", - "HashTag(#26)", - "RegularText(7%)", - "RegularText(giacomozucco,)", - "Email(giacomozucco@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(ef151c7a380f40a75d7d1493ac347b6777a9d9b5fa0aa3cddb47fc78fab69a8b)", - "HashTag(#27)", - "RegularText(7%)", - "RegularText(kr,)", - "Email(kr@stacker.news)", - "RegularText(-)", - "RegularText(08b80da85ba68ac031885ea555ab42bb42231fde9b690bbd0f48c128dfbf8009)", - "HashTag(#28)", - "RegularText(7%)", - "RegularText(phil,)", - "Email(phil@nostrpurple.com)", - "RegularText(-)", - "RegularText(e07773a92a610a28da20748fdd98bfb5af694b0cad085224801265594a98108a)", - "HashTag(#29)", - "RegularText(7%)", - "RegularText(angela,)", - "Email(angela@nostr.world)", - "RegularText(-)", - "RegularText(2b1964b885de3fcbb33777874d06b05c254fecd561511622ce86e3d1851949fa)", - "HashTag(#30)", - "RegularText(7%)", - "RegularText(mason)", - "RegularText(๐“„€)", - "RegularText(๐“…ฆ,)", - "Email(mason@lacosanostr.com)", - "RegularText(-)", - "RegularText(5ef92421b5df0ed97df6c1a98fc038ea7962a29e7f33a060f7a8ddeb9ee587e9)", - "HashTag(#31)", - "RegularText(7%)", - "RegularText(Lau,)", - "Email(lau@nostr.report)", - "RegularText(-)", - "RegularText(5a9c48c8f4782351135dd89c5d8930feb59cb70652ffd37d9167bf922f2d1069)", - "HashTag(#32)", - "RegularText(7%)", - "RegularText(Rex)", - "RegularText(Damascus)", - "RegularText(,)", - "Email(damascusrex@iris.to)", - "RegularText(-)", - "RegularText(50c5c98ccc31ca9f1ef56a547afc4cb48195fe5603d4f7874a221db965867c8e)", - "HashTag(#33)", - "RegularText(6%)", - "RegularText(nym,)", - "Email(nym@nostr.fan)", - "RegularText(-)", - "RegularText(9936a53def39d712f886ac7e2ed509ce223b534834dd29d95caba9f6bc01ef35)", - "HashTag(#34)", - "RegularText(6%)", - "RegularText(nico,)", - "Email(nico@nostrplebs.com)", - "RegularText(-)", - "RegularText(0000000033f569c7069cdec575ca000591a31831ebb68de20ed9fb783e3fc287)", - "HashTag(#35)", - "RegularText(6%)", - "RegularText(anna,)", - "Email(seekerdreamer1@stacker.news)", - "RegularText(-)", - "RegularText(6f2347c6fc4cbcc26d66e74247abadd4151592277b3048331f52aa3a5c244af9)", - "HashTag(#36)", - "RegularText(6%)", - "RegularText(TheSameCat,)", - "Email(thesamecat@iris.to)", - "RegularText(-)", - "RegularText(72f9755501e1a4464f7277d86120f67e7f7ec3a84ef6813cc7606bf5e0870ff3)", - "HashTag(#37)", - "RegularText(6%)", - "RegularText(nitesh_btc,)", - "Email(nitesh@noderunner.wtf)", - "RegularText(-)", - "RegularText(021d7ef7aafc034a8fefba4de07622d78fd369df1e5f9dd7d41dc2cffa74ae02)", - "HashTag(#38)", - "RegularText(6%)", - "RegularText(gpt3,)", - "Email(gpt3@jb55.com)", - "RegularText(-)", - "RegularText(5c10ed0678805156d39ef1ef6d46110fe1e7e590ae04986ccf48ba1299cb53e2)", - "HashTag(#39)", - "RegularText(6%)", - "RegularText(Byzantine,)", - "Email(byzantine@stacker.news)", - "RegularText(-)", - "RegularText(5d1d83de3ee5edde157071d5091a6d03ead8cce1d46bc585a9642abdd0db5aa0)", - "HashTag(#40)", - "RegularText(6%)", - "RegularText(wealththeory,)", - "Email(wealththeory@nostrplebs.com)", - "RegularText(-)", - "RegularText(3004d45a0ab6352c61a62586a57c50f11591416c29db1143367a4f0623b491ca)", - "HashTag(#41)", - "RegularText(6%)", - "RegularText(IshBit,)", - "Email(gug@nostrplebs.com)", - "RegularText(-)", - "RegularText(8e27ffb5c9bb8cdd0131ade6efa49d56d401b5424d9fdf9a63e074d527b0715c)", - "HashTag(#42)", - "RegularText(5%)", - "RegularText(Lana,)", - "Email(lana@b.tc)", - "RegularText(-)", - "RegularText(e8795f9f4821f63116572ed4998924c6f0e01682945bf7a3d9d6132f1c7dace7)", - "HashTag(#43)", - "RegularText(5%)", - "RegularText(Shevacai,)", - "Email(shevacai@nostrplebs.com)", - "RegularText(-)", - "RegularText(2f175fe4348f4da2da157e84d119b5165c84559158e64729ff00b16394718bbf)", - "HashTag(#44)", - "RegularText(5%)", - "RegularText(joe,)", - "Email(joe@nostrpurple.com)", - "RegularText(-)", - "RegularText(907a5a23635ea02be052c31f465b1982aefb756710ccc9f628aa31b70d2e262e)", - "HashTag(#45)", - "RegularText(5%)", - "RegularText(SimplestBitcoinBook,)", - "Email(simplestbitcoinbook@nostrplebs.com)", - "RegularText(-)", - "RegularText(6867d899ce6b677b89052602cfe04a165f26bb6a1a6390355f497f9ee5cb0796)", - "HashTag(#46)", - "RegularText(5%)", - "RegularText(knutsvanholm,)", - "Email(knutsvanholm@iris.to)", - "RegularText(-)", - "RegularText(92cbe5861cfc5213dd89f0a6f6084486f85e6f03cfeb70a13f455938116433b8)", - "HashTag(#47)", - "RegularText(5%)", - "RegularText(rajwinder,)", - "Email(rs@zbd.ai)", - "RegularText(-)", - "RegularText(1c9d368fc24e8549ce2d95eba63cb34b82b363f3036d90c12e5f13afe2981fba)", - "HashTag(#48)", - "RegularText(5%)", - "RegularText(Vlad,)", - "RegularText()", - "RegularText(-)", - "RegularText(50054d07e2cdf32b1035777bd9cf73992a4ae22f91c14a762efdaa5bf61f4755)", - "HashTag(#49)", - "RegularText(5%)", - "RegularText(GRANTGILLIAM,)", - "Email(GRANTGILLIAM@grantgilliam.com)", - "RegularText(-)", - "RegularText(874db6d2db7b39035fe7aac19e83a48257915e37d4f2a55cb4ca66be2d77aa88)", - "HashTag(#50)", - "RegularText(5%)", - "RegularText(LifeLoveLiberty,)", - "Email(lifeloveliberty@iris.to)", - "RegularText(-)", - "RegularText(c07a2ea48b6753d11ad29d622925cb48bab48a8f38e954e85aec46953a0752a2)", - "HashTag(#51)", - "RegularText(5%)", - "RegularText(hackernews,)", - "Email(npub1s9c53smfq925qx6fgkqgw8as2e99l2hmj32gz0hjjhe8q67fxdvs3ga9je@nost.vip)", - "RegularText(-)", - "RegularText(817148c3690155401b494580871fb0564a5faafb9454813ef295f2706bc93359)", - "HashTag(#52)", - "RegularText(5%)", - "RegularText(arbedout,)", - "Email(arbedout@granddecentral.com)", - "RegularText(-)", - "RegularText(a67e98faf32f2520ae574d84262534e7b94625ce0d4e14a50c97e362c06b770e)", - "HashTag(#53)", - "RegularText(5%)", - "RegularText(nobody,)", - "RegularText()", - "RegularText(-)", - "RegularText(5f735049528d831f544b49a585e6f058c1655dfaed9fc338374cd4f3a5a06bf7)", - "HashTag(#54)", - "RegularText(5%)", - "RegularText(glowleaf,)", - "Email(glowleaf@nostrplebs.com)", - "RegularText(-)", - "RegularText(34c0a53283bacd5cb6c45f9b057bea05dfb276333dcf14e9b167680b5d3638e4)", - "HashTag(#55)", - "RegularText(5%)", - "RegularText(Modus,)", - "Email(modus@lacosanostr.com)", - "RegularText(-)", - "RegularText(547fcc5c7e655fe7c83da5a812e6332f0a4779c87bf540d8e75a4edbbf36fe4a)", - "HashTag(#56)", - "RegularText(5%)", - "RegularText(Melvin)", - "RegularText(Carvalho)", - "RegularText(Old)", - "RegularText(Key)", - "RegularText(DO)", - "RegularText(NOT)", - "RegularText(USE,)", - "RegularText(USE)", - "Bech(npub1melv683fw6n2mvhl5h6dhqd8mqfv3wmxnz4qph83ua4dk4006ezsrt5c24,)", - "RegularText()", - "RegularText(-)", - "RegularText(ed1d0e1f743a7d19aa2dfb0162df73bacdbc699f67cc55bb91a98c35f7deac69)", - "HashTag(#57)", - "RegularText(5%)", - "RegularText(anil,)", - "Email(anil@bitcoinnostr.com)", - "RegularText(-)", - "RegularText(ade7a0c6acca095c5b36f88f20163bccda4d97b071c4acc8fe329dc724eec8fb)", - "HashTag(#58)", - "RegularText(4%)", - "RegularText(DocumentingBTC,)", - "Email(documentingbtc@uselessshit.co)", - "RegularText(-)", - "RegularText(641ac8fea1478c27839fb7a0850676c2873c22aa70c6216996862c98861b7e2f)", - "HashTag(#59)", - "RegularText(4%)", - "RegularText(wolfbearclaw,)", - "Email(wolfbearclaw@nostr.messagepush.io)", - "RegularText(-)", - "RegularText(0b963191ab21680a63307aedb50fd7b01392c9c6bef79cd0ceb6748afc5e7ffd)", - "HashTag(#60)", - "RegularText(4%)", - "RegularText(Amboss,)", - "Email(_@amboss.space)", - "RegularText(-)", - "RegularText(2af01e0d6bd1b9fbb9e3d43157d64590fb27dcfbcabe28784a5832e17befb87b)", - "HashTag(#61)", - "RegularText(4%)", - "RegularText(k3tan,)", - "Email(k3tan@k3tan.com)", - "RegularText(-)", - "RegularText(599c4f2380b0c1a9a18b7257e107cf9e6d8b4f8dea06c18c84538d311ff2b28c)", - "HashTag(#62)", - "RegularText(4%)", - "RegularText(wolzie)", - "RegularText(,)", - "Email(wolzie@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(aabedc1f237853aeeb22bd985556036f262f8507842d64f3ecce01adbd7207e2)", - "HashTag(#63)", - "RegularText(4%)", - "RegularText(trey,)", - "Email(trey@nostrplebs.com)", - "RegularText(-)", - "RegularText(d5415a313d38461ff93a8c170f941b2cd4a66a5cfdbb093406960f6cb317849f)", - "HashTag(#64)", - "RegularText(4%)", - "RegularText(sillystev,)", - "RegularText()", - "RegularText(-)", - "RegularText(d541ef2e4830f2e1543c8bdc40128ceceb062b08c7e3f53d141552d5f5bc0cfc)", - "HashTag(#65)", - "RegularText(4%)", - "RegularText(sovereignmox,)", - "Email(woody@fountain.fm)", - "RegularText(-)", - "RegularText(1c4123b2431c60be030d641b4b68300eb464415405035b199428c0913b879c0c)", - "HashTag(#66)", - "RegularText(4%)", - "RegularText(CosmicDimension,)", - "Email(cosmicdimension@nostrplebs.com)", - "RegularText(-)", - "RegularText(4afec6c875e81dc28a760cc828345c0c5b61ec464ba20224148f9fd854a868ff)", - "HashTag(#67)", - "RegularText(4%)", - "RegularText(Mir,)", - "Email(mirbtc@getalby.com)", - "RegularText(-)", - "RegularText(234c45ff85a31c19bf7108a747fa7be9cd4af95c7d621e07080ca2d663bb47d2)", - "HashTag(#68)", - "RegularText(4%)", - "RegularText(Tacozilla,)", - "RegularText()", - "RegularText(-)", - "RegularText(5f70f80ddcf4f6a022467bd5196a1fdfc53d59f1e735a90443e7f7c980564c88)", - "HashTag(#69)", - "RegularText(4%)", - "RegularText(marks,)", - "Email(marks@nostrplebs.com)", - "RegularText(-)", - "RegularText(8ea485266b2285463b13bf835907161c22bb3da1e652b443db14f9cee6720a43)", - "HashTag(#70)", - "RegularText(4%)", - "RegularText(blacktomcat,)", - "Email(barrensatin40@walletofsatoshi.com)", - "RegularText(-)", - "RegularText(16b7e4b067cba8c86bda96a8d932e7593f398118d24bd8060da39ccfd7315f5c)", - "HashTag(#71)", - "RegularText(4%)", - "RegularText(Alex)", - "RegularText(Emidio,)", - "Email(alexemidio@alexemidio.github.io)", - "RegularText(-)", - "RegularText(4ba8e86d2d97896dc9337c3e500691893d7317572fd81f8b41ddda5d89d32de4)", - "HashTag(#72)", - "RegularText(4%)", - "RegularText(Jenn,)", - "Email(Jenn@mintgreen.co)", - "RegularText(-)", - "RegularText(e0f59d89047b868a188c5efd6b93dd8c16b65643b8718884dad8542386c60ddd)", - "HashTag(#73)", - "RegularText(4%)", - "RegularText(spacemonkey,)", - "Email(spacemonkey@nostrich.love)", - "RegularText(-)", - "RegularText(23b26fea28700cd1e2e3a8acca5c445c37ab89acaad549a36d50e9c0eb0f5806)", - "HashTag(#74)", - "RegularText(4%)", - "RegularText(ishak,)", - "Email(ishak@nostrplebs.com)", - "RegularText(-)", - "RegularText(052466631c6c0aed84171f83ef3c95cb81848d4dcdc1d1ee9dfdf75b850c1cb4)", - "HashTag(#75)", - "RegularText(4%)", - "RegularText(nakamoto_army,)", - "RegularText()", - "RegularText(-)", - "RegularText(62f6c5ff12fd24251f0bfb3b7eb1e512d7f1f577a1a97a595db01c66b52ad04f)", - "HashTag(#76)", - "RegularText(4%)", - "RegularText(GrassFedBitcoin,)", - "Email(GrassFedBitcoin@start9.com)", - "RegularText(-)", - "RegularText(74ffc51cc30150cf79b6cb316d3a15cf332ab29a38fec9eb484ab1551d6d1856)", - "HashTag(#77)", - "RegularText(4%)", - "RegularText(NinoHodls,)", - "Email(ninoholds@nostrplebs.com)", - "RegularText(-)", - "RegularText(43ccdbcb1e4dff7e3dea2a91b851ca0e22f50e3c560364a12b64b8c6587924f0)", - "HashTag(#78)", - "RegularText(4%)", - "RegularText(satcap,)", - "Email(satcap@nostr.satcap.io)", - "RegularText(-)", - "RegularText(11dfaa43ae0faa0a06d8c67f89759214c58b60a021521627bc76cb2d3ad0b2e8)", - "HashTag(#79)", - "RegularText(4%)", - "RegularText(DuneMessias,)", - "RegularText()", - "RegularText(-)", - "RegularText(96a578f6b504646de141ba90bec5651965aa01df0605928b3785a1372504e93d)", - "HashTag(#80)", - "RegularText(4%)", - "RegularText(Idaeus,)", - "RegularText()", - "RegularText(-)", - "RegularText(eb473e8fd55ced7af32abaf89578647ddba75e38a860b1c41682bbfb774f5579)", - "HashTag(#81)", - "RegularText(4%)", - "RegularText(tpmoreira,)", - "Email(tpmoreira@nostrplebs.com)", - "RegularText(-)", - "RegularText(f514ef7d18da12ecfce55c964add719ce00a1392c187f20ccb57d99290720e03)", - "HashTag(#82)", - "RegularText(4%)", - "RegularText(force2B,)", - "Email(force2b@nostrplebs.com)", - "RegularText(-)", - "RegularText(d411848a42a11ad2747c439b00fc881120a4121e04917d38bebd156212e2f4ad)", - "HashTag(#83)", - "RegularText(4%)", - "RegularText(Hendrix,)", - "Email(hendrix@nostrplebs.com)", - "RegularText(-)", - "RegularText(cbd92008e1fe949072cbea02e54228140c43d14d14519108b1d7a32d9102665b)", - "HashTag(#84)", - "RegularText(4%)", - "RegularText(TXMC,)", - "Email(TXMC@alphabetasoup.tv)", - "RegularText(-)", - "RegularText(37359e92ece5c6fc8d5755de008ceb6270808b814ddd517d38ebeab269836c96)", - "HashTag(#85)", - "RegularText(4%)", - "RegularText(norman188,)", - "RegularText()", - "RegularText(-)", - "RegularText(662a4476a9c15a5778f379ce41ceb2841ac72dfa1829b492d67796a8443ac2ca)", - "HashTag(#86)", - "RegularText(4%)", - "RegularText(pipleb,)", - "Email(pipleb@iris.to)", - "RegularText(-)", - "RegularText(3c4280ef3b792fa919b1964460d34ca6af93b83fa55f633a3b0eb8fde556235a)", - "HashTag(#87)", - "RegularText(4%)", - "RegularText(reallhex,)", - "Email(reallhex@terranostr.com)", - "RegularText(-)", - "RegularText(29630aed66aeec73b6519a11547f40ca15c3f6aa79907e640f1efcf5a2ee9dc8)", - "HashTag(#88)", - "RegularText(4%)", - "RegularText(374324โ€ฆef9f78,)", - "RegularText()", - "RegularText(-)", - "RegularText(3743244390be53473a7e3b3b8d04dce83f6c9514b81a997fb3b123c072ef9f78)", - "HashTag(#89)", - "RegularText(4%)", - "RegularText(Nostradamus,)", - "RegularText()", - "RegularText(-)", - "RegularText(7acce9b3da22ceedc511a15cb730c898235ab551623955314b003e9f33e8b10c)", - "HashTag(#90)", - "RegularText(4%)", - "RegularText(Nicโ‚ฟ,)", - "Email(nicb@nicb.me)", - "RegularText(-)", - "RegularText(000000002d4f4733f1ee417a405637fd0d81dbfbc6dbd8c0d1c95f04ec3db973)", - "HashTag(#91)", - "RegularText(4%)", - "RegularText(NabismoPrime,)", - "Email(NabismoPrime@BostonBTC.com)", - "RegularText(-)", - "RegularText(4503baa127bdfd0b054384dc5ba82cb0e2a8367cbdb0629179f00db1a34caacc)", - "HashTag(#92)", - "RegularText(4%)", - "RegularText(paco,)", - "Email(paco@iris.to)", - "RegularText(-)", - "RegularText(66bd8fed3590f2299ef0128f58d67879289e6a99a660e83ead94feab7606fd17)", - "HashTag(#93)", - "RegularText(3%)", - "RegularText(globalstatesmen,)", - "Email(globalstatesmen@nostrplebs.com)", - "RegularText(-)", - "RegularText(237506ca399e5b1b9ce89455fe960bc98dfab6a71936772a89c5145720b681f4)", - "HashTag(#94)", - "RegularText(3%)", - "RegularText(Nostryfied,)", - "Email(_@NostrNet.work)", - "RegularText(-)", - "RegularText(c2c20ec0a555959713ca4c404c4d2cc80e6cb906f5b64217070612a0cae29c62)", - "HashTag(#95)", - "RegularText(3%)", - "RegularText(crayonsmell,)", - "Email(crayonsmell@habel.net)", - "RegularText(-)", - "RegularText(3ef3be9db1e3f268f84e937ad73c68772a58c6ffcec1d42feeef5f214ad1eaf9)", - "HashTag(#96)", - "RegularText(3%)", - "RegularText(Toxikat27,)", - "Email(ToxiKat27@Bitcoiner.social)", - "RegularText(-)", - "RegularText(12cfc2ec5a39a39d02f921f77e701dbc175b6287f22ddf0247af39706967f1d9)", - "HashTag(#97)", - "RegularText(3%)", - "RegularText(James)", - "RegularText(Trageser,)", - "Email(jtrag@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(d29bc58353389481e302569835661c95838bee076137533eb365bca752c38316)", - "HashTag(#98)", - "RegularText(3%)", - "RegularText(Joe)", - "RegularText(Martin)", - "RegularText(Music,)", - "Email(joemartinmusic@nostrplebs.com)", - "RegularText(-)", - "RegularText(28ca019b78b494c25a9da2d645975a8501c7e99b11302e5cbe748ee593fcb2cc)", - "HashTag(#99)", - "RegularText(3%)", - "RegularText(Fundamentals,)", - "Email(ph@nostrplebs.com)", - "RegularText(-)", - "RegularText(5677fa5b6b1cb6d5bee785d088a904cd08082552bf75df3e4302cea015a5d3e1)", - "HashTag(#100)", - "RegularText(3%)", - "RegularText(bb,)", - "RegularText()", - "RegularText(-)", - "RegularText(1f254ae909a36b0000c3b68f36b92aad168f4532725d7cd9b67f5b09088f2125)", - "HashTag(#101)", - "RegularText(3%)", - "RegularText(ๆŽๅญๆŸ’,)", - "RegularText()", - "RegularText(-)", - "RegularText(c70c8e55e0228c3ce171ae0d357452e386489f3a2d14e6deca174c2fbfc8da52)", - "HashTag(#102)", - "RegularText(3%)", - "RegularText(Horse)", - "RegularText(๐Ÿด,)", - "Email(horse@iris.to)", - "RegularText(-)", - "RegularText(e4d3420c0b77926cfbf107f9cb606238efaf5524af39ff1c86e6d6fdd1515a57)", - "HashTag(#103)", - "RegularText(3%)", - "RegularText(KP,)", - "Email(kp@no.str.cr)", - "RegularText(-)", - "RegularText(b2e777c827e20215e905ab90b6d81d5b84be5bf66c944ce34943540b462ea362)", - "HashTag(#104)", - "RegularText(3%)", - "RegularText(Azarakhsh,)", - "Email(rebornbitcoiner@getalby.com)", - "RegularText(-)", - "RegularText(c734992a115c2ad9b4df40dd7c14d153695b29081a995df39b4fc8e6f1dcfb14)", - "HashTag(#105)", - "RegularText(3%)", - "RegularText(Toshi,)", - "Email(toshi@nostr-check.com)", - "RegularText(-)", - "RegularText(79d434176b64745d2793cf307f20967e27912994f6e81632de18da3106c2cbb4)", - "HashTag(#106)", - "RegularText(3%)", - "RegularText(FreeBorn,)", - "Email(freeborn@nostrplebs.com)", - "RegularText(-)", - "RegularText(408e04e9a5b02ef6d82edb9ecb2cca1d5a3121cb26b0ca5e6511800a0269b069)", - "HashTag(#107)", - "RegularText(3%)", - "RegularText(blee,)", - "Email(blee@bitcoiner.social)", - "RegularText(-)", - "RegularText(69a0a0910b49a1dbfbc4e4f10df22b5806af5403a228267638f2e908c968228d)", - "HashTag(#108)", - "RegularText(3%)", - "RegularText(SatsTonight,)", - "Email(SatsTonight@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(eb3b94533dafeb8ebd58a4947a3dce11d83a9931c622bdf30a4257d3347ee1bf)", - "HashTag(#109)", - "RegularText(3%)", - "SchemelessUrl(Nostr-Check.com,)", - "Email(freeverification@Nostr-Check.com)", - "RegularText(-)", - "RegularText(ddfbb06a722e51933cd37e4ecdb30b1864f262f9bb5bd6c2d95cbeefc728f096)", - "HashTag(#110)", - "RegularText(3%)", - "RegularText(cowmaster,)", - "Email(cowmaster@getalby.com)", - "RegularText(-)", - "RegularText(6af9411d742c74611e149d19037e7a2ba4d44bbceb429b209c451902b6740bb8)", - "HashTag(#111)", - "RegularText(3%)", - "RegularText(Hacker,)", - "Email(hacker818@iris.to)", - "RegularText(-)", - "RegularText(40e10350fed534e5226b73761925030134d9f85306ee1db5cfbd663118034e84)", - "HashTag(#112)", - "RegularText(3%)", - "RegularText(BitcasaHomes,)", - "Email(amandabitcasa@nostrplebs.com)", - "RegularText(-)", - "RegularText(f96a2a2552c08f99c30b9e2441d64ca4c6b3d761735e7cd74580bafe549326e0)", - "HashTag(#113)", - "RegularText(3%)", - "RegularText(footstr,)", - "RegularText()", - "RegularText(-)", - "RegularText(aa1aa6af6be3a2903e2fb18690d7df128a10eec0f3a015157daf371c688b4cff)", - "HashTag(#114)", - "RegularText(3%)", - "RegularText(tiago,)", - "Email(tiago@nostrplebs.com)", - "RegularText(-)", - "RegularText(780ab38a843423c61502550474b016e006f2b56f2f7d18e9cd02737e11113262)", - "HashTag(#115)", - "RegularText(3%)", - "RegularText(Sepehr,)", - "Email(sepehr@nostribe.com)", - "RegularText(-)", - "RegularText(3e294d2fd339bb16a5403a86e3664947dd408c4d87a0066524f8a573ae53ca8e)", - "HashTag(#116)", - "RegularText(3%)", - "RegularText(dhruv,)", - "RegularText()", - "RegularText(-)", - "RegularText(297bc16357b314be291c893755b25d66999c1525bbf3537fbc637a0c767f14bb)", - "HashTag(#117)", - "RegularText(3%)", - "RegularText(b310edโ€ฆ4f793a,)", - "RegularText()", - "RegularText(-)", - "RegularText(b310ed0a54a71ccf8a8368032dd3b4b83b7aca2840bb10a4d5e6ef4b6a4f793a)", - "HashTag(#118)", - "RegularText(3%)", - "RegularText(MichZ)", - "RegularText(๐Ÿง˜๐Ÿปโ€โ™€๏ธ,)", - "RegularText()", - "RegularText(-)", - "RegularText(9349d012686caab46f6bfefd2f4c361c52e14b1cde1cd027476e0ae6d3e98946)", - "HashTag(#119)", - "RegularText(3%)", - "RegularText(gfy,)", - "Email(gfy@stacker.news)", - "RegularText(-)", - "RegularText(01e4fc2adc0ff7a0465d3e70b3267d375ebe4292828fa3888f972313f3a1248e)", - "HashTag(#120)", - "RegularText(3%)", - "RegularText(Dude,)", - "RegularText()", - "RegularText(-)", - "RegularText(67cbb3d83800cc1af6f5d2821f1c911f033ea21e1269ff2ad613ab3ae099b1f3)", - "HashTag(#121)", - "RegularText(3%)", - "RegularText(HODL_MFER,)", - "RegularText()", - "RegularText(-)", - "RegularText(7c6a9e6231570a6773e608d1c0a709acb9c21193a5c2df9cebfa9e9db09411a3)", - "HashTag(#122)", - "RegularText(3%)", - "RegularText(renatarodrigues,)", - "RegularText()", - "RegularText(-)", - "RegularText(aa116590cf23dc761a8a9e38ff224a3d07db45c66be3035b9f87144bda0eeaa5)", - "HashTag(#123)", - "RegularText(3%)", - "RegularText(CryptoJournaal,)", - "Email(cryptojournaal@iris.to)", - "RegularText(-)", - "RegularText(fb649213b88e9927a5c8f470d7affe88441de995deaccf283bf60a78f771b825)", - "HashTag(#124)", - "RegularText(3%)", - "RegularText(Bon,)", - "Email(bon@nostrplebs.com)", - "RegularText(-)", - "RegularText(b2722dd1e13ff9b82ff2f432186019045fee39911d5652d6b4263562061af908)", - "HashTag(#125)", - "RegularText(3%)", - "RegularText(binarywatch,)", - "Email(bot@binarywatch.org)", - "RegularText(-)", - "RegularText(0095c837e8ed370de6505c2c631551af08c110853b519055d0cdf3d981da5ac3)", - "HashTag(#126)", - "RegularText(3%)", - "RegularText(Moritz,)", - "Email(moritz@getalby.com)", - "RegularText(-)", - "RegularText(0521db9531096dff700dcf410b01db47ab6598de7e5ef2c5a2bd7e1160315bf6)", - "HashTag(#127)", - "RegularText(3%)", - "RegularText(hodlish,)", - "Email(hodlish@Nostr-Check.com)", - "RegularText(-)", - "RegularText(3575a3a7a6b5236443d6af03606aa9297c3177a45cf5314b9fd57bff894ee3ae)", - "HashTag(#128)", - "RegularText(3%)", - "RegularText(HolgerHatGarKeineNode,)", - "Email(HolgerHatGarKeineNode@nip05.easify.de)", - "RegularText(-)", - "RegularText(0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033)", - "HashTag(#129)", - "RegularText(3%)", - "RegularText(joe,)", - "Email(joe@jaxo.github.io)", - "RegularText(-)", - "RegularText(6827ef2b75ee652dcc83958b83aea0bc6580705b56041a9ee70a4178e1046cdb)", - "HashTag(#130)", - "RegularText(3%)", - "RegularText(hahattpro,)", - "Email(hahattpro@iris.to)", - "RegularText(-)", - "RegularText(53ac90ebaef84b0439cdf4f1d955ff1f1e98febc04fb789eff4a08fe53316483)", - "HashTag(#131)", - "RegularText(3%)", - "RegularText(bensima,)", - "Email(bensima@simatime.com)", - "RegularText(-)", - "RegularText(2fa4b9ba71b6dab17c4723745bb7850dfdafcb6ae1a8642f76f9c64fa5f43436)", - "HashTag(#132)", - "RegularText(3%)", - "RegularText(satan,)", - "Email(satan@nostrcheck.me)", - "RegularText(-)", - "RegularText(d6b44ef322f6d67806ff06aaa9623b22ff5c2b0f0705c5e7a5a35684af9e5101)", - "HashTag(#133)", - "RegularText(3%)", - "RegularText(RadVladdy,)", - "Email(radvladdy@nostrplebs.com)", - "RegularText(-)", - "RegularText(7933ea1abdb329139b4eb37157649229b41d0ae445907238b07926182f717924)", - "HashTag(#134)", - "RegularText(3%)", - "RegularText(horacio,)", - "RegularText()", - "RegularText(-)", - "RegularText(f61abb9886e1f4cd5d20419c197d5d7f3649addab24b6a32a2367124ca3194b4)", - "HashTag(#135)", - "RegularText(3%)", - "RegularText(yidneth,)", - "Email(yidneth@getalby.com)", - "RegularText(-)", - "RegularText(f28be20326c6779b2f8bfa75a865d0fa4af384e9c6c99dc6a803e542f9d2085e)", - "HashTag(#136)", - "RegularText(3%)", - "RegularText(JonO,)", - "RegularText()", - "RegularText(-)", - "RegularText(edecf91d15e03c921806ae6ebff86771c79e1641e899787e4d7689f68314d447)", - "HashTag(#137)", - "RegularText(3%)", - "RegularText(bellatrix,)", - "Email(bellatrix@iris.to)", - "RegularText(-)", - "RegularText(f9d7f0b271b5bb19ed400d8baeee1c22ac3a5be5cf20da55219c4929e523987a)", - "HashTag(#138)", - "RegularText(3%)", - "RegularText(SecureCoop,)", - "Email(securecoop@iris.to)", - "RegularText(-)", - "RegularText(d244e3cd0842d514a0725e0e0a00b712b7f2ed515a1d7ef362fd12c957b95549)", - "HashTag(#139)", - "RegularText(3%)", - "RegularText(charliesurf,)", - "Email(charliesurf@ln.tips)", - "RegularText(-)", - "RegularText(a396e36e962a991dac21731dd45da2ee3fd9265d65f9839c15847294ec991f1c)", - "HashTag(#140)", - "RegularText(3%)", - "RegularText(Bitcoin)", - "RegularText(ATM,)", - "Email(bitcoinatm@Nostr-Check.com)", - "RegularText(-)", - "RegularText(01a69fa5a7cbb4a185904bdc7cae6137ff353889bba95619c619debe9e3b8b09)", - "HashTag(#141)", - "RegularText(3%)", - "RegularText(lnstallone,)", - "Email(lnstallone@allmysats.com)", - "RegularText(-)", - "RegularText(84fe3febc748470ff1a363db8a375ffa1ff86603f2653d1c3c311ad0a70b5d0c)", - "HashTag(#142)", - "RegularText(3%)", - "RegularText(a652f6โ€ฆ9124f3,)", - "RegularText()", - "RegularText(-)", - "RegularText(a652f66df4ddb5280ff466b6ff444fbc310b8e83238660473d5ccffa9e9124f3)", - "HashTag(#143)", - "RegularText(3%)", - "RegularText(hmichellerose,)", - "RegularText()", - "RegularText(-)", - "RegularText(5b29255d5eaaaeb577552bf0d11030376f477d19a009c5f5a80ddc73d49359f6)", - "HashTag(#144)", - "RegularText(3%)", - "RegularText(L0la)", - "RegularText(L33tz,)", - "Email(L0laL33tz@cashu.me)", - "RegularText(-)", - "RegularText(d8a6ecf0c396eaa8f79a4497fe9b77dc977633451f3ca5c634e208659116647b)", - "HashTag(#145)", - "RegularText(3%)", - "RegularText(Lommy,)", - "Email(Lommy@nostrplebs.com)", - "RegularText(-)", - "RegularText(014b9837dabb358fc0f416ceb58f72c4e6ed8fc6d317f0578dd704fc879f16f8)", - "HashTag(#146)", - "RegularText(3%)", - "RegularText(jgmontoya,)", - "Email(jgmontoya@nostrplebs.com)", - "RegularText(-)", - "RegularText(9236f9ac521be2ee0a54f1cfffdf2df7f4982df4e6eb992867d733debcf95b35)", - "HashTag(#147)", - "RegularText(3%)", - "RegularText(bavarianledger,)", - "Email(bavarianledger@iris.to)", - "RegularText(-)", - "RegularText(f27c20bc6e64407f805a92c3190089060f9d85efa67ccc80b85f007c3323c221)", - "HashTag(#148)", - "RegularText(3%)", - "RegularText(operator,)", - "Email(operator@brb.io)", - "RegularText(-)", - "RegularText(3c1ba7d42c873c2f89caf1ca79b4ead6513385de53743fa6eb98c3705655695c)", - "HashTag(#149)", - "RegularText(3%)", - "RegularText(awaremoma,)", - "RegularText()", - "RegularText(-)", - "RegularText(44313b79dfc3303e3bd0c4aee0c872e96a84f23a2a45624b3ab630f24f43012f)", - "HashTag(#150)", - "RegularText(3%)", - "RegularText(Tรญo)", - "RegularText(Tito,)", - "Email(tiotito@nostriches.net)", - "RegularText(-)", - "RegularText(dc6e531596c52a218a6fae2e1ea359a1365d5eda02ec176c945ed06a9400ec72)", - "HashTag(#151)", - "RegularText(3%)", - "RegularText(javi,)", - "Email(javi@www.javiergonzalez.io)", - "RegularText(-)", - "RegularText(2eab634b27a78107c98599a982849b4f71c605316c8f4994861f83dc565df5c8)", - "HashTag(#152)", - "RegularText(3%)", - "RegularText(NathanCPerry,)", - "RegularText()", - "RegularText(-)", - "RegularText(cec9808bbb00bc9c3eab4c2f23e9440a5ea775201b65a18462bc77080e39e336)", - "HashTag(#153)", - "RegularText(3%)", - "RegularText(Jason)", - "RegularText(Hodlers)", - "RegularText(โ™พ๏ธ/2099999997690000๐Ÿด,)", - "Email(geekigai@nostrplebs.com)", - "RegularText(-)", - "RegularText(d162a53c3b0bfb5c3ebd787d7b08feab206b112362eca25aa291251cd70fe225)", - "HashTag(#154)", - "RegularText(3%)", - "SchemelessUrl(MR.Rabbit,)", - "Email(Mr.Rabbit@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(42af69b2384071f31e55cb2d368c8a3351c8f2da03207e1fb6885991ac2522bf)", - "HashTag(#155)", - "RegularText(3%)", - "RegularText(kilicl,)", - "Email(kilicl@nostr-check.com)", - "RegularText(-)", - "RegularText(48a94f890f4dc3625b9926cdccded61e353ad1fe76600bc6acea44bdb9efceb7)", - "HashTag(#156)", - "RegularText(3%)", - "RegularText(retired,)", - "RegularText()", - "RegularText(-)", - "RegularText(82ba83731adcfe5a65ced992fde81efc756d10670c56a58cb8870210f859d3c1)", - "HashTag(#157)", - "RegularText(3%)", - "RegularText(Alex)", - "RegularText(Bit,)", - "Email(alexbit@nostrbr.online)", - "RegularText(-)", - "RegularText(9db334a465cc3f6107ed847eec0bc6c835e76ba50625f4c1900cbcb9df808d91)", - "HashTag(#158)", - "RegularText(3%)", - "RegularText(freeeedom21,)", - "Email(william@nostrplebs.com)", - "RegularText(-)", - "RegularText(fd254541619b6d4baa467412058321f70cf108d773adcda69083bd500e502033)", - "HashTag(#159)", - "RegularText(3%)", - "RegularText(OneEzra,)", - "Email(oneezra@nostrplebs.com)", - "RegularText(-)", - "RegularText(0078d4cb1652552475ba61ec439cd50c37c3a3a439853d830d7c9d338826ade2)", - "HashTag(#160)", - "RegularText(3%)", - "RegularText(lightsats,)", - "RegularText()", - "RegularText(-)", - "RegularText(88185e27e96cfcfc3c58c625cf70c4dba757f8d2e9ab7cab80f5012a343eb7d2)", - "HashTag(#161)", - "RegularText(3%)", - "RegularText(IceAndFireBTC,)", - "Email(iceandfirebtc@nostrplebs.com)", - "RegularText(-)", - "RegularText(edb50fd8286e36878f8dd9346c138598052e5d914f0c3c6072f12eb152f307d8)", - "HashTag(#162)", - "RegularText(3%)", - "RegularText(Nostr)", - "RegularText(Gang,)", - "Email(nostrgang@nostrplebs.com)", - "RegularText(-)", - "RegularText(91aeab23b5664edaa57dbe00b041ccb50544f89d7d956345bbd78b7dbaa48660)", - "HashTag(#163)", - "RegularText(3%)", - "RegularText(kexkey,)", - "RegularText()", - "RegularText(-)", - "RegularText(436456869bdd7fcb3aaaa91bed05173ea1510879004250b9f69b2c4370d58cf7)", - "HashTag(#164)", - "RegularText(3%)", - "RegularText(freebitcoin,)", - "Email(npub1vez5zekuzc3qk989q5gtly2zg9k2gz4l3wuplv5xs8y3se09yussg4vp7p@carteclip.com)", - "RegularText(-)", - "RegularText(66454166dc16220b14e50510bf9142416ca40abf8bb81fb28681c91865e52721)", - "HashTag(#165)", - "RegularText(3%)", - "RegularText(Sqvaznyak,)", - "Email(Sqvaznyak@uselessshit.co)", - "RegularText(-)", - "RegularText(056d6999f3283778d50aa85c25985716857cfeaffdbad92e73cf8aeaf394a5cd)", - "HashTag(#166)", - "RegularText(3%)", - "RegularText(koba,)", - "RegularText()", - "RegularText(-)", - "RegularText(b5926366f9ac01d8ed427c9bb4cdcb86b7b4a44aaad00d262ef436621e30ea5a)", - "HashTag(#167)", - "RegularText(3%)", - "RegularText(braj,)", - "Email(braj@nostrplebs.com)", - "RegularText(-)", - "RegularText(5921b801183f10b0143c2e48c22c8192fa38d27ac614a20251cac30ab729d3a5)", - "HashTag(#168)", - "RegularText(3%)", - "RegularText(Libertus,)", - "Email(libertus@getalby.com)", - "RegularText(-)", - "RegularText(2154d20dace7b28018621edf9c3a56ab842b901db0d9b02616dbed3d15fc5490)", - "HashTag(#169)", - "RegularText(3%)", - "RegularText(ZoeBoudreault,)", - "Email(ZoeBoudreault@id.nostrfy.me)", - "RegularText(-)", - "RegularText(3c43dc2a4c996832ae3a1830250d5f0917476783132969db4e14955b6e394047)", - "HashTag(#170)", - "RegularText(3%)", - "RegularText(Saiga,)", - "RegularText()", - "RegularText(-)", - "RegularText(8f5f3a60edc875315d9c1348d6ad5dddbca806d02400049632589cb32b3f0493)", - "HashTag(#171)", - "RegularText(3%)", - "RegularText(n,)", - "RegularText()", - "RegularText(-)", - "RegularText(aceff8abf70a60d7b378469ab80513c83c5d70a4f82872bac7bd619acbc71ff1)", - "HashTag(#172)", - "RegularText(3%)", - "RegularText(dnilso,)", - "Email(dnilso@iris.to)", - "RegularText(-)", - "RegularText(5ae325f930f53fad2a1a9ebefdb943bba1bef7b411e7712d2173bf3c38a49b17)", - "HashTag(#173)", - "RegularText(3%)", - "RegularText(Shroom,)", - "Email(shroom@nostrplebs.com)", - "RegularText(-)", - "RegularText(a4ee688a599c9493b8641cc61987ef42b7556ba1e79d35bca92a1dce186dac85)", - "HashTag(#174)", - "RegularText(3%)", - "RegularText(0a92e7โ€ฆbc2d3d,)", - "RegularText()", - "RegularText(-)", - "RegularText(0a92e765595bbf3368c44338479df5351cf5b0028215ba95e1c9e8de99bc2d3d)", - "HashTag(#175)", - "RegularText(3%)", - "RegularText(olegaba,)", - "Email(olegaba@olegaba.com)", - "RegularText(-)", - "RegularText(7fb2a29bd1a41d9a8ca43a19a7dcf3a8522f1bc09b4086253539190e9c29c51a)", - "HashTag(#176)", - "RegularText(3%)", - "RegularText(CJButcher,)", - "RegularText()", - "RegularText(-)", - "RegularText(15fdc4596019e2b9b702ae229d5c7a17d9527226f8cf5526006908901612b200)", - "HashTag(#177)", - "RegularText(3%)", - "RegularText(wasabi-pea,)", - "Email(wasabi@nostrplebs.com)", - "RegularText(-)", - "RegularText(abe1c8a87aca21e9b6a32a8c2fae5acbaf3212a01d9ccc13a80981c853e8fa02)", - "HashTag(#178)", - "RegularText(3%)", - "RegularText(045a6fโ€ฆf32334,)", - "RegularText()", - "RegularText(-)", - "RegularText(045a6fa0da5d278ac1c3aee79df23b7372ea03ee4da04ad4b8db9a5967f32334)", - "HashTag(#179)", - "RegularText(3%)", - "RegularText(Artur,)", - "Email(artur@getalby.com)", - "RegularText(-)", - "RegularText(762a3c15c6fa90911bf13d50fc3a29f1663dc1f04b4397a89eef604f622ecd60)", - "HashTag(#180)", - "RegularText(3%)", - "RegularText(ihsanmd๐Ÿ’€,)", - "Email(ihsanmd@getalby.com)", - "RegularText(-)", - "RegularText(d030bd233a1347e510c372b1878e00204b228072814361451623707896435da9)", - "HashTag(#181)", - "RegularText(2%)", - "RegularText(Satoshee,)", - "Email(satoshee@vida.page)", - "RegularText(-)", - "RegularText(0e88aac7368d5f2582437826042b3fb3a26a126f3d857618c6b6652a9f5bfa0a)", - "HashTag(#182)", - "RegularText(2%)", - "RegularText(39ed0aโ€ฆ60271a,)", - "RegularText()", - "RegularText(-)", - "RegularText(39ed0aea2338477103e0b5a820532ded27dbfe4f203e7270392d55f63e60271a)", - "HashTag(#183)", - "RegularText(2%)", - "SchemelessUrl(Ancap.su,)", - "Email(ancapsu@getalby.com)", - "RegularText(-)", - "RegularText(2fe5292a2df25047a392fceead75458875c775c31cc28f4be04cef3e8db15291)", - "HashTag(#184)", - "RegularText(2%)", - "RegularText(NiceAction,)", - "Email(niceaction@www.niceaction.com)", - "RegularText(-)", - "RegularText(32891ace6802507077035ba6064f7e1db29667002165b9bf5c1c9b3f84e2303c)", - "HashTag(#185)", - "RegularText(2%)", - "RegularText(seak,)", - "Email(seak@nostrplebs.com)", - "RegularText(-)", - "RegularText(d70f1bca430a2158f0e4c88b158ae18efffe8a91d436edbeee27acf2d9012cf5)", - "HashTag(#186)", - "RegularText(2%)", - "RegularText(twochickshomestead,)", - "RegularText()", - "RegularText(-)", - "RegularText(5bf5ab367f45b01b1cac72d73703fb30c704f3dbd5d376396fc0b6f39cac456b)", - "HashTag(#187)", - "RegularText(2%)", - "RegularText(Andy,)", - "Email(andy@nodeless.io)", - "RegularText(-)", - "RegularText(08cd52a46ab37a9894b3333785c2ff50e068d1b01fb03d702608da83e9817d82)", - "HashTag(#188)", - "RegularText(2%)", - "RegularText(coinbitstwitterfollows,)", - "RegularText()", - "RegularText(-)", - "RegularText(1341010418f272ed6db469d77dffdf1d946dd0701e33bdc84bb72269cef5bfed)", - "HashTag(#189)", - "RegularText(2%)", - "RegularText(Annonymal,)", - "RegularText()", - "RegularText(-)", - "RegularText(5c7794d47115a1b133a19673d57346ca494d367379458d8e98bf24a498abc46b)", - "HashTag(#190)", - "RegularText(2%)", - "RegularText(lindsey,)", - "RegularText()", - "RegularText(-)", - "RegularText(f81d7cbdfe99ff2b11932fb4cdcd94f18e629e3fedafcd25ee0a4ddc0967f0f9)", - "HashTag(#191)", - "RegularText(2%)", - "RegularText(pinkyjay,)", - "Email(pinkyjay@nostrplebs.com)", - "RegularText(-)", - "RegularText(b0dbac368a5ac474bc19ab11a0b3fd4260cf56b40c60944c4a331b8ad8ced926)", - "HashTag(#192)", - "RegularText(2%)", - "RegularText(criptobastardo,)", - "Email(criptobastardo@nostrplebs.com)", - "RegularText(-)", - "RegularText(311262ac14efb7011f23223b662aa1f18b3bb7c238206cb1c07424f051a11cce)", - "HashTag(#193)", - "RegularText(2%)", - "RegularText(lacosanostr,)", - "Email(lacosanostr@lacosanostr.com)", - "RegularText(-)", - "RegularText(6ce2001e7f070fade19d4817006747e4164089886a0faca950a6b0ab2a3b58b2)", - "HashTag(#194)", - "RegularText(2%)", - "RegularText(teeJem,)", - "Email(teejem@nostrplebs.com)", - "RegularText(-)", - "RegularText(36f7bc3a3f40b11095f546a86b11ff1babc7ca7111c8498d6b6950cfc7663694)", - "HashTag(#195)", - "RegularText(2%)", - "RegularText(BiancaBtcArt,)", - "RegularText()", - "RegularText(-)", - "RegularText(1f2c17bd3bcaf12f9c7e78fe798eeea59c1b22e1ee036694d5dc2886ddfa35d7)", - "HashTag(#196)", - "RegularText(2%)", - "RegularText(ruto,)", - "RegularText()", - "RegularText(-)", - "RegularText(2888961a564e080dfe35ad8fc6517b920d2fcd2b7830c73f7c3f9f2abae90ea9)", - "HashTag(#197)", - "RegularText(2%)", - "RegularText(Pocketcows,)", - "RegularText()", - "RegularText(-)", - "RegularText(e462fd4f25682164bdb7c51fc1b2cd3c7e6ddba13a1d7094b06f6f4fe47f9ae3)", - "HashTag(#198)", - "RegularText(2%)", - "RegularText(mewj,)", - "Email(mewj@elder.nostr.land)", - "RegularText(-)", - "RegularText(489ac583fc30cfbee0095dd736ec46468faa8b187e311fda6269c4e18284ed0c)", - "HashTag(#199)", - "RegularText(2%)", - "RegularText(nostr,)", - "RegularText()", - "RegularText(-)", - "RegularText(2bd053345e10aed28bd0e97c311aab3470f6d7f405dc588b056bce1e3797d2f0)", - "HashTag(#200)", - "RegularText(2%)", - "RegularText(Bobolo,)", - "RegularText()", - "RegularText(-)", - "RegularText(ca7799f00a9d792f9bba6947b32e3142e6c6c4733e52906cbaf92a2961216b46)", - "HashTag(#201)", - "RegularText(2%)", - "RegularText(InsolentBitcoin,)", - "RegularText()", - "RegularText(-)", - "RegularText(6484df04c9403a64c3039f5f00d24ac0535f497cdfa1f187bc6a2d34cf017b97)", - "HashTag(#202)", - "RegularText(2%)", - "RegularText(Monero)", - "RegularText(Directory,)", - "RegularText()", - "RegularText(-)", - "RegularText(1abdef52155dc52a21a2ac9ed19e444317f6cf83500df139fbe73c2a7ac78e2a)", - "HashTag(#203)", - "RegularText(2%)", - "RegularText(thetonewrecker,)", - "Email(thetonewrecker@nostrplebs.com)", - "RegularText(-)", - "RegularText(3762d3159bfd9d8acb56677eec9a6f8a5a05ea86636186ca6ed6714a69975fed)", - "HashTag(#204)", - "RegularText(2%)", - "RegularText(yodatravels,)", - "Email(yodatravels@iris.to)", - "RegularText(-)", - "RegularText(67eb726f7bb8e316418cd46cfa170d580345e51adbc186f8f7aa0d4380579350)", - "HashTag(#205)", - "RegularText(2%)", - "RegularText(Bitcoin)", - "RegularText(Bandit,)", - "Email(bitcoin69@iris.to)", - "RegularText(-)", - "RegularText(907842aa7b5d00054473d261e814c011c5d8e13bf8a585cc76121b1e6c51900f)", - "HashTag(#206)", - "RegularText(2%)", - "RegularText(Zzar,)", - "Email(Zzar@nostrplebs.com)", - "RegularText(-)", - "RegularText(ca1dd2422cb94874c1666c9c76b7961bbaea432632643f7a2dc9d4d2bfb35db9)", - "HashTag(#207)", - "RegularText(2%)", - "RegularText(vidalBidi,)", - "Email(vidalbidi@getalby.com)", - "RegularText(-)", - "RegularText(0c28a25357c76ac5ac3714eddc25d81fe98134df13351ab526fc2479cc306e65)", - "HashTag(#208)", - "RegularText(2%)", - "RegularText(994e89โ€ฆf75447,)", - "RegularText()", - "RegularText(-)", - "RegularText(994e892582261fd933af25bcc9672f2fbd5e769e3d1c889ecd292a7a92f75447)", - "HashTag(#209)", - "RegularText(2%)", - "RegularText(juangalt,)", - "Email(juangalt@current.ninja)", - "RegularText(-)", - "RegularText(372da077d6353430f343d5853d85311b3fd27018d5a83b8c1b397b92518ec7ac)", - "HashTag(#210)", - "RegularText(2%)", - "RegularText(Dean,)", - "Email(dean@nostrplebs.com)", - "RegularText(-)", - "RegularText(83f018060171dfee116b077f0f455472b6b6de59abf4730994022bf6f27d16be)", - "HashTag(#211)", - "RegularText(2%)", - "RegularText(alexli,)", - "Email(alex2@nostrverified.com)", - "RegularText(-)", - "RegularText(8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934)", - "HashTag(#212)", - "RegularText(2%)", - "RegularText(Khidthungban,)", - "RegularText()", - "RegularText(-)", - "RegularText(8d5cf93afb8d9ef1d08acee4e7147348d0c573bf7e5f57886a8a9a137cbe890c)", - "HashTag(#213)", - "RegularText(2%)", - "RegularText(Trooper,)", - "Email(trooper@iris.to)", - "RegularText(-)", - "RegularText(2c8d81a4e5cd9a99caba73f14c087ca7c05e554bb9988a900ccd76dbd828407d)", - "HashTag(#214)", - "RegularText(2%)", - "RegularText(Satscoinsv,)", - "RegularText(โšก๏ธsatscoinsv@getalby.com)", - "RegularText(-)", - "RegularText(80db64657ea0358c5332c5cca01565eeddd4b8799688b1c46d3cb2d7c966671f)", - "HashTag(#215)", - "RegularText(2%)", - "RegularText(AARBTC,)", - "Email(aarbtc@iris.to)", - "RegularText(-)", - "RegularText(6d23993803386c313b7d4dcdfffdbe4e1be706c2f0c89cb5afaa542bf2be1b90)", - "HashTag(#216)", - "RegularText(2%)", - "RegularText(yogsite,)", - "Email(_@gue.yogsite.com)", - "RegularText(-)", - "RegularText(d3ab705ec57f3ea963fc7c467bddc7b17bf01b85acc4fbb14eed87df794a116c)", - "HashTag(#217)", - "RegularText(2%)", - "RegularText(NostrMemes,)", - "Email(nostrmemes@iris.to)", - "RegularText(-)", - "RegularText(6399694ca3b8c40d8be9762f50c9c420bf0bd73fb7d7d244a195814c9ab8fb7e)", - "HashTag(#218)", - "RegularText(2%)", - "RegularText(btcpavao,)", - "Email(btcpavao@iris.to)", - "RegularText(-)", - "RegularText(1a8ed3216bd2b81768363b4326e1ae270a7cd6fe570bafeda2dc070f34f3aedc)", - "HashTag(#219)", - "RegularText(2%)", - "RegularText(Anonymous,)", - "Email(Anonymous@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(ac076f8f80ee4a49f22c2ce258dcfe6e105de0bf029a048fa3a8de4b51c1b957)", - "HashTag(#220)", - "RegularText(2%)", - "RegularText(zoltanAB,)", - "Email(zoltanab@iris.to)", - "RegularText(-)", - "RegularText(42aafd1217089d68c757671a251507a194587dd3adfc3a3a76bb1e38a78a3453)", - "HashTag(#221)", - "RegularText(2%)", - "RegularText(katsu,)", - "Email(katsu@onsats.org)", - "RegularText(-)", - "RegularText(76f64475795661961801389aeaa7869a005735266c9e3df9bc93d127fad04154)", - "HashTag(#222)", - "RegularText(2%)", - "RegularText(bryan,)", - "Email(bryan@nonni.io)", - "RegularText(-)", - "RegularText(9ddf6fe3a194d330a6c6e278a432ae1309e52cc08587254b337d0f491f7ff642)", - "HashTag(#223)", - "RegularText(2%)", - "RegularText(pedromvpg,)", - "Email(pedromvpg@pedromvpg.com)", - "RegularText(-)", - "RegularText(8cd2d0f8310f7009e94f50231870756cb39ba68f37506044910e2f71482b1788)", - "HashTag(#224)", - "RegularText(2%)", - "RegularText(Nellie,)", - "Email(sonicstudio@getalby.com)", - "RegularText(-)", - "RegularText(37fbbf7707e70a8a7787e5b1b75f3e977e70aab4f41ddf7b3c0f38caedd875d4)", - "HashTag(#225)", - "RegularText(2%)", - "RegularText(nicknash,)", - "RegularText()", - "RegularText(-)", - "RegularText(636b4e6f5a594893c544b49a5742f0a90f109b70d659585e0427a1c0361c0b09)", - "HashTag(#226)", - "RegularText(2%)", - "RegularText(dlegal,)", - "Email(kounsellor@nostrplebs.com)", - "RegularText(-)", - "RegularText(201e51e71a753af3699cf684d7f4113c59a73c4b7bd26ef3f4c187a6173fbf06)", - "HashTag(#227)", - "RegularText(2%)", - "RegularText(BitcoinLake,)", - "RegularText()", - "RegularText(-)", - "RegularText(5babddf98277e3db6c88ae1d322bc63fd637764370e1d5e4fe5226104d82034f)", - "HashTag(#228)", - "RegularText(2%)", - "RegularText(BitcoinKeegan,)", - "RegularText()", - "RegularText(-)", - "RegularText(b457120b6cfb2589d48718f2ab71362dd0db43e13266771725129d35cc602dbe)", - "HashTag(#229)", - "RegularText(2%)", - "RegularText(KatieRoss,)", - "Email(katieross@nostrplebs.com)", - "RegularText(-)", - "RegularText(90f09238f3514f249e2b333e6119eef49697020f956fd7b6732ce118dd1b53cb)", - "HashTag(#230)", - "RegularText(2%)", - "RegularText(efcfa6โ€ฆe3f485,)", - "RegularText()", - "RegularText(-)", - "RegularText(efcfa63ac0324e37fb138c2b9dbbf9372f64ec857c923c5c1f713d3592e3f485)", - "HashTag(#231)", - "RegularText(2%)", - "RegularText(bc9e89โ€ฆb519d3,)", - "RegularText()", - "RegularText(-)", - "RegularText(bc9e89110e6e7ec5540b8ad0467d8a39554a7527c27e7af4cd45b2b8c4b519d3)", - "HashTag(#232)", - "RegularText(2%)", - "RegularText(Ilj,)", - "Email(iamlj@iris.to)", - "RegularText(-)", - "RegularText(fa3e7bcc5e588a8111ffb9d9eb8bf62c87d8a0ef6e1e5e0c74311b61f6ced8e7)", - "HashTag(#233)", - "RegularText(2%)", - "RegularText(ayelen,)", - "RegularText()", - "RegularText(-)", - "RegularText(1c31ccda2709fc6cf5db0a0b0873613e25646c4a944779dfb5e8d6cbbcd2ee1c)", - "HashTag(#234)", - "RegularText(2%)", - "RegularText(zach,)", - "Email(Zach@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(d99211aeeb643695ee1aad0517696bbc822e2fb443afe2dc9dadc0ca50b040e2)", - "HashTag(#235)", - "RegularText(2%)", - "RegularText(Yi,)", - "RegularText()", - "RegularText(-)", - "RegularText(248caad2f8392c7f72502da41ee62bbe256ea66fb365e395c988198660562ff7)", - "HashTag(#236)", - "RegularText(2%)", - "RegularText(Amouranth,)", - "Email(amouranth@nostrcheck.me)", - "RegularText(-)", - "RegularText(be5aa097ad9f4d872c70e432ad8c09565ee7dc1aee24a50b683ddca771b14901)", - "HashTag(#237)", - "RegularText(2%)", - "RegularText(hss5qy,)", - "Email(hss5qy@getalby.com)", - "RegularText(-)", - "RegularText(bc21401161327647e0bbd31f2dec1be168ef7fa5d05689fca0d063b114ed9b46)", - "HashTag(#238)", - "RegularText(2%)", - "RegularText(dpc,)", - "Email(dpcpw@iris.to)", - "RegularText(-)", - "RegularText(274611b4728b0c40be1cf180d8f3427d7d3eebc55645d869a002e8b657f8cd61)", - "HashTag(#239)", - "RegularText(2%)", - "RegularText(pred,)", - "RegularText()", - "RegularText(-)", - "RegularText(3946adbb2fc7c95f75356d8f3952c8e2705ee2431f8bd33f5cae0f9ede0298e2)", - "HashTag(#240)", - "RegularText(2%)", - "RegularText(jamesgore,)", - "RegularText()", - "RegularText(-)", - "RegularText(a94921403ac0ccf1a150ccac3679b11adcb3c3bb78b490452db43a8b6964a5c7)", - "HashTag(#241)", - "RegularText(2%)", - "RegularText(bitcoinfinity,)", - "Email(bitcoinfinity@nostrplebs.com)", - "RegularText(-)", - "RegularText(afbda6a942f975ddf8728bda3e6e5c9e440f067fcde719c6f57512f0f7ed4bf2)", - "HashTag(#242)", - "RegularText(2%)", - "RegularText(tonyseries,)", - "Email(TonySeries@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(ba5a614a48719361f515f6efa62c3e213da4bcddbb78dafd3121daa839192275)", - "HashTag(#243)", - "RegularText(2%)", - "RegularText(kuobano,)", - "Email(kuobano@nostrplebs.com)", - "RegularText(-)", - "RegularText(3f6d0bbb073839671f4c7f1e23452c6c3080f6c5f4cbc2f56c17e2b57ee01442)", - "HashTag(#244)", - "RegularText(2%)", - "RegularText(kitakripto,)", - "Email(kitakripto@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(0b11a45bf4ff7f000886b2227e43404d212bf585f71514d54ae5ae685f4c8fbb)", - "HashTag(#245)", - "RegularText(2%)", - "RegularText(Bashy,)", - "Email(_@localhost.re)", - "RegularText(-)", - "RegularText(566516663d91d4fef824eaeccbf9c2631a8d8a2efee8048ca5ee6095e6e5c843)", - "HashTag(#246)", - "RegularText(2%)", - "RegularText(alxc,)", - "Email(alxc@uselessshit.co)", - "RegularText(-)", - "RegularText(c13cb9426a4f85aff08019d246d1240a6cbf49ab9525a06d54fb496b9a3592b0)", - "HashTag(#247)", - "RegularText(2%)", - "RegularText(Kukryr,)", - "Email(kukryr@orangepill.dev)", - "RegularText(-)", - "RegularText(3f03ab6555d2e36ba970d83b8dfe1a9c09d1b89048cf7db0c85d40850f406e54)", - "HashTag(#248)", - "RegularText(2%)", - "RegularText(Saidah,)", - "Email(saidah@nostrplebs.com)", - "RegularText(-)", - "RegularText(909efa6667b28627f107764ce3c28895c46fffd1811b7415dcab03f48c44b597)", - "HashTag(#249)", - "RegularText(2%)", - "RegularText(micmad,)", - "RegularText(miceliomad@miceliomad.github.io/nostr/)", - "RegularText(-)", - "RegularText(cd806edcf8ff40ea94fa574ea9cd97da16e5beb2b85aac6e1d648b8388504343)", - "HashTag(#250)", - "RegularText(2%)", - "RegularText(Zack)", - "RegularText(Wynne,)", - "RegularText()", - "RegularText(-)", - "RegularText(9156e62c7d2f49a91b55effec6c111d3fb343e9de6ff05650e7fd89a039a9dce)", - "HashTag(#251)", - "RegularText(2%)", - "RegularText(Sharon21M,)", - "Email(sharon21m@nostr.fan)", - "RegularText(-)", - "RegularText(66b5c5be6cec2b4a124c532e97d8342f8d763d6b507caced9185168603751f25)", - "HashTag(#252)", - "RegularText(2%)", - "RegularText(bitcoinheirodomanto,)", - "RegularText()", - "RegularText(-)", - "RegularText(93d16b6fcd11199cc113e28976999ff94137ded02ddf6b84bf671daf9358c54a)", - "HashTag(#253)", - "RegularText(2%)", - "RegularText(tyler,)", - "RegularText()", - "RegularText(-)", - "RegularText(272fe1597e8d938b9a7ae5eb23aa50c5048aabbf68f27a428afe3aecd08192da)", - "HashTag(#254)", - "RegularText(2%)", - "RegularText(DMN,)", - "Email(dmn@noderunners.org)", - "RegularText(-)", - "RegularText(176d6e6ceef73b3c66e1cb1ed19b9f2473eaa514678159bc41361b3f29ddb065)", - "HashTag(#255)", - "RegularText(2%)", - "RegularText(Nela@Nostrica2023,)", - "Email(nela_at_nostrica2023@Nostr-Check.com)", - "RegularText(-)", - "RegularText(4b0bcab460adda31fad5a326fb0c04f6ec821fb24be85dbdc03c04cc0e12fc07)", - "HashTag(#256)", - "RegularText(2%)", - "RegularText(xbolo,)", - "Email(xbologg@nanostr.deno.dev)", - "RegularText(-)", - "RegularText(7aabf4a15df15074deeffdb597e6be54be4a211cbd6303436cb1ccea6c9cf87b)", - "HashTag(#257)", - "RegularText(2%)", - "RegularText(btcurenas,)", - "Email(btcurenas@nostr.fan)", - "RegularText(-)", - "RegularText(206a1264c89e8f29355e792782e83ca62331ca3d70169327cb315171b4a7ce2c)", - "HashTag(#258)", - "RegularText(2%)", - "RegularText(amaluenda,)", - "Email(amaluenda@getalby.com)", - "RegularText(-)", - "RegularText(129a80a580a0cb88d5eae9d3924d7bb8a29e0c03ef9fb723091de69c22eaaff8)", - "HashTag(#259)", - "RegularText(2%)", - "RegularText(DeveRoSt,)", - "RegularText()", - "RegularText(-)", - "RegularText(f838b6a03d8d0127a9a98e87c0142b528916a4336ba537e14131a2f513becc17)", - "HashTag(#260)", - "RegularText(2%)", - "RegularText(phoenixpyro,)", - "RegularText()", - "RegularText(-)", - "RegularText(5122cee9af93a36be4bb9b08ee7897ef88fe446c0a5d2f8db60da9faa0f72f27)", - "HashTag(#261)", - "RegularText(2%)", - "RegularText(Queen)", - "RegularText(โ‚ฟ,)", - "Email(queenb@nostrplebs.com)", - "RegularText(-)", - "RegularText(735e573b24b78138e86c96aaf37cf47547d6287c9acbd4eda173e01826b6647a)", - "HashTag(#262)", - "RegularText(2%)", - "RegularText(L.,)", - "Email(ezekiel@Nostr-Check.com)", - "RegularText(-)", - "RegularText(83663cd936892679cbd1ccdf22e017cb9fee11aef494713192c93ad6a155e287)", - "HashTag(#263)", - "RegularText(2%)", - "RegularText(dolu)", - "RegularText((compromised),)", - "RegularText()", - "RegularText(-)", - "RegularText(e668a111aa647e63ef587c17fb0e2513d5c2859cd8d389563c7640ffea1fc216)", - "HashTag(#264)", - "RegularText(2%)", - "RegularText(Marakesh)", - "RegularText(๐“…ฆ,)", - "Email(marakesh@getalby.com)", - "RegularText(-)", - "RegularText(dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491)", - "HashTag(#265)", - "RegularText(2%)", - "RegularText(Storm,)", - "Email(storm@reddirtmining.io)", - "RegularText(-)", - "RegularText(eaba072268fbb5409bdd2e8199e2878cf5d0b51ce3493122d03d7c69585d17f2)", - "HashTag(#266)", - "RegularText(2%)", - "RegularText(fiore,)", - "RegularText()", - "RegularText(-)", - "RegularText(155fd584b69fea049a428935cef11c093b6b80ca067fe4362eab0564d0774f10)", - "HashTag(#267)", - "RegularText(2%)", - "RegularText(.b.o.n.e.s.,)", - "Email(_b_o_n_e_s_@stacker.news)", - "RegularText(-)", - "RegularText(b91257b518ee7226972fc7b726e96d8a63477750a1b40589e36a090735a4f92f)", - "HashTag(#268)", - "RegularText(2%)", - "RegularText(btchodl,)", - "Email(bdichdbd@stacker.news)", - "RegularText(-)", - "RegularText(d3ca4d0144b7608eceb214734a098d50dd6c728eb72e47b0e5b1e04480db1009)", - "HashTag(#269)", - "RegularText(2%)", - "RegularText(Rosie,)", - "RegularText()", - "RegularText(-)", - "RegularText(caf0d967570ab0702c3402d50c4ab12dc6855ea062519b1ac048708cb663b0c8)", - "HashTag(#270)", - "RegularText(2%)", - "RegularText(j9,)", - "Email(j9@nostrplebs.com)", - "RegularText(-)", - "RegularText(c2797c4c633d3005d60a469d154b85766277454b648252d927660d41ecec4163)", - "HashTag(#271)", - "RegularText(2%)", - "RegularText(nokyctranslate,)", - "Email(nokyctranslate@iris.to)", - "RegularText(-)", - "RegularText(794366f1f67b7bc5604fd47e21a27e6fcbff7ec7e7a72c6d4c386d50fd5d2f04)", - "HashTag(#272)", - "RegularText(2%)", - "RegularText(Neomobius,)", - "Email(Neomobius_at_mstdn.jp@mostr.pub)", - "RegularText(-)", - "RegularText(9134bd35097c03abdcd9d61819aa8948880b6e49fc548d8a751b719dced7f7da)", - "HashTag(#273)", - "RegularText(2%)", - "RegularText(dojomaster,)", - "RegularText()", - "RegularText(-)", - "RegularText(30be56daec34e8b319d730f2c2f1cba28ef076660be33d7811dd385698a9cb40)", - "HashTag(#274)", - "RegularText(2%)", - "RegularText(paddepadde)", - "RegularText(โšก๏ธ,)", - "Email(paddepadde@getcurrent.io)", - "RegularText(-)", - "RegularText(430169631f2f0682c60cebb4f902d68f0c71c498fd1711fd982f052cf1fd4279)", - "HashTag(#275)", - "RegularText(2%)", - "RegularText(Val,)", - "Email(val@nostrplebs.com)", - "RegularText(-)", - "RegularText(e2004cb6f21a23878f0000131363e557638e47a804bcfc200103dd653fc9b7dc)", - "HashTag(#276)", - "RegularText(2%)", - "RegularText(Nickfost_,)", - "RegularText()", - "RegularText(-)", - "RegularText(a3e4cba409d392a81521d8714578948979557c8b2d56994b2026a06f6b7e97d2)", - "HashTag(#277)", - "RegularText(2%)", - "RegularText(dishwasher_iot,)", - "Email(dishwasher_iot@wlvs.space)", - "RegularText(-)", - "RegularText(5c6c25b7ef18d8633e97512159954e1aa22809c6b763e94b9f91071836d00217)", - "HashTag(#278)", - "RegularText(2%)", - "RegularText(๐•ฌ๐–“๐–”๐–“๐–ž๐–’๐–”๐–š๐–˜,)", - "Link(zapper.lol)", - "RegularText(-)", - "RegularText(96aceca84aa381eeda084167dd317e1bf7a45d874cd14147f0a9e0df86fb44c2)", - "HashTag(#279)", - "RegularText(2%)", - "RegularText(Peter,)", - "RegularText()", - "RegularText(-)", - "RegularText(b649ca5743312176174cbe76cf81d3eec493b21a52b822b6aa12bd4473da0d01)", - "HashTag(#280)", - "RegularText(2%)", - "RegularText(justin,)", - "Email(1@justinrezvani.com)", - "RegularText(-)", - "RegularText(84d535055542132100ea22e96e33349844422e6e698cc98bd8fb5eae08d76752)", - "HashTag(#281)", - "RegularText(2%)", - "RegularText(vikeymehta,)", - "RegularText()", - "RegularText(-)", - "RegularText(1a3d05e13fa38543b3d45f31c638e94e113b35c0e1db7371cdfa69861e150830)", - "HashTag(#282)", - "RegularText(2%)", - "RegularText(sshh,)", - "Email(sshh@nostrplebs.com)", - "RegularText(-)", - "RegularText(b0f86106d59d2ce292a4d89e70ff4057d7adf4b1b42bb913f37ceb9159bb2aea)", - "HashTag(#283)", - "RegularText(2%)", - "RegularText(Red_Eye_Jedi,)", - "RegularText()", - "RegularText(-)", - "RegularText(3603dbbea53ee52ab34e0f96a8d42aa55486cf5e2e05483533613e97274155f5)", - "HashTag(#284)", - "RegularText(2%)", - "RegularText(jim,)", - "Email(mk05@iris.to)", - "RegularText(-)", - "RegularText(2ed67b778522bfa0245ee57306dea40d6fd9b023db5fff43e2de0419cfe2164e)", - "HashTag(#285)", - "RegularText(2%)", - "RegularText(pniraj007,)", - "RegularText()", - "RegularText(-)", - "RegularText(99f7ba6cfb2fcd60853446b45cec2a467f65faa3245a95513bcf372eec4fbb0e)", - "HashTag(#286)", - "RegularText(2%)", - "RegularText(b676ebโ€ฆ7c389b,)", - "RegularText()", - "RegularText(-)", - "RegularText(b676ebe5ebd490523dda7db35407b7370974b4df25be32335f0652a1f07c389b)", - "HashTag(#287)", - "RegularText(2%)", - "RegularText(herald,)", - "Email(herald@bitcoin-herald.org)", - "RegularText(-)", - "RegularText(7e7224cfe0af5aaf9131af8f3e9d34ff615ff91ce2694640f1f1fee5d8febb7d)", - "HashTag(#288)", - "RegularText(2%)", - "RegularText(Giuseppe)", - "RegularText(Atorino,)", - "Email(nostr@pos.btcpayserver.it)", - "RegularText(-)", - "RegularText(e6eaf2368767307b45fcbea2d96dcb34a93af8877147203fadc10b8f741b71c9)", - "HashTag(#289)", - "RegularText(2%)", - "RegularText(a8b7b0โ€ฆd90ac2,)", - "RegularText()", - "RegularText(-)", - "RegularText(a8b7b07222485f8b845961dd4ca4d8b63c575e060b4d9386e32463e513d90ac2)", - "HashTag(#290)", - "RegularText(2%)", - "RegularText(genosonic,)", - "RegularText()", - "RegularText(-)", - "RegularText(05ffbdf4b71930d0e93ae0caa8f34bcfb5100cfba71f07b9fad4d8b5a80e4df3)", - "HashTag(#291)", - "RegularText(2%)", - "RegularText(JohnnyG,)", - "Email(thumpgofast@NostrVerified.com)", - "RegularText(-)", - "RegularText(241d6b169d62fa3d673fccf66ab62d49c0a1147ab6ab81f7a526d890e1d68a2b)", - "HashTag(#292)", - "RegularText(2%)", - "RegularText(neoop,)", - "Email(neo@elder.nostr.land)", - "RegularText(-)", - "RegularText(ea64386dba380b76c86f671f2f3c5b2a93febe8d3e2e968ac26f33569da36f87)", - "HashTag(#293)", - "RegularText(2%)", - "RegularText(Alchemist,)", - "Email(alchemist@electronalchemy.com)", - "RegularText(-)", - "RegularText(734aac327175cb770b9aa75c8816156ea439a79c6f87a16801248c1c793a8bfc)", - "HashTag(#294)", - "RegularText(2%)", - "RegularText(timp,)", - "Email(timp@iris.to)", - "RegularText(-)", - "RegularText(24cf74e1125833e9752b4843e2887dedddf6910896e6e82a2def68c8527d0814)", - "HashTag(#295)", - "RegularText(2%)", - "RegularText(ken,)", - "Email(ken@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(3505b759f075da83e9d503530d3238361b1603c28e0ee309d928174e87341713)", - "HashTag(#296)", - "RegularText(2%)", - "RegularText(Shea,)", - "RegularText()", - "RegularText(-)", - "RegularText(8dc289f2b5896057e23edc6b806407dc09162147164f4cae1d00dcb1bcd3f084)", - "HashTag(#297)", - "RegularText(2%)", - "RegularText(Devcat,)", - "RegularText()", - "RegularText(-)", - "RegularText(7f1052e59569dee4c6587507c69032af5d6883d2aa659a55bbfe1cb2e8233daf)", - "HashTag(#298)", - "RegularText(2%)", - "RegularText(173a2eโ€ฆ36436a,)", - "RegularText()", - "RegularText(-)", - "RegularText(173a2e04860656e9bab4a62cd5ec2b46ac8814e240c183e47b6badf7b936436a)", - "HashTag(#299)", - "RegularText(2%)", - "RegularText(Irebus,)", - "Email(irebus@nostr.red)", - "RegularText(-)", - "RegularText(1aaaa8e2a2094e2fdd70def09eae4e329ceb01a6a29473cb0b5e0c118f85bd35)", - "HashTag(#300)", - "RegularText(2%)", - "RegularText(b720b6โ€ฆe48a8f,)", - "RegularText()", - "RegularText(-)", - "RegularText(b720b63c47b3292dcb3339782c612462a7a42c9eece06d609a49cf951de48a8f)", - "HashTag(#301)", - "RegularText(2%)", - "RegularText(theflywheel,)", - "RegularText()", - "RegularText(-)", - "RegularText(57dcc9ed500a26a465ddb12c51de05963d4dec8a596708629558495c4acacab3)", - "HashTag(#302)", - "RegularText(2%)", - "RegularText(223597โ€ฆ002c18,)", - "RegularText()", - "RegularText(-)", - "RegularText(22359794c50e2945aa768ee500ffb2ddb388696ad078a350ae570152ff002c18)", - "HashTag(#303)", - "RegularText(2%)", - "RegularText(gratitude,)", - "RegularText()", - "RegularText(-)", - "RegularText(4686358c60bae7694e8b39dad26d1c834d5dd27726a56e2501fc06dec6942be1)", - "HashTag(#304)", - "RegularText(2%)", - "RegularText(stim4444,)", - "Email(stim4444@no.str.cr)", - "RegularText(-)", - "RegularText(0aeaec333bf9a0638de51ea837590ca64522ec590ed160ce87cb6e30d10df537)", - "HashTag(#305)", - "RegularText(2%)", - "RegularText(756240โ€ฆ265fc2,)", - "RegularText()", - "RegularText(-)", - "RegularText(756240d3be0d553b0cd174b3499cffa37fbe8394ee06b9ab50652e314c265fc2)", - "HashTag(#306)", - "RegularText(2%)", - "RegularText(4d38edโ€ฆd26aad,)", - "RegularText()", - "RegularText(-)", - "RegularText(4d38ed26a6d1080806534818a668c71381bcb04bc4ca1083d9d9572977d26aad)", - "HashTag(#307)", - "RegularText(2%)", - "RegularText(Kwinten,)", - "RegularText()", - "RegularText(-)", - "RegularText(c29da265739bc3886c76d84b0a351849fa45a31a64fcb72f47c600ab2623f90c)", - "HashTag(#308)", - "RegularText(2%)", - "RegularText(b36506โ€ฆ7ca32c,)", - "RegularText()", - "RegularText(-)", - "RegularText(b365069ada41fc7190f8b11e8342f7f66f9777eaaa9882722d0be863c27ca32c)", - "HashTag(#309)", - "RegularText(2%)", - "RegularText(Cole)", - "RegularText(Albon,)", - "RegularText()", - "RegularText(-)", - "RegularText(c3ff9a851ca965ed266ba54c9263f680be91e2465628c64bab6a5992521d5c5d)", - "HashTag(#310)", - "RegularText(2%)", - "RegularText(Onecoin,)", - "RegularText()", - "RegularText(-)", - "RegularText(b23ce47262373574d6653fad2da09db1fb20bb2919f3e697b8edd1966fffd8ec)", - "HashTag(#311)", - "RegularText(2%)", - "RegularText(Disabled,)", - "RegularText()", - "RegularText(-)", - "RegularText(7d706eaefb905ea9b3af885879fb5911b50b39db539c319438703373424204ec)", - "HashTag(#312)", - "RegularText(2%)", - "RegularText(xdamman,)", - "RegularText()", - "RegularText(-)", - "RegularText(340254e011abda2e82585cbfee4f91b3f07549a6c468fe009bf3ec7665a2e31b)", - "HashTag(#313)", - "RegularText(2%)", - "RegularText(jmrichner,)", - "RegularText()", - "RegularText(-)", - "RegularText(797750041d1366a80d45e130c831f0562b5f7266662b07acef50dd541bfa2535)", - "HashTag(#314)", - "RegularText(2%)", - "RegularText(pentoshi,)", - "RegularText()", - "RegularText(-)", - "RegularText(db6ad1e2a4cbbacbbdf79377a9ebb2fc30eb417ce9b061003771cb40b8e00d56)", - "HashTag(#315)", - "RegularText(2%)", - "RegularText(35453dโ€ฆ45d10b,)", - "RegularText()", - "RegularText(-)", - "RegularText(35453d2e49a0282c4dd694e5a364bf29600a9b5443e4712cfc86a0495345d10b)", - "HashTag(#316)", - "RegularText(2%)", - "RegularText(LayerLNW,)", - "Email(layerlnw@nostr.fan)", - "RegularText(-)", - "RegularText(33c9edf7ade19188685997136e6ffb4ed89939178fa5f2259428de1cd3301380)", - "HashTag(#317)", - "RegularText(2%)", - "RegularText(Bitcoincouch,)", - "RegularText()", - "RegularText(-)", - "RegularText(fbd3c6eb5ef06e82583d3b533663ba86036462a02e686881d8cb2de5aaa9fa4a)", - "HashTag(#318)", - "RegularText(2%)", - "RegularText(BritishHodl,)", - "RegularText()", - "RegularText(-)", - "RegularText(22fb17c6657bb317be84421335ef6b0f9f1777617aa220cf27dc06fb5788f438)", - "HashTag(#319)", - "RegularText(2%)", - "RegularText(enhickman,)", - "Email(enhickman@enhickman.net)", - "RegularText(-)", - "RegularText(0cf08d280aa5fcfaf340c269abcf66357526fdc90b94b3e9ff6d347a41f090b7)", - "HashTag(#320)", - "RegularText(2%)", - "RegularText(4d6e72โ€ฆ219298,)", - "RegularText()", - "RegularText(-)", - "RegularText(4d6e72aba0e8a033c973acd7e42f915d5fa1708be7229d477869e91136219298)", - "HashTag(#321)", - "RegularText(2%)", - "RegularText(f75326โ€ฆaf65e0,)", - "RegularText()", - "RegularText(-)", - "RegularText(f7532615471b029a34e41e080b2af4bad2b80f8105c008378d0095991eaf65e0)", - "HashTag(#322)", - "RegularText(2%)", - "RegularText(LiveFreeBTC,)", - "Email(LiveFreeBTC@livefreebtc.org)", - "RegularText(-)", - "RegularText(49f586188679af2b31e8d62ff153baf767fd0c586939f4142ef65606d236ff75)", - "HashTag(#323)", - "RegularText(2%)", - "RegularText(aptx4869,)", - "Email(aptx4869@aptx4869.app)", - "RegularText(-)", - "RegularText(64aaa73189af814977ff5dedbbab022df030f1d7df3e6307aceb1fddb30df847)", - "HashTag(#324)", - "RegularText(2%)", - "RegularText(khalil,)", - "Email(khalil@klouche.com)", - "RegularText(-)", - "RegularText(5a03bdb5448b440428d8459d4afe9b553e705737ef8cd7a0d25569ccead4d6ce)", - "HashTag(#325)", - "RegularText(2%)", - "RegularText(nsec1wnppl0xqw2lysecymwmz3hgxuzk60dgyur6mqtgexln20qp4xv9sugxghg,)", - "Email(nsec@ittybitty.tips)", - "RegularText(-)", - "RegularText(f1ea91eeab7988ed00e3253d5d50c66837433995348d7d97f968a0ceb81e0929)", - "HashTag(#326)", - "RegularText(2%)", - "RegularText(BTC_P2P,)", - "RegularText()", - "RegularText(-)", - "RegularText(ecf468164bd743b75683db3870ce01cb9a1d4b8ec203ed26de50f96255bbc75a)", - "HashTag(#327)", - "RegularText(2%)", - "RegularText(Big)", - "RegularText(FISH,)", - "Email(bigfish@iris.to)", - "RegularText(-)", - "RegularText(963100cf40967a70cdea802c6b4b97956cf8c5e3b09e492b24a847d4c535a794)", - "HashTag(#328)", - "RegularText(2%)", - "RegularText(9e93fbโ€ฆ2483b6,)", - "RegularText()", - "RegularText(-)", - "RegularText(9e93fb0012a6177faddf2fd324fb61eafbe8b142b31c5e89fd85bfafd12483b6)", - "HashTag(#329)", - "RegularText(2%)", - "RegularText(Mynameis,)", - "RegularText()", - "RegularText(-)", - "RegularText(6bec23b4a17da33d0a2f44e258371e869ff124775e8e38b9581dcd49c8d1d4a6)", - "HashTag(#330)", - "RegularText(2%)", - "RegularText(3f2342โ€ฆd689b8,)", - "RegularText()", - "RegularText(-)", - "RegularText(3f23426af245168f8112e441c046ecdb29aca56a6d33d21e276b8ac00bd689b8)", - "HashTag(#331)", - "RegularText(2%)", - "RegularText(865c92โ€ฆ136ced,)", - "RegularText()", - "RegularText(-)", - "RegularText(865c92a207a156a2d48404694a2eed5ceca5c163b7a845b86a6c75e142136ced)", - "HashTag(#332)", - "RegularText(2%)", - "RegularText(95d4d6โ€ฆfe1673,)", - "RegularText()", - "RegularText(-)", - "RegularText(95d4d60e643f283cef8d70ab7a9c09ab5a85924f97e11b22cf99779c4ffe1673)", - "HashTag(#333)", - "RegularText(2%)", - "RegularText(verse,)", - "RegularText()", - "RegularText(-)", - "RegularText(0ff7a93751d37ffcca05579c59ac69053d8d0c6f2c57ed9101ba8758eebc0d6b)", - "HashTag(#334)", - "RegularText(2%)", - "RegularText(oldschool,)", - "Email(oldschool@iris.to)", - "RegularText(-)", - "RegularText(19dba8f974322c7345d3b491925896d19e7f432a4f41223c5daf96e31fae338d)", - "HashTag(#335)", - "RegularText(2%)", - "RegularText(Danton๐Ÿ‡จ๐Ÿ‡ญ,)", - "Email(danton@nostrplebs.com)", - "RegularText(-)", - "RegularText(dbe693bc2d16c52e18e75f2cb76401cb7d74132cc956f7315ea5ebee1adfc966)", - "HashTag(#336)", - "RegularText(2%)", - "RegularText(BitcoinZavior,)", - "Email(bitcoinzavior@nostrplebs.com)", - "RegularText(-)", - "RegularText(c6e86c9b95ef289600800b855b9a6ca42019cc9453937020289d8b3e01dab865)", - "HashTag(#337)", - "RegularText(2%)", - "RegularText(BitcoinSermons,)", - "Email(BitcoinSermons@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(615f40fae8f2e08da81b5c76a0143cb04b4e9e044bf6047efe15c56c7cc1a6b2)", - "HashTag(#338)", - "RegularText(2%)", - "RegularText(skreep,)", - "RegularText()", - "RegularText(-)", - "RegularText(a4992688b449c2bdd6fa9c39a880d7fe27d5f5e3e9fd4c47d65d824588fd660f)", - "HashTag(#339)", - "RegularText(2%)", - "RegularText(db830bโ€ฆ4bb85c,)", - "RegularText()", - "RegularText(-)", - "RegularText(db830b864876a0f3109ae3447e43715711250d53f310092052aabb5bdc4bb85c)", - "HashTag(#340)", - "RegularText(2%)", - "RegularText(UKNW22LINUX,)", - "Email(uknwlinux@plebs.place)", - "RegularText(-)", - "RegularText(ab1ef3f15fc29b3da324eb401122382ceb5ea9c61adaad498192879fd9a5d057)", - "HashTag(#341)", - "RegularText(2%)", - "RegularText(Satoshism,)", - "Email(satoshism@nostrplebs.com)", - "RegularText(-)", - "RegularText(e262ed3a22ad8c478b077ef5d7c56b2c3c7a530519ed696ed2e57c65e147fbcb)", - "HashTag(#342)", - "RegularText(2%)", - "RegularText(William,)", - "RegularText()", - "RegularText(-)", - "RegularText(8c55174d8fc29d4da650b273fdd18ad4dda478faa4b0ea14726d81ac6c7bef48)", - "HashTag(#343)", - "RegularText(2%)", - "RegularText(thebitcoinyogi,)", - "Email(jon@nostrplebs.com)", - "RegularText(-)", - "RegularText(59c2e15ad7bc0b5c97b8438b2763a5c409ff76ab985ab5f1f47c4bcdd25e6e8d)", - "HashTag(#344)", - "RegularText(2%)", - "RegularText(vake,)", - "RegularText()", - "RegularText(-)", - "RegularText(547f45b91c1e6b4137917cde4fa1da867c8cdfe43d0f646c836a622769795a14)", - "HashTag(#345)", - "RegularText(2%)", - "RegularText(hobozakki,)", - "Email(hobozakki@nostrplebs.com)", - "RegularText(-)", - "RegularText(29e31c4103b85fab499132fa71870bd5446de8f7e2ac040ec0372aa61ae22f98)", - "HashTag(#346)", - "RegularText(2%)", - "RegularText(SirGalahodl,)", - "Email(sirgalahodl@satstream.me)", - "RegularText(-)", - "RegularText(25ee676190e2b6145ad8dd137630eca55fc503dde715ce8af4c171815d018797)", - "HashTag(#347)", - "RegularText(2%)", - "RegularText(1f6c76โ€ฆebb9c9,)", - "RegularText()", - "RegularText(-)", - "RegularText(1f6c76ddbab213cdd43db2695b1474605639862302c7cfae35362be8caebb9c9)", - "HashTag(#348)", - "RegularText(2%)", - "RegularText(greencandleit,)", - "RegularText()", - "RegularText(-)", - "RegularText(3d4b358b50d20c3e4d855f273ff06c49bc6b3f6e62c42aed44f278742fd579da)", - "HashTag(#349)", - "RegularText(2%)", - "RegularText(ichigo,)", - "RegularText()", - "RegularText(-)", - "RegularText(477e0b3c0c6029e31562b39650efa8f871d52e3ab09145d72e99b9b74dd384d7)", - "HashTag(#350)", - "RegularText(2%)", - "RegularText(Niko,)", - "RegularText()", - "RegularText(-)", - "RegularText(636fdb4de194bca39ab30ab5793a38b8d15c1b1c0a968d04f7fe14eb1a6a8c42)", - "HashTag(#351)", - "RegularText(2%)", - "RegularText(afa,)", - "Email(victor@lnmarkets.com)", - "RegularText(-)", - "RegularText(8f6945b4726112826ac6abd56ec041c87d8bdc4ec02e86bb388a97481f372b97)", - "HashTag(#352)", - "RegularText(2%)", - "RegularText(BushBrook,)", - "RegularText()", - "RegularText(-)", - "RegularText(a39fd86ed75c654550bf813430877819beb77a3b670e01a9680a84a844db9620)", - "HashTag(#353)", - "RegularText(2%)", - "RegularText(naoise,)", - "RegularText()", - "RegularText(-)", - "RegularText(c4a9caef93e93f484274c04cd981d1de1424902451aca2f5602bd0835fe4393d)", - "HashTag(#354)", - "RegularText(2%)", - "SchemelessUrl(smies.me,)", - "Email(jacksmies@iris.to)", - "RegularText(-)", - "RegularText(cdecbc48e35a351582e3e030fd8cf5d5f44681613d2949353d9c6644d32d451f)", - "HashTag(#355)", - "RegularText(2%)", - "RegularText(Chemaclass,)", - "Email(chemaclass@snort.social)", - "RegularText(-)", - "RegularText(c5d4815c26e18e2c178133004a6ddba9a96a5f7af795a3ab606d11aa1055146a)", - "HashTag(#356)", - "RegularText(2%)", - "RegularText(BTCingularity,)", - "RegularText()", - "RegularText(-)", - "RegularText(aa1f96f685d0ac3e28a52feb87a20399a91afb3ac3137afeb7698dfcc99bc454)", - "HashTag(#357)", - "RegularText(2%)", - "RegularText(the_man,)", - "RegularText()", - "RegularText(-)", - "RegularText(dad77f3814964b5cdcd120a3a8d7b40c6218d413ae6328801b9929ed90123687)", - "HashTag(#358)", - "RegularText(2%)", - "RegularText(jayson,)", - "Email(jayson@tautic.com)", - "RegularText(-)", - "RegularText(7be5d241f3cc10922545e31aeb8d5735be2bc3230480e038c7fd503e7349a2cc)", - "HashTag(#359)", - "RegularText(2%)", - "RegularText(jesterhodl,)", - "Email(jesterhodl@jesterhodl.com)", - "RegularText(-)", - "RegularText(3c285d830bf433135ae61c721b750ce11ae5b2e187712d7a171afa7cda649e50)", - "HashTag(#360)", - "RegularText(2%)", - "RegularText(06d694โ€ฆc3ab96,)", - "RegularText()", - "RegularText(-)", - "RegularText(06d6946fd1ff1fba6ac530e0b5683db4c73cdc11d6c42324246e10f4f2c3ab96)", - "HashTag(#361)", - "RegularText(2%)", - "RegularText(sardin,)", - "RegularText()", - "RegularText(-)", - "RegularText(f26470570bcb67a18a90890dbe02d565eadc6c955912977c64c99d4b9a7fd29f)", - "HashTag(#362)", - "RegularText(2%)", - "RegularText(Bitcoin_Gamer_21,)", - "Email(Bitcoin_Gamer_21@bitcoin-21.org)", - "RegularText(-)", - "RegularText(021df4103ede2cdc32de4058d4bdb29ffcbfd13070f05c4688f6974bd9a67176)", - "HashTag(#363)", - "RegularText(2%)", - "RegularText(water-bot,)", - "Email(water-bot@gourcetools.github.io)", - "RegularText(-)", - "RegularText(000000dd7a2e54c77a521237a516eefb1d41df39047a9c64882d05bc84c9d666)", - "HashTag(#364)", - "RegularText(1%)", - "RegularText(ondorevillager,)", - "RegularText()", - "RegularText(-)", - "RegularText(5d7b460173010efd682c0d7bc8cc36ca9bf7dcc7990288f642c04b8e05713c83)", - "HashTag(#365)", - "RegularText(1%)", - "RegularText(Tomfantasia,)", - "RegularText()", - "RegularText(-)", - "RegularText(d856af932000c292ad723dee490ebcf908a1031b486dea05267ee50b473349b2)", - "HashTag(#366)", - "RegularText(1%)", - "RegularText(W3crypto,)", - "Email(w3crypto@iris.to)", - "RegularText(-)", - "RegularText(d001bca923ab56b1c759fc9471fbe6baadac50aeba7d963155772ac7b6779027)", - "HashTag(#367)", - "RegularText(1%)", - "RegularText(bradjpn,)", - "RegularText()", - "RegularText(-)", - "RegularText(c4da3be8e10fa86128530885d18e455900cccff39d7a24c4a6ac12b0284f62b3)", - "HashTag(#368)", - "RegularText(1%)", - "RegularText(@discretelog,)", - "RegularText()", - "RegularText(-)", - "RegularText(03e4804b4a28c051f43185d6bf5b4643cb3f0d9632c4394b60a2ffad0f852340)", - "HashTag(#369)", - "RegularText(1%)", - "RegularText(makaveli,)", - "Email(makaveli@nostrplebs.com)", - "RegularText(-)", - "RegularText(570469cbc969ea6c7e94c41c6496a2951f52d3399011992bf45f4b2216d99119)", - "HashTag(#370)", - "RegularText(1%)", - "RegularText(JamieAnders,)", - "Email(jamieanders@ln.tips)", - "RegularText(-)", - "RegularText(7601e743ad432d78471ac57178402a57cd3f3a92fb208be7de788af2d6a57669)", - "HashTag(#371)", - "RegularText(1%)", - "RegularText(LightningVentures,)", - "RegularText()", - "RegularText(-)", - "RegularText(37de18e08cdc01ce7ced1808b241ec0b4a69e754d576ce0e08f0cf3375bb0a6b)", - "HashTag(#372)", - "RegularText(1%)", - "RegularText(Colorado)", - "RegularText(Craig,)", - "Email(cball@nostrplebs.com)", - "RegularText(-)", - "RegularText(a2c20d6856545b145bc76cdfaffd04ddad4e58d73b2352dcc5de86aa4ba38e7b)", - "HashTag(#373)", - "RegularText(1%)", - "RegularText(21fadbโ€ฆ3d8f6f,)", - "RegularText()", - "RegularText(-)", - "RegularText(21fadb45755a5f41d1b84ecf4610657dd9336d24419d61efffb947aeec3d8f6f)", - "HashTag(#374)", - "RegularText(1%)", - "RegularText(castaway,)", - "RegularText()", - "RegularText(-)", - "RegularText(0cbde76a61cc539059f7da7b4fb19c0197f9f781674d307b52264cbb0144c739)", - "HashTag(#375)", - "RegularText(1%)", - "RegularText(chames,)", - "RegularText()", - "RegularText(-)", - "RegularText(a721f4370afd51fcbc7e2a685f24a454f14fea84448e1c2aa4a9a94b89f3ea7d)", - "HashTag(#376)", - "RegularText(1%)", - "RegularText(laura,)", - "Email(laura@nostrich.zone)", - "RegularText(-)", - "RegularText(ac2250f83aaa7c4a8503f9c15c0cc11ac992315e5ac3e634541223a8deb6c09c)", - "HashTag(#377)", - "RegularText(1%)", - "RegularText(Kaz,)", - "Email(kaz@reddirtmining.io)", - "RegularText(-)", - "RegularText(826d71153f4938c43b930f90cc3130f33430d1e069d43a2f705f9538450b9369)", - "HashTag(#378)", - "RegularText(1%)", - "RegularText(Verismus,)", - "Email(verismus@nostrplebs.com)", - "RegularText(-)", - "RegularText(9e79aed207461f0d5ebc2c8b94e6875e2a6d5dd15990f8ea3ad2540786d07528)", - "HashTag(#379)", - "RegularText(1%)", - "RegularText(cafc4fโ€ฆ107e85,)", - "RegularText()", - "RegularText(-)", - "RegularText(cafc4fbaa558e466bba6c667fcf14506728ff70975f2817c8e5b6fb062107e85)", - "HashTag(#380)", - "RegularText(1%)", - "RegularText(bitpetro,)", - "Email(bitpetro@nostrplebs.com)", - "RegularText(-)", - "RegularText(22470b963e71fa04e1f330ce55f66ff9783c7a9c4851b903d332a59f2327891e)", - "HashTag(#381)", - "RegularText(1%)", - "RegularText(nossence,)", - "Email(nossence@nossence.xyz)", - "RegularText(-)", - "RegularText(56899e6a55c14771a45a88cb90a802623a0e3211ea1447057e2c9871796ce57c)", - "HashTag(#382)", - "RegularText(1%)", - "RegularText(The)", - "RegularText(Progressive)", - "RegularText(Bitcoiner,)", - "RegularText()", - "RegularText(-)", - "RegularText(4870d5500a121e5187544a3e6e5c2fee1d0a03e1b85073f27edb710b110d6208)", - "HashTag(#383)", - "RegularText(1%)", - "RegularText(orangepillstacker,)", - "RegularText()", - "RegularText(-)", - "RegularText(affe861d3e4c42bb956a35d8f9d2c76a99ba16581f3d0dbf762d807e1de8e234)", - "HashTag(#384)", - "RegularText(1%)", - "RegularText(Nostrdamus,)", - "Email(manbearpig@nostrplebs.com)", - "RegularText(-)", - "RegularText(84a42d3efa48018e187027e2bbdd013285a27d8faf970f83a35691d7e2e1a310)", - "HashTag(#385)", - "RegularText(1%)", - "RegularText(JohnSmith,)", - "Email(johnsmith@nostrplebs.com)", - "RegularText(-)", - "RegularText(7c939a7211f1b818567d10b7e65bb03e2830420acf3d6f4f65a7320e2e66d97e)", - "HashTag(#386)", - "RegularText(1%)", - "RegularText(Matty,)", - "RegularText()", - "RegularText(-)", - "RegularText(1cb599e80e7933a7144bbebfb39168c6ee75a27bacd6d8a67e80c442a32a52a8)", - "HashTag(#387)", - "RegularText(1%)", - "RegularText(epodrulz,)", - "Email(bitcoin@bitcoinedu.com)", - "RegularText(-)", - "RegularText(a249234ba07c832c8ee99915f145c02838245499589a6ab8a7461f2ef3eec748)", - "HashTag(#388)", - "RegularText(1%)", - "RegularText(paul,)", - "RegularText()", - "RegularText(-)", - "RegularText(52b9e1aca3df269710568d1caa051abf40fbdf8c2489afb8d2b7cdb1d1d0ce6f)", - "HashTag(#389)", - "RegularText(1%)", - "RegularText(0ec37aโ€ฆba5855,)", - "RegularText()", - "RegularText(-)", - "RegularText(0ec37a784c894b8c8f96a0ccb6055d4ce7b8420482bc41d00e235723a9ba5855)", - "HashTag(#390)", - "RegularText(1%)", - "RegularText(jor,)", - "Email(knggolf@nostrplebs.com)", - "RegularText(-)", - "RegularText(7c765d407d3a9d5ea117cb8b8699628560787fc084a0c76afaa449bfbd121d84)", - "HashTag(#391)", - "RegularText(1%)", - "RegularText(Nighthaven,)", - "Email(nighthaven@iris.to)", - "RegularText(-)", - "RegularText(510e0096e4e622e9f2877af7e7af979ac2fdf50702b9cd77021658344d1a682c)", - "HashTag(#392)", - "RegularText(1%)", - "RegularText(00f454โ€ฆ929254,)", - "RegularText()", - "RegularText(-)", - "RegularText(00f45459dcd6c6e04706ddafd03a9f52a28833efc04b3ff0a66b89146b929254)", - "HashTag(#393)", - "RegularText(1%)", - "RegularText(XBT_fi,)", - "Email(xbt_fi@iris.to)", - "RegularText(-)", - "RegularText(6e1bee4bdfc34056ffcde2c0685ae6468867aedd0843ed5d0cfcde41f64bfda8)", - "HashTag(#394)", - "RegularText(1%)", - "RegularText(e9f332โ€ฆ6474aa,)", - "RegularText()", - "RegularText(-)", - "RegularText(e9f33272af64080287624176253ed2b468d17cec5f2a3d927a3ee36c356474aa)", - "HashTag(#395)", - "RegularText(1%)", - "RegularText(ulrichard,)", - "RegularText()", - "RegularText(-)", - "RegularText(cd0ea239c10e2dbe12e5171537ff0b8619747bfcd8dcf939f4bceed340b38c87)", - "HashTag(#396)", - "RegularText(1%)", - "RegularText(54ff28โ€ฆd7090d,)", - "RegularText()", - "RegularText(-)", - "RegularText(54ff28f1abbceddea50cf35cac69e5df32b982c3e872d40aa9ec035431d7090d)", - "HashTag(#397)", - "RegularText(1%)", - "RegularText(GeneralCarlosQ17,)", - "Email(gencarlosq17@iris.to)", - "RegularText(-)", - "RegularText(b13cc2d0b7b70ba41c13f09cc78dc6ce7f72049b1fe59a8194a237e23e37216e)", - "HashTag(#398)", - "RegularText(1%)", - "RegularText(BitcoinIslandPH,)", - "RegularText()", - "RegularText(-)", - "RegularText(b4ab403c8215e0606f11be21670126a501d85ea2027b6d15bf4b54c3236d0994)", - "HashTag(#399)", - "RegularText(1%)", - "RegularText(rotciv,)", - "Email(rotciv@plebs.place)", - "RegularText(-)", - "RegularText(b70c9bfb254b6072804212643beb077b6ba941609ed40515d9b10961d7767899)", - "HashTag(#400)", - "RegularText(1%)", - "RegularText(Alfa,)", - "RegularText()", - "RegularText(-)", - "RegularText(0575bc052fed6c729a0ab828efa45da77e28685da91bdfebc7a7640cb0728d12)", - "HashTag(#401)", - "RegularText(1%)", - "RegularText(ben_dewaal,)", - "RegularText()", - "RegularText(-)", - "RegularText(aac02781318dfc8c3d7ed0978ef9a7e8154a6b8ae6c910b3a52b42fd56875002)", - "HashTag(#402)", - "RegularText(1%)", - "RegularText(cguida,)", - "RegularText()", - "RegularText(-)", - "RegularText(2895c330c23f383196c0ef988de6da83b83b4583ed5f9c1edb0a559cecd1f900)", - "HashTag(#403)", - "RegularText(1%)", - "RegularText(nout,)", - "RegularText()", - "RegularText(-)", - "RegularText(52cb4b34775fa781b6a964bda0432dbcdfede7a59bf8dfc279cbff0ad8fb09ff)", - "HashTag(#404)", - "RegularText(1%)", - "RegularText(Merlin,)", - "Email(Merlin@bitcoinnostr.com)", - "RegularText(-)", - "RegularText(76dd32f31619b8e35e9f32e015224b633a0df8be8d5613c25b8838a370407698)", - "HashTag(#405)", - "RegularText(1%)", - "RegularText(millymischiefx,)", - "RegularText()", - "RegularText(-)", - "RegularText(868d9200af6e6fe1604a28d587b30c2712100b0edab76982551d56ebc6ae061f)", - "HashTag(#406)", - "RegularText(1%)", - "RegularText(yegorpetrov(alternative),)", - "Email(yeg0rpetrov@iris.to)", - "RegularText(-)", - "RegularText(2650f1f87e1dc974ffcc7b5813a234f6f1b1c92d56732f7db4fef986c80a31f7)", - "HashTag(#407)", - "RegularText(1%)", - "RegularText(baloo,)", - "Email(baloo@nostrpurple.com)", - "RegularText(-)", - "RegularText(c49f8402ef410fce5a1e5b2e6da06f351804988dd2e0bad18ae17a50fc76c221)", - "HashTag(#408)", - "RegularText(1%)", - "RegularText(jamesgospodyn,)", - "Email(jamesgospodyn@nostr.theorangepillapp.com)", - "RegularText(-)", - "RegularText(11edfa8182cf3d843ef36aa2fa270137d1aee9e4f0cd2add67707c8fc5ff2a0d)", - "HashTag(#409)", - "RegularText(1%)", - "RegularText(Mysterious_Minx,)", - "RegularText()", - "RegularText(-)", - "RegularText(381dbcc7138eab9a71e814c57837c9d623f4036ec0240ef302330684ffc8b38f)", - "HashTag(#410)", - "RegularText(1%)", - "RegularText(878bf5โ€ฆf7cb86,)", - "RegularText()", - "RegularText(-)", - "RegularText(878bf5d63ed5b13d2dac3f463e1bd73d0502bd3462ebf2ea3a0825ca11f7cb86)", - "HashTag(#411)", - "RegularText(1%)", - "RegularText(carl,)", - "Email(carl@armadalabs.studio)", - "RegularText(-)", - "RegularText(cd1197bede3b3c0cdc7412d076228e3f48b5b66e88760f53142e91485d128e07)", - "HashTag(#412)", - "RegularText(1%)", - "RegularText(NIMBUS,)", - "RegularText()", - "RegularText(-)", - "RegularText(c48a8ced6dfcc450056bb069b4007607c68a3e93cf3ae6e62b75bf3509f78178)", - "HashTag(#413)", - "RegularText(1%)", - "RegularText(btcportal,)", - "Email(btcportal@nostrplebs.com)", - "RegularText(-)", - "RegularText(9fc1e0ef750dba8cdb3b360b8a00ccad6dcef6b7ad7644f628e952ed8b7eebfb)", - "HashTag(#414)", - "RegularText(1%)", - "RegularText(9652baโ€ฆccd3f1,)", - "RegularText()", - "RegularText(-)", - "RegularText(9652ba74b6981f69a3ffad088aa0f16c8af7fe38a72e5d82176878acdcccd3f1)", - "HashTag(#415)", - "RegularText(1%)", - "RegularText(mjbonham,)", - "Email(mjb@nostrplebs.com)", - "RegularText(-)", - "RegularText(802afdddebfb60a516b39d649ea35401749622e394f85a687674907c4588dc7a)", - "HashTag(#416)", - "RegularText(1%)", - "RegularText(โŒœJanโŒ,)", - "RegularText()", - "RegularText(-)", - "RegularText(fca142a3a900fed71d831aa0aa9c21bb86a5917a9e1183659857b684f25ae1ce)", - "HashTag(#417)", - "RegularText(1%)", - "RegularText(DontTraceMeBruh,)", - "RegularText()", - "RegularText(-)", - "RegularText(3fef59378dce7726d3ef35d4699f57becf76d3be0a13187677126a66c9ade3b8)", - "HashTag(#418)", - "RegularText(1%)", - "RegularText(9a73c0โ€ฆ1707f2,)", - "RegularText()", - "RegularText(-)", - "RegularText(9a73c0ecd5049ae38b50d0d9eaaabd49390cdd08c3d3d666d0d8476c411707f2)", - "HashTag(#419)", - "RegularText(1%)", - "RegularText(esbewolkt,)", - "Email(esbewolkt@nostr.fan)", - "RegularText(-)", - "RegularText(50ea483ddffeeed3231c6f41fddfe8fb71f891fa736de46e3e06f748bbdeb307)", - "HashTag(#420)", - "RegularText(1%)", - "RegularText(morningstar,)", - "RegularText()", - "RegularText(-)", - "RegularText(82671c61fa007b0f70496dec2420238efd3df2f76cdaf6c1f810def8ce95ba45)", - "HashTag(#421)", - "RegularText(1%)", - "RegularText(Sweedgraffixx,)", - "RegularText()", - "RegularText(-)", - "RegularText(ee5f4a67cb434317dd7b931d9d23cb2978ab728a008e4c4dcca9cc781d3ae576)", - "HashTag(#422)", - "RegularText(1%)", - "RegularText(878492โ€ฆ165b4f,)", - "RegularText()", - "RegularText(-)", - "RegularText(878492807168be8dfbae71d721a9b7f6833a9928fcf9acc3274dfdb113165b4f)", - "HashTag(#423)", - "RegularText(1%)", - "RegularText(koukos,)", - "Email(koukos@iris.to)", - "RegularText(-)", - "RegularText(4260122b8a141e888413082dea2d93568488bae4726358e9e6b7da741852dfc8)", - "HashTag(#424)", - "RegularText(1%)", - "RegularText(nopara73,)", - "RegularText()", - "RegularText(-)", - "RegularText(001892e9b48b430d7e37c27051ff7bf414cbc52a7f48f451d857409ce7839dde)", - "HashTag(#425)", - "RegularText(1%)", - "RegularText(BeโšกBANK,)", - "RegularText()", - "RegularText(-)", - "RegularText(fbfb3855d50c37866af00484a6476680ae1e2ff04ceb9dd8936465f70d39150b)", - "HashTag(#426)", - "RegularText(1%)", - "RegularText(davekrock,)", - "Email(davekrock@NostrVerified.com)", - "RegularText(-)", - "RegularText(e26b5f261cb29354def8a8ba6af49b137e3144388a81ef78eed8e77cfb18fd44)", - "HashTag(#427)", - "RegularText(1%)", - "RegularText(BitcoinLoveLife,)", - "Email(Bitcoinlovelife@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(3c08d854ef6c86b1dc11159fdabc09209eaeba01790ce96690c55787daf3c415)", - "HashTag(#428)", - "RegularText(1%)", - "RegularText(Steam,)", - "RegularText()", - "RegularText(-)", - "RegularText(111a1ae50a7e30a465126b0ab10c3eac6ddaa3cca016a4117470e6715a2dfdef)", - "HashTag(#429)", - "RegularText(1%)", - "RegularText(xolag,)", - "Email(xolagl2@getalby.com)", - "RegularText(-)", - "RegularText(fb64b9c3386a9ababaf8c4f80b47c071c4a38f7b8acdc4dafb009875a64f8c37)", - "HashTag(#430)", - "RegularText(1%)", - "RegularText(relay9may,)", - "RegularText()", - "RegularText(-)", - "RegularText(1e7fd2177d20c97f326cda699551f085b8e7f93650b48b6e87a0bebcdfeebc8b)", - "HashTag(#431)", - "RegularText(1%)", - "RegularText(f2c817โ€ฆ8a2f3b,)", - "RegularText()", - "RegularText(-)", - "RegularText(f2c817a3bbf07517a38beac228a12e3460d18f1ec2ed928d2e6d2e67308a2f3b)", - "HashTag(#432)", - "RegularText(1%)", - "RegularText(remoney,)", - "Email(remoney@nostrplebs.com)", - "RegularText(-)", - "RegularText(3939a929101b17f4782171b5e0e49996fbe2215b226bd847bd76be3c2de80e9a)", - "HashTag(#433)", - "RegularText(1%)", - "RegularText(387eb9โ€ฆa6f87f,)", - "RegularText()", - "RegularText(-)", - "RegularText(387eb9a5c4f43e40e6abd1f6fe953477464ae5830d104e325f362209c2a6f87f)", - "HashTag(#434)", - "RegularText(1%)", - "RegularText(846b76โ€ฆ539eca,)", - "RegularText()", - "RegularText(-)", - "RegularText(846b763b1234c5652f1e327e59570dcb6535d2d20589c67c2a9a90b323539eca)", - "HashTag(#435)", - "RegularText(1%)", - "RegularText(Shawn)", - "RegularText(C.,)", - "RegularText()", - "RegularText(-)", - "RegularText(83ea7cb5a3ab517f24eb2948b23f39466dd5f200fd4e6951fed43ba34e9a4a83)", - "HashTag(#436)", - "RegularText(1%)", - "RegularText(roberto,)", - "Email(roberto@bitcoiner.chat)", - "RegularText(-)", - "RegularText(319a588a77cd798b358724234b534bff3f3c294b4f6512bde94d070da93237c9)", - "HashTag(#437)", - "RegularText(1%)", - "RegularText(LazyNinja,)", - "Email(cryptolazyninja@stacker.news)", - "RegularText(-)", - "RegularText(ff444d454bc6ba2c16abdfd843124e6ad494297cf424fa81fb0604a24ee188e2)", - "HashTag(#438)", - "RegularText(1%)", - "RegularText(e5ae7bโ€ฆc8b2ef,)", - "RegularText()", - "RegularText(-)", - "RegularText(e5ae7b9cc5177675654400db194878601ee8ff5c355acb85daa50f7551c8b2ef)", - "HashTag(#439)", - "RegularText(1%)", - "RegularText(kimymt,)", - "Email(kimymt@getalby.com)", - "RegularText(-)", - "RegularText(3009318aa9544a2caf401ece529fd772e26cdd7e60349ec175423b302dafd521)", - "HashTag(#440)", - "RegularText(1%)", - "RegularText(z_hq,)", - "RegularText()", - "RegularText(-)", - "RegularText(215e2d416a8663d5b2e44f30d6c46750db7254cdbd2cf87fea4c1549d97486d4)", - "HashTag(#441)", - "RegularText(1%)", - "RegularText(Reza,)", - "RegularText()", - "RegularText(-)", - "RegularText(e7c0d1e42929695b972e90e88fb2210b3567af45206aac51fff85ba011f79093)", - "HashTag(#442)", - "RegularText(1%)", - "RegularText(benderlogic,)", - "Email(benderlogic@rogue.earth)", - "RegularText(-)", - "RegularText(d656ffcaf523f15899db0ea3289d04d00528714651d624814695cabe9cb34114)", - "HashTag(#443)", - "RegularText(1%)", - "RegularText(maestro,)", - "Email(MAESTRO@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(8c3e08bbc47297021be7e6e2c59dab237fab9056b3a5302a8cd2fc2959037466)", - "HashTag(#444)", - "RegularText(1%)", - "RegularText(travis,)", - "Email(travis@west.report)", - "RegularText(-)", - "RegularText(3dc0b75592823507f5f625f889d36ba2607487550b4f38335a603eda010f2bc2)", - "HashTag(#445)", - "RegularText(1%)", - "RegularText(Coffee)", - "RegularText(Lover,)", - "Email(coffeelover@nostrplebs.com)", - "RegularText(-)", - "RegularText(9ecbaa6dc307291c3cf205c8a79ad8174411874cf244ca06f58a5a73e491222c)", - "HashTag(#446)", - "RegularText(1%)", - "RegularText(shadowysuperstore,)", - "Email(shadowysuperstore@shadowysuperstore.com)", - "RegularText(-)", - "RegularText(7abbf3067536c6b70fbc8ac1965e485dce6ebb3d5c125aac248bc0fe906c6818)", - "HashTag(#447)", - "RegularText(1%)", - "RegularText(bhaskar,)", - "RegularText()", - "RegularText(-)", - "RegularText(5beb5d04939db36498e0736003771294317c1c018953d18433276a042bf9a39d)", - "HashTag(#448)", - "RegularText(1%)", - "RegularText(kylum)", - "RegularText(๐ŸŸฃ,)", - "RegularText()", - "RegularText(-)", - "RegularText(e651489d08a27970aac55b222b8a3ea5f3c00419f2976a3cf4006f3add2b6f3c)", - "HashTag(#449)", - "RegularText(1%)", - "RegularText(็‰น็ซ‹็‹ฌ่กŒ็š„ๆŽๅ‘˜ๅค–,)", - "Email(npub1wg2dsjnh0g7phheq23v288k0mj8x75fffmq7rghtkhv53027hnassf4w8t@nost.vip)", - "RegularText(-)", - "RegularText(7214d84a777a3c1bdf205458a39ecfdc8e6f51294ec1e1a2ebb5d948bd5ebcfb)", - "HashTag(#450)", - "RegularText(1%)", - "RegularText(eynhaender,)", - "Email(eynhaender@nostrplebs.com)", - "RegularText(-)", - "RegularText(a21babb54929f10164ca8f8fcca5138d25a892c32fabc8df7d732b8b52b68d82)", - "HashTag(#451)", - "RegularText(1%)", - "RegularText(8340fdโ€ฆ8c7a30,)", - "RegularText()", - "RegularText(-)", - "RegularText(8340fd16fb4414765af8f59192ed68814920e7d33522709de2457490c28c7a30)", - "HashTag(#452)", - "RegularText(1%)", - "RegularText(B1ackSwan,)", - "Email(b1ackswan@nostrplebs.com)", - "RegularText(-)", - "RegularText(1f695a6883cef577dcebf9c60041111772a64e3490cb299c3b97fc81ad3901f4)", - "HashTag(#453)", - "RegularText(1%)", - "RegularText(91dac4โ€ฆ599398,)", - "RegularText()", - "RegularText(-)", - "RegularText(91dac44e3f9d0e3b839aaf7fd81e6c19cf2ce02356fca5096af9e92f58599398)", - "HashTag(#454)", - "RegularText(1%)", - "RegularText(356e99โ€ฆfc3ba8,)", - "RegularText()", - "RegularText(-)", - "RegularText(356e99a0f75e973c0512873cbdce0385df39712653020af825556ceb4afc3ba8)", - "HashTag(#455)", - "RegularText(1%)", - "RegularText(mcdean,)", - "RegularText()", - "RegularText(-)", - "RegularText(54def063abe1657a22cc886eaba75f6636845c601efe9ad56709b4cb3dcc62f1)", - "HashTag(#456)", - "RegularText(1%)", - "RegularText(mrbitcoin,)", - "Email(mrbitc0in@nostrplebs.com)", - "RegularText(-)", - "RegularText(da41332116804e9c4396f6dbb77ec9ad338197993e9d8af18f332e53dcc1bfeb)", - "HashTag(#457)", - "RegularText(1%)", - "RegularText(Jedi,)", - "Email(jedi@nostrplebs.com)", - "RegularText(-)", - "RegularText(246498aa79542482499086f9ab0134750a23047dad0cca38b696750f9ed8072c)", - "HashTag(#458)", - "RegularText(1%)", - "RegularText(CloudNull,)", - "Email(cloudnull@nostrplebs.com)", - "RegularText(-)", - "RegularText(5f53baca8cb88a18320a032957bf0b6f8dc8b33db007310b0e2f573edf2703a3)", - "HashTag(#459)", - "RegularText(1%)", - "RegularText(Mrwh0,)", - "Email(Mrwh0@Mrwh0.github.io)", - "RegularText(-)", - "RegularText(d8dd77e3dff24bd8c2da9b4c4fb321f5f99e8713bad40dd748ab59656b5ed27d)", - "HashTag(#460)", - "RegularText(1%)", - "RegularText(shinohai,)", - "Email(shinohai@iris.to)", - "RegularText(-)", - "RegularText(4bc7982c4ee4078b2ada5340ae673f18d3b6a664b1f97e8d6799e6074cb5c39d)", - "HashTag(#461)", - "RegularText(1%)", - "RegularText(awoi,)", - "Email(awoi@iris.to)", - "RegularText(-)", - "RegularText(edc083016d344679566ae8205b362530ecbafc6e064e224a0c2df1850cecfb4a)", - "HashTag(#462)", - "RegularText(1%)", - "RegularText(TheShopRat,)", - "RegularText()", - "RegularText(-)", - "RegularText(8362e77d9fd268720a15840af33fd9ab5cdf13fabc66f0910111580960cd297a)", - "HashTag(#463)", - "RegularText(1%)", - "RegularText(Dajjal,)", - "RegularText()", - "RegularText(-)", - "RegularText(614aee83d7eaffc7bc6bbf02feda0cc53e7f97eeceac08a897c4cea3c023b804)", - "HashTag(#464)", - "RegularText(1%)", - "RegularText(felipe,)", - "RegularText()", - "RegularText(-)", - "RegularText(0ee8894f1f663fd76b682c16e6a92db0fe14ada98db35b4a4cfa5f9068be0b3a)", - "HashTag(#465)", - "RegularText(1%)", - "RegularText(crypt0-j3sus,)", - "RegularText()", - "RegularText(-)", - "RegularText(9a7b7cbe37b2caa703062c51b207eb6ec4c42d06bfa909d979aa2d5005ac3d65)", - "HashTag(#466)", - "RegularText(1%)", - "RegularText(Just)", - "RegularText(J,)", - "Email(jcope101@nostrplebs.com)", - "RegularText(-)", - "RegularText(5f6f376733b1a8682a0f330e07b6a6064d738fdd8159db6c8df44c6c9419ff88)", - "HashTag(#467)", - "RegularText(1%)", - "RegularText(mmasnick,)", - "RegularText()", - "RegularText(-)", - "RegularText(4d53de27a24feb84d6383962e350219fc09e572c22a17c542545a69cd35b067f)", - "HashTag(#468)", - "RegularText(1%)", - "RegularText(Murmur,)", - "Email(murmur@nostrplebs.com)", - "RegularText(-)", - "RegularText(f7e84b92a5457546894daedaff9abd66f3d289f92435d6ac068a33cb170b01a4)", - "HashTag(#469)", - "RegularText(1%)", - "RegularText(JD,)", - "RegularText()", - "RegularText(-)", - "RegularText(1a9ba80629e2f8f77340ac13e67fdb4fcc66f4bb4124f9beff6a8c75e4ce29b0)", - "HashTag(#470)", - "RegularText(1%)", - "RegularText(dario,)", - "Email(dario@nostrplebs.com)", - "RegularText(-)", - "RegularText(d9987652d3cbb2c0fa39b6305cc0f2d03ca987afc1e56bc97a81c79e138152a8)", - "HashTag(#471)", - "RegularText(1%)", - "RegularText(leonwankum,)", - "RegularText(@leonawankum@BitcoinNostr.com)", - "RegularText()", - "RegularText(-)", - "RegularText(652d58acafa105af8475c0fe8029a52e7ddbc337b2bd9c98bb17a111dc4cde60)", - "HashTag(#472)", - "RegularText(1%)", - "RegularText(phil,)", - "Email(phil@iris.to)", - "RegularText(-)", - "RegularText(8352b55a828a60bb0e86b0ac9ef1928999ebe636c905dcbe0cd3c0f95c61b83b)", - "HashTag(#473)", - "RegularText(1%)", - "RegularText(hkmccullough,)", - "Email(thatirdude@nostrplebs.com)", - "RegularText(-)", - "RegularText(836059a05aeb8498dd53a0d422e04aced6b4b71eb3621d312626c46715d259d8)", - "HashTag(#474)", - "RegularText(1%)", - "RegularText(BitBox,)", - "RegularText()", - "RegularText(-)", - "RegularText(5a3de28ffd09d7506cff0a2672dbdb1f836307bcff0217cc144f48e19eea3fff)", - "HashTag(#475)", - "RegularText(1%)", - "RegularText(5eff6cโ€ฆ60bd07,)", - "RegularText()", - "RegularText(-)", - "RegularText(5eff6c1205c9db582863978b5b2e9c9aa73a57e6c1df526fddc2b9996060bd07)", - "HashTag(#476)", - "RegularText(1%)", - "RegularText(nobody,)", - "RegularText()", - "RegularText(-)", - "RegularText(2e472c6d072c0bcc28f1b260e0fc309f1f919667d238f4e703f8f1db0f0eb424)", - "HashTag(#477)", - "RegularText(1%)", - "RegularText(K_hole,)", - "Email(K_hole@ketamine.com)", - "RegularText(-)", - "RegularText(5ac74532e23b7573f8f6f3248fe5174c0b7230aec0b653c0ec8f11d540209fd7)", - "HashTag(#478)", - "RegularText(1%)", - "RegularText(bitcoinIllustrated,)", - "RegularText()", - "RegularText(-)", - "RegularText(90fb6b9607bba40686fe70aad74a07e5af96d152778f3a09fcda5967dcb0daba)", - "HashTag(#479)", - "RegularText(1%)", - "RegularText(kingfisher,)", - "RegularText()", - "RegularText(-)", - "RegularText(33d4c61d7354e1d5872e26218eda73170646d12a8e7b9cb6d3069a7058ebabfd)", - "HashTag(#480)", - "RegularText(1%)", - "RegularText(cfc11eโ€ฆb4f6e4,)", - "RegularText()", - "RegularText(-)", - "RegularText(cfc11ef4b31e2ab18261a71b79097c60199f532605a0c3aa73ad36acc6b4f6e4)", - "HashTag(#481)", - "RegularText(1%)", - "RegularText(d06848โ€ฆ2f86b3,)", - "RegularText()", - "RegularText(-)", - "RegularText(d06848a9ea53f9e9c15cafaf41b1729d6d7b84083cfbac2c76a0506dd72f86b3)", - "HashTag(#482)", - "RegularText(1%)", - "RegularText(nostrceo,)", - "RegularText()", - "RegularText(-)", - "RegularText(3159e1a148ca235cb55365a2ffde608b17e84c4c3bff6ed309f3e320307d5ab3)", - "HashTag(#483)", - "RegularText(1%)", - "RegularText(Lokuyow2,)", - "Email(2@lokuyow.github.io)", - "RegularText(-)", - "RegularText(f5f02030cb4b22ed15c3d7cc35ae616e6ce6bb3fa537f6e9e91aaa274b9cd716)", - "HashTag(#484)", - "RegularText(1%)", - "RegularText(fatushi,)", - "RegularText()", - "RegularText(-)", - "RegularText(49a458319060806221990e90e6bf2b1654201f08a40828d1a5d215a85f449df0)", - "HashTag(#485)", - "RegularText(1%)", - "RegularText(Omnia,)", - "RegularText()", - "RegularText(-)", - "RegularText(026d2251aa211684ef63e7a28e21c611c087bb3131a9c90b11dff6c16d68ce77)", - "HashTag(#486)", - "RegularText(1%)", - "RegularText(joey,)", - "RegularText()", - "RegularText(-)", - "RegularText(5f8a5bbf8d26104547a3942e82d7a5159554b3a5a3bc1275c47674b5e8c4c1d7)", - "HashTag(#487)", - "RegularText(1%)", - "RegularText(Hazey,)", - "Email(hazey@iris.to)", - "RegularText(-)", - "RegularText(800e0fe3d8638ce3f75a56ed865df9d96fc9d9cd2f75550df0d7f5c1d8468b0b)", - "HashTag(#488)", - "RegularText(1%)", - "RegularText(Milad)", - "RegularText(Younis,)", - "RegularText()", - "RegularText(-)", - "RegularText(64c24e0991f9bb6f59f9da486ba29242bc562b09ce051882f7b3bcc7fd055227)", - "HashTag(#489)", - "RegularText(1%)", - "RegularText(jlgalley,)", - "RegularText()", - "RegularText(-)", - "RegularText(920535dd1487975ccc75ed82b7b4753260ec4041dcf9ce24657623164f6586e3)", - "HashTag(#490)", - "RegularText(1%)", - "RegularText(paulgallo28,)", - "RegularText()", - "RegularText(-)", - "RegularText(690af9eed15cc3a7439c39b228bf194da134f75d64f40114a41d77bff6a60699)", - "HashTag(#491)", - "RegularText(1%)", - "RegularText(HeineNon,)", - "Email(HeineNon@tomottodx.github.io)", - "RegularText(-)", - "RegularText(64c66c231ea1c25ebd66b14fe4a0b1b39a6928d6824ad43e035f54aa667bc650)", - "HashTag(#492)", - "RegularText(1%)", - "RegularText(a9b9adโ€ฆ2b9f4c,)", - "RegularText()", - "RegularText(-)", - "RegularText(a9b9ad000e2ada08326bbcc1836effcdfa4e64b9c937e406fe5912dc562b9f4c)", - "HashTag(#493)", - "RegularText(1%)", - "RegularText(legxxi,)", - "RegularText()", - "RegularText(-)", - "RegularText(8476d0dcdb53f1cc67efc8d33f40104394da2d33e61369a8a8ade288036977c6)", - "HashTag(#494)", - "RegularText(1%)", - "RegularText(99f1b7โ€ฆ559c31,)", - "RegularText()", - "RegularText(-)", - "RegularText(99f1b7b39201d0e142f9ec3c8101b6be0eee8a389d16d53667ca4f57b1559c31)", - "HashTag(#495)", - "RegularText(1%)", - "RegularText(mbz,)", - "RegularText()", - "RegularText(-)", - "RegularText(e5195850d4fed08183f0b274ca30777094daad67be235a5cd15548b9b0341031)", - "HashTag(#496)", - "RegularText(1%)", - "RegularText(Titan,)", - "Email(titan@nostrplebs.com)", - "RegularText(-)", - "RegularText(672b1637bd65b6206c7a603158c2ecee15599648e10dd15a82f2fcb4e47735bf)", - "HashTag(#497)", - "RegularText(1%)", - "RegularText(Highlandhodl,)", - "RegularText()", - "RegularText(-)", - "RegularText(f0c74190cd05d85d843cdc5f355afe0fbac6d30d18da91243d6cae30a69713f7)", - "HashTag(#498)", - "RegularText(1%)", - "RegularText(CodeWarrior,)", - "RegularText()", - "RegularText(-)", - "RegularText(21a7014db2ba17acc8bbb9496645084866b46e1ba0062a80513afda405450183)", - "HashTag(#499)", - "RegularText(1%)", - "SchemelessUrl(baller.hodl,)", - "RegularText()", - "RegularText(-)", - "RegularText(d8150dc0631f834a004f231f0747d5ec8409b1a9214d246f675dfef39807a224)", - "HashTag(#500)", - "RegularText(1%)", - "RegularText(Now)", - "RegularText(Playing)", - "RegularText(on)", - "RegularText(GMโ‚ฟ,)", - "RegularText()", - "RegularText(-)", - "RegularText(9c6907de72e59daf5272103a34649bf7ca01050a68f402955520fc53dba9730d)", - "RegularText()", - "RegularText(Inspector monitor)", - "RegularText()", - "RegularText(New events inspected today: 720.71K (4.85GB))", - "RegularText(Average events inspected per second: 8.34)", - "RegularText(Uptime: Server 99.93%, NostrInspector: 99.93%)", - "RegularText(Spam estimate: )", - "RegularText(74.12 %)", - "RegularText()", - "RegularText(About the NostrInspector Report)", - "RegularText()", - "RegularText(โœ… The 24 Hour NostrInspector Report is generated by listening for new events on the top relays using the Nostr Protocol. The statistics report that )", - "RegularText(it generates includes de data layer as well as the social layer.)", - "RegularText(๐Ÿ’œ To support this free effort share, like, comment or zap.)", - "RegularText(๐Ÿซ‚ Thank you ๐Ÿ™ )", - "RegularText()", - "RegularText(๐Ÿ•ต๏ธ @nostrin \"The Nostr Inspector\" )", - "Bech(npub17m7f7q08k4x746s2v45eyvwppck32dcahw7uj2mu5txuswldgqkqw9zms7)", - ) - - state.paragraphs - .map { it.words } - .flatten() - .forEachIndexed { index, seg -> + @Test + fun testTextToParse() { + val state = RichTextParser().parseText(textToParse, EmptyTagList) Assert.assertEquals( - expectedResult[index], - "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", + "relay.shitforce.one, relayable.org, universe.nostrich.land, nos.lol, universe.nostrich.land?lang=zh, universe.nostrich.land?lang=en, relay.damus.io, relay.nostr.wirednet.jp, offchain.pub, nostr.rocks, relay.wellorder.net, nostr.oxtr.dev, universe.nostrich.land?lang=ja, relay.mostr.pub, nostr.bitcoiner.social, Nostr-Check.com, MR.Rabbit, Ancap.su, zapper.lol, smies.me, baller.hodl", + state.urlSet.joinToString(", "), ) - } - Assert.assertTrue(state.imagesForPager.isEmpty()) - Assert.assertTrue(state.imageList.isEmpty()) - Assert.assertTrue(state.customEmoji.isEmpty()) - Assert.assertEquals(651, state.paragraphs.size) - } + printStateForDebug(state) - @Test - fun testShortTextToParse() { - val state = RichTextParser().parseText("Hi, how are you doing? ", EmptyTagList) - Assert.assertTrue(state.urlSet.isEmpty()) - Assert.assertTrue(state.imagesForPager.isEmpty()) - Assert.assertTrue(state.imageList.isEmpty()) - Assert.assertTrue(state.customEmoji.isEmpty()) - Assert.assertEquals( - "Hi, how are you doing? ", - state.paragraphs.firstOrNull()?.words?.firstOrNull()?.segmentText, - ) - } + val expectedResult = + listOf( + "RegularText(๐Ÿ“ฐ 24 Hour NostrInspector Report ๐Ÿ•ต (TEXT ONLY VERSION))", + "RegularText()", + "RegularText(Generated Friday June 30 2023 03:59:01 UTC-6 (CST))", + "RegularText()", + "RegularText(Network statistics)", + "RegularText()", + "RegularText(New events witnessed (top 110 relays) )", + "RegularText()", + "RegularText(Kind, count, (% count), size, (% size))", + "RegularText(1, 207.9K, (28.8%), 458.02MB, (9.2%))", + "RegularText(7, 158.3K, (22%), 280.83MB, (5.7%))", + "RegularText(0, 84.1K, (11.7%), 192.89MB, (3.9%))", + "RegularText(9735, 57.2K, (7.9%), 353.16MB, (7.1%))", + "RegularText(3, 54.7K, (7.6%), 2.75GB, (56.7%))", + "RegularText(6, 31.6K, (4.4%), 111.27MB, (2.2%))", + "RegularText(4, 30.8K, (4.3%), 89.79MB, (1.8%))", + "RegularText(30000, 29.1K, (4%), 115.33MB, (2.3%))", + "RegularText(30078, 12.1K, (1.7%), 317.25MB, (6.4%))", + "RegularText(5, 11K, (1.5%), 16.86MB, (0.3%))", + "RegularText(10002, 8.6K, (1.2%), 16.59MB, (0.3%))", + "RegularText(1311, 7.7K, (1.1%), 12.71MB, (0.3%))", + "RegularText(1984, 6.3K, (0.9%), 10.93MB, (0.2%))", + "RegularText(9734, 3.7K, (0.5%), 10.88MB, (0.2%))", + "RegularText(30001, 3.1K, (0.4%), 66.91MB, (1.3%))", + "RegularText(1000, 2.8K, (0.4%), 13.43MB, (0.3%))", + "RegularText(20100, 1.4K, (0.2%), 2.32MB, (0%))", + "RegularText(42, 1.1K, (0.2%), 2.30MB, (0%))", + "RegularText(13194, 1K, (0.1%), 1.22MB, (0%))", + "RegularText(1063, 875, (0.1%), 1.96MB, (0%))", + "RegularText()", + "RegularText(New events by relay (top 50%))", + "RegularText()", + "RegularText(Events (%) Relay)", + "RegularText(33.4K)", + "RegularText((4.6%))", + "Link(relay.shitforce.one)", + "RegularText(32.9K)", + "RegularText((4.6%))", + "Link(relayable.org)", + "RegularText(26.6K)", + "RegularText((3.7%))", + "Link(universe.nostrich.land)", + "RegularText(22.8K)", + "RegularText((3.2%))", + "Link(nos.lol)", + "RegularText(22.7K)", + "RegularText((3.1%))", + "Link(universe.nostrich.land?lang=zh)", + "RegularText(22.5K)", + "RegularText((3.1%))", + "Link(universe.nostrich.land?lang=en)", + "RegularText(21.2K)", + "RegularText((2.9%))", + "Link(relay.damus.io)", + "RegularText(20.6K)", + "RegularText((2.9%))", + "Link(relay.nostr.wirednet.jp)", + "RegularText(20.1K)", + "RegularText((2.8%))", + "Link(offchain.pub)", + "RegularText(19.9K)", + "RegularText((2.8%))", + "Link(nostr.rocks)", + "RegularText(19.5K)", + "RegularText((2.7%))", + "Link(relay.wellorder.net)", + "RegularText(19.4K)", + "RegularText((2.7%))", + "Link(nostr.oxtr.dev)", + "RegularText(19K)", + "RegularText((2.6%))", + "Link(universe.nostrich.land?lang=ja)", + "RegularText(18.4K)", + "RegularText((2.6%))", + "Link(relay.mostr.pub)", + "RegularText(17.5K)", + "RegularText((2.4%))", + "Link(universe.nostrich.land?lang=zh)", + "RegularText(16.3K)", + "RegularText((2.3%))", + "Link(nostr.bitcoiner.social)", + "RegularText()", + "RegularText(30 day global new events)", + "RegularText()", + "RegularText(23-05-29 1M)", + "RegularText(23-05-30 861.9K)", + "RegularText(23-05-31 752.5K)", + "RegularText(23-06-01 0.9M)", + "RegularText(23-06-02 808.9K)", + "RegularText(23-06-03 683.8K)", + "RegularText(23-06-04 0.9M)", + "RegularText(23-06-05 890.6K)", + "RegularText(23-06-06 839.4K)", + "RegularText(23-06-07 827K)", + "RegularText(23-06-08 804.8K)", + "RegularText(23-06-09 736.7K)", + "RegularText(23-06-10 709.7K)", + "RegularText(23-06-11 772.2K)", + "RegularText(23-06-12 882K)", + "RegularText(23-06-13 794.9K)", + "RegularText(23-06-14 842.2K)", + "RegularText(23-06-15 812.1K)", + "RegularText(23-06-16 839.6K)", + "RegularText(23-06-17 730.2K)", + "RegularText(23-06-18 811.9K)", + "RegularText(23-06-19 721.9K)", + "RegularText(23-06-20 786.2K)", + "RegularText(23-06-21 756.6K)", + "RegularText(23-06-22 736K)", + "RegularText(23-06-23 723.5K)", + "RegularText(23-06-24 703.9K)", + "RegularText(23-06-25 734.9K)", + "RegularText(23-06-26 742.4K)", + "RegularText(23-06-27 707.8K)", + "RegularText(23-06-28 747.7K)", + "RegularText()", + "RegularText(Social Network Statistics)", + "RegularText()", + "RegularText(Top 30 hashtags found today)", + "RegularText()", + "HashTag(#hashtag,)", + "RegularText(mentions)", + "RegularText(today,)", + "RegularText(days)", + "RegularText(in)", + "RegularText(top)", + "RegularText(30)", + "RegularText()", + "HashTag(#bitcoin,)", + "RegularText(1.7K,)", + "RegularText(109)", + "HashTag(#concussion,)", + "RegularText(1.1K,)", + "RegularText(25)", + "HashTag(#press,)", + "RegularText(0.9K,)", + "RegularText(65)", + "HashTag(#france,)", + "RegularText(492,)", + "RegularText(46)", + "HashTag(#presse,)", + "RegularText(480,)", + "RegularText(42)", + "HashTag(#covid19,)", + "RegularText(465,)", + "RegularText(65)", + "HashTag(#nostr,)", + "RegularText(414,)", + "RegularText(109)", + "HashTag(#zapathon,)", + "RegularText(386,)", + "RegularText(76)", + "HashTag(#rssfeed,)", + "RegularText(309,)", + "RegularText(53)", + "HashTag(#btc,)", + "RegularText(299,)", + "RegularText(109)", + "HashTag(#news,)", + "RegularText(294,)", + "RegularText(91)", + "HashTag(#zap,)", + "RegularText(283,)", + "RegularText(109)", + "HashTag(#linux,)", + "RegularText(253,)", + "RegularText(88)", + "HashTag(#respond,)", + "RegularText(246,)", + "RegularText(90)", + "HashTag(#kompost,)", + "RegularText(240,)", + "RegularText(31)", + "HashTag(#plebchain,)", + "RegularText(236,)", + "RegularText(109)", + "HashTag(#gardenaward,)", + "RegularText(236,)", + "RegularText(31)", + "HashTag(#start,)", + "RegularText(236,)", + "RegularText(31)", + "HashTag(#unicef,)", + "RegularText(233,)", + "RegularText(32)", + "HashTag(#coronavirus,)", + "RegularText(233,)", + "RegularText(33)", + "HashTag(#bew,)", + "RegularText(229,)", + "RegularText(31)", + "HashTag(#balkon,)", + "RegularText(229,)", + "RegularText(31)", + "HashTag(#terrasse,)", + "RegularText(229,)", + "RegularText(31)", + "HashTag(#braininjuryawareness,)", + "RegularText(229,)", + "RegularText(24)", + "HashTag(#garten,)", + "RegularText(220,)", + "RegularText(21)", + "HashTag(#smart,)", + "RegularText(220,)", + "RegularText(21)", + "HashTag(#nsfw,)", + "RegularText(211,)", + "RegularText(85)", + "HashTag(#protoncalendar,)", + "RegularText(206,)", + "RegularText(31)", + "HashTag(#stacksats,)", + "RegularText(195,)", + "RegularText(99)", + "HashTag(#nokyc,)", + "RegularText(179,)", + "RegularText(98)", + "RegularText()", + "RegularText(Emoji sentiment today)", + "RegularText()", + "RegularText(โšก (1.6K) ๐Ÿ‘‰ (1.4K) ๐Ÿ‡ช๐Ÿ‡บ (1.2K) ๐Ÿซ‚ (1.2K) ๐Ÿ‡บ๐Ÿ‡ธ (1.1K) ๐Ÿ’œ (875) ๐Ÿง  (858) ๐Ÿ˜‚ (830) ๐Ÿ”ฅ (690) ๐Ÿคฃ (566) ๐Ÿค™ (525) โ˜• (444) ๐Ÿ‘‡ (443) ๐Ÿ™Œ๐Ÿป (425) โ˜€ (307) ๐Ÿ˜Ž (305) ๐Ÿฅณ (301) ๐Ÿค” (276) ๐ŸŒป (270) ๐Ÿงก (270) ๐Ÿฅ‡ (269) ๐Ÿ—“ (269) ๐Ÿ™ (268) ๐Ÿ† (267) ๐ŸŒฑ (264) ๐Ÿ“ฐ (230) ๐Ÿ‰ (221) ๐Ÿ˜ญ (220) ๐Ÿ’ฐ (219) ๐Ÿ”— (209) ๐Ÿ‘€ (201) ๐Ÿ˜… (199) โœจ (193) ๐Ÿ‡ท๐Ÿ‡บ (182) ๐Ÿ’ช (167) โœ… (164) ๐Ÿ’ค (163) ๐Ÿถ (151) ๐Ÿ‡จ๐Ÿ‡ญ (141) ๐Ÿ“ (137) ๐Ÿ˜ (136) ๐ŸŒž (136) ๐Ÿพ (136) โค (132) ๐Ÿ’ป (126) ๐Ÿš€ (125) ๐Ÿ‘ (125) ๐Ÿ‡ง๐Ÿ‡ท (125) ๐Ÿ˜Š (121) ๐Ÿ“š (120) โžก (120) ๐Ÿ‘ (118) ๐ŸŽ‰ (117) ๐ŸŽฎ (115) ๐Ÿคท (113) ๐Ÿ‘‹ (112) ๐Ÿ’ƒ (108) ๐Ÿ•บ๐Ÿป (106) ๐Ÿ’ก (104) ๐Ÿšจ (99) ๐Ÿ˜† (97) ๐Ÿ’ฏ (95) โš  (92) ๐Ÿ“ข (92) ๐Ÿค— (89) ๐Ÿ˜ด (87) ๐Ÿ” (83) ๐Ÿฐ (81) ๐Ÿ˜€ (79) ๐ŸŽŸ (78) โ› (78) ๐Ÿฆ (76) ๐Ÿ’ธ (76) โœŒ๐Ÿป (75) ๐Ÿค (73) ๐Ÿ‡ฌ๐Ÿ‡ง (73) ๐ŸŒฝ (70) ๐Ÿคก (69) ๐Ÿคฎ (69) โ— (66) ๐Ÿค (65) ๐Ÿ˜‰ (65) ๐Ÿ™‡ (65) ๐Ÿป (64) ๐ŸŒ (64) ๐Ÿ’• (63) ๐ŸŒธ (62) ๐Ÿ’ฌ (61) โ˜บ (61) ๐Ÿ‡ฆ๐Ÿ‡ท (59) ๐Ÿ‡ฎ๐Ÿ‡ฉ (57) ๐Ÿ˜ณ (57) ๐Ÿ˜„ (57) ๐ŸŽถ (57) ๐Ÿฅท๐Ÿป (56) ๐ŸŽต (56) ๐Ÿ˜ƒ (56) ๐Ÿ” (55) ๐Ÿ’ฅ (55) ๐ŸŽฒ (54) โœ (54) ๐Ÿ•’ (53) โฌ‡ (53) ๐Ÿ’™ (51) ๐Ÿ”’ (50) ๐Ÿ“ˆ (50) ๐Ÿช™ (50) ๐ŸŒง (50) ๐Ÿฅฐ (50) ๐Ÿ•ธ (50) ๐ŸŒ (50) ๐Ÿ’ญ (49) ๐ŸŒ™ (49) ๐Ÿ˜ (49) ๐Ÿ“ฑ (48) ๐ŸŒŸ (48) ๐Ÿคฉ (48) ๐Ÿ’” (47) ๐Ÿ”Œ (47) ๐Ÿ˜‹ (47) ๐ŸŽ– (47) ๐Ÿฃ (46) ๐Ÿ“ท (46) ๐Ÿ’ผ (45) โญ (45) ๐Ÿฅ” (45) ๐Ÿฅบ (45) ๐Ÿ‘Œ (44) ๐Ÿ‘ท๐Ÿผ (43) ๐Ÿ˜ฑ (43) ๐Ÿ“… (43) ๐Ÿค– (43) ๐Ÿ“ธ (42) ๐Ÿ“Š (42) ๐Ÿฆ‘ (40) ๐Ÿ’ต (40) ๐Ÿคฆ (39) โฃ (38) ๐Ÿ’Ž (38) ๐Ÿ–ค (38) ๐Ÿ“บ (37) ๐Ÿ‡ต๐Ÿ‡ฑ (37) ๐Ÿ‡ฏ๐Ÿ‡ต (36) ๐Ÿ”ง (36) ๐Ÿค˜ (36) ๐Ÿ’– (36) โ€ผ (35) ๐Ÿ˜ข (35) ๐Ÿ˜บ (34) ๐Ÿ”Š (34) ๐Ÿ˜ (34) ๐Ÿ‡ธ๐Ÿ‡ฐ (34) ๐Ÿƒ (34) ๐Ÿ‘ฉโ€๐Ÿ‘ง (34) โฐ (33) ๐Ÿ‘จโ€๐Ÿ’ป (33) ๐Ÿ‘‘ (33) ๐Ÿ‘ฅ (32) ๐Ÿ–ฅ (32) ๐Ÿ’จ (32) ๐Ÿ’— (31) ๐Ÿ‡ฒ๐Ÿ‡ฝ (31) ๐Ÿ“– (31) ๐Ÿšซ (31) ๐Ÿ‘Š๐Ÿป (31) ๐Ÿ˜ก (31) ๐ŸŒŽ (31) ๐Ÿ‘ (30) ๐Ÿ—ž (30) ๐Ÿ€ (30) ๐Ÿฝ (29) ๐Ÿธ (29) ๐Ÿฅš (29) ๐Ÿ’ฉ (29) โœŠ๐Ÿพ (29) ๐Ÿ˜ฎ (29) ๐ŸŒก (29) ๐Ÿ™ƒ (28) ๐Ÿ”” (28) ๐Ÿ‡ป๐Ÿ‡ช (28) ๐Ÿ’ฆ (28) ๐ŸŽฏ (28) ๐ŸŽจ (28) ๐Ÿ› (28) ๐Ÿ–ผ (27) โ˜๐Ÿป (27) ๐Ÿ›‘ (27) ๐Ÿ™„ (27) ๐Ÿง‘๐Ÿปโ€๐Ÿคโ€๐Ÿง‘๐Ÿฝ (27) ๐ŸŒˆ (27) ๐Ÿฅ‚ (26) ๐Ÿ‡ซ๐Ÿ‡ฎ (26) ๐ŸŽฅ (26) ๐Ÿ˜ฌ (26) ๐Ÿฅฒ (25) ๐Ÿฆพ (24) ๐Ÿคœ (24) ๐Ÿ™‚ (24) ๐Ÿ–• (24) ๐Ÿ˜ฉ (24) )", + "RegularText()", + "RegularText(Zap economy)", + "RegularText()", + "RegularText(โšก41.7M sats (โ‚ฟ0.417) )", + "RegularText(1,816 zappers & 920 zapped (unique pubkeys))", + "RegularText(๐ŸŒฉ๏ธ 33,248 zaps, 1,253 sats per zap (avg))", + "RegularText()", + "RegularText(Most followed )", + "RegularText()", + "HashTag(#1)", + "RegularText(30%)", + "RegularText(jb55,)", + "Email(jb55@jb55.com)", + "RegularText(-)", + "RegularText(32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245)", + "HashTag(#2)", + "RegularText(19%)", + "RegularText(Snowden,)", + "Email(Snowden@Nostr-Check.com)", + "RegularText(-)", + "RegularText(84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240)", + "HashTag(#3)", + "RegularText(18%)", + "RegularText(cameri,)", + "Email(cameri@elder.nostr.land)", + "RegularText(-)", + "RegularText(00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700)", + "HashTag(#4)", + "RegularText(11%)", + "RegularText(Natalie,)", + "Email(natalie@NostrVerified.com)", + "RegularText(-)", + "RegularText(edcd20558f17d99327d841e4582f9b006331ac4010806efa020ef0d40078e6da)", + "HashTag(#5)", + "RegularText(11%)", + "RegularText(saifedean,)", + "RegularText()", + "RegularText(-)", + "RegularText(4379e76bfa76a80b8db9ea759211d90bb3e67b2202f8880cc4f5ffe2065061ad)", + "HashTag(#6)", + "RegularText(11%)", + "RegularText(alanbwt,)", + "Email(alanbwt@nostrplebs.com)", + "RegularText(-)", + "RegularText(1bd32a386a7be6f688b3dc7c480efc21cd946b43eac14ba4ba7834ac77a23e69)", + "HashTag(#7)", + "RegularText(10%)", + "RegularText(rick,)", + "Email(rick@no.str.cr)", + "RegularText(-)", + "RegularText(978c8f26ea9b3c58bfd4c8ddfde83741a6c2496fab72774109fe46819ca49708)", + "HashTag(#8)", + "RegularText(9%)", + "RegularText(shawn,)", + "Email(shawn@shawnyeager.com)", + "RegularText(-)", + "RegularText(c7eda660a6bc8270530e82b4a7712acdea2e31dc0a56f8dc955ac009efd97c86)", + "HashTag(#9)", + "RegularText(9%)", + "RegularText(0xtr,)", + "Email(0xtr@oxtr.dev)", + "RegularText(-)", + "RegularText(b2d670de53b27691c0c3400225b65c35a26d06093bcc41f48ffc71e0907f9d4a)", + "HashTag(#10)", + "RegularText(9%)", + "RegularText(stick,)", + "Email(pavol@rusnak.io)", + "RegularText(-)", + "RegularText(d7f0e3917c466f1e2233e9624fbd6d4bd1392dbcfcaf3574f457569d496cb731)", + "HashTag(#11)", + "RegularText(9%)", + "RegularText(caitlinlong,)", + "Email(caitlin@nostrverified.com)", + "RegularText(-)", + "RegularText(e1055729d51e037b3c14e8c56e2c79c22183385d94aadb32e5dc88092cd0fef4)", + "HashTag(#12)", + "RegularText(9%)", + "RegularText(ralf,)", + "Email(ralf@snort.social)", + "RegularText(-)", + "RegularText(c89cf36deea286da912d4145f7140c73495d77e2cfedfb652158daa7c771f2f8)", + "HashTag(#13)", + "RegularText(9%)", + "RegularText(StackSats,)", + "Email(stacksats@nostrplebs.com)", + "RegularText(-)", + "RegularText(b93049a6e2547a36a7692d90e4baa809012526175546a17337454def9ab69d30)", + "HashTag(#14)", + "RegularText(9%)", + "RegularText(MrHodl,)", + "Email(MrHodl@nostrpurple.com)", + "RegularText(-)", + "RegularText(29fbc05acee671fb579182ca33b0e41b455bb1f9564b90a3d8f2f39dee3f2779)", + "HashTag(#15)", + "RegularText(9%)", + "RegularText(mikedilger,)", + "Email(_@mikedilger.com)", + "RegularText(-)", + "RegularText(ee11a5dff40c19a555f41fe42b48f00e618c91225622ae37b6c2bb67b76c4e49)", + "HashTag(#16)", + "RegularText(9%)", + "RegularText(jascha,)", + "Email(jascha@relayable.org)", + "RegularText(-)", + "RegularText(2479739594ed5802a96703e5a870b515d986982474a71feae180e8ecffa302c6)", + "HashTag(#17)", + "RegularText(8%)", + "RegularText(Nakadaimon,)", + "Email(Nakadaimon@nostrplebs.com)", + "RegularText(-)", + "RegularText(803a613997a26e8714116f99aa1f98e8589cb6116e1aaa1fc9c389984fcd9bb8)", + "HashTag(#18)", + "RegularText(8%)", + "RegularText(KeithMukai,)", + "Email(KeithMukai@nostr.seedsigner.com)", + "RegularText(-)", + "RegularText(5b0e8da6fdfba663038690b37d216d8345a623cc33e111afd0f738ed7792bc54)", + "HashTag(#19)", + "RegularText(8%)", + "RegularText(TheGuySwann,)", + "Email(theguyswann@NostrVerified.com)", + "RegularText(-)", + "RegularText(b0b8fbd9578ac23e782d97a32b7b3a72cda0760761359bd65661d42752b4090a)", + "HashTag(#20)", + "RegularText(8%)", + "RegularText(dk,)", + "Email(dk@stacker.news)", + "RegularText(-)", + "RegularText(b708f7392f588406212c3882e7b3bc0d9b08d62f95fa170d099127ece2770e5e)", + "HashTag(#21)", + "RegularText(7%)", + "RegularText(zerohedge,)", + "Email(npub1z7eqn5603ltuxr77w70t3sasep8hyngzr6lxqpa9hfcqjwe9wmdqhw0qhv@nost.vip)", + "RegularText(-)", + "RegularText(17b209d34f8fd7c30fde779eb8c3b0c84f724d021ebe6007a5ba70093b2576da)", + "HashTag(#22)", + "RegularText(7%)", + "RegularText(miljan,)", + "Email(miljan@primal.net)", + "RegularText(-)", + "RegularText(d61f3bc5b3eb4400efdae6169a5c17cabf3246b514361de939ce4a1a0da6ef4a)", + "HashTag(#23)", + "RegularText(7%)", + "RegularText(jared,)", + "Email(jared@nostrplebs.com)", + "RegularText(-)", + "RegularText(92e3aac668edb25319edd1d87cadef0b189557fdd13b123d82a19d67fd211909)", + "HashTag(#24)", + "RegularText(7%)", + "RegularText(radii,)", + "Email(radii@orangepill.dev)", + "RegularText(-)", + "RegularText(acedd3597025cb13b84f9a89643645aeb61a3b4a3af8d7ac01f8553171bf17c5)", + "HashTag(#25)", + "RegularText(7%)", + "RegularText(katie,)", + "Email(_@katieannbaker.com)", + "RegularText(-)", + "RegularText(07eced8b63b883cedbd8520bdb3303bf9c2b37c2c7921ca5c59f64e0f79ad2a6)", + "HashTag(#26)", + "RegularText(7%)", + "RegularText(giacomozucco,)", + "Email(giacomozucco@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(ef151c7a380f40a75d7d1493ac347b6777a9d9b5fa0aa3cddb47fc78fab69a8b)", + "HashTag(#27)", + "RegularText(7%)", + "RegularText(kr,)", + "Email(kr@stacker.news)", + "RegularText(-)", + "RegularText(08b80da85ba68ac031885ea555ab42bb42231fde9b690bbd0f48c128dfbf8009)", + "HashTag(#28)", + "RegularText(7%)", + "RegularText(phil,)", + "Email(phil@nostrpurple.com)", + "RegularText(-)", + "RegularText(e07773a92a610a28da20748fdd98bfb5af694b0cad085224801265594a98108a)", + "HashTag(#29)", + "RegularText(7%)", + "RegularText(angela,)", + "Email(angela@nostr.world)", + "RegularText(-)", + "RegularText(2b1964b885de3fcbb33777874d06b05c254fecd561511622ce86e3d1851949fa)", + "HashTag(#30)", + "RegularText(7%)", + "RegularText(mason)", + "RegularText(๐“„€)", + "RegularText(๐“…ฆ,)", + "Email(mason@lacosanostr.com)", + "RegularText(-)", + "RegularText(5ef92421b5df0ed97df6c1a98fc038ea7962a29e7f33a060f7a8ddeb9ee587e9)", + "HashTag(#31)", + "RegularText(7%)", + "RegularText(Lau,)", + "Email(lau@nostr.report)", + "RegularText(-)", + "RegularText(5a9c48c8f4782351135dd89c5d8930feb59cb70652ffd37d9167bf922f2d1069)", + "HashTag(#32)", + "RegularText(7%)", + "RegularText(Rex)", + "RegularText(Damascus)", + "RegularText(,)", + "Email(damascusrex@iris.to)", + "RegularText(-)", + "RegularText(50c5c98ccc31ca9f1ef56a547afc4cb48195fe5603d4f7874a221db965867c8e)", + "HashTag(#33)", + "RegularText(6%)", + "RegularText(nym,)", + "Email(nym@nostr.fan)", + "RegularText(-)", + "RegularText(9936a53def39d712f886ac7e2ed509ce223b534834dd29d95caba9f6bc01ef35)", + "HashTag(#34)", + "RegularText(6%)", + "RegularText(nico,)", + "Email(nico@nostrplebs.com)", + "RegularText(-)", + "RegularText(0000000033f569c7069cdec575ca000591a31831ebb68de20ed9fb783e3fc287)", + "HashTag(#35)", + "RegularText(6%)", + "RegularText(anna,)", + "Email(seekerdreamer1@stacker.news)", + "RegularText(-)", + "RegularText(6f2347c6fc4cbcc26d66e74247abadd4151592277b3048331f52aa3a5c244af9)", + "HashTag(#36)", + "RegularText(6%)", + "RegularText(TheSameCat,)", + "Email(thesamecat@iris.to)", + "RegularText(-)", + "RegularText(72f9755501e1a4464f7277d86120f67e7f7ec3a84ef6813cc7606bf5e0870ff3)", + "HashTag(#37)", + "RegularText(6%)", + "RegularText(nitesh_btc,)", + "Email(nitesh@noderunner.wtf)", + "RegularText(-)", + "RegularText(021d7ef7aafc034a8fefba4de07622d78fd369df1e5f9dd7d41dc2cffa74ae02)", + "HashTag(#38)", + "RegularText(6%)", + "RegularText(gpt3,)", + "Email(gpt3@jb55.com)", + "RegularText(-)", + "RegularText(5c10ed0678805156d39ef1ef6d46110fe1e7e590ae04986ccf48ba1299cb53e2)", + "HashTag(#39)", + "RegularText(6%)", + "RegularText(Byzantine,)", + "Email(byzantine@stacker.news)", + "RegularText(-)", + "RegularText(5d1d83de3ee5edde157071d5091a6d03ead8cce1d46bc585a9642abdd0db5aa0)", + "HashTag(#40)", + "RegularText(6%)", + "RegularText(wealththeory,)", + "Email(wealththeory@nostrplebs.com)", + "RegularText(-)", + "RegularText(3004d45a0ab6352c61a62586a57c50f11591416c29db1143367a4f0623b491ca)", + "HashTag(#41)", + "RegularText(6%)", + "RegularText(IshBit,)", + "Email(gug@nostrplebs.com)", + "RegularText(-)", + "RegularText(8e27ffb5c9bb8cdd0131ade6efa49d56d401b5424d9fdf9a63e074d527b0715c)", + "HashTag(#42)", + "RegularText(5%)", + "RegularText(Lana,)", + "Email(lana@b.tc)", + "RegularText(-)", + "RegularText(e8795f9f4821f63116572ed4998924c6f0e01682945bf7a3d9d6132f1c7dace7)", + "HashTag(#43)", + "RegularText(5%)", + "RegularText(Shevacai,)", + "Email(shevacai@nostrplebs.com)", + "RegularText(-)", + "RegularText(2f175fe4348f4da2da157e84d119b5165c84559158e64729ff00b16394718bbf)", + "HashTag(#44)", + "RegularText(5%)", + "RegularText(joe,)", + "Email(joe@nostrpurple.com)", + "RegularText(-)", + "RegularText(907a5a23635ea02be052c31f465b1982aefb756710ccc9f628aa31b70d2e262e)", + "HashTag(#45)", + "RegularText(5%)", + "RegularText(SimplestBitcoinBook,)", + "Email(simplestbitcoinbook@nostrplebs.com)", + "RegularText(-)", + "RegularText(6867d899ce6b677b89052602cfe04a165f26bb6a1a6390355f497f9ee5cb0796)", + "HashTag(#46)", + "RegularText(5%)", + "RegularText(knutsvanholm,)", + "Email(knutsvanholm@iris.to)", + "RegularText(-)", + "RegularText(92cbe5861cfc5213dd89f0a6f6084486f85e6f03cfeb70a13f455938116433b8)", + "HashTag(#47)", + "RegularText(5%)", + "RegularText(rajwinder,)", + "Email(rs@zbd.ai)", + "RegularText(-)", + "RegularText(1c9d368fc24e8549ce2d95eba63cb34b82b363f3036d90c12e5f13afe2981fba)", + "HashTag(#48)", + "RegularText(5%)", + "RegularText(Vlad,)", + "RegularText()", + "RegularText(-)", + "RegularText(50054d07e2cdf32b1035777bd9cf73992a4ae22f91c14a762efdaa5bf61f4755)", + "HashTag(#49)", + "RegularText(5%)", + "RegularText(GRANTGILLIAM,)", + "Email(GRANTGILLIAM@grantgilliam.com)", + "RegularText(-)", + "RegularText(874db6d2db7b39035fe7aac19e83a48257915e37d4f2a55cb4ca66be2d77aa88)", + "HashTag(#50)", + "RegularText(5%)", + "RegularText(LifeLoveLiberty,)", + "Email(lifeloveliberty@iris.to)", + "RegularText(-)", + "RegularText(c07a2ea48b6753d11ad29d622925cb48bab48a8f38e954e85aec46953a0752a2)", + "HashTag(#51)", + "RegularText(5%)", + "RegularText(hackernews,)", + "Email(npub1s9c53smfq925qx6fgkqgw8as2e99l2hmj32gz0hjjhe8q67fxdvs3ga9je@nost.vip)", + "RegularText(-)", + "RegularText(817148c3690155401b494580871fb0564a5faafb9454813ef295f2706bc93359)", + "HashTag(#52)", + "RegularText(5%)", + "RegularText(arbedout,)", + "Email(arbedout@granddecentral.com)", + "RegularText(-)", + "RegularText(a67e98faf32f2520ae574d84262534e7b94625ce0d4e14a50c97e362c06b770e)", + "HashTag(#53)", + "RegularText(5%)", + "RegularText(nobody,)", + "RegularText()", + "RegularText(-)", + "RegularText(5f735049528d831f544b49a585e6f058c1655dfaed9fc338374cd4f3a5a06bf7)", + "HashTag(#54)", + "RegularText(5%)", + "RegularText(glowleaf,)", + "Email(glowleaf@nostrplebs.com)", + "RegularText(-)", + "RegularText(34c0a53283bacd5cb6c45f9b057bea05dfb276333dcf14e9b167680b5d3638e4)", + "HashTag(#55)", + "RegularText(5%)", + "RegularText(Modus,)", + "Email(modus@lacosanostr.com)", + "RegularText(-)", + "RegularText(547fcc5c7e655fe7c83da5a812e6332f0a4779c87bf540d8e75a4edbbf36fe4a)", + "HashTag(#56)", + "RegularText(5%)", + "RegularText(Melvin)", + "RegularText(Carvalho)", + "RegularText(Old)", + "RegularText(Key)", + "RegularText(DO)", + "RegularText(NOT)", + "RegularText(USE,)", + "RegularText(USE)", + "Bech(npub1melv683fw6n2mvhl5h6dhqd8mqfv3wmxnz4qph83ua4dk4006ezsrt5c24,)", + "RegularText()", + "RegularText(-)", + "RegularText(ed1d0e1f743a7d19aa2dfb0162df73bacdbc699f67cc55bb91a98c35f7deac69)", + "HashTag(#57)", + "RegularText(5%)", + "RegularText(anil,)", + "Email(anil@bitcoinnostr.com)", + "RegularText(-)", + "RegularText(ade7a0c6acca095c5b36f88f20163bccda4d97b071c4acc8fe329dc724eec8fb)", + "HashTag(#58)", + "RegularText(4%)", + "RegularText(DocumentingBTC,)", + "Email(documentingbtc@uselessshit.co)", + "RegularText(-)", + "RegularText(641ac8fea1478c27839fb7a0850676c2873c22aa70c6216996862c98861b7e2f)", + "HashTag(#59)", + "RegularText(4%)", + "RegularText(wolfbearclaw,)", + "Email(wolfbearclaw@nostr.messagepush.io)", + "RegularText(-)", + "RegularText(0b963191ab21680a63307aedb50fd7b01392c9c6bef79cd0ceb6748afc5e7ffd)", + "HashTag(#60)", + "RegularText(4%)", + "RegularText(Amboss,)", + "Email(_@amboss.space)", + "RegularText(-)", + "RegularText(2af01e0d6bd1b9fbb9e3d43157d64590fb27dcfbcabe28784a5832e17befb87b)", + "HashTag(#61)", + "RegularText(4%)", + "RegularText(k3tan,)", + "Email(k3tan@k3tan.com)", + "RegularText(-)", + "RegularText(599c4f2380b0c1a9a18b7257e107cf9e6d8b4f8dea06c18c84538d311ff2b28c)", + "HashTag(#62)", + "RegularText(4%)", + "RegularText(wolzie)", + "RegularText(,)", + "Email(wolzie@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(aabedc1f237853aeeb22bd985556036f262f8507842d64f3ecce01adbd7207e2)", + "HashTag(#63)", + "RegularText(4%)", + "RegularText(trey,)", + "Email(trey@nostrplebs.com)", + "RegularText(-)", + "RegularText(d5415a313d38461ff93a8c170f941b2cd4a66a5cfdbb093406960f6cb317849f)", + "HashTag(#64)", + "RegularText(4%)", + "RegularText(sillystev,)", + "RegularText()", + "RegularText(-)", + "RegularText(d541ef2e4830f2e1543c8bdc40128ceceb062b08c7e3f53d141552d5f5bc0cfc)", + "HashTag(#65)", + "RegularText(4%)", + "RegularText(sovereignmox,)", + "Email(woody@fountain.fm)", + "RegularText(-)", + "RegularText(1c4123b2431c60be030d641b4b68300eb464415405035b199428c0913b879c0c)", + "HashTag(#66)", + "RegularText(4%)", + "RegularText(CosmicDimension,)", + "Email(cosmicdimension@nostrplebs.com)", + "RegularText(-)", + "RegularText(4afec6c875e81dc28a760cc828345c0c5b61ec464ba20224148f9fd854a868ff)", + "HashTag(#67)", + "RegularText(4%)", + "RegularText(Mir,)", + "Email(mirbtc@getalby.com)", + "RegularText(-)", + "RegularText(234c45ff85a31c19bf7108a747fa7be9cd4af95c7d621e07080ca2d663bb47d2)", + "HashTag(#68)", + "RegularText(4%)", + "RegularText(Tacozilla,)", + "RegularText()", + "RegularText(-)", + "RegularText(5f70f80ddcf4f6a022467bd5196a1fdfc53d59f1e735a90443e7f7c980564c88)", + "HashTag(#69)", + "RegularText(4%)", + "RegularText(marks,)", + "Email(marks@nostrplebs.com)", + "RegularText(-)", + "RegularText(8ea485266b2285463b13bf835907161c22bb3da1e652b443db14f9cee6720a43)", + "HashTag(#70)", + "RegularText(4%)", + "RegularText(blacktomcat,)", + "Email(barrensatin40@walletofsatoshi.com)", + "RegularText(-)", + "RegularText(16b7e4b067cba8c86bda96a8d932e7593f398118d24bd8060da39ccfd7315f5c)", + "HashTag(#71)", + "RegularText(4%)", + "RegularText(Alex)", + "RegularText(Emidio,)", + "Email(alexemidio@alexemidio.github.io)", + "RegularText(-)", + "RegularText(4ba8e86d2d97896dc9337c3e500691893d7317572fd81f8b41ddda5d89d32de4)", + "HashTag(#72)", + "RegularText(4%)", + "RegularText(Jenn,)", + "Email(Jenn@mintgreen.co)", + "RegularText(-)", + "RegularText(e0f59d89047b868a188c5efd6b93dd8c16b65643b8718884dad8542386c60ddd)", + "HashTag(#73)", + "RegularText(4%)", + "RegularText(spacemonkey,)", + "Email(spacemonkey@nostrich.love)", + "RegularText(-)", + "RegularText(23b26fea28700cd1e2e3a8acca5c445c37ab89acaad549a36d50e9c0eb0f5806)", + "HashTag(#74)", + "RegularText(4%)", + "RegularText(ishak,)", + "Email(ishak@nostrplebs.com)", + "RegularText(-)", + "RegularText(052466631c6c0aed84171f83ef3c95cb81848d4dcdc1d1ee9dfdf75b850c1cb4)", + "HashTag(#75)", + "RegularText(4%)", + "RegularText(nakamoto_army,)", + "RegularText()", + "RegularText(-)", + "RegularText(62f6c5ff12fd24251f0bfb3b7eb1e512d7f1f577a1a97a595db01c66b52ad04f)", + "HashTag(#76)", + "RegularText(4%)", + "RegularText(GrassFedBitcoin,)", + "Email(GrassFedBitcoin@start9.com)", + "RegularText(-)", + "RegularText(74ffc51cc30150cf79b6cb316d3a15cf332ab29a38fec9eb484ab1551d6d1856)", + "HashTag(#77)", + "RegularText(4%)", + "RegularText(NinoHodls,)", + "Email(ninoholds@nostrplebs.com)", + "RegularText(-)", + "RegularText(43ccdbcb1e4dff7e3dea2a91b851ca0e22f50e3c560364a12b64b8c6587924f0)", + "HashTag(#78)", + "RegularText(4%)", + "RegularText(satcap,)", + "Email(satcap@nostr.satcap.io)", + "RegularText(-)", + "RegularText(11dfaa43ae0faa0a06d8c67f89759214c58b60a021521627bc76cb2d3ad0b2e8)", + "HashTag(#79)", + "RegularText(4%)", + "RegularText(DuneMessias,)", + "RegularText()", + "RegularText(-)", + "RegularText(96a578f6b504646de141ba90bec5651965aa01df0605928b3785a1372504e93d)", + "HashTag(#80)", + "RegularText(4%)", + "RegularText(Idaeus,)", + "RegularText()", + "RegularText(-)", + "RegularText(eb473e8fd55ced7af32abaf89578647ddba75e38a860b1c41682bbfb774f5579)", + "HashTag(#81)", + "RegularText(4%)", + "RegularText(tpmoreira,)", + "Email(tpmoreira@nostrplebs.com)", + "RegularText(-)", + "RegularText(f514ef7d18da12ecfce55c964add719ce00a1392c187f20ccb57d99290720e03)", + "HashTag(#82)", + "RegularText(4%)", + "RegularText(force2B,)", + "Email(force2b@nostrplebs.com)", + "RegularText(-)", + "RegularText(d411848a42a11ad2747c439b00fc881120a4121e04917d38bebd156212e2f4ad)", + "HashTag(#83)", + "RegularText(4%)", + "RegularText(Hendrix,)", + "Email(hendrix@nostrplebs.com)", + "RegularText(-)", + "RegularText(cbd92008e1fe949072cbea02e54228140c43d14d14519108b1d7a32d9102665b)", + "HashTag(#84)", + "RegularText(4%)", + "RegularText(TXMC,)", + "Email(TXMC@alphabetasoup.tv)", + "RegularText(-)", + "RegularText(37359e92ece5c6fc8d5755de008ceb6270808b814ddd517d38ebeab269836c96)", + "HashTag(#85)", + "RegularText(4%)", + "RegularText(norman188,)", + "RegularText()", + "RegularText(-)", + "RegularText(662a4476a9c15a5778f379ce41ceb2841ac72dfa1829b492d67796a8443ac2ca)", + "HashTag(#86)", + "RegularText(4%)", + "RegularText(pipleb,)", + "Email(pipleb@iris.to)", + "RegularText(-)", + "RegularText(3c4280ef3b792fa919b1964460d34ca6af93b83fa55f633a3b0eb8fde556235a)", + "HashTag(#87)", + "RegularText(4%)", + "RegularText(reallhex,)", + "Email(reallhex@terranostr.com)", + "RegularText(-)", + "RegularText(29630aed66aeec73b6519a11547f40ca15c3f6aa79907e640f1efcf5a2ee9dc8)", + "HashTag(#88)", + "RegularText(4%)", + "RegularText(374324โ€ฆef9f78,)", + "RegularText()", + "RegularText(-)", + "RegularText(3743244390be53473a7e3b3b8d04dce83f6c9514b81a997fb3b123c072ef9f78)", + "HashTag(#89)", + "RegularText(4%)", + "RegularText(Nostradamus,)", + "RegularText()", + "RegularText(-)", + "RegularText(7acce9b3da22ceedc511a15cb730c898235ab551623955314b003e9f33e8b10c)", + "HashTag(#90)", + "RegularText(4%)", + "RegularText(Nicโ‚ฟ,)", + "Email(nicb@nicb.me)", + "RegularText(-)", + "RegularText(000000002d4f4733f1ee417a405637fd0d81dbfbc6dbd8c0d1c95f04ec3db973)", + "HashTag(#91)", + "RegularText(4%)", + "RegularText(NabismoPrime,)", + "Email(NabismoPrime@BostonBTC.com)", + "RegularText(-)", + "RegularText(4503baa127bdfd0b054384dc5ba82cb0e2a8367cbdb0629179f00db1a34caacc)", + "HashTag(#92)", + "RegularText(4%)", + "RegularText(paco,)", + "Email(paco@iris.to)", + "RegularText(-)", + "RegularText(66bd8fed3590f2299ef0128f58d67879289e6a99a660e83ead94feab7606fd17)", + "HashTag(#93)", + "RegularText(3%)", + "RegularText(globalstatesmen,)", + "Email(globalstatesmen@nostrplebs.com)", + "RegularText(-)", + "RegularText(237506ca399e5b1b9ce89455fe960bc98dfab6a71936772a89c5145720b681f4)", + "HashTag(#94)", + "RegularText(3%)", + "RegularText(Nostryfied,)", + "Email(_@NostrNet.work)", + "RegularText(-)", + "RegularText(c2c20ec0a555959713ca4c404c4d2cc80e6cb906f5b64217070612a0cae29c62)", + "HashTag(#95)", + "RegularText(3%)", + "RegularText(crayonsmell,)", + "Email(crayonsmell@habel.net)", + "RegularText(-)", + "RegularText(3ef3be9db1e3f268f84e937ad73c68772a58c6ffcec1d42feeef5f214ad1eaf9)", + "HashTag(#96)", + "RegularText(3%)", + "RegularText(Toxikat27,)", + "Email(ToxiKat27@Bitcoiner.social)", + "RegularText(-)", + "RegularText(12cfc2ec5a39a39d02f921f77e701dbc175b6287f22ddf0247af39706967f1d9)", + "HashTag(#97)", + "RegularText(3%)", + "RegularText(James)", + "RegularText(Trageser,)", + "Email(jtrag@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(d29bc58353389481e302569835661c95838bee076137533eb365bca752c38316)", + "HashTag(#98)", + "RegularText(3%)", + "RegularText(Joe)", + "RegularText(Martin)", + "RegularText(Music,)", + "Email(joemartinmusic@nostrplebs.com)", + "RegularText(-)", + "RegularText(28ca019b78b494c25a9da2d645975a8501c7e99b11302e5cbe748ee593fcb2cc)", + "HashTag(#99)", + "RegularText(3%)", + "RegularText(Fundamentals,)", + "Email(ph@nostrplebs.com)", + "RegularText(-)", + "RegularText(5677fa5b6b1cb6d5bee785d088a904cd08082552bf75df3e4302cea015a5d3e1)", + "HashTag(#100)", + "RegularText(3%)", + "RegularText(bb,)", + "RegularText()", + "RegularText(-)", + "RegularText(1f254ae909a36b0000c3b68f36b92aad168f4532725d7cd9b67f5b09088f2125)", + "HashTag(#101)", + "RegularText(3%)", + "RegularText(ๆŽๅญๆŸ’,)", + "RegularText()", + "RegularText(-)", + "RegularText(c70c8e55e0228c3ce171ae0d357452e386489f3a2d14e6deca174c2fbfc8da52)", + "HashTag(#102)", + "RegularText(3%)", + "RegularText(Horse)", + "RegularText(๐Ÿด,)", + "Email(horse@iris.to)", + "RegularText(-)", + "RegularText(e4d3420c0b77926cfbf107f9cb606238efaf5524af39ff1c86e6d6fdd1515a57)", + "HashTag(#103)", + "RegularText(3%)", + "RegularText(KP,)", + "Email(kp@no.str.cr)", + "RegularText(-)", + "RegularText(b2e777c827e20215e905ab90b6d81d5b84be5bf66c944ce34943540b462ea362)", + "HashTag(#104)", + "RegularText(3%)", + "RegularText(Azarakhsh,)", + "Email(rebornbitcoiner@getalby.com)", + "RegularText(-)", + "RegularText(c734992a115c2ad9b4df40dd7c14d153695b29081a995df39b4fc8e6f1dcfb14)", + "HashTag(#105)", + "RegularText(3%)", + "RegularText(Toshi,)", + "Email(toshi@nostr-check.com)", + "RegularText(-)", + "RegularText(79d434176b64745d2793cf307f20967e27912994f6e81632de18da3106c2cbb4)", + "HashTag(#106)", + "RegularText(3%)", + "RegularText(FreeBorn,)", + "Email(freeborn@nostrplebs.com)", + "RegularText(-)", + "RegularText(408e04e9a5b02ef6d82edb9ecb2cca1d5a3121cb26b0ca5e6511800a0269b069)", + "HashTag(#107)", + "RegularText(3%)", + "RegularText(blee,)", + "Email(blee@bitcoiner.social)", + "RegularText(-)", + "RegularText(69a0a0910b49a1dbfbc4e4f10df22b5806af5403a228267638f2e908c968228d)", + "HashTag(#108)", + "RegularText(3%)", + "RegularText(SatsTonight,)", + "Email(SatsTonight@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(eb3b94533dafeb8ebd58a4947a3dce11d83a9931c622bdf30a4257d3347ee1bf)", + "HashTag(#109)", + "RegularText(3%)", + "SchemelessUrl(Nostr-Check.com,)", + "Email(freeverification@Nostr-Check.com)", + "RegularText(-)", + "RegularText(ddfbb06a722e51933cd37e4ecdb30b1864f262f9bb5bd6c2d95cbeefc728f096)", + "HashTag(#110)", + "RegularText(3%)", + "RegularText(cowmaster,)", + "Email(cowmaster@getalby.com)", + "RegularText(-)", + "RegularText(6af9411d742c74611e149d19037e7a2ba4d44bbceb429b209c451902b6740bb8)", + "HashTag(#111)", + "RegularText(3%)", + "RegularText(Hacker,)", + "Email(hacker818@iris.to)", + "RegularText(-)", + "RegularText(40e10350fed534e5226b73761925030134d9f85306ee1db5cfbd663118034e84)", + "HashTag(#112)", + "RegularText(3%)", + "RegularText(BitcasaHomes,)", + "Email(amandabitcasa@nostrplebs.com)", + "RegularText(-)", + "RegularText(f96a2a2552c08f99c30b9e2441d64ca4c6b3d761735e7cd74580bafe549326e0)", + "HashTag(#113)", + "RegularText(3%)", + "RegularText(footstr,)", + "RegularText()", + "RegularText(-)", + "RegularText(aa1aa6af6be3a2903e2fb18690d7df128a10eec0f3a015157daf371c688b4cff)", + "HashTag(#114)", + "RegularText(3%)", + "RegularText(tiago,)", + "Email(tiago@nostrplebs.com)", + "RegularText(-)", + "RegularText(780ab38a843423c61502550474b016e006f2b56f2f7d18e9cd02737e11113262)", + "HashTag(#115)", + "RegularText(3%)", + "RegularText(Sepehr,)", + "Email(sepehr@nostribe.com)", + "RegularText(-)", + "RegularText(3e294d2fd339bb16a5403a86e3664947dd408c4d87a0066524f8a573ae53ca8e)", + "HashTag(#116)", + "RegularText(3%)", + "RegularText(dhruv,)", + "RegularText()", + "RegularText(-)", + "RegularText(297bc16357b314be291c893755b25d66999c1525bbf3537fbc637a0c767f14bb)", + "HashTag(#117)", + "RegularText(3%)", + "RegularText(b310edโ€ฆ4f793a,)", + "RegularText()", + "RegularText(-)", + "RegularText(b310ed0a54a71ccf8a8368032dd3b4b83b7aca2840bb10a4d5e6ef4b6a4f793a)", + "HashTag(#118)", + "RegularText(3%)", + "RegularText(MichZ)", + "RegularText(๐Ÿง˜๐Ÿปโ€โ™€๏ธ,)", + "RegularText()", + "RegularText(-)", + "RegularText(9349d012686caab46f6bfefd2f4c361c52e14b1cde1cd027476e0ae6d3e98946)", + "HashTag(#119)", + "RegularText(3%)", + "RegularText(gfy,)", + "Email(gfy@stacker.news)", + "RegularText(-)", + "RegularText(01e4fc2adc0ff7a0465d3e70b3267d375ebe4292828fa3888f972313f3a1248e)", + "HashTag(#120)", + "RegularText(3%)", + "RegularText(Dude,)", + "RegularText()", + "RegularText(-)", + "RegularText(67cbb3d83800cc1af6f5d2821f1c911f033ea21e1269ff2ad613ab3ae099b1f3)", + "HashTag(#121)", + "RegularText(3%)", + "RegularText(HODL_MFER,)", + "RegularText()", + "RegularText(-)", + "RegularText(7c6a9e6231570a6773e608d1c0a709acb9c21193a5c2df9cebfa9e9db09411a3)", + "HashTag(#122)", + "RegularText(3%)", + "RegularText(renatarodrigues,)", + "RegularText()", + "RegularText(-)", + "RegularText(aa116590cf23dc761a8a9e38ff224a3d07db45c66be3035b9f87144bda0eeaa5)", + "HashTag(#123)", + "RegularText(3%)", + "RegularText(CryptoJournaal,)", + "Email(cryptojournaal@iris.to)", + "RegularText(-)", + "RegularText(fb649213b88e9927a5c8f470d7affe88441de995deaccf283bf60a78f771b825)", + "HashTag(#124)", + "RegularText(3%)", + "RegularText(Bon,)", + "Email(bon@nostrplebs.com)", + "RegularText(-)", + "RegularText(b2722dd1e13ff9b82ff2f432186019045fee39911d5652d6b4263562061af908)", + "HashTag(#125)", + "RegularText(3%)", + "RegularText(binarywatch,)", + "Email(bot@binarywatch.org)", + "RegularText(-)", + "RegularText(0095c837e8ed370de6505c2c631551af08c110853b519055d0cdf3d981da5ac3)", + "HashTag(#126)", + "RegularText(3%)", + "RegularText(Moritz,)", + "Email(moritz@getalby.com)", + "RegularText(-)", + "RegularText(0521db9531096dff700dcf410b01db47ab6598de7e5ef2c5a2bd7e1160315bf6)", + "HashTag(#127)", + "RegularText(3%)", + "RegularText(hodlish,)", + "Email(hodlish@Nostr-Check.com)", + "RegularText(-)", + "RegularText(3575a3a7a6b5236443d6af03606aa9297c3177a45cf5314b9fd57bff894ee3ae)", + "HashTag(#128)", + "RegularText(3%)", + "RegularText(HolgerHatGarKeineNode,)", + "Email(HolgerHatGarKeineNode@nip05.easify.de)", + "RegularText(-)", + "RegularText(0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033)", + "HashTag(#129)", + "RegularText(3%)", + "RegularText(joe,)", + "Email(joe@jaxo.github.io)", + "RegularText(-)", + "RegularText(6827ef2b75ee652dcc83958b83aea0bc6580705b56041a9ee70a4178e1046cdb)", + "HashTag(#130)", + "RegularText(3%)", + "RegularText(hahattpro,)", + "Email(hahattpro@iris.to)", + "RegularText(-)", + "RegularText(53ac90ebaef84b0439cdf4f1d955ff1f1e98febc04fb789eff4a08fe53316483)", + "HashTag(#131)", + "RegularText(3%)", + "RegularText(bensima,)", + "Email(bensima@simatime.com)", + "RegularText(-)", + "RegularText(2fa4b9ba71b6dab17c4723745bb7850dfdafcb6ae1a8642f76f9c64fa5f43436)", + "HashTag(#132)", + "RegularText(3%)", + "RegularText(satan,)", + "Email(satan@nostrcheck.me)", + "RegularText(-)", + "RegularText(d6b44ef322f6d67806ff06aaa9623b22ff5c2b0f0705c5e7a5a35684af9e5101)", + "HashTag(#133)", + "RegularText(3%)", + "RegularText(RadVladdy,)", + "Email(radvladdy@nostrplebs.com)", + "RegularText(-)", + "RegularText(7933ea1abdb329139b4eb37157649229b41d0ae445907238b07926182f717924)", + "HashTag(#134)", + "RegularText(3%)", + "RegularText(horacio,)", + "RegularText()", + "RegularText(-)", + "RegularText(f61abb9886e1f4cd5d20419c197d5d7f3649addab24b6a32a2367124ca3194b4)", + "HashTag(#135)", + "RegularText(3%)", + "RegularText(yidneth,)", + "Email(yidneth@getalby.com)", + "RegularText(-)", + "RegularText(f28be20326c6779b2f8bfa75a865d0fa4af384e9c6c99dc6a803e542f9d2085e)", + "HashTag(#136)", + "RegularText(3%)", + "RegularText(JonO,)", + "RegularText()", + "RegularText(-)", + "RegularText(edecf91d15e03c921806ae6ebff86771c79e1641e899787e4d7689f68314d447)", + "HashTag(#137)", + "RegularText(3%)", + "RegularText(bellatrix,)", + "Email(bellatrix@iris.to)", + "RegularText(-)", + "RegularText(f9d7f0b271b5bb19ed400d8baeee1c22ac3a5be5cf20da55219c4929e523987a)", + "HashTag(#138)", + "RegularText(3%)", + "RegularText(SecureCoop,)", + "Email(securecoop@iris.to)", + "RegularText(-)", + "RegularText(d244e3cd0842d514a0725e0e0a00b712b7f2ed515a1d7ef362fd12c957b95549)", + "HashTag(#139)", + "RegularText(3%)", + "RegularText(charliesurf,)", + "Email(charliesurf@ln.tips)", + "RegularText(-)", + "RegularText(a396e36e962a991dac21731dd45da2ee3fd9265d65f9839c15847294ec991f1c)", + "HashTag(#140)", + "RegularText(3%)", + "RegularText(Bitcoin)", + "RegularText(ATM,)", + "Email(bitcoinatm@Nostr-Check.com)", + "RegularText(-)", + "RegularText(01a69fa5a7cbb4a185904bdc7cae6137ff353889bba95619c619debe9e3b8b09)", + "HashTag(#141)", + "RegularText(3%)", + "RegularText(lnstallone,)", + "Email(lnstallone@allmysats.com)", + "RegularText(-)", + "RegularText(84fe3febc748470ff1a363db8a375ffa1ff86603f2653d1c3c311ad0a70b5d0c)", + "HashTag(#142)", + "RegularText(3%)", + "RegularText(a652f6โ€ฆ9124f3,)", + "RegularText()", + "RegularText(-)", + "RegularText(a652f66df4ddb5280ff466b6ff444fbc310b8e83238660473d5ccffa9e9124f3)", + "HashTag(#143)", + "RegularText(3%)", + "RegularText(hmichellerose,)", + "RegularText()", + "RegularText(-)", + "RegularText(5b29255d5eaaaeb577552bf0d11030376f477d19a009c5f5a80ddc73d49359f6)", + "HashTag(#144)", + "RegularText(3%)", + "RegularText(L0la)", + "RegularText(L33tz,)", + "Email(L0laL33tz@cashu.me)", + "RegularText(-)", + "RegularText(d8a6ecf0c396eaa8f79a4497fe9b77dc977633451f3ca5c634e208659116647b)", + "HashTag(#145)", + "RegularText(3%)", + "RegularText(Lommy,)", + "Email(Lommy@nostrplebs.com)", + "RegularText(-)", + "RegularText(014b9837dabb358fc0f416ceb58f72c4e6ed8fc6d317f0578dd704fc879f16f8)", + "HashTag(#146)", + "RegularText(3%)", + "RegularText(jgmontoya,)", + "Email(jgmontoya@nostrplebs.com)", + "RegularText(-)", + "RegularText(9236f9ac521be2ee0a54f1cfffdf2df7f4982df4e6eb992867d733debcf95b35)", + "HashTag(#147)", + "RegularText(3%)", + "RegularText(bavarianledger,)", + "Email(bavarianledger@iris.to)", + "RegularText(-)", + "RegularText(f27c20bc6e64407f805a92c3190089060f9d85efa67ccc80b85f007c3323c221)", + "HashTag(#148)", + "RegularText(3%)", + "RegularText(operator,)", + "Email(operator@brb.io)", + "RegularText(-)", + "RegularText(3c1ba7d42c873c2f89caf1ca79b4ead6513385de53743fa6eb98c3705655695c)", + "HashTag(#149)", + "RegularText(3%)", + "RegularText(awaremoma,)", + "RegularText()", + "RegularText(-)", + "RegularText(44313b79dfc3303e3bd0c4aee0c872e96a84f23a2a45624b3ab630f24f43012f)", + "HashTag(#150)", + "RegularText(3%)", + "RegularText(Tรญo)", + "RegularText(Tito,)", + "Email(tiotito@nostriches.net)", + "RegularText(-)", + "RegularText(dc6e531596c52a218a6fae2e1ea359a1365d5eda02ec176c945ed06a9400ec72)", + "HashTag(#151)", + "RegularText(3%)", + "RegularText(javi,)", + "Email(javi@www.javiergonzalez.io)", + "RegularText(-)", + "RegularText(2eab634b27a78107c98599a982849b4f71c605316c8f4994861f83dc565df5c8)", + "HashTag(#152)", + "RegularText(3%)", + "RegularText(NathanCPerry,)", + "RegularText()", + "RegularText(-)", + "RegularText(cec9808bbb00bc9c3eab4c2f23e9440a5ea775201b65a18462bc77080e39e336)", + "HashTag(#153)", + "RegularText(3%)", + "RegularText(Jason)", + "RegularText(Hodlers)", + "RegularText(โ™พ๏ธ/2099999997690000๐Ÿด,)", + "Email(geekigai@nostrplebs.com)", + "RegularText(-)", + "RegularText(d162a53c3b0bfb5c3ebd787d7b08feab206b112362eca25aa291251cd70fe225)", + "HashTag(#154)", + "RegularText(3%)", + "SchemelessUrl(MR.Rabbit,)", + "Email(Mr.Rabbit@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(42af69b2384071f31e55cb2d368c8a3351c8f2da03207e1fb6885991ac2522bf)", + "HashTag(#155)", + "RegularText(3%)", + "RegularText(kilicl,)", + "Email(kilicl@nostr-check.com)", + "RegularText(-)", + "RegularText(48a94f890f4dc3625b9926cdccded61e353ad1fe76600bc6acea44bdb9efceb7)", + "HashTag(#156)", + "RegularText(3%)", + "RegularText(retired,)", + "RegularText()", + "RegularText(-)", + "RegularText(82ba83731adcfe5a65ced992fde81efc756d10670c56a58cb8870210f859d3c1)", + "HashTag(#157)", + "RegularText(3%)", + "RegularText(Alex)", + "RegularText(Bit,)", + "Email(alexbit@nostrbr.online)", + "RegularText(-)", + "RegularText(9db334a465cc3f6107ed847eec0bc6c835e76ba50625f4c1900cbcb9df808d91)", + "HashTag(#158)", + "RegularText(3%)", + "RegularText(freeeedom21,)", + "Email(william@nostrplebs.com)", + "RegularText(-)", + "RegularText(fd254541619b6d4baa467412058321f70cf108d773adcda69083bd500e502033)", + "HashTag(#159)", + "RegularText(3%)", + "RegularText(OneEzra,)", + "Email(oneezra@nostrplebs.com)", + "RegularText(-)", + "RegularText(0078d4cb1652552475ba61ec439cd50c37c3a3a439853d830d7c9d338826ade2)", + "HashTag(#160)", + "RegularText(3%)", + "RegularText(lightsats,)", + "RegularText()", + "RegularText(-)", + "RegularText(88185e27e96cfcfc3c58c625cf70c4dba757f8d2e9ab7cab80f5012a343eb7d2)", + "HashTag(#161)", + "RegularText(3%)", + "RegularText(IceAndFireBTC,)", + "Email(iceandfirebtc@nostrplebs.com)", + "RegularText(-)", + "RegularText(edb50fd8286e36878f8dd9346c138598052e5d914f0c3c6072f12eb152f307d8)", + "HashTag(#162)", + "RegularText(3%)", + "RegularText(Nostr)", + "RegularText(Gang,)", + "Email(nostrgang@nostrplebs.com)", + "RegularText(-)", + "RegularText(91aeab23b5664edaa57dbe00b041ccb50544f89d7d956345bbd78b7dbaa48660)", + "HashTag(#163)", + "RegularText(3%)", + "RegularText(kexkey,)", + "RegularText()", + "RegularText(-)", + "RegularText(436456869bdd7fcb3aaaa91bed05173ea1510879004250b9f69b2c4370d58cf7)", + "HashTag(#164)", + "RegularText(3%)", + "RegularText(freebitcoin,)", + "Email(npub1vez5zekuzc3qk989q5gtly2zg9k2gz4l3wuplv5xs8y3se09yussg4vp7p@carteclip.com)", + "RegularText(-)", + "RegularText(66454166dc16220b14e50510bf9142416ca40abf8bb81fb28681c91865e52721)", + "HashTag(#165)", + "RegularText(3%)", + "RegularText(Sqvaznyak,)", + "Email(Sqvaznyak@uselessshit.co)", + "RegularText(-)", + "RegularText(056d6999f3283778d50aa85c25985716857cfeaffdbad92e73cf8aeaf394a5cd)", + "HashTag(#166)", + "RegularText(3%)", + "RegularText(koba,)", + "RegularText()", + "RegularText(-)", + "RegularText(b5926366f9ac01d8ed427c9bb4cdcb86b7b4a44aaad00d262ef436621e30ea5a)", + "HashTag(#167)", + "RegularText(3%)", + "RegularText(braj,)", + "Email(braj@nostrplebs.com)", + "RegularText(-)", + "RegularText(5921b801183f10b0143c2e48c22c8192fa38d27ac614a20251cac30ab729d3a5)", + "HashTag(#168)", + "RegularText(3%)", + "RegularText(Libertus,)", + "Email(libertus@getalby.com)", + "RegularText(-)", + "RegularText(2154d20dace7b28018621edf9c3a56ab842b901db0d9b02616dbed3d15fc5490)", + "HashTag(#169)", + "RegularText(3%)", + "RegularText(ZoeBoudreault,)", + "Email(ZoeBoudreault@id.nostrfy.me)", + "RegularText(-)", + "RegularText(3c43dc2a4c996832ae3a1830250d5f0917476783132969db4e14955b6e394047)", + "HashTag(#170)", + "RegularText(3%)", + "RegularText(Saiga,)", + "RegularText()", + "RegularText(-)", + "RegularText(8f5f3a60edc875315d9c1348d6ad5dddbca806d02400049632589cb32b3f0493)", + "HashTag(#171)", + "RegularText(3%)", + "RegularText(n,)", + "RegularText()", + "RegularText(-)", + "RegularText(aceff8abf70a60d7b378469ab80513c83c5d70a4f82872bac7bd619acbc71ff1)", + "HashTag(#172)", + "RegularText(3%)", + "RegularText(dnilso,)", + "Email(dnilso@iris.to)", + "RegularText(-)", + "RegularText(5ae325f930f53fad2a1a9ebefdb943bba1bef7b411e7712d2173bf3c38a49b17)", + "HashTag(#173)", + "RegularText(3%)", + "RegularText(Shroom,)", + "Email(shroom@nostrplebs.com)", + "RegularText(-)", + "RegularText(a4ee688a599c9493b8641cc61987ef42b7556ba1e79d35bca92a1dce186dac85)", + "HashTag(#174)", + "RegularText(3%)", + "RegularText(0a92e7โ€ฆbc2d3d,)", + "RegularText()", + "RegularText(-)", + "RegularText(0a92e765595bbf3368c44338479df5351cf5b0028215ba95e1c9e8de99bc2d3d)", + "HashTag(#175)", + "RegularText(3%)", + "RegularText(olegaba,)", + "Email(olegaba@olegaba.com)", + "RegularText(-)", + "RegularText(7fb2a29bd1a41d9a8ca43a19a7dcf3a8522f1bc09b4086253539190e9c29c51a)", + "HashTag(#176)", + "RegularText(3%)", + "RegularText(CJButcher,)", + "RegularText()", + "RegularText(-)", + "RegularText(15fdc4596019e2b9b702ae229d5c7a17d9527226f8cf5526006908901612b200)", + "HashTag(#177)", + "RegularText(3%)", + "RegularText(wasabi-pea,)", + "Email(wasabi@nostrplebs.com)", + "RegularText(-)", + "RegularText(abe1c8a87aca21e9b6a32a8c2fae5acbaf3212a01d9ccc13a80981c853e8fa02)", + "HashTag(#178)", + "RegularText(3%)", + "RegularText(045a6fโ€ฆf32334,)", + "RegularText()", + "RegularText(-)", + "RegularText(045a6fa0da5d278ac1c3aee79df23b7372ea03ee4da04ad4b8db9a5967f32334)", + "HashTag(#179)", + "RegularText(3%)", + "RegularText(Artur,)", + "Email(artur@getalby.com)", + "RegularText(-)", + "RegularText(762a3c15c6fa90911bf13d50fc3a29f1663dc1f04b4397a89eef604f622ecd60)", + "HashTag(#180)", + "RegularText(3%)", + "RegularText(ihsanmd๐Ÿ’€,)", + "Email(ihsanmd@getalby.com)", + "RegularText(-)", + "RegularText(d030bd233a1347e510c372b1878e00204b228072814361451623707896435da9)", + "HashTag(#181)", + "RegularText(2%)", + "RegularText(Satoshee,)", + "Email(satoshee@vida.page)", + "RegularText(-)", + "RegularText(0e88aac7368d5f2582437826042b3fb3a26a126f3d857618c6b6652a9f5bfa0a)", + "HashTag(#182)", + "RegularText(2%)", + "RegularText(39ed0aโ€ฆ60271a,)", + "RegularText()", + "RegularText(-)", + "RegularText(39ed0aea2338477103e0b5a820532ded27dbfe4f203e7270392d55f63e60271a)", + "HashTag(#183)", + "RegularText(2%)", + "SchemelessUrl(Ancap.su,)", + "Email(ancapsu@getalby.com)", + "RegularText(-)", + "RegularText(2fe5292a2df25047a392fceead75458875c775c31cc28f4be04cef3e8db15291)", + "HashTag(#184)", + "RegularText(2%)", + "RegularText(NiceAction,)", + "Email(niceaction@www.niceaction.com)", + "RegularText(-)", + "RegularText(32891ace6802507077035ba6064f7e1db29667002165b9bf5c1c9b3f84e2303c)", + "HashTag(#185)", + "RegularText(2%)", + "RegularText(seak,)", + "Email(seak@nostrplebs.com)", + "RegularText(-)", + "RegularText(d70f1bca430a2158f0e4c88b158ae18efffe8a91d436edbeee27acf2d9012cf5)", + "HashTag(#186)", + "RegularText(2%)", + "RegularText(twochickshomestead,)", + "RegularText()", + "RegularText(-)", + "RegularText(5bf5ab367f45b01b1cac72d73703fb30c704f3dbd5d376396fc0b6f39cac456b)", + "HashTag(#187)", + "RegularText(2%)", + "RegularText(Andy,)", + "Email(andy@nodeless.io)", + "RegularText(-)", + "RegularText(08cd52a46ab37a9894b3333785c2ff50e068d1b01fb03d702608da83e9817d82)", + "HashTag(#188)", + "RegularText(2%)", + "RegularText(coinbitstwitterfollows,)", + "RegularText()", + "RegularText(-)", + "RegularText(1341010418f272ed6db469d77dffdf1d946dd0701e33bdc84bb72269cef5bfed)", + "HashTag(#189)", + "RegularText(2%)", + "RegularText(Annonymal,)", + "RegularText()", + "RegularText(-)", + "RegularText(5c7794d47115a1b133a19673d57346ca494d367379458d8e98bf24a498abc46b)", + "HashTag(#190)", + "RegularText(2%)", + "RegularText(lindsey,)", + "RegularText()", + "RegularText(-)", + "RegularText(f81d7cbdfe99ff2b11932fb4cdcd94f18e629e3fedafcd25ee0a4ddc0967f0f9)", + "HashTag(#191)", + "RegularText(2%)", + "RegularText(pinkyjay,)", + "Email(pinkyjay@nostrplebs.com)", + "RegularText(-)", + "RegularText(b0dbac368a5ac474bc19ab11a0b3fd4260cf56b40c60944c4a331b8ad8ced926)", + "HashTag(#192)", + "RegularText(2%)", + "RegularText(criptobastardo,)", + "Email(criptobastardo@nostrplebs.com)", + "RegularText(-)", + "RegularText(311262ac14efb7011f23223b662aa1f18b3bb7c238206cb1c07424f051a11cce)", + "HashTag(#193)", + "RegularText(2%)", + "RegularText(lacosanostr,)", + "Email(lacosanostr@lacosanostr.com)", + "RegularText(-)", + "RegularText(6ce2001e7f070fade19d4817006747e4164089886a0faca950a6b0ab2a3b58b2)", + "HashTag(#194)", + "RegularText(2%)", + "RegularText(teeJem,)", + "Email(teejem@nostrplebs.com)", + "RegularText(-)", + "RegularText(36f7bc3a3f40b11095f546a86b11ff1babc7ca7111c8498d6b6950cfc7663694)", + "HashTag(#195)", + "RegularText(2%)", + "RegularText(BiancaBtcArt,)", + "RegularText()", + "RegularText(-)", + "RegularText(1f2c17bd3bcaf12f9c7e78fe798eeea59c1b22e1ee036694d5dc2886ddfa35d7)", + "HashTag(#196)", + "RegularText(2%)", + "RegularText(ruto,)", + "RegularText()", + "RegularText(-)", + "RegularText(2888961a564e080dfe35ad8fc6517b920d2fcd2b7830c73f7c3f9f2abae90ea9)", + "HashTag(#197)", + "RegularText(2%)", + "RegularText(Pocketcows,)", + "RegularText()", + "RegularText(-)", + "RegularText(e462fd4f25682164bdb7c51fc1b2cd3c7e6ddba13a1d7094b06f6f4fe47f9ae3)", + "HashTag(#198)", + "RegularText(2%)", + "RegularText(mewj,)", + "Email(mewj@elder.nostr.land)", + "RegularText(-)", + "RegularText(489ac583fc30cfbee0095dd736ec46468faa8b187e311fda6269c4e18284ed0c)", + "HashTag(#199)", + "RegularText(2%)", + "RegularText(nostr,)", + "RegularText()", + "RegularText(-)", + "RegularText(2bd053345e10aed28bd0e97c311aab3470f6d7f405dc588b056bce1e3797d2f0)", + "HashTag(#200)", + "RegularText(2%)", + "RegularText(Bobolo,)", + "RegularText()", + "RegularText(-)", + "RegularText(ca7799f00a9d792f9bba6947b32e3142e6c6c4733e52906cbaf92a2961216b46)", + "HashTag(#201)", + "RegularText(2%)", + "RegularText(InsolentBitcoin,)", + "RegularText()", + "RegularText(-)", + "RegularText(6484df04c9403a64c3039f5f00d24ac0535f497cdfa1f187bc6a2d34cf017b97)", + "HashTag(#202)", + "RegularText(2%)", + "RegularText(Monero)", + "RegularText(Directory,)", + "RegularText()", + "RegularText(-)", + "RegularText(1abdef52155dc52a21a2ac9ed19e444317f6cf83500df139fbe73c2a7ac78e2a)", + "HashTag(#203)", + "RegularText(2%)", + "RegularText(thetonewrecker,)", + "Email(thetonewrecker@nostrplebs.com)", + "RegularText(-)", + "RegularText(3762d3159bfd9d8acb56677eec9a6f8a5a05ea86636186ca6ed6714a69975fed)", + "HashTag(#204)", + "RegularText(2%)", + "RegularText(yodatravels,)", + "Email(yodatravels@iris.to)", + "RegularText(-)", + "RegularText(67eb726f7bb8e316418cd46cfa170d580345e51adbc186f8f7aa0d4380579350)", + "HashTag(#205)", + "RegularText(2%)", + "RegularText(Bitcoin)", + "RegularText(Bandit,)", + "Email(bitcoin69@iris.to)", + "RegularText(-)", + "RegularText(907842aa7b5d00054473d261e814c011c5d8e13bf8a585cc76121b1e6c51900f)", + "HashTag(#206)", + "RegularText(2%)", + "RegularText(Zzar,)", + "Email(Zzar@nostrplebs.com)", + "RegularText(-)", + "RegularText(ca1dd2422cb94874c1666c9c76b7961bbaea432632643f7a2dc9d4d2bfb35db9)", + "HashTag(#207)", + "RegularText(2%)", + "RegularText(vidalBidi,)", + "Email(vidalbidi@getalby.com)", + "RegularText(-)", + "RegularText(0c28a25357c76ac5ac3714eddc25d81fe98134df13351ab526fc2479cc306e65)", + "HashTag(#208)", + "RegularText(2%)", + "RegularText(994e89โ€ฆf75447,)", + "RegularText()", + "RegularText(-)", + "RegularText(994e892582261fd933af25bcc9672f2fbd5e769e3d1c889ecd292a7a92f75447)", + "HashTag(#209)", + "RegularText(2%)", + "RegularText(juangalt,)", + "Email(juangalt@current.ninja)", + "RegularText(-)", + "RegularText(372da077d6353430f343d5853d85311b3fd27018d5a83b8c1b397b92518ec7ac)", + "HashTag(#210)", + "RegularText(2%)", + "RegularText(Dean,)", + "Email(dean@nostrplebs.com)", + "RegularText(-)", + "RegularText(83f018060171dfee116b077f0f455472b6b6de59abf4730994022bf6f27d16be)", + "HashTag(#211)", + "RegularText(2%)", + "RegularText(alexli,)", + "Email(alex2@nostrverified.com)", + "RegularText(-)", + "RegularText(8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934)", + "HashTag(#212)", + "RegularText(2%)", + "RegularText(Khidthungban,)", + "RegularText()", + "RegularText(-)", + "RegularText(8d5cf93afb8d9ef1d08acee4e7147348d0c573bf7e5f57886a8a9a137cbe890c)", + "HashTag(#213)", + "RegularText(2%)", + "RegularText(Trooper,)", + "Email(trooper@iris.to)", + "RegularText(-)", + "RegularText(2c8d81a4e5cd9a99caba73f14c087ca7c05e554bb9988a900ccd76dbd828407d)", + "HashTag(#214)", + "RegularText(2%)", + "RegularText(Satscoinsv,)", + "RegularText(โšก๏ธsatscoinsv@getalby.com)", + "RegularText(-)", + "RegularText(80db64657ea0358c5332c5cca01565eeddd4b8799688b1c46d3cb2d7c966671f)", + "HashTag(#215)", + "RegularText(2%)", + "RegularText(AARBTC,)", + "Email(aarbtc@iris.to)", + "RegularText(-)", + "RegularText(6d23993803386c313b7d4dcdfffdbe4e1be706c2f0c89cb5afaa542bf2be1b90)", + "HashTag(#216)", + "RegularText(2%)", + "RegularText(yogsite,)", + "Email(_@gue.yogsite.com)", + "RegularText(-)", + "RegularText(d3ab705ec57f3ea963fc7c467bddc7b17bf01b85acc4fbb14eed87df794a116c)", + "HashTag(#217)", + "RegularText(2%)", + "RegularText(NostrMemes,)", + "Email(nostrmemes@iris.to)", + "RegularText(-)", + "RegularText(6399694ca3b8c40d8be9762f50c9c420bf0bd73fb7d7d244a195814c9ab8fb7e)", + "HashTag(#218)", + "RegularText(2%)", + "RegularText(btcpavao,)", + "Email(btcpavao@iris.to)", + "RegularText(-)", + "RegularText(1a8ed3216bd2b81768363b4326e1ae270a7cd6fe570bafeda2dc070f34f3aedc)", + "HashTag(#219)", + "RegularText(2%)", + "RegularText(Anonymous,)", + "Email(Anonymous@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(ac076f8f80ee4a49f22c2ce258dcfe6e105de0bf029a048fa3a8de4b51c1b957)", + "HashTag(#220)", + "RegularText(2%)", + "RegularText(zoltanAB,)", + "Email(zoltanab@iris.to)", + "RegularText(-)", + "RegularText(42aafd1217089d68c757671a251507a194587dd3adfc3a3a76bb1e38a78a3453)", + "HashTag(#221)", + "RegularText(2%)", + "RegularText(katsu,)", + "Email(katsu@onsats.org)", + "RegularText(-)", + "RegularText(76f64475795661961801389aeaa7869a005735266c9e3df9bc93d127fad04154)", + "HashTag(#222)", + "RegularText(2%)", + "RegularText(bryan,)", + "Email(bryan@nonni.io)", + "RegularText(-)", + "RegularText(9ddf6fe3a194d330a6c6e278a432ae1309e52cc08587254b337d0f491f7ff642)", + "HashTag(#223)", + "RegularText(2%)", + "RegularText(pedromvpg,)", + "Email(pedromvpg@pedromvpg.com)", + "RegularText(-)", + "RegularText(8cd2d0f8310f7009e94f50231870756cb39ba68f37506044910e2f71482b1788)", + "HashTag(#224)", + "RegularText(2%)", + "RegularText(Nellie,)", + "Email(sonicstudio@getalby.com)", + "RegularText(-)", + "RegularText(37fbbf7707e70a8a7787e5b1b75f3e977e70aab4f41ddf7b3c0f38caedd875d4)", + "HashTag(#225)", + "RegularText(2%)", + "RegularText(nicknash,)", + "RegularText()", + "RegularText(-)", + "RegularText(636b4e6f5a594893c544b49a5742f0a90f109b70d659585e0427a1c0361c0b09)", + "HashTag(#226)", + "RegularText(2%)", + "RegularText(dlegal,)", + "Email(kounsellor@nostrplebs.com)", + "RegularText(-)", + "RegularText(201e51e71a753af3699cf684d7f4113c59a73c4b7bd26ef3f4c187a6173fbf06)", + "HashTag(#227)", + "RegularText(2%)", + "RegularText(BitcoinLake,)", + "RegularText()", + "RegularText(-)", + "RegularText(5babddf98277e3db6c88ae1d322bc63fd637764370e1d5e4fe5226104d82034f)", + "HashTag(#228)", + "RegularText(2%)", + "RegularText(BitcoinKeegan,)", + "RegularText()", + "RegularText(-)", + "RegularText(b457120b6cfb2589d48718f2ab71362dd0db43e13266771725129d35cc602dbe)", + "HashTag(#229)", + "RegularText(2%)", + "RegularText(KatieRoss,)", + "Email(katieross@nostrplebs.com)", + "RegularText(-)", + "RegularText(90f09238f3514f249e2b333e6119eef49697020f956fd7b6732ce118dd1b53cb)", + "HashTag(#230)", + "RegularText(2%)", + "RegularText(efcfa6โ€ฆe3f485,)", + "RegularText()", + "RegularText(-)", + "RegularText(efcfa63ac0324e37fb138c2b9dbbf9372f64ec857c923c5c1f713d3592e3f485)", + "HashTag(#231)", + "RegularText(2%)", + "RegularText(bc9e89โ€ฆb519d3,)", + "RegularText()", + "RegularText(-)", + "RegularText(bc9e89110e6e7ec5540b8ad0467d8a39554a7527c27e7af4cd45b2b8c4b519d3)", + "HashTag(#232)", + "RegularText(2%)", + "RegularText(Ilj,)", + "Email(iamlj@iris.to)", + "RegularText(-)", + "RegularText(fa3e7bcc5e588a8111ffb9d9eb8bf62c87d8a0ef6e1e5e0c74311b61f6ced8e7)", + "HashTag(#233)", + "RegularText(2%)", + "RegularText(ayelen,)", + "RegularText()", + "RegularText(-)", + "RegularText(1c31ccda2709fc6cf5db0a0b0873613e25646c4a944779dfb5e8d6cbbcd2ee1c)", + "HashTag(#234)", + "RegularText(2%)", + "RegularText(zach,)", + "Email(Zach@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(d99211aeeb643695ee1aad0517696bbc822e2fb443afe2dc9dadc0ca50b040e2)", + "HashTag(#235)", + "RegularText(2%)", + "RegularText(Yi,)", + "RegularText()", + "RegularText(-)", + "RegularText(248caad2f8392c7f72502da41ee62bbe256ea66fb365e395c988198660562ff7)", + "HashTag(#236)", + "RegularText(2%)", + "RegularText(Amouranth,)", + "Email(amouranth@nostrcheck.me)", + "RegularText(-)", + "RegularText(be5aa097ad9f4d872c70e432ad8c09565ee7dc1aee24a50b683ddca771b14901)", + "HashTag(#237)", + "RegularText(2%)", + "RegularText(hss5qy,)", + "Email(hss5qy@getalby.com)", + "RegularText(-)", + "RegularText(bc21401161327647e0bbd31f2dec1be168ef7fa5d05689fca0d063b114ed9b46)", + "HashTag(#238)", + "RegularText(2%)", + "RegularText(dpc,)", + "Email(dpcpw@iris.to)", + "RegularText(-)", + "RegularText(274611b4728b0c40be1cf180d8f3427d7d3eebc55645d869a002e8b657f8cd61)", + "HashTag(#239)", + "RegularText(2%)", + "RegularText(pred,)", + "RegularText()", + "RegularText(-)", + "RegularText(3946adbb2fc7c95f75356d8f3952c8e2705ee2431f8bd33f5cae0f9ede0298e2)", + "HashTag(#240)", + "RegularText(2%)", + "RegularText(jamesgore,)", + "RegularText()", + "RegularText(-)", + "RegularText(a94921403ac0ccf1a150ccac3679b11adcb3c3bb78b490452db43a8b6964a5c7)", + "HashTag(#241)", + "RegularText(2%)", + "RegularText(bitcoinfinity,)", + "Email(bitcoinfinity@nostrplebs.com)", + "RegularText(-)", + "RegularText(afbda6a942f975ddf8728bda3e6e5c9e440f067fcde719c6f57512f0f7ed4bf2)", + "HashTag(#242)", + "RegularText(2%)", + "RegularText(tonyseries,)", + "Email(TonySeries@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(ba5a614a48719361f515f6efa62c3e213da4bcddbb78dafd3121daa839192275)", + "HashTag(#243)", + "RegularText(2%)", + "RegularText(kuobano,)", + "Email(kuobano@nostrplebs.com)", + "RegularText(-)", + "RegularText(3f6d0bbb073839671f4c7f1e23452c6c3080f6c5f4cbc2f56c17e2b57ee01442)", + "HashTag(#244)", + "RegularText(2%)", + "RegularText(kitakripto,)", + "Email(kitakripto@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(0b11a45bf4ff7f000886b2227e43404d212bf585f71514d54ae5ae685f4c8fbb)", + "HashTag(#245)", + "RegularText(2%)", + "RegularText(Bashy,)", + "Email(_@localhost.re)", + "RegularText(-)", + "RegularText(566516663d91d4fef824eaeccbf9c2631a8d8a2efee8048ca5ee6095e6e5c843)", + "HashTag(#246)", + "RegularText(2%)", + "RegularText(alxc,)", + "Email(alxc@uselessshit.co)", + "RegularText(-)", + "RegularText(c13cb9426a4f85aff08019d246d1240a6cbf49ab9525a06d54fb496b9a3592b0)", + "HashTag(#247)", + "RegularText(2%)", + "RegularText(Kukryr,)", + "Email(kukryr@orangepill.dev)", + "RegularText(-)", + "RegularText(3f03ab6555d2e36ba970d83b8dfe1a9c09d1b89048cf7db0c85d40850f406e54)", + "HashTag(#248)", + "RegularText(2%)", + "RegularText(Saidah,)", + "Email(saidah@nostrplebs.com)", + "RegularText(-)", + "RegularText(909efa6667b28627f107764ce3c28895c46fffd1811b7415dcab03f48c44b597)", + "HashTag(#249)", + "RegularText(2%)", + "RegularText(micmad,)", + "RegularText(miceliomad@miceliomad.github.io/nostr/)", + "RegularText(-)", + "RegularText(cd806edcf8ff40ea94fa574ea9cd97da16e5beb2b85aac6e1d648b8388504343)", + "HashTag(#250)", + "RegularText(2%)", + "RegularText(Zack)", + "RegularText(Wynne,)", + "RegularText()", + "RegularText(-)", + "RegularText(9156e62c7d2f49a91b55effec6c111d3fb343e9de6ff05650e7fd89a039a9dce)", + "HashTag(#251)", + "RegularText(2%)", + "RegularText(Sharon21M,)", + "Email(sharon21m@nostr.fan)", + "RegularText(-)", + "RegularText(66b5c5be6cec2b4a124c532e97d8342f8d763d6b507caced9185168603751f25)", + "HashTag(#252)", + "RegularText(2%)", + "RegularText(bitcoinheirodomanto,)", + "RegularText()", + "RegularText(-)", + "RegularText(93d16b6fcd11199cc113e28976999ff94137ded02ddf6b84bf671daf9358c54a)", + "HashTag(#253)", + "RegularText(2%)", + "RegularText(tyler,)", + "RegularText()", + "RegularText(-)", + "RegularText(272fe1597e8d938b9a7ae5eb23aa50c5048aabbf68f27a428afe3aecd08192da)", + "HashTag(#254)", + "RegularText(2%)", + "RegularText(DMN,)", + "Email(dmn@noderunners.org)", + "RegularText(-)", + "RegularText(176d6e6ceef73b3c66e1cb1ed19b9f2473eaa514678159bc41361b3f29ddb065)", + "HashTag(#255)", + "RegularText(2%)", + "RegularText(Nela@Nostrica2023,)", + "Email(nela_at_nostrica2023@Nostr-Check.com)", + "RegularText(-)", + "RegularText(4b0bcab460adda31fad5a326fb0c04f6ec821fb24be85dbdc03c04cc0e12fc07)", + "HashTag(#256)", + "RegularText(2%)", + "RegularText(xbolo,)", + "Email(xbologg@nanostr.deno.dev)", + "RegularText(-)", + "RegularText(7aabf4a15df15074deeffdb597e6be54be4a211cbd6303436cb1ccea6c9cf87b)", + "HashTag(#257)", + "RegularText(2%)", + "RegularText(btcurenas,)", + "Email(btcurenas@nostr.fan)", + "RegularText(-)", + "RegularText(206a1264c89e8f29355e792782e83ca62331ca3d70169327cb315171b4a7ce2c)", + "HashTag(#258)", + "RegularText(2%)", + "RegularText(amaluenda,)", + "Email(amaluenda@getalby.com)", + "RegularText(-)", + "RegularText(129a80a580a0cb88d5eae9d3924d7bb8a29e0c03ef9fb723091de69c22eaaff8)", + "HashTag(#259)", + "RegularText(2%)", + "RegularText(DeveRoSt,)", + "RegularText()", + "RegularText(-)", + "RegularText(f838b6a03d8d0127a9a98e87c0142b528916a4336ba537e14131a2f513becc17)", + "HashTag(#260)", + "RegularText(2%)", + "RegularText(phoenixpyro,)", + "RegularText()", + "RegularText(-)", + "RegularText(5122cee9af93a36be4bb9b08ee7897ef88fe446c0a5d2f8db60da9faa0f72f27)", + "HashTag(#261)", + "RegularText(2%)", + "RegularText(Queen)", + "RegularText(โ‚ฟ,)", + "Email(queenb@nostrplebs.com)", + "RegularText(-)", + "RegularText(735e573b24b78138e86c96aaf37cf47547d6287c9acbd4eda173e01826b6647a)", + "HashTag(#262)", + "RegularText(2%)", + "RegularText(L.,)", + "Email(ezekiel@Nostr-Check.com)", + "RegularText(-)", + "RegularText(83663cd936892679cbd1ccdf22e017cb9fee11aef494713192c93ad6a155e287)", + "HashTag(#263)", + "RegularText(2%)", + "RegularText(dolu)", + "RegularText((compromised),)", + "RegularText()", + "RegularText(-)", + "RegularText(e668a111aa647e63ef587c17fb0e2513d5c2859cd8d389563c7640ffea1fc216)", + "HashTag(#264)", + "RegularText(2%)", + "RegularText(Marakesh)", + "RegularText(๐“…ฆ,)", + "Email(marakesh@getalby.com)", + "RegularText(-)", + "RegularText(dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491)", + "HashTag(#265)", + "RegularText(2%)", + "RegularText(Storm,)", + "Email(storm@reddirtmining.io)", + "RegularText(-)", + "RegularText(eaba072268fbb5409bdd2e8199e2878cf5d0b51ce3493122d03d7c69585d17f2)", + "HashTag(#266)", + "RegularText(2%)", + "RegularText(fiore,)", + "RegularText()", + "RegularText(-)", + "RegularText(155fd584b69fea049a428935cef11c093b6b80ca067fe4362eab0564d0774f10)", + "HashTag(#267)", + "RegularText(2%)", + "RegularText(.b.o.n.e.s.,)", + "Email(_b_o_n_e_s_@stacker.news)", + "RegularText(-)", + "RegularText(b91257b518ee7226972fc7b726e96d8a63477750a1b40589e36a090735a4f92f)", + "HashTag(#268)", + "RegularText(2%)", + "RegularText(btchodl,)", + "Email(bdichdbd@stacker.news)", + "RegularText(-)", + "RegularText(d3ca4d0144b7608eceb214734a098d50dd6c728eb72e47b0e5b1e04480db1009)", + "HashTag(#269)", + "RegularText(2%)", + "RegularText(Rosie,)", + "RegularText()", + "RegularText(-)", + "RegularText(caf0d967570ab0702c3402d50c4ab12dc6855ea062519b1ac048708cb663b0c8)", + "HashTag(#270)", + "RegularText(2%)", + "RegularText(j9,)", + "Email(j9@nostrplebs.com)", + "RegularText(-)", + "RegularText(c2797c4c633d3005d60a469d154b85766277454b648252d927660d41ecec4163)", + "HashTag(#271)", + "RegularText(2%)", + "RegularText(nokyctranslate,)", + "Email(nokyctranslate@iris.to)", + "RegularText(-)", + "RegularText(794366f1f67b7bc5604fd47e21a27e6fcbff7ec7e7a72c6d4c386d50fd5d2f04)", + "HashTag(#272)", + "RegularText(2%)", + "RegularText(Neomobius,)", + "Email(Neomobius_at_mstdn.jp@mostr.pub)", + "RegularText(-)", + "RegularText(9134bd35097c03abdcd9d61819aa8948880b6e49fc548d8a751b719dced7f7da)", + "HashTag(#273)", + "RegularText(2%)", + "RegularText(dojomaster,)", + "RegularText()", + "RegularText(-)", + "RegularText(30be56daec34e8b319d730f2c2f1cba28ef076660be33d7811dd385698a9cb40)", + "HashTag(#274)", + "RegularText(2%)", + "RegularText(paddepadde)", + "RegularText(โšก๏ธ,)", + "Email(paddepadde@getcurrent.io)", + "RegularText(-)", + "RegularText(430169631f2f0682c60cebb4f902d68f0c71c498fd1711fd982f052cf1fd4279)", + "HashTag(#275)", + "RegularText(2%)", + "RegularText(Val,)", + "Email(val@nostrplebs.com)", + "RegularText(-)", + "RegularText(e2004cb6f21a23878f0000131363e557638e47a804bcfc200103dd653fc9b7dc)", + "HashTag(#276)", + "RegularText(2%)", + "RegularText(Nickfost_,)", + "RegularText()", + "RegularText(-)", + "RegularText(a3e4cba409d392a81521d8714578948979557c8b2d56994b2026a06f6b7e97d2)", + "HashTag(#277)", + "RegularText(2%)", + "RegularText(dishwasher_iot,)", + "Email(dishwasher_iot@wlvs.space)", + "RegularText(-)", + "RegularText(5c6c25b7ef18d8633e97512159954e1aa22809c6b763e94b9f91071836d00217)", + "HashTag(#278)", + "RegularText(2%)", + "RegularText(๐•ฌ๐–“๐–”๐–“๐–ž๐–’๐–”๐–š๐–˜,)", + "Link(zapper.lol)", + "RegularText(-)", + "RegularText(96aceca84aa381eeda084167dd317e1bf7a45d874cd14147f0a9e0df86fb44c2)", + "HashTag(#279)", + "RegularText(2%)", + "RegularText(Peter,)", + "RegularText()", + "RegularText(-)", + "RegularText(b649ca5743312176174cbe76cf81d3eec493b21a52b822b6aa12bd4473da0d01)", + "HashTag(#280)", + "RegularText(2%)", + "RegularText(justin,)", + "Email(1@justinrezvani.com)", + "RegularText(-)", + "RegularText(84d535055542132100ea22e96e33349844422e6e698cc98bd8fb5eae08d76752)", + "HashTag(#281)", + "RegularText(2%)", + "RegularText(vikeymehta,)", + "RegularText()", + "RegularText(-)", + "RegularText(1a3d05e13fa38543b3d45f31c638e94e113b35c0e1db7371cdfa69861e150830)", + "HashTag(#282)", + "RegularText(2%)", + "RegularText(sshh,)", + "Email(sshh@nostrplebs.com)", + "RegularText(-)", + "RegularText(b0f86106d59d2ce292a4d89e70ff4057d7adf4b1b42bb913f37ceb9159bb2aea)", + "HashTag(#283)", + "RegularText(2%)", + "RegularText(Red_Eye_Jedi,)", + "RegularText()", + "RegularText(-)", + "RegularText(3603dbbea53ee52ab34e0f96a8d42aa55486cf5e2e05483533613e97274155f5)", + "HashTag(#284)", + "RegularText(2%)", + "RegularText(jim,)", + "Email(mk05@iris.to)", + "RegularText(-)", + "RegularText(2ed67b778522bfa0245ee57306dea40d6fd9b023db5fff43e2de0419cfe2164e)", + "HashTag(#285)", + "RegularText(2%)", + "RegularText(pniraj007,)", + "RegularText()", + "RegularText(-)", + "RegularText(99f7ba6cfb2fcd60853446b45cec2a467f65faa3245a95513bcf372eec4fbb0e)", + "HashTag(#286)", + "RegularText(2%)", + "RegularText(b676ebโ€ฆ7c389b,)", + "RegularText()", + "RegularText(-)", + "RegularText(b676ebe5ebd490523dda7db35407b7370974b4df25be32335f0652a1f07c389b)", + "HashTag(#287)", + "RegularText(2%)", + "RegularText(herald,)", + "Email(herald@bitcoin-herald.org)", + "RegularText(-)", + "RegularText(7e7224cfe0af5aaf9131af8f3e9d34ff615ff91ce2694640f1f1fee5d8febb7d)", + "HashTag(#288)", + "RegularText(2%)", + "RegularText(Giuseppe)", + "RegularText(Atorino,)", + "Email(nostr@pos.btcpayserver.it)", + "RegularText(-)", + "RegularText(e6eaf2368767307b45fcbea2d96dcb34a93af8877147203fadc10b8f741b71c9)", + "HashTag(#289)", + "RegularText(2%)", + "RegularText(a8b7b0โ€ฆd90ac2,)", + "RegularText()", + "RegularText(-)", + "RegularText(a8b7b07222485f8b845961dd4ca4d8b63c575e060b4d9386e32463e513d90ac2)", + "HashTag(#290)", + "RegularText(2%)", + "RegularText(genosonic,)", + "RegularText()", + "RegularText(-)", + "RegularText(05ffbdf4b71930d0e93ae0caa8f34bcfb5100cfba71f07b9fad4d8b5a80e4df3)", + "HashTag(#291)", + "RegularText(2%)", + "RegularText(JohnnyG,)", + "Email(thumpgofast@NostrVerified.com)", + "RegularText(-)", + "RegularText(241d6b169d62fa3d673fccf66ab62d49c0a1147ab6ab81f7a526d890e1d68a2b)", + "HashTag(#292)", + "RegularText(2%)", + "RegularText(neoop,)", + "Email(neo@elder.nostr.land)", + "RegularText(-)", + "RegularText(ea64386dba380b76c86f671f2f3c5b2a93febe8d3e2e968ac26f33569da36f87)", + "HashTag(#293)", + "RegularText(2%)", + "RegularText(Alchemist,)", + "Email(alchemist@electronalchemy.com)", + "RegularText(-)", + "RegularText(734aac327175cb770b9aa75c8816156ea439a79c6f87a16801248c1c793a8bfc)", + "HashTag(#294)", + "RegularText(2%)", + "RegularText(timp,)", + "Email(timp@iris.to)", + "RegularText(-)", + "RegularText(24cf74e1125833e9752b4843e2887dedddf6910896e6e82a2def68c8527d0814)", + "HashTag(#295)", + "RegularText(2%)", + "RegularText(ken,)", + "Email(ken@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(3505b759f075da83e9d503530d3238361b1603c28e0ee309d928174e87341713)", + "HashTag(#296)", + "RegularText(2%)", + "RegularText(Shea,)", + "RegularText()", + "RegularText(-)", + "RegularText(8dc289f2b5896057e23edc6b806407dc09162147164f4cae1d00dcb1bcd3f084)", + "HashTag(#297)", + "RegularText(2%)", + "RegularText(Devcat,)", + "RegularText()", + "RegularText(-)", + "RegularText(7f1052e59569dee4c6587507c69032af5d6883d2aa659a55bbfe1cb2e8233daf)", + "HashTag(#298)", + "RegularText(2%)", + "RegularText(173a2eโ€ฆ36436a,)", + "RegularText()", + "RegularText(-)", + "RegularText(173a2e04860656e9bab4a62cd5ec2b46ac8814e240c183e47b6badf7b936436a)", + "HashTag(#299)", + "RegularText(2%)", + "RegularText(Irebus,)", + "Email(irebus@nostr.red)", + "RegularText(-)", + "RegularText(1aaaa8e2a2094e2fdd70def09eae4e329ceb01a6a29473cb0b5e0c118f85bd35)", + "HashTag(#300)", + "RegularText(2%)", + "RegularText(b720b6โ€ฆe48a8f,)", + "RegularText()", + "RegularText(-)", + "RegularText(b720b63c47b3292dcb3339782c612462a7a42c9eece06d609a49cf951de48a8f)", + "HashTag(#301)", + "RegularText(2%)", + "RegularText(theflywheel,)", + "RegularText()", + "RegularText(-)", + "RegularText(57dcc9ed500a26a465ddb12c51de05963d4dec8a596708629558495c4acacab3)", + "HashTag(#302)", + "RegularText(2%)", + "RegularText(223597โ€ฆ002c18,)", + "RegularText()", + "RegularText(-)", + "RegularText(22359794c50e2945aa768ee500ffb2ddb388696ad078a350ae570152ff002c18)", + "HashTag(#303)", + "RegularText(2%)", + "RegularText(gratitude,)", + "RegularText()", + "RegularText(-)", + "RegularText(4686358c60bae7694e8b39dad26d1c834d5dd27726a56e2501fc06dec6942be1)", + "HashTag(#304)", + "RegularText(2%)", + "RegularText(stim4444,)", + "Email(stim4444@no.str.cr)", + "RegularText(-)", + "RegularText(0aeaec333bf9a0638de51ea837590ca64522ec590ed160ce87cb6e30d10df537)", + "HashTag(#305)", + "RegularText(2%)", + "RegularText(756240โ€ฆ265fc2,)", + "RegularText()", + "RegularText(-)", + "RegularText(756240d3be0d553b0cd174b3499cffa37fbe8394ee06b9ab50652e314c265fc2)", + "HashTag(#306)", + "RegularText(2%)", + "RegularText(4d38edโ€ฆd26aad,)", + "RegularText()", + "RegularText(-)", + "RegularText(4d38ed26a6d1080806534818a668c71381bcb04bc4ca1083d9d9572977d26aad)", + "HashTag(#307)", + "RegularText(2%)", + "RegularText(Kwinten,)", + "RegularText()", + "RegularText(-)", + "RegularText(c29da265739bc3886c76d84b0a351849fa45a31a64fcb72f47c600ab2623f90c)", + "HashTag(#308)", + "RegularText(2%)", + "RegularText(b36506โ€ฆ7ca32c,)", + "RegularText()", + "RegularText(-)", + "RegularText(b365069ada41fc7190f8b11e8342f7f66f9777eaaa9882722d0be863c27ca32c)", + "HashTag(#309)", + "RegularText(2%)", + "RegularText(Cole)", + "RegularText(Albon,)", + "RegularText()", + "RegularText(-)", + "RegularText(c3ff9a851ca965ed266ba54c9263f680be91e2465628c64bab6a5992521d5c5d)", + "HashTag(#310)", + "RegularText(2%)", + "RegularText(Onecoin,)", + "RegularText()", + "RegularText(-)", + "RegularText(b23ce47262373574d6653fad2da09db1fb20bb2919f3e697b8edd1966fffd8ec)", + "HashTag(#311)", + "RegularText(2%)", + "RegularText(Disabled,)", + "RegularText()", + "RegularText(-)", + "RegularText(7d706eaefb905ea9b3af885879fb5911b50b39db539c319438703373424204ec)", + "HashTag(#312)", + "RegularText(2%)", + "RegularText(xdamman,)", + "RegularText()", + "RegularText(-)", + "RegularText(340254e011abda2e82585cbfee4f91b3f07549a6c468fe009bf3ec7665a2e31b)", + "HashTag(#313)", + "RegularText(2%)", + "RegularText(jmrichner,)", + "RegularText()", + "RegularText(-)", + "RegularText(797750041d1366a80d45e130c831f0562b5f7266662b07acef50dd541bfa2535)", + "HashTag(#314)", + "RegularText(2%)", + "RegularText(pentoshi,)", + "RegularText()", + "RegularText(-)", + "RegularText(db6ad1e2a4cbbacbbdf79377a9ebb2fc30eb417ce9b061003771cb40b8e00d56)", + "HashTag(#315)", + "RegularText(2%)", + "RegularText(35453dโ€ฆ45d10b,)", + "RegularText()", + "RegularText(-)", + "RegularText(35453d2e49a0282c4dd694e5a364bf29600a9b5443e4712cfc86a0495345d10b)", + "HashTag(#316)", + "RegularText(2%)", + "RegularText(LayerLNW,)", + "Email(layerlnw@nostr.fan)", + "RegularText(-)", + "RegularText(33c9edf7ade19188685997136e6ffb4ed89939178fa5f2259428de1cd3301380)", + "HashTag(#317)", + "RegularText(2%)", + "RegularText(Bitcoincouch,)", + "RegularText()", + "RegularText(-)", + "RegularText(fbd3c6eb5ef06e82583d3b533663ba86036462a02e686881d8cb2de5aaa9fa4a)", + "HashTag(#318)", + "RegularText(2%)", + "RegularText(BritishHodl,)", + "RegularText()", + "RegularText(-)", + "RegularText(22fb17c6657bb317be84421335ef6b0f9f1777617aa220cf27dc06fb5788f438)", + "HashTag(#319)", + "RegularText(2%)", + "RegularText(enhickman,)", + "Email(enhickman@enhickman.net)", + "RegularText(-)", + "RegularText(0cf08d280aa5fcfaf340c269abcf66357526fdc90b94b3e9ff6d347a41f090b7)", + "HashTag(#320)", + "RegularText(2%)", + "RegularText(4d6e72โ€ฆ219298,)", + "RegularText()", + "RegularText(-)", + "RegularText(4d6e72aba0e8a033c973acd7e42f915d5fa1708be7229d477869e91136219298)", + "HashTag(#321)", + "RegularText(2%)", + "RegularText(f75326โ€ฆaf65e0,)", + "RegularText()", + "RegularText(-)", + "RegularText(f7532615471b029a34e41e080b2af4bad2b80f8105c008378d0095991eaf65e0)", + "HashTag(#322)", + "RegularText(2%)", + "RegularText(LiveFreeBTC,)", + "Email(LiveFreeBTC@livefreebtc.org)", + "RegularText(-)", + "RegularText(49f586188679af2b31e8d62ff153baf767fd0c586939f4142ef65606d236ff75)", + "HashTag(#323)", + "RegularText(2%)", + "RegularText(aptx4869,)", + "Email(aptx4869@aptx4869.app)", + "RegularText(-)", + "RegularText(64aaa73189af814977ff5dedbbab022df030f1d7df3e6307aceb1fddb30df847)", + "HashTag(#324)", + "RegularText(2%)", + "RegularText(khalil,)", + "Email(khalil@klouche.com)", + "RegularText(-)", + "RegularText(5a03bdb5448b440428d8459d4afe9b553e705737ef8cd7a0d25569ccead4d6ce)", + "HashTag(#325)", + "RegularText(2%)", + "RegularText(nsec1wnppl0xqw2lysecymwmz3hgxuzk60dgyur6mqtgexln20qp4xv9sugxghg,)", + "Email(nsec@ittybitty.tips)", + "RegularText(-)", + "RegularText(f1ea91eeab7988ed00e3253d5d50c66837433995348d7d97f968a0ceb81e0929)", + "HashTag(#326)", + "RegularText(2%)", + "RegularText(BTC_P2P,)", + "RegularText()", + "RegularText(-)", + "RegularText(ecf468164bd743b75683db3870ce01cb9a1d4b8ec203ed26de50f96255bbc75a)", + "HashTag(#327)", + "RegularText(2%)", + "RegularText(Big)", + "RegularText(FISH,)", + "Email(bigfish@iris.to)", + "RegularText(-)", + "RegularText(963100cf40967a70cdea802c6b4b97956cf8c5e3b09e492b24a847d4c535a794)", + "HashTag(#328)", + "RegularText(2%)", + "RegularText(9e93fbโ€ฆ2483b6,)", + "RegularText()", + "RegularText(-)", + "RegularText(9e93fb0012a6177faddf2fd324fb61eafbe8b142b31c5e89fd85bfafd12483b6)", + "HashTag(#329)", + "RegularText(2%)", + "RegularText(Mynameis,)", + "RegularText()", + "RegularText(-)", + "RegularText(6bec23b4a17da33d0a2f44e258371e869ff124775e8e38b9581dcd49c8d1d4a6)", + "HashTag(#330)", + "RegularText(2%)", + "RegularText(3f2342โ€ฆd689b8,)", + "RegularText()", + "RegularText(-)", + "RegularText(3f23426af245168f8112e441c046ecdb29aca56a6d33d21e276b8ac00bd689b8)", + "HashTag(#331)", + "RegularText(2%)", + "RegularText(865c92โ€ฆ136ced,)", + "RegularText()", + "RegularText(-)", + "RegularText(865c92a207a156a2d48404694a2eed5ceca5c163b7a845b86a6c75e142136ced)", + "HashTag(#332)", + "RegularText(2%)", + "RegularText(95d4d6โ€ฆfe1673,)", + "RegularText()", + "RegularText(-)", + "RegularText(95d4d60e643f283cef8d70ab7a9c09ab5a85924f97e11b22cf99779c4ffe1673)", + "HashTag(#333)", + "RegularText(2%)", + "RegularText(verse,)", + "RegularText()", + "RegularText(-)", + "RegularText(0ff7a93751d37ffcca05579c59ac69053d8d0c6f2c57ed9101ba8758eebc0d6b)", + "HashTag(#334)", + "RegularText(2%)", + "RegularText(oldschool,)", + "Email(oldschool@iris.to)", + "RegularText(-)", + "RegularText(19dba8f974322c7345d3b491925896d19e7f432a4f41223c5daf96e31fae338d)", + "HashTag(#335)", + "RegularText(2%)", + "RegularText(Danton๐Ÿ‡จ๐Ÿ‡ญ,)", + "Email(danton@nostrplebs.com)", + "RegularText(-)", + "RegularText(dbe693bc2d16c52e18e75f2cb76401cb7d74132cc956f7315ea5ebee1adfc966)", + "HashTag(#336)", + "RegularText(2%)", + "RegularText(BitcoinZavior,)", + "Email(bitcoinzavior@nostrplebs.com)", + "RegularText(-)", + "RegularText(c6e86c9b95ef289600800b855b9a6ca42019cc9453937020289d8b3e01dab865)", + "HashTag(#337)", + "RegularText(2%)", + "RegularText(BitcoinSermons,)", + "Email(BitcoinSermons@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(615f40fae8f2e08da81b5c76a0143cb04b4e9e044bf6047efe15c56c7cc1a6b2)", + "HashTag(#338)", + "RegularText(2%)", + "RegularText(skreep,)", + "RegularText()", + "RegularText(-)", + "RegularText(a4992688b449c2bdd6fa9c39a880d7fe27d5f5e3e9fd4c47d65d824588fd660f)", + "HashTag(#339)", + "RegularText(2%)", + "RegularText(db830bโ€ฆ4bb85c,)", + "RegularText()", + "RegularText(-)", + "RegularText(db830b864876a0f3109ae3447e43715711250d53f310092052aabb5bdc4bb85c)", + "HashTag(#340)", + "RegularText(2%)", + "RegularText(UKNW22LINUX,)", + "Email(uknwlinux@plebs.place)", + "RegularText(-)", + "RegularText(ab1ef3f15fc29b3da324eb401122382ceb5ea9c61adaad498192879fd9a5d057)", + "HashTag(#341)", + "RegularText(2%)", + "RegularText(Satoshism,)", + "Email(satoshism@nostrplebs.com)", + "RegularText(-)", + "RegularText(e262ed3a22ad8c478b077ef5d7c56b2c3c7a530519ed696ed2e57c65e147fbcb)", + "HashTag(#342)", + "RegularText(2%)", + "RegularText(William,)", + "RegularText()", + "RegularText(-)", + "RegularText(8c55174d8fc29d4da650b273fdd18ad4dda478faa4b0ea14726d81ac6c7bef48)", + "HashTag(#343)", + "RegularText(2%)", + "RegularText(thebitcoinyogi,)", + "Email(jon@nostrplebs.com)", + "RegularText(-)", + "RegularText(59c2e15ad7bc0b5c97b8438b2763a5c409ff76ab985ab5f1f47c4bcdd25e6e8d)", + "HashTag(#344)", + "RegularText(2%)", + "RegularText(vake,)", + "RegularText()", + "RegularText(-)", + "RegularText(547f45b91c1e6b4137917cde4fa1da867c8cdfe43d0f646c836a622769795a14)", + "HashTag(#345)", + "RegularText(2%)", + "RegularText(hobozakki,)", + "Email(hobozakki@nostrplebs.com)", + "RegularText(-)", + "RegularText(29e31c4103b85fab499132fa71870bd5446de8f7e2ac040ec0372aa61ae22f98)", + "HashTag(#346)", + "RegularText(2%)", + "RegularText(SirGalahodl,)", + "Email(sirgalahodl@satstream.me)", + "RegularText(-)", + "RegularText(25ee676190e2b6145ad8dd137630eca55fc503dde715ce8af4c171815d018797)", + "HashTag(#347)", + "RegularText(2%)", + "RegularText(1f6c76โ€ฆebb9c9,)", + "RegularText()", + "RegularText(-)", + "RegularText(1f6c76ddbab213cdd43db2695b1474605639862302c7cfae35362be8caebb9c9)", + "HashTag(#348)", + "RegularText(2%)", + "RegularText(greencandleit,)", + "RegularText()", + "RegularText(-)", + "RegularText(3d4b358b50d20c3e4d855f273ff06c49bc6b3f6e62c42aed44f278742fd579da)", + "HashTag(#349)", + "RegularText(2%)", + "RegularText(ichigo,)", + "RegularText()", + "RegularText(-)", + "RegularText(477e0b3c0c6029e31562b39650efa8f871d52e3ab09145d72e99b9b74dd384d7)", + "HashTag(#350)", + "RegularText(2%)", + "RegularText(Niko,)", + "RegularText()", + "RegularText(-)", + "RegularText(636fdb4de194bca39ab30ab5793a38b8d15c1b1c0a968d04f7fe14eb1a6a8c42)", + "HashTag(#351)", + "RegularText(2%)", + "RegularText(afa,)", + "Email(victor@lnmarkets.com)", + "RegularText(-)", + "RegularText(8f6945b4726112826ac6abd56ec041c87d8bdc4ec02e86bb388a97481f372b97)", + "HashTag(#352)", + "RegularText(2%)", + "RegularText(BushBrook,)", + "RegularText()", + "RegularText(-)", + "RegularText(a39fd86ed75c654550bf813430877819beb77a3b670e01a9680a84a844db9620)", + "HashTag(#353)", + "RegularText(2%)", + "RegularText(naoise,)", + "RegularText()", + "RegularText(-)", + "RegularText(c4a9caef93e93f484274c04cd981d1de1424902451aca2f5602bd0835fe4393d)", + "HashTag(#354)", + "RegularText(2%)", + "SchemelessUrl(smies.me,)", + "Email(jacksmies@iris.to)", + "RegularText(-)", + "RegularText(cdecbc48e35a351582e3e030fd8cf5d5f44681613d2949353d9c6644d32d451f)", + "HashTag(#355)", + "RegularText(2%)", + "RegularText(Chemaclass,)", + "Email(chemaclass@snort.social)", + "RegularText(-)", + "RegularText(c5d4815c26e18e2c178133004a6ddba9a96a5f7af795a3ab606d11aa1055146a)", + "HashTag(#356)", + "RegularText(2%)", + "RegularText(BTCingularity,)", + "RegularText()", + "RegularText(-)", + "RegularText(aa1f96f685d0ac3e28a52feb87a20399a91afb3ac3137afeb7698dfcc99bc454)", + "HashTag(#357)", + "RegularText(2%)", + "RegularText(the_man,)", + "RegularText()", + "RegularText(-)", + "RegularText(dad77f3814964b5cdcd120a3a8d7b40c6218d413ae6328801b9929ed90123687)", + "HashTag(#358)", + "RegularText(2%)", + "RegularText(jayson,)", + "Email(jayson@tautic.com)", + "RegularText(-)", + "RegularText(7be5d241f3cc10922545e31aeb8d5735be2bc3230480e038c7fd503e7349a2cc)", + "HashTag(#359)", + "RegularText(2%)", + "RegularText(jesterhodl,)", + "Email(jesterhodl@jesterhodl.com)", + "RegularText(-)", + "RegularText(3c285d830bf433135ae61c721b750ce11ae5b2e187712d7a171afa7cda649e50)", + "HashTag(#360)", + "RegularText(2%)", + "RegularText(06d694โ€ฆc3ab96,)", + "RegularText()", + "RegularText(-)", + "RegularText(06d6946fd1ff1fba6ac530e0b5683db4c73cdc11d6c42324246e10f4f2c3ab96)", + "HashTag(#361)", + "RegularText(2%)", + "RegularText(sardin,)", + "RegularText()", + "RegularText(-)", + "RegularText(f26470570bcb67a18a90890dbe02d565eadc6c955912977c64c99d4b9a7fd29f)", + "HashTag(#362)", + "RegularText(2%)", + "RegularText(Bitcoin_Gamer_21,)", + "Email(Bitcoin_Gamer_21@bitcoin-21.org)", + "RegularText(-)", + "RegularText(021df4103ede2cdc32de4058d4bdb29ffcbfd13070f05c4688f6974bd9a67176)", + "HashTag(#363)", + "RegularText(2%)", + "RegularText(water-bot,)", + "Email(water-bot@gourcetools.github.io)", + "RegularText(-)", + "RegularText(000000dd7a2e54c77a521237a516eefb1d41df39047a9c64882d05bc84c9d666)", + "HashTag(#364)", + "RegularText(1%)", + "RegularText(ondorevillager,)", + "RegularText()", + "RegularText(-)", + "RegularText(5d7b460173010efd682c0d7bc8cc36ca9bf7dcc7990288f642c04b8e05713c83)", + "HashTag(#365)", + "RegularText(1%)", + "RegularText(Tomfantasia,)", + "RegularText()", + "RegularText(-)", + "RegularText(d856af932000c292ad723dee490ebcf908a1031b486dea05267ee50b473349b2)", + "HashTag(#366)", + "RegularText(1%)", + "RegularText(W3crypto,)", + "Email(w3crypto@iris.to)", + "RegularText(-)", + "RegularText(d001bca923ab56b1c759fc9471fbe6baadac50aeba7d963155772ac7b6779027)", + "HashTag(#367)", + "RegularText(1%)", + "RegularText(bradjpn,)", + "RegularText()", + "RegularText(-)", + "RegularText(c4da3be8e10fa86128530885d18e455900cccff39d7a24c4a6ac12b0284f62b3)", + "HashTag(#368)", + "RegularText(1%)", + "RegularText(@discretelog,)", + "RegularText()", + "RegularText(-)", + "RegularText(03e4804b4a28c051f43185d6bf5b4643cb3f0d9632c4394b60a2ffad0f852340)", + "HashTag(#369)", + "RegularText(1%)", + "RegularText(makaveli,)", + "Email(makaveli@nostrplebs.com)", + "RegularText(-)", + "RegularText(570469cbc969ea6c7e94c41c6496a2951f52d3399011992bf45f4b2216d99119)", + "HashTag(#370)", + "RegularText(1%)", + "RegularText(JamieAnders,)", + "Email(jamieanders@ln.tips)", + "RegularText(-)", + "RegularText(7601e743ad432d78471ac57178402a57cd3f3a92fb208be7de788af2d6a57669)", + "HashTag(#371)", + "RegularText(1%)", + "RegularText(LightningVentures,)", + "RegularText()", + "RegularText(-)", + "RegularText(37de18e08cdc01ce7ced1808b241ec0b4a69e754d576ce0e08f0cf3375bb0a6b)", + "HashTag(#372)", + "RegularText(1%)", + "RegularText(Colorado)", + "RegularText(Craig,)", + "Email(cball@nostrplebs.com)", + "RegularText(-)", + "RegularText(a2c20d6856545b145bc76cdfaffd04ddad4e58d73b2352dcc5de86aa4ba38e7b)", + "HashTag(#373)", + "RegularText(1%)", + "RegularText(21fadbโ€ฆ3d8f6f,)", + "RegularText()", + "RegularText(-)", + "RegularText(21fadb45755a5f41d1b84ecf4610657dd9336d24419d61efffb947aeec3d8f6f)", + "HashTag(#374)", + "RegularText(1%)", + "RegularText(castaway,)", + "RegularText()", + "RegularText(-)", + "RegularText(0cbde76a61cc539059f7da7b4fb19c0197f9f781674d307b52264cbb0144c739)", + "HashTag(#375)", + "RegularText(1%)", + "RegularText(chames,)", + "RegularText()", + "RegularText(-)", + "RegularText(a721f4370afd51fcbc7e2a685f24a454f14fea84448e1c2aa4a9a94b89f3ea7d)", + "HashTag(#376)", + "RegularText(1%)", + "RegularText(laura,)", + "Email(laura@nostrich.zone)", + "RegularText(-)", + "RegularText(ac2250f83aaa7c4a8503f9c15c0cc11ac992315e5ac3e634541223a8deb6c09c)", + "HashTag(#377)", + "RegularText(1%)", + "RegularText(Kaz,)", + "Email(kaz@reddirtmining.io)", + "RegularText(-)", + "RegularText(826d71153f4938c43b930f90cc3130f33430d1e069d43a2f705f9538450b9369)", + "HashTag(#378)", + "RegularText(1%)", + "RegularText(Verismus,)", + "Email(verismus@nostrplebs.com)", + "RegularText(-)", + "RegularText(9e79aed207461f0d5ebc2c8b94e6875e2a6d5dd15990f8ea3ad2540786d07528)", + "HashTag(#379)", + "RegularText(1%)", + "RegularText(cafc4fโ€ฆ107e85,)", + "RegularText()", + "RegularText(-)", + "RegularText(cafc4fbaa558e466bba6c667fcf14506728ff70975f2817c8e5b6fb062107e85)", + "HashTag(#380)", + "RegularText(1%)", + "RegularText(bitpetro,)", + "Email(bitpetro@nostrplebs.com)", + "RegularText(-)", + "RegularText(22470b963e71fa04e1f330ce55f66ff9783c7a9c4851b903d332a59f2327891e)", + "HashTag(#381)", + "RegularText(1%)", + "RegularText(nossence,)", + "Email(nossence@nossence.xyz)", + "RegularText(-)", + "RegularText(56899e6a55c14771a45a88cb90a802623a0e3211ea1447057e2c9871796ce57c)", + "HashTag(#382)", + "RegularText(1%)", + "RegularText(The)", + "RegularText(Progressive)", + "RegularText(Bitcoiner,)", + "RegularText()", + "RegularText(-)", + "RegularText(4870d5500a121e5187544a3e6e5c2fee1d0a03e1b85073f27edb710b110d6208)", + "HashTag(#383)", + "RegularText(1%)", + "RegularText(orangepillstacker,)", + "RegularText()", + "RegularText(-)", + "RegularText(affe861d3e4c42bb956a35d8f9d2c76a99ba16581f3d0dbf762d807e1de8e234)", + "HashTag(#384)", + "RegularText(1%)", + "RegularText(Nostrdamus,)", + "Email(manbearpig@nostrplebs.com)", + "RegularText(-)", + "RegularText(84a42d3efa48018e187027e2bbdd013285a27d8faf970f83a35691d7e2e1a310)", + "HashTag(#385)", + "RegularText(1%)", + "RegularText(JohnSmith,)", + "Email(johnsmith@nostrplebs.com)", + "RegularText(-)", + "RegularText(7c939a7211f1b818567d10b7e65bb03e2830420acf3d6f4f65a7320e2e66d97e)", + "HashTag(#386)", + "RegularText(1%)", + "RegularText(Matty,)", + "RegularText()", + "RegularText(-)", + "RegularText(1cb599e80e7933a7144bbebfb39168c6ee75a27bacd6d8a67e80c442a32a52a8)", + "HashTag(#387)", + "RegularText(1%)", + "RegularText(epodrulz,)", + "Email(bitcoin@bitcoinedu.com)", + "RegularText(-)", + "RegularText(a249234ba07c832c8ee99915f145c02838245499589a6ab8a7461f2ef3eec748)", + "HashTag(#388)", + "RegularText(1%)", + "RegularText(paul,)", + "RegularText()", + "RegularText(-)", + "RegularText(52b9e1aca3df269710568d1caa051abf40fbdf8c2489afb8d2b7cdb1d1d0ce6f)", + "HashTag(#389)", + "RegularText(1%)", + "RegularText(0ec37aโ€ฆba5855,)", + "RegularText()", + "RegularText(-)", + "RegularText(0ec37a784c894b8c8f96a0ccb6055d4ce7b8420482bc41d00e235723a9ba5855)", + "HashTag(#390)", + "RegularText(1%)", + "RegularText(jor,)", + "Email(knggolf@nostrplebs.com)", + "RegularText(-)", + "RegularText(7c765d407d3a9d5ea117cb8b8699628560787fc084a0c76afaa449bfbd121d84)", + "HashTag(#391)", + "RegularText(1%)", + "RegularText(Nighthaven,)", + "Email(nighthaven@iris.to)", + "RegularText(-)", + "RegularText(510e0096e4e622e9f2877af7e7af979ac2fdf50702b9cd77021658344d1a682c)", + "HashTag(#392)", + "RegularText(1%)", + "RegularText(00f454โ€ฆ929254,)", + "RegularText()", + "RegularText(-)", + "RegularText(00f45459dcd6c6e04706ddafd03a9f52a28833efc04b3ff0a66b89146b929254)", + "HashTag(#393)", + "RegularText(1%)", + "RegularText(XBT_fi,)", + "Email(xbt_fi@iris.to)", + "RegularText(-)", + "RegularText(6e1bee4bdfc34056ffcde2c0685ae6468867aedd0843ed5d0cfcde41f64bfda8)", + "HashTag(#394)", + "RegularText(1%)", + "RegularText(e9f332โ€ฆ6474aa,)", + "RegularText()", + "RegularText(-)", + "RegularText(e9f33272af64080287624176253ed2b468d17cec5f2a3d927a3ee36c356474aa)", + "HashTag(#395)", + "RegularText(1%)", + "RegularText(ulrichard,)", + "RegularText()", + "RegularText(-)", + "RegularText(cd0ea239c10e2dbe12e5171537ff0b8619747bfcd8dcf939f4bceed340b38c87)", + "HashTag(#396)", + "RegularText(1%)", + "RegularText(54ff28โ€ฆd7090d,)", + "RegularText()", + "RegularText(-)", + "RegularText(54ff28f1abbceddea50cf35cac69e5df32b982c3e872d40aa9ec035431d7090d)", + "HashTag(#397)", + "RegularText(1%)", + "RegularText(GeneralCarlosQ17,)", + "Email(gencarlosq17@iris.to)", + "RegularText(-)", + "RegularText(b13cc2d0b7b70ba41c13f09cc78dc6ce7f72049b1fe59a8194a237e23e37216e)", + "HashTag(#398)", + "RegularText(1%)", + "RegularText(BitcoinIslandPH,)", + "RegularText()", + "RegularText(-)", + "RegularText(b4ab403c8215e0606f11be21670126a501d85ea2027b6d15bf4b54c3236d0994)", + "HashTag(#399)", + "RegularText(1%)", + "RegularText(rotciv,)", + "Email(rotciv@plebs.place)", + "RegularText(-)", + "RegularText(b70c9bfb254b6072804212643beb077b6ba941609ed40515d9b10961d7767899)", + "HashTag(#400)", + "RegularText(1%)", + "RegularText(Alfa,)", + "RegularText()", + "RegularText(-)", + "RegularText(0575bc052fed6c729a0ab828efa45da77e28685da91bdfebc7a7640cb0728d12)", + "HashTag(#401)", + "RegularText(1%)", + "RegularText(ben_dewaal,)", + "RegularText()", + "RegularText(-)", + "RegularText(aac02781318dfc8c3d7ed0978ef9a7e8154a6b8ae6c910b3a52b42fd56875002)", + "HashTag(#402)", + "RegularText(1%)", + "RegularText(cguida,)", + "RegularText()", + "RegularText(-)", + "RegularText(2895c330c23f383196c0ef988de6da83b83b4583ed5f9c1edb0a559cecd1f900)", + "HashTag(#403)", + "RegularText(1%)", + "RegularText(nout,)", + "RegularText()", + "RegularText(-)", + "RegularText(52cb4b34775fa781b6a964bda0432dbcdfede7a59bf8dfc279cbff0ad8fb09ff)", + "HashTag(#404)", + "RegularText(1%)", + "RegularText(Merlin,)", + "Email(Merlin@bitcoinnostr.com)", + "RegularText(-)", + "RegularText(76dd32f31619b8e35e9f32e015224b633a0df8be8d5613c25b8838a370407698)", + "HashTag(#405)", + "RegularText(1%)", + "RegularText(millymischiefx,)", + "RegularText()", + "RegularText(-)", + "RegularText(868d9200af6e6fe1604a28d587b30c2712100b0edab76982551d56ebc6ae061f)", + "HashTag(#406)", + "RegularText(1%)", + "RegularText(yegorpetrov(alternative),)", + "Email(yeg0rpetrov@iris.to)", + "RegularText(-)", + "RegularText(2650f1f87e1dc974ffcc7b5813a234f6f1b1c92d56732f7db4fef986c80a31f7)", + "HashTag(#407)", + "RegularText(1%)", + "RegularText(baloo,)", + "Email(baloo@nostrpurple.com)", + "RegularText(-)", + "RegularText(c49f8402ef410fce5a1e5b2e6da06f351804988dd2e0bad18ae17a50fc76c221)", + "HashTag(#408)", + "RegularText(1%)", + "RegularText(jamesgospodyn,)", + "Email(jamesgospodyn@nostr.theorangepillapp.com)", + "RegularText(-)", + "RegularText(11edfa8182cf3d843ef36aa2fa270137d1aee9e4f0cd2add67707c8fc5ff2a0d)", + "HashTag(#409)", + "RegularText(1%)", + "RegularText(Mysterious_Minx,)", + "RegularText()", + "RegularText(-)", + "RegularText(381dbcc7138eab9a71e814c57837c9d623f4036ec0240ef302330684ffc8b38f)", + "HashTag(#410)", + "RegularText(1%)", + "RegularText(878bf5โ€ฆf7cb86,)", + "RegularText()", + "RegularText(-)", + "RegularText(878bf5d63ed5b13d2dac3f463e1bd73d0502bd3462ebf2ea3a0825ca11f7cb86)", + "HashTag(#411)", + "RegularText(1%)", + "RegularText(carl,)", + "Email(carl@armadalabs.studio)", + "RegularText(-)", + "RegularText(cd1197bede3b3c0cdc7412d076228e3f48b5b66e88760f53142e91485d128e07)", + "HashTag(#412)", + "RegularText(1%)", + "RegularText(NIMBUS,)", + "RegularText()", + "RegularText(-)", + "RegularText(c48a8ced6dfcc450056bb069b4007607c68a3e93cf3ae6e62b75bf3509f78178)", + "HashTag(#413)", + "RegularText(1%)", + "RegularText(btcportal,)", + "Email(btcportal@nostrplebs.com)", + "RegularText(-)", + "RegularText(9fc1e0ef750dba8cdb3b360b8a00ccad6dcef6b7ad7644f628e952ed8b7eebfb)", + "HashTag(#414)", + "RegularText(1%)", + "RegularText(9652baโ€ฆccd3f1,)", + "RegularText()", + "RegularText(-)", + "RegularText(9652ba74b6981f69a3ffad088aa0f16c8af7fe38a72e5d82176878acdcccd3f1)", + "HashTag(#415)", + "RegularText(1%)", + "RegularText(mjbonham,)", + "Email(mjb@nostrplebs.com)", + "RegularText(-)", + "RegularText(802afdddebfb60a516b39d649ea35401749622e394f85a687674907c4588dc7a)", + "HashTag(#416)", + "RegularText(1%)", + "RegularText(โŒœJanโŒ,)", + "RegularText()", + "RegularText(-)", + "RegularText(fca142a3a900fed71d831aa0aa9c21bb86a5917a9e1183659857b684f25ae1ce)", + "HashTag(#417)", + "RegularText(1%)", + "RegularText(DontTraceMeBruh,)", + "RegularText()", + "RegularText(-)", + "RegularText(3fef59378dce7726d3ef35d4699f57becf76d3be0a13187677126a66c9ade3b8)", + "HashTag(#418)", + "RegularText(1%)", + "RegularText(9a73c0โ€ฆ1707f2,)", + "RegularText()", + "RegularText(-)", + "RegularText(9a73c0ecd5049ae38b50d0d9eaaabd49390cdd08c3d3d666d0d8476c411707f2)", + "HashTag(#419)", + "RegularText(1%)", + "RegularText(esbewolkt,)", + "Email(esbewolkt@nostr.fan)", + "RegularText(-)", + "RegularText(50ea483ddffeeed3231c6f41fddfe8fb71f891fa736de46e3e06f748bbdeb307)", + "HashTag(#420)", + "RegularText(1%)", + "RegularText(morningstar,)", + "RegularText()", + "RegularText(-)", + "RegularText(82671c61fa007b0f70496dec2420238efd3df2f76cdaf6c1f810def8ce95ba45)", + "HashTag(#421)", + "RegularText(1%)", + "RegularText(Sweedgraffixx,)", + "RegularText()", + "RegularText(-)", + "RegularText(ee5f4a67cb434317dd7b931d9d23cb2978ab728a008e4c4dcca9cc781d3ae576)", + "HashTag(#422)", + "RegularText(1%)", + "RegularText(878492โ€ฆ165b4f,)", + "RegularText()", + "RegularText(-)", + "RegularText(878492807168be8dfbae71d721a9b7f6833a9928fcf9acc3274dfdb113165b4f)", + "HashTag(#423)", + "RegularText(1%)", + "RegularText(koukos,)", + "Email(koukos@iris.to)", + "RegularText(-)", + "RegularText(4260122b8a141e888413082dea2d93568488bae4726358e9e6b7da741852dfc8)", + "HashTag(#424)", + "RegularText(1%)", + "RegularText(nopara73,)", + "RegularText()", + "RegularText(-)", + "RegularText(001892e9b48b430d7e37c27051ff7bf414cbc52a7f48f451d857409ce7839dde)", + "HashTag(#425)", + "RegularText(1%)", + "RegularText(BeโšกBANK,)", + "RegularText()", + "RegularText(-)", + "RegularText(fbfb3855d50c37866af00484a6476680ae1e2ff04ceb9dd8936465f70d39150b)", + "HashTag(#426)", + "RegularText(1%)", + "RegularText(davekrock,)", + "Email(davekrock@NostrVerified.com)", + "RegularText(-)", + "RegularText(e26b5f261cb29354def8a8ba6af49b137e3144388a81ef78eed8e77cfb18fd44)", + "HashTag(#427)", + "RegularText(1%)", + "RegularText(BitcoinLoveLife,)", + "Email(Bitcoinlovelife@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(3c08d854ef6c86b1dc11159fdabc09209eaeba01790ce96690c55787daf3c415)", + "HashTag(#428)", + "RegularText(1%)", + "RegularText(Steam,)", + "RegularText()", + "RegularText(-)", + "RegularText(111a1ae50a7e30a465126b0ab10c3eac6ddaa3cca016a4117470e6715a2dfdef)", + "HashTag(#429)", + "RegularText(1%)", + "RegularText(xolag,)", + "Email(xolagl2@getalby.com)", + "RegularText(-)", + "RegularText(fb64b9c3386a9ababaf8c4f80b47c071c4a38f7b8acdc4dafb009875a64f8c37)", + "HashTag(#430)", + "RegularText(1%)", + "RegularText(relay9may,)", + "RegularText()", + "RegularText(-)", + "RegularText(1e7fd2177d20c97f326cda699551f085b8e7f93650b48b6e87a0bebcdfeebc8b)", + "HashTag(#431)", + "RegularText(1%)", + "RegularText(f2c817โ€ฆ8a2f3b,)", + "RegularText()", + "RegularText(-)", + "RegularText(f2c817a3bbf07517a38beac228a12e3460d18f1ec2ed928d2e6d2e67308a2f3b)", + "HashTag(#432)", + "RegularText(1%)", + "RegularText(remoney,)", + "Email(remoney@nostrplebs.com)", + "RegularText(-)", + "RegularText(3939a929101b17f4782171b5e0e49996fbe2215b226bd847bd76be3c2de80e9a)", + "HashTag(#433)", + "RegularText(1%)", + "RegularText(387eb9โ€ฆa6f87f,)", + "RegularText()", + "RegularText(-)", + "RegularText(387eb9a5c4f43e40e6abd1f6fe953477464ae5830d104e325f362209c2a6f87f)", + "HashTag(#434)", + "RegularText(1%)", + "RegularText(846b76โ€ฆ539eca,)", + "RegularText()", + "RegularText(-)", + "RegularText(846b763b1234c5652f1e327e59570dcb6535d2d20589c67c2a9a90b323539eca)", + "HashTag(#435)", + "RegularText(1%)", + "RegularText(Shawn)", + "RegularText(C.,)", + "RegularText()", + "RegularText(-)", + "RegularText(83ea7cb5a3ab517f24eb2948b23f39466dd5f200fd4e6951fed43ba34e9a4a83)", + "HashTag(#436)", + "RegularText(1%)", + "RegularText(roberto,)", + "Email(roberto@bitcoiner.chat)", + "RegularText(-)", + "RegularText(319a588a77cd798b358724234b534bff3f3c294b4f6512bde94d070da93237c9)", + "HashTag(#437)", + "RegularText(1%)", + "RegularText(LazyNinja,)", + "Email(cryptolazyninja@stacker.news)", + "RegularText(-)", + "RegularText(ff444d454bc6ba2c16abdfd843124e6ad494297cf424fa81fb0604a24ee188e2)", + "HashTag(#438)", + "RegularText(1%)", + "RegularText(e5ae7bโ€ฆc8b2ef,)", + "RegularText()", + "RegularText(-)", + "RegularText(e5ae7b9cc5177675654400db194878601ee8ff5c355acb85daa50f7551c8b2ef)", + "HashTag(#439)", + "RegularText(1%)", + "RegularText(kimymt,)", + "Email(kimymt@getalby.com)", + "RegularText(-)", + "RegularText(3009318aa9544a2caf401ece529fd772e26cdd7e60349ec175423b302dafd521)", + "HashTag(#440)", + "RegularText(1%)", + "RegularText(z_hq,)", + "RegularText()", + "RegularText(-)", + "RegularText(215e2d416a8663d5b2e44f30d6c46750db7254cdbd2cf87fea4c1549d97486d4)", + "HashTag(#441)", + "RegularText(1%)", + "RegularText(Reza,)", + "RegularText()", + "RegularText(-)", + "RegularText(e7c0d1e42929695b972e90e88fb2210b3567af45206aac51fff85ba011f79093)", + "HashTag(#442)", + "RegularText(1%)", + "RegularText(benderlogic,)", + "Email(benderlogic@rogue.earth)", + "RegularText(-)", + "RegularText(d656ffcaf523f15899db0ea3289d04d00528714651d624814695cabe9cb34114)", + "HashTag(#443)", + "RegularText(1%)", + "RegularText(maestro,)", + "Email(MAESTRO@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(8c3e08bbc47297021be7e6e2c59dab237fab9056b3a5302a8cd2fc2959037466)", + "HashTag(#444)", + "RegularText(1%)", + "RegularText(travis,)", + "Email(travis@west.report)", + "RegularText(-)", + "RegularText(3dc0b75592823507f5f625f889d36ba2607487550b4f38335a603eda010f2bc2)", + "HashTag(#445)", + "RegularText(1%)", + "RegularText(Coffee)", + "RegularText(Lover,)", + "Email(coffeelover@nostrplebs.com)", + "RegularText(-)", + "RegularText(9ecbaa6dc307291c3cf205c8a79ad8174411874cf244ca06f58a5a73e491222c)", + "HashTag(#446)", + "RegularText(1%)", + "RegularText(shadowysuperstore,)", + "Email(shadowysuperstore@shadowysuperstore.com)", + "RegularText(-)", + "RegularText(7abbf3067536c6b70fbc8ac1965e485dce6ebb3d5c125aac248bc0fe906c6818)", + "HashTag(#447)", + "RegularText(1%)", + "RegularText(bhaskar,)", + "RegularText()", + "RegularText(-)", + "RegularText(5beb5d04939db36498e0736003771294317c1c018953d18433276a042bf9a39d)", + "HashTag(#448)", + "RegularText(1%)", + "RegularText(kylum)", + "RegularText(๐ŸŸฃ,)", + "RegularText()", + "RegularText(-)", + "RegularText(e651489d08a27970aac55b222b8a3ea5f3c00419f2976a3cf4006f3add2b6f3c)", + "HashTag(#449)", + "RegularText(1%)", + "RegularText(็‰น็ซ‹็‹ฌ่กŒ็š„ๆŽๅ‘˜ๅค–,)", + "Email(npub1wg2dsjnh0g7phheq23v288k0mj8x75fffmq7rghtkhv53027hnassf4w8t@nost.vip)", + "RegularText(-)", + "RegularText(7214d84a777a3c1bdf205458a39ecfdc8e6f51294ec1e1a2ebb5d948bd5ebcfb)", + "HashTag(#450)", + "RegularText(1%)", + "RegularText(eynhaender,)", + "Email(eynhaender@nostrplebs.com)", + "RegularText(-)", + "RegularText(a21babb54929f10164ca8f8fcca5138d25a892c32fabc8df7d732b8b52b68d82)", + "HashTag(#451)", + "RegularText(1%)", + "RegularText(8340fdโ€ฆ8c7a30,)", + "RegularText()", + "RegularText(-)", + "RegularText(8340fd16fb4414765af8f59192ed68814920e7d33522709de2457490c28c7a30)", + "HashTag(#452)", + "RegularText(1%)", + "RegularText(B1ackSwan,)", + "Email(b1ackswan@nostrplebs.com)", + "RegularText(-)", + "RegularText(1f695a6883cef577dcebf9c60041111772a64e3490cb299c3b97fc81ad3901f4)", + "HashTag(#453)", + "RegularText(1%)", + "RegularText(91dac4โ€ฆ599398,)", + "RegularText()", + "RegularText(-)", + "RegularText(91dac44e3f9d0e3b839aaf7fd81e6c19cf2ce02356fca5096af9e92f58599398)", + "HashTag(#454)", + "RegularText(1%)", + "RegularText(356e99โ€ฆfc3ba8,)", + "RegularText()", + "RegularText(-)", + "RegularText(356e99a0f75e973c0512873cbdce0385df39712653020af825556ceb4afc3ba8)", + "HashTag(#455)", + "RegularText(1%)", + "RegularText(mcdean,)", + "RegularText()", + "RegularText(-)", + "RegularText(54def063abe1657a22cc886eaba75f6636845c601efe9ad56709b4cb3dcc62f1)", + "HashTag(#456)", + "RegularText(1%)", + "RegularText(mrbitcoin,)", + "Email(mrbitc0in@nostrplebs.com)", + "RegularText(-)", + "RegularText(da41332116804e9c4396f6dbb77ec9ad338197993e9d8af18f332e53dcc1bfeb)", + "HashTag(#457)", + "RegularText(1%)", + "RegularText(Jedi,)", + "Email(jedi@nostrplebs.com)", + "RegularText(-)", + "RegularText(246498aa79542482499086f9ab0134750a23047dad0cca38b696750f9ed8072c)", + "HashTag(#458)", + "RegularText(1%)", + "RegularText(CloudNull,)", + "Email(cloudnull@nostrplebs.com)", + "RegularText(-)", + "RegularText(5f53baca8cb88a18320a032957bf0b6f8dc8b33db007310b0e2f573edf2703a3)", + "HashTag(#459)", + "RegularText(1%)", + "RegularText(Mrwh0,)", + "Email(Mrwh0@Mrwh0.github.io)", + "RegularText(-)", + "RegularText(d8dd77e3dff24bd8c2da9b4c4fb321f5f99e8713bad40dd748ab59656b5ed27d)", + "HashTag(#460)", + "RegularText(1%)", + "RegularText(shinohai,)", + "Email(shinohai@iris.to)", + "RegularText(-)", + "RegularText(4bc7982c4ee4078b2ada5340ae673f18d3b6a664b1f97e8d6799e6074cb5c39d)", + "HashTag(#461)", + "RegularText(1%)", + "RegularText(awoi,)", + "Email(awoi@iris.to)", + "RegularText(-)", + "RegularText(edc083016d344679566ae8205b362530ecbafc6e064e224a0c2df1850cecfb4a)", + "HashTag(#462)", + "RegularText(1%)", + "RegularText(TheShopRat,)", + "RegularText()", + "RegularText(-)", + "RegularText(8362e77d9fd268720a15840af33fd9ab5cdf13fabc66f0910111580960cd297a)", + "HashTag(#463)", + "RegularText(1%)", + "RegularText(Dajjal,)", + "RegularText()", + "RegularText(-)", + "RegularText(614aee83d7eaffc7bc6bbf02feda0cc53e7f97eeceac08a897c4cea3c023b804)", + "HashTag(#464)", + "RegularText(1%)", + "RegularText(felipe,)", + "RegularText()", + "RegularText(-)", + "RegularText(0ee8894f1f663fd76b682c16e6a92db0fe14ada98db35b4a4cfa5f9068be0b3a)", + "HashTag(#465)", + "RegularText(1%)", + "RegularText(crypt0-j3sus,)", + "RegularText()", + "RegularText(-)", + "RegularText(9a7b7cbe37b2caa703062c51b207eb6ec4c42d06bfa909d979aa2d5005ac3d65)", + "HashTag(#466)", + "RegularText(1%)", + "RegularText(Just)", + "RegularText(J,)", + "Email(jcope101@nostrplebs.com)", + "RegularText(-)", + "RegularText(5f6f376733b1a8682a0f330e07b6a6064d738fdd8159db6c8df44c6c9419ff88)", + "HashTag(#467)", + "RegularText(1%)", + "RegularText(mmasnick,)", + "RegularText()", + "RegularText(-)", + "RegularText(4d53de27a24feb84d6383962e350219fc09e572c22a17c542545a69cd35b067f)", + "HashTag(#468)", + "RegularText(1%)", + "RegularText(Murmur,)", + "Email(murmur@nostrplebs.com)", + "RegularText(-)", + "RegularText(f7e84b92a5457546894daedaff9abd66f3d289f92435d6ac068a33cb170b01a4)", + "HashTag(#469)", + "RegularText(1%)", + "RegularText(JD,)", + "RegularText()", + "RegularText(-)", + "RegularText(1a9ba80629e2f8f77340ac13e67fdb4fcc66f4bb4124f9beff6a8c75e4ce29b0)", + "HashTag(#470)", + "RegularText(1%)", + "RegularText(dario,)", + "Email(dario@nostrplebs.com)", + "RegularText(-)", + "RegularText(d9987652d3cbb2c0fa39b6305cc0f2d03ca987afc1e56bc97a81c79e138152a8)", + "HashTag(#471)", + "RegularText(1%)", + "RegularText(leonwankum,)", + "RegularText(@leonawankum@BitcoinNostr.com)", + "RegularText()", + "RegularText(-)", + "RegularText(652d58acafa105af8475c0fe8029a52e7ddbc337b2bd9c98bb17a111dc4cde60)", + "HashTag(#472)", + "RegularText(1%)", + "RegularText(phil,)", + "Email(phil@iris.to)", + "RegularText(-)", + "RegularText(8352b55a828a60bb0e86b0ac9ef1928999ebe636c905dcbe0cd3c0f95c61b83b)", + "HashTag(#473)", + "RegularText(1%)", + "RegularText(hkmccullough,)", + "Email(thatirdude@nostrplebs.com)", + "RegularText(-)", + "RegularText(836059a05aeb8498dd53a0d422e04aced6b4b71eb3621d312626c46715d259d8)", + "HashTag(#474)", + "RegularText(1%)", + "RegularText(BitBox,)", + "RegularText()", + "RegularText(-)", + "RegularText(5a3de28ffd09d7506cff0a2672dbdb1f836307bcff0217cc144f48e19eea3fff)", + "HashTag(#475)", + "RegularText(1%)", + "RegularText(5eff6cโ€ฆ60bd07,)", + "RegularText()", + "RegularText(-)", + "RegularText(5eff6c1205c9db582863978b5b2e9c9aa73a57e6c1df526fddc2b9996060bd07)", + "HashTag(#476)", + "RegularText(1%)", + "RegularText(nobody,)", + "RegularText()", + "RegularText(-)", + "RegularText(2e472c6d072c0bcc28f1b260e0fc309f1f919667d238f4e703f8f1db0f0eb424)", + "HashTag(#477)", + "RegularText(1%)", + "RegularText(K_hole,)", + "Email(K_hole@ketamine.com)", + "RegularText(-)", + "RegularText(5ac74532e23b7573f8f6f3248fe5174c0b7230aec0b653c0ec8f11d540209fd7)", + "HashTag(#478)", + "RegularText(1%)", + "RegularText(bitcoinIllustrated,)", + "RegularText()", + "RegularText(-)", + "RegularText(90fb6b9607bba40686fe70aad74a07e5af96d152778f3a09fcda5967dcb0daba)", + "HashTag(#479)", + "RegularText(1%)", + "RegularText(kingfisher,)", + "RegularText()", + "RegularText(-)", + "RegularText(33d4c61d7354e1d5872e26218eda73170646d12a8e7b9cb6d3069a7058ebabfd)", + "HashTag(#480)", + "RegularText(1%)", + "RegularText(cfc11eโ€ฆb4f6e4,)", + "RegularText()", + "RegularText(-)", + "RegularText(cfc11ef4b31e2ab18261a71b79097c60199f532605a0c3aa73ad36acc6b4f6e4)", + "HashTag(#481)", + "RegularText(1%)", + "RegularText(d06848โ€ฆ2f86b3,)", + "RegularText()", + "RegularText(-)", + "RegularText(d06848a9ea53f9e9c15cafaf41b1729d6d7b84083cfbac2c76a0506dd72f86b3)", + "HashTag(#482)", + "RegularText(1%)", + "RegularText(nostrceo,)", + "RegularText()", + "RegularText(-)", + "RegularText(3159e1a148ca235cb55365a2ffde608b17e84c4c3bff6ed309f3e320307d5ab3)", + "HashTag(#483)", + "RegularText(1%)", + "RegularText(Lokuyow2,)", + "Email(2@lokuyow.github.io)", + "RegularText(-)", + "RegularText(f5f02030cb4b22ed15c3d7cc35ae616e6ce6bb3fa537f6e9e91aaa274b9cd716)", + "HashTag(#484)", + "RegularText(1%)", + "RegularText(fatushi,)", + "RegularText()", + "RegularText(-)", + "RegularText(49a458319060806221990e90e6bf2b1654201f08a40828d1a5d215a85f449df0)", + "HashTag(#485)", + "RegularText(1%)", + "RegularText(Omnia,)", + "RegularText()", + "RegularText(-)", + "RegularText(026d2251aa211684ef63e7a28e21c611c087bb3131a9c90b11dff6c16d68ce77)", + "HashTag(#486)", + "RegularText(1%)", + "RegularText(joey,)", + "RegularText()", + "RegularText(-)", + "RegularText(5f8a5bbf8d26104547a3942e82d7a5159554b3a5a3bc1275c47674b5e8c4c1d7)", + "HashTag(#487)", + "RegularText(1%)", + "RegularText(Hazey,)", + "Email(hazey@iris.to)", + "RegularText(-)", + "RegularText(800e0fe3d8638ce3f75a56ed865df9d96fc9d9cd2f75550df0d7f5c1d8468b0b)", + "HashTag(#488)", + "RegularText(1%)", + "RegularText(Milad)", + "RegularText(Younis,)", + "RegularText()", + "RegularText(-)", + "RegularText(64c24e0991f9bb6f59f9da486ba29242bc562b09ce051882f7b3bcc7fd055227)", + "HashTag(#489)", + "RegularText(1%)", + "RegularText(jlgalley,)", + "RegularText()", + "RegularText(-)", + "RegularText(920535dd1487975ccc75ed82b7b4753260ec4041dcf9ce24657623164f6586e3)", + "HashTag(#490)", + "RegularText(1%)", + "RegularText(paulgallo28,)", + "RegularText()", + "RegularText(-)", + "RegularText(690af9eed15cc3a7439c39b228bf194da134f75d64f40114a41d77bff6a60699)", + "HashTag(#491)", + "RegularText(1%)", + "RegularText(HeineNon,)", + "Email(HeineNon@tomottodx.github.io)", + "RegularText(-)", + "RegularText(64c66c231ea1c25ebd66b14fe4a0b1b39a6928d6824ad43e035f54aa667bc650)", + "HashTag(#492)", + "RegularText(1%)", + "RegularText(a9b9adโ€ฆ2b9f4c,)", + "RegularText()", + "RegularText(-)", + "RegularText(a9b9ad000e2ada08326bbcc1836effcdfa4e64b9c937e406fe5912dc562b9f4c)", + "HashTag(#493)", + "RegularText(1%)", + "RegularText(legxxi,)", + "RegularText()", + "RegularText(-)", + "RegularText(8476d0dcdb53f1cc67efc8d33f40104394da2d33e61369a8a8ade288036977c6)", + "HashTag(#494)", + "RegularText(1%)", + "RegularText(99f1b7โ€ฆ559c31,)", + "RegularText()", + "RegularText(-)", + "RegularText(99f1b7b39201d0e142f9ec3c8101b6be0eee8a389d16d53667ca4f57b1559c31)", + "HashTag(#495)", + "RegularText(1%)", + "RegularText(mbz,)", + "RegularText()", + "RegularText(-)", + "RegularText(e5195850d4fed08183f0b274ca30777094daad67be235a5cd15548b9b0341031)", + "HashTag(#496)", + "RegularText(1%)", + "RegularText(Titan,)", + "Email(titan@nostrplebs.com)", + "RegularText(-)", + "RegularText(672b1637bd65b6206c7a603158c2ecee15599648e10dd15a82f2fcb4e47735bf)", + "HashTag(#497)", + "RegularText(1%)", + "RegularText(Highlandhodl,)", + "RegularText()", + "RegularText(-)", + "RegularText(f0c74190cd05d85d843cdc5f355afe0fbac6d30d18da91243d6cae30a69713f7)", + "HashTag(#498)", + "RegularText(1%)", + "RegularText(CodeWarrior,)", + "RegularText()", + "RegularText(-)", + "RegularText(21a7014db2ba17acc8bbb9496645084866b46e1ba0062a80513afda405450183)", + "HashTag(#499)", + "RegularText(1%)", + "SchemelessUrl(baller.hodl,)", + "RegularText()", + "RegularText(-)", + "RegularText(d8150dc0631f834a004f231f0747d5ec8409b1a9214d246f675dfef39807a224)", + "HashTag(#500)", + "RegularText(1%)", + "RegularText(Now)", + "RegularText(Playing)", + "RegularText(on)", + "RegularText(GMโ‚ฟ,)", + "RegularText()", + "RegularText(-)", + "RegularText(9c6907de72e59daf5272103a34649bf7ca01050a68f402955520fc53dba9730d)", + "RegularText()", + "RegularText(Inspector monitor)", + "RegularText()", + "RegularText(New events inspected today: 720.71K (4.85GB))", + "RegularText(Average events inspected per second: 8.34)", + "RegularText(Uptime: Server 99.93%, NostrInspector: 99.93%)", + "RegularText(Spam estimate: )", + "RegularText(74.12 %)", + "RegularText()", + "RegularText(About the NostrInspector Report)", + "RegularText()", + "RegularText(โœ… The 24 Hour NostrInspector Report is generated by listening for new events on the top relays using the Nostr Protocol. The statistics report that )", + "RegularText(it generates includes de data layer as well as the social layer.)", + "RegularText(๐Ÿ’œ To support this free effort share, like, comment or zap.)", + "RegularText(๐Ÿซ‚ Thank you ๐Ÿ™ )", + "RegularText()", + "RegularText(๐Ÿ•ต๏ธ @nostrin \"The Nostr Inspector\" )", + "Bech(npub17m7f7q08k4x746s2v45eyvwppck32dcahw7uj2mu5txuswldgqkqw9zms7)", + ) - @Test - fun testShortNewLinesTextToParse() { - val state = RichTextParser().parseText("\nHi, \nhow\n\n\n are you doing? \n", EmptyTagList) - Assert.assertTrue(state.urlSet.isEmpty()) - Assert.assertTrue(state.imagesForPager.isEmpty()) - Assert.assertTrue(state.imageList.isEmpty()) - Assert.assertTrue(state.customEmoji.isEmpty()) - Assert.assertEquals( - "\nHi, \nhow\n\n\n are you doing? \n", - state.paragraphs.joinToString("\n") { it.words.joinToString(" ") { it.segmentText } }, - ) - } + state.paragraphs + .map { it.words } + .flatten() + .forEachIndexed { index, seg -> + Assert.assertEquals( + expectedResult[index], + "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", + ) + } - @Test - fun testMultiLine() { - val text = - """ + Assert.assertTrue(state.imagesForPager.isEmpty()) + Assert.assertTrue(state.imageList.isEmpty()) + Assert.assertTrue(state.customEmoji.isEmpty()) + Assert.assertEquals(651, state.paragraphs.size) + } + + @Test + fun testShortTextToParse() { + val state = RichTextParser().parseText("Hi, how are you doing? ", EmptyTagList) + Assert.assertTrue(state.urlSet.isEmpty()) + Assert.assertTrue(state.imagesForPager.isEmpty()) + Assert.assertTrue(state.imageList.isEmpty()) + Assert.assertTrue(state.customEmoji.isEmpty()) + Assert.assertEquals( + "Hi, how are you doing? ", + state.paragraphs.firstOrNull()?.words?.firstOrNull()?.segmentText, + ) + } + + @Test + fun testShortNewLinesTextToParse() { + val state = RichTextParser().parseText("\nHi, \nhow\n\n\n are you doing? \n", EmptyTagList) + Assert.assertTrue(state.urlSet.isEmpty()) + Assert.assertTrue(state.imagesForPager.isEmpty()) + Assert.assertTrue(state.imageList.isEmpty()) + Assert.assertTrue(state.customEmoji.isEmpty()) + Assert.assertEquals( + "\nHi, \nhow\n\n\n are you doing? \n", + state.paragraphs.joinToString("\n") { it.words.joinToString(" ") { it.segmentText } }, + ) + } + + @Test + fun testMultiLine() { + val text = + """ Did you know you can embed #Nostr live streams into #Nostr long-form posts? Sounds like an obvious thing, but it's only supported by nostr:npub1048qg5p6kfnpth2l98kq3dffg097tutm4npsz2exygx25ge2k9xqf5x3nf at the moment. See how it can be done here: https://lnshort.it/live-stream-embeds/ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg """ - .trimIndent() + .trimIndent() - val state = RichTextParser().parseText(text, EmptyTagList) - Assert.assertEquals("https://lnshort.it/live-stream-embeds/", state.urlSet.firstOrNull()) - Assert.assertEquals( - "https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg", - state.imagesForPager.keys.firstOrNull(), - ) - Assert.assertEquals( - "https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg", - state.imageList.firstOrNull()?.url, - ) - Assert.assertTrue(state.customEmoji.isEmpty()) - - printStateForDebug(state) - - val expectedResult = - listOf( - "RegularText(Did)", - "RegularText(you)", - "RegularText(know)", - "RegularText(you)", - "RegularText(can)", - "RegularText(embed)", - "HashTag(#Nostr)", - "RegularText(live)", - "RegularText(streams)", - "RegularText(into)", - "HashTag(#Nostr)", - "RegularText(long-form)", - "RegularText(posts?)", - "RegularText(Sounds)", - "RegularText(like)", - "RegularText(an)", - "RegularText(obvious)", - "RegularText(thing,)", - "RegularText(but)", - "RegularText(it's)", - "RegularText(only)", - "RegularText(supported)", - "RegularText(by)", - "Bech(nostr:npub1048qg5p6kfnpth2l98kq3dffg097tutm4npsz2exygx25ge2k9xqf5x3nf)", - "RegularText(at)", - "RegularText(the)", - "RegularText(moment.)", - "RegularText()", - "RegularText(See)", - "RegularText(how)", - "RegularText(it)", - "RegularText(can)", - "RegularText(be)", - "RegularText(done)", - "RegularText(here:)", - "Link(https://lnshort.it/live-stream-embeds/)", - "RegularText()", - "Image(https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg)", - ) - - state.paragraphs - .map { it.words } - .flatten() - .forEachIndexed { index, seg -> + val state = RichTextParser().parseText(text, EmptyTagList) + Assert.assertEquals("https://lnshort.it/live-stream-embeds/", state.urlSet.firstOrNull()) Assert.assertEquals( - expectedResult[index], - "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", + "https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg", + state.imagesForPager.keys.firstOrNull(), ) - } - } - - @Test - fun testNewLineAfterImage() { - val text = - "Thatโ€™s it ! Thatโ€™s the #note https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg " - - val state = RichTextParser().parseText(text, EmptyTagList) - - printStateForDebug(state) - - val expectedResult = - listOf( - "RegularText(Thatโ€™s)", - "RegularText(it)", - "RegularText(!)", - "RegularText(Thatโ€™s)", - "RegularText(the)", - "HashTag(#note)", - "Image(https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg)", - ) - - state.paragraphs - .map { it.words } - .flatten() - .forEachIndexed { index, seg -> Assert.assertEquals( - expectedResult[index], - "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", + "https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg", + state.imageList.firstOrNull()?.url, ) - } - } + Assert.assertTrue(state.customEmoji.isEmpty()) - @Test - fun testSapceAfterImage() { - val text = - "Thatโ€™s it! https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg Thatโ€™s the #note" + printStateForDebug(state) - val state = RichTextParser().parseText(text, EmptyTagList) + val expectedResult = + listOf( + "RegularText(Did)", + "RegularText(you)", + "RegularText(know)", + "RegularText(you)", + "RegularText(can)", + "RegularText(embed)", + "HashTag(#Nostr)", + "RegularText(live)", + "RegularText(streams)", + "RegularText(into)", + "HashTag(#Nostr)", + "RegularText(long-form)", + "RegularText(posts?)", + "RegularText(Sounds)", + "RegularText(like)", + "RegularText(an)", + "RegularText(obvious)", + "RegularText(thing,)", + "RegularText(but)", + "RegularText(it's)", + "RegularText(only)", + "RegularText(supported)", + "RegularText(by)", + "Bech(nostr:npub1048qg5p6kfnpth2l98kq3dffg097tutm4npsz2exygx25ge2k9xqf5x3nf)", + "RegularText(at)", + "RegularText(the)", + "RegularText(moment.)", + "RegularText()", + "RegularText(See)", + "RegularText(how)", + "RegularText(it)", + "RegularText(can)", + "RegularText(be)", + "RegularText(done)", + "RegularText(here:)", + "Link(https://lnshort.it/live-stream-embeds/)", + "RegularText()", + "Image(https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg)", + ) - printStateForDebug(state) + state.paragraphs + .map { it.words } + .flatten() + .forEachIndexed { index, seg -> + Assert.assertEquals( + expectedResult[index], + "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", + ) + } + } - val expectedResult = - listOf( - "RegularText(Thatโ€™s)", - "RegularText(it!)", - "Image(https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg)", - "RegularText(Thatโ€™s)", - "RegularText(the)", - "HashTag(#note)", - ) + @Test + fun testNewLineAfterImage() { + val text = + "Thatโ€™s it ! Thatโ€™s the #note https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg " - state.paragraphs - .map { it.words } - .flatten() - .forEachIndexed { index, seg -> - Assert.assertEquals( - expectedResult[index], - "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", - ) - } - } + val state = RichTextParser().parseText(text, EmptyTagList) - @Test - fun testUrlsEndingInPeriod() { - val text = "Thatโ€™s it! http://vitorpamplona.com/. Thatโ€™s the note" + printStateForDebug(state) - val state = RichTextParser().parseText(text, EmptyTagList) + val expectedResult = + listOf( + "RegularText(Thatโ€™s)", + "RegularText(it)", + "RegularText(!)", + "RegularText(Thatโ€™s)", + "RegularText(the)", + "HashTag(#note)", + "Image(https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg)", + ) - printStateForDebug(state) + state.paragraphs + .map { it.words } + .flatten() + .forEachIndexed { index, seg -> + Assert.assertEquals( + expectedResult[index], + "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", + ) + } + } - val expectedResult = - listOf( - "RegularText(Thatโ€™s)", - "RegularText(it!)", - "Link(http://vitorpamplona.com/.)", - "RegularText(Thatโ€™s)", - "RegularText(the)", - "RegularText(note)", - ) + @Test + fun testSapceAfterImage() { + val text = + "Thatโ€™s it! https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg Thatโ€™s the #note" - state.paragraphs - .map { it.words } - .flatten() - .forEachIndexed { index, seg -> - Assert.assertEquals( - expectedResult[index], - "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", - ) - } - } + val state = RichTextParser().parseText(text, EmptyTagList) - private fun printStateForDebug(state: RichTextViewerState) { - state.paragraphs.forEach { paragraph -> - paragraph.words.forEach { seg -> - println( - "\"${ + printStateForDebug(state) + + val expectedResult = + listOf( + "RegularText(Thatโ€™s)", + "RegularText(it!)", + "Image(https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg)", + "RegularText(Thatโ€™s)", + "RegularText(the)", + "HashTag(#note)", + ) + + state.paragraphs + .map { it.words } + .flatten() + .forEachIndexed { index, seg -> + Assert.assertEquals( + expectedResult[index], + "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", + ) + } + } + + @Test + fun testUrlsEndingInPeriod() { + val text = "Thatโ€™s it! http://vitorpamplona.com/. Thatโ€™s the note" + + val state = RichTextParser().parseText(text, EmptyTagList) + + printStateForDebug(state) + + val expectedResult = + listOf( + "RegularText(Thatโ€™s)", + "RegularText(it!)", + "Link(http://vitorpamplona.com/.)", + "RegularText(Thatโ€™s)", + "RegularText(the)", + "RegularText(note)", + ) + + state.paragraphs + .map { it.words } + .flatten() + .forEachIndexed { index, seg -> + Assert.assertEquals( + expectedResult[index], + "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", + ) + } + } + + private fun printStateForDebug(state: RichTextViewerState) { + state.paragraphs.forEach { paragraph -> + paragraph.words.forEach { seg -> + println( + "\"${ seg.javaClass.simpleName.replace( "Segment", "", ) }(${seg.segmentText.replace("\n", "\\n").replace("\"", "\\")})\",", - ) - } + ) + } + } } - } } diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/ThreadAssemblerTest.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/ThreadAssemblerTest.kt index bf89e5385..9c878d65e 100644 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/ThreadAssemblerTest.kt +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/ThreadAssemblerTest.kt @@ -38,8 +38,8 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ThreadAssemblerTest { - val db = - """ + val db = + """ [ {"id":"741f5367a9415f4d6f19c0f57a1e4647c8ed8309b53b0da2d82fc4ebfba03b2c","pubkey":"d85c99afd244911e0aaf800cbea4221df557f06f8a4ff2cbe84b24e0b9e728fc","created_at":1684674845,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Getting my head around this","sig":"069874040bac26a219777fc0f90b8f4df71e38c30e3e6a953d53222499d8e0c5a8f32c6b4204d14eb335bb654f01c5610372d9dc00062284b8e0f2bb98c7ed85"}, {"id":"22323fc72b4c37f93ea21f6069684339ce5f63111161c81f2aa3de4a21bfe83b","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1683571810,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"test","sig":"f6d3bcf0f8e07d06720a2527f95910ee5a66aa889dd9261514921a155bbc3e9a3c7d721f5a2ec948cd795183f85dbe80f2ee2e36663f9d1de33fc03274ccb9d9"}, @@ -80,10 +80,10 @@ class ThreadAssemblerTest { {"id":"ce6e32e3e17b6901d2cc70b60f3743e24f885bb6e9da6d88cff516079eac1883","pubkey":"726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11","created_at":1688234921,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","512962dbada5fd5015fc727a107d5c3f569662de67eab8e5da5a8065012cf11e","","reply"]],"content":"yet another one, testing 0.0.3","sig":"13bd41c4029c6a7ee41cb03d12e831e9e9b6e14d43a61c78a72657070b45385ba2ce98e8121049fbcbdb7b2dd777c36c7867ba70b5e6a4798a9b866910ae5b62"} ] """ - .trimIndent() + .trimIndent() - val header = - """ + val header = + """ { "content": "Not too long ago, I tried to paint a picture of what\na [vision for a value-enabled web][vew]\ncould look like. Now, only a couple of months later,\nall this stuff is being built. On nostr, and on lightning. Orange and\npurple, a match made in heaven.\n\nIt goes without saying that I'm beyond delighted. What a time to be alive!\n\n## nostr\n\nHere's the thing that nostr got right, and it's the same thing that\nBitcoin got right: information is easy to spread and hard to stifle.[^fn-stifle]\nInformation can be copied quickly and perfectly, which is, I believe,\nthe underlying reason for its desire to be free.\n\n[^fn-stifle]: That's a [Satoshi quote][stifle], of course: \"Bitcoin's solution is to use a peer-to-peer network to check for double-spending. In a nutshell, the network works like a distributed timestamp server, stamping the first transaction to spend a coin. It takes advantage of the nature of information being easy to spread but hard to stifle.\"\n\n[stifle]: https://satoshi.nakamotoinstitute.org/posts/p2pfoundation/1/\n\nEasy to spread, hard to stifle. That's the base reality of the nature\nof information. As always, the smart thing is to work with nature, not\nagainst it.[^1] That's what's beautiful about the orange coin and\nthe purple ostrich: both manage to work with the peculiarities of\ninformation, not against them. Both realize that information can and should be\ncopied, as it can be perfectly read and easily spread, always. Both understand\nthat resistance to censorship comes from writing to many places, making the cost\nof deletion prohibitive.\n\n> Information does not just want to be free,\n> it longs to be free. Information expands to fill the available\n> storage space. Information is Rumor's younger, stronger cousin;\n> Information is fleeter of foot, has more eyes, knows more, and\n> understands less than Rumor.\n>\n> Eric Hughes, [A Cypherpunk's Manifesto][manifesto]\n\n[manifesto]: https://nakamotoinstitute.org/static/docs/cypherpunk-manifesto.txt\n\nNostr is quickly establishing itself as a base layer for information exchange,\none that is identity-native and value-enabled. It is distinctly different from\nsystems that came before it, just like Bitcoin is distinctly different from\nmonies that came before it.\n\nAs of today, the focus of nostr is mostly on short text notes, the so-called\n\"type 1\" events more commonly known as *tweets*.[^fn-kinds] However, as you should be aware\nby now, nostr is way more than just an alternative to twitter. It is a new\nparadigm. Change the note kind from `1` to `30023` and you don't have an\nalternative to Twitter, but a replacement for Medium, Substack, and all the\nother long-form platforms. I believe that special-purpose clients that focus on\ncertain content types will emerge over time, just like we have seen the\nemergence of special-purpose platforms in the Web 2.0 era. This time, however,\nthe network effects are cumulative, not separate. A new paradigm.\n\nLet me now turn to one such special-purpose client, a nostr-based reading app.\n\n[^fn-kinds]: Refer to the various NIPs to discover the multitude of [event kinds][kinds] defined by the protocol.\n\n[kinds]: https://github.com/nostr-protocol/nips#event-kinds\n[nip23]: https://github.com/nostr-protocol/nips/blob/master/23.md\n\n## Reading\n\nI'm constantly surprised that, even though most people do read a lot\nonline, very few people seem to have a reading workflow or reading\ntools.\n\nWhy that is is anyone's guess, but maybe the added value of such tools\nis not readily apparent. You can just read the stuff right there, on the\nad-ridden, dead-ugly site, right? Why should you sign up for another\nsite, use another app, or bind yourself to another closed platform?\n\nThat's a fair point, but the success of Medium and Substack shows that\nthere is an appetite for clean reading and writing, as well as providing\navenues for authors to get paid for their writing (and a willingness of\nreaders to support said authors, just because).\n\nThe problem is, of course, that all of these platforms areย *platforms*,\nwhich is to say, walled gardens that imprison readers and writers alike.\nWorse than that: they are fiat platforms, which means that\npermissionless value-flows are not only absent from their DNA, they are\noutright impossible.[^2]\n\nNostr fixes this.\n\n![Nostriches like to read, or so I've heard](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/nostrich-reading-a-newspaper.jpg)\n\nThe beauty of nostr is that it is not a platform. It's a protocol,\nwhich means that you don't have to sign up for it---you can create an\nidentity yourself. You don't have to ask for permission; you just *do*,\nwithout having to rely on the benevolence of whatever dictator is in\ncharge of the platform right now.\n\nNostr isย *not*ย a platform, and yet, powerful tools and services can be\nbuilt and monetized on top of it. This is good for users, good for\nservice providers, and good for the network(s) at large. Win-win-win.\n\nSo what am I talking about, exactly? How can nostr improve everyone's\nreading (and writing) experience?\n\nAllow me to paint a (rough) picture of what I have in mind. Nostr\nalready supports private and public bookmarks, so let's start from\nthere.\n\nImagine a special-purpose client that scans all your bookmarks for long-form\ncontent.[^fn-urls] Everything that you marked to be read later is shown in an orderly\nfashion, which is to say searchable, sortable, filterable, and displayed without\ndistractions. Voilร , you have yourself a reading app. That's, in essence, how\nPocket, Readwise, and other reading apps work. But all these apps are walled\ngardens without much interoperability and without direct monetization.\n\n[^fn-urls]: In the nostr world long-form content is simply markdown as defined in [NIP-23][nip23], but it could also be a link to an article or PDF, which in turn could get [converted into markdown][readability] and posted as an event to a special relay.\n\n[readability]: https://github.com/mozilla/readability\n\nBitcoin fixes the direct monetization part.[^fn-v4v] Nostr fixes the interoperability part.\n\n[^fn-v4v]: ...because Bitcoin makes [V4V][busking] practical. (Paywalls are not the way.)\n\nAlright, we got ourselves a boring reading app. Great. Now, imagine that\nusers are able to highlight passages. These highlights, just like\nbookmarks now, could be private or public. When shared publicly,\nsomething interesting emerges: an overlay on existing content, a lens on\nthe written Web. In other words:ย *swarm highlights*.\n\nImagine a visual overlay of all public highlights, automatically shining\na light on what the swarm of readers found most useful, insightful,\nfunny, etc.\n\n![Swarm Highlights](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/highlights.png)\n\nFurther, imagine the possibility of sharing these highlights as aย \"type 1\"ย event\nwith one click, automatically tagging the highlighter(s)---as well as the\nauthor, of course---so that eventual sat-flows can be split and forwarded\nautomatically.\n\n![Automated value splits](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/sat-flows.png)\n\nVoilร , you have a system that allows for value to flow back to those who\nprovide it, be it authors, editors, curators, or readers that willingly\nslog through the information jungle to share and highlight the best\nstuff (which is a form of curation, of course).\n\nZaps make nostr a defacto address book[^fn-pp] of payment information, which is\nto say lightning addresses, as of now. Thanks to [nostr wallet connect][nwc] (among\nother developments), sending sats ~~will soon be~~ is already as\nfrictionless as leaving a like.\n\n[^fn-pp]: The Yellow Pages are dead, long live [The Purple Pages](http://purplepag.es/)!\n\nValue-for-value and participatory payment flows are something that\ntraditional reading apps desperately lack, be it Pocket, Instapaper,\nReadwise, or the simple reading mode that is part of every browser.\n\nA neat side-effect of a more structured way to share passages of text is\nthat it enables semi-structured discussions around said\npassages---which could be another useful overlay inside\nspecial-purpose clients, providing context and further insights.[^5]\n\nFurther, imagine the option of seamlessly switching from text-on-screen\nto text-to-speech, allowing the user to stream sats if desired, as\nPodcasting 2.0 clients already do.[^3]\n\nImagine user-built curations of the best articles of the week, bundled\nneatly for your reading pleasure, incentivized by a small value split\nthat allows the curator to participate in the flow of sats.\n\nYou get the idea.\n\nI'm sure that the various implementation details will be hashed out,\nbut as I see it, 90% of the stuff is already there. Maybe we'll need\nanother NIP or two, but I don't see a reason why this can't be\nbuilt---and, more importantly: I don't see a reason why it wouldn't\nbe sustainable for everyone involved.\n\nMost puzzle pieces are already there, and the rest of them can probably\nbe implemented by custom event types. From the point of view of nostr,\nmost everything is an event: bookmarks are events, highlights are\nevents, marking something as read is an event, and sharing an excerpt or\na highlight is an event. Public actions are out in the open, private\nactions are encrypted, the data is not in a silo, and everyone wins.\nEspecially the users, those who are at the edge of the network and\nusually lose out on the value generated.\n\nIn this case, the reading case, the users are mostly \"consumers\" of\ncontent. What changes from the producing perspective, the perspective of\nthe writer?\n\n## Writing\n\nBack to the one thing that nostr got right: information is easy to\nspread but hard to stifle. In addition to that, digital information can\nbe copied perfectly, which is why it shouldn't matter where stuff is\npublished in the first place.\n\nAllow me to repeat this point in all caps, for emphasis:ย **IT SHOULD NOT\nMATTER WHERE INFORMATION IS PUBLISHED**, and, maybe even more\nimportantly, it shouldn't matter if it is published in a hundred\ndifferent places at once.[^fn-torrents]\n\nWhat matters is trust and accuracy, which is to say, digital signatures\nand reputation. To translate this to nostr speak: because every event is\nsigned by default, as long as you trust the person behind the signature,\nit doesn't matter from which relay the information is fetched.\n\nThis is already true (or mostly true) on the regular web. Whether you\nread the internet archive version of an article or the version that is\npublished by an online magazine, the version on the author's website,\nor the version read by some guy that has read more about Bitcoin than\nanyone else you know[^fn-guy]---it's all the same, essentially. What matters\nis the information itself.\n\n[^fn-guy]: There is only one such guy, as we all know, and it's this Guy: nostr:npub1h8nk2346qezka5cpm8jjh3yl5j88pf4ly2ptu7s6uu55wcfqy0wq36rpev\n\nPractically speaking, the source of truth in a hypernostrized world is---you\nguessed it---an event. An event signed by the author, which allows for\nthe information to be wrapped in a tamper-proof manner, which in turn\nallows the information to spread far and wide---without it being\nhosted in one place.\n\nThe first clients that focus on long-form content already exist, and I expect\nmore clients to pop up over time.[^4] As mentioned before, one could easily\nimagine [prism-like value splits][prism] seamlessly integrated into these\nclients, splitting zaps automatically to compensate writers, editors,\nproofreaders, and illustrators in a V4V fashion. Further, one could imagine\nvarious compute-intensive services built into these special-purpose clients,\nsuch as GPT Ghostwriters, or writing aids such as Grammarly and the like. All\nthese services could be seamlessly paid for in sats, without the requirement of\nany sign-ups or the gathering of any user data. That's the beauty of [money\nproper][rediscovery].\n\n![A clean and simple reading and writing interface](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/nostr-reader-and-writer.png)\n\nPlagiarism is one issue that needs to be dealt with, of course. Humans\nare greedy, and some humans are assholes. Neither bitcoin nor nostr\nfixes this. However, while plagiarism detection is not necessarily\ntrivial, it is also not impossible, especially if most texts are\npublished on nostr first. Nostr-based publishing tools allow for\nOpenTimestamp attestations thanks\ntoย [NIP-03](https://github.com/nostr-protocol/nips/blob/master/03.md),\nwhich in turn allows for plagiarism detection based on \"first seen\"\nlookups.\n\nThat's just one way to deal with the problem, of course. In any case,\nI'm confident that we'll figure it out.\n\n## Value\n\nI believe that in the open ~~attention~~ information economy we find\nourselves in, value will mostly derive from effective curation,\ndissemination, and transmission of information, *not* the exclusive\nownership of it.\n\nAlthough it is still early days,\ntheย [statistics](https://stats.podcastindex.org/v4v)ย around Podcasting\n2.0 andย [nostr zaps](https://zaplife.lol/)ย clearly show that (a) people\nare willing to monetarily reward content they care about, and (b) the\nwillingness to send sats *increases* as friction *decreases*.\n\nThe ingenious thing about boostagrams and zaps is that they are direct\nand visible, which is to say, public and interactive. They are neither\nregular transactions nor simple donations---they are something else\nentirely. An unforgable value signal, a special form of gratitude and\nappreciation.\n\nContrast that with a link to Paypal or Patreon: impersonal, slow,\nindirect, and friction-laden. It's the opposite of a super-charged\ninteraction.\n\nWhile today's information jungle increasingly presents itself in the\nform of (short) videos and (long-form) audio, I believe that we will see\na renaissance of the written word, especially if we manage to move away\nfrom an economy built around attention, towards an economy built upon\nvalue and insight.\n\nThe orange future now has a purple hue, and I believe that it will be as\nbright as ever. We just have a lot of building to do.\n\n---\n\n## Further Reading\n\n- [A Vision for a Value-Enabled Web][vew]\n- [The Freedom of Value][busking]\n- [The Rediscovery of Money][prism]\n- [Lightning Prisms][rediscovery]\n\n[vew]: https://dergigi.com/vew\n[prism]: https://dergigi.com/prism\n[rediscovery]: https://dergigi.com/rediscovery\n[busking]: https://dergigi.com/busking\n\n## NIPs and Resources\n\n- [Nostr Resources][nr]\n- [value4value.info](https://value4value.info/)\n- [nips.be](https://nips.be/)\n- [NIP-23: Long-form content](https://github.com/nostr-protocol/nips/blob/master/23.md)\n- [NIP-57: Event-specific zap markers](https://github.com/nostr-protocol/nips/blob/master/57.md)\n- [NIP-47: Nostr Wallet Connect](https://github.com/getAlby/nips/blob/master/47.md)\n- [NIP-03: OpenTimestamps attestations for events](https://github.com/nostr-protocol/nips/blob/master/03.md)\n\nOriginally published on [dergigi.com](https://dergigi.com/reader)\n\n---\n\n[^1]: Paywalls work against this nature, which is why I consider them misguided at best and incredibly retarded at worst.\n\n[^2]: Fiat doesn't work for the [value-enabled web][vew], as fiat rails can never be open and permissionless. Digital fiat is never money. It is---and always will be---[credit][rediscovery].\n\n[^3]: Whether the recipient is a text-to-speech service provider or a human narrator doesn't even matter too much, sats will flow just the same.\n\n[^4]: [BlogStack](https://blogstack.io/) and [Habla](https://habla.news/) being two of them.\n\n[^5]: Use a URI as the discussion base (instead of a highlight), and you got yourself a [Disqus](https://disqus.com/) in purple feathers!\n\n[^fn-torrents]: That's what torrents got right, and [ipfs] for that matter.\n\n[nr]: https://nostr-resources.com\n[nwc]: https://nwc.getalby.com/\n[ipfs]: https://fiatjaf.com/d5031e5b.html\n", "created_at": 1680614039, @@ -112,80 +112,81 @@ class ThreadAssemblerTest { ] } """ - .trimIndent() + .trimIndent() - @Test - fun threadOrderTest() = runBlocking { - val eventArray = - Event.mapper.readValue>(db) as List + Event.fromJson(header) + @Test + fun threadOrderTest() = + runBlocking { + val eventArray = + Event.mapper.readValue>(db) as List + Event.fromJson(header) - var counter = 0 - eventArray.forEach { - TestCase.assertTrue("${it.id} failed signature check", it.hasValidSignature()) - LocalCache.verifyAndConsume(it, null) - counter++ - } + var counter = 0 + eventArray.forEach { + TestCase.assertTrue("${it.id} failed signature check", it.hasValidSignature()) + LocalCache.verifyAndConsume(it, null) + counter++ + } - val naddr = - ATag( - 30023, - "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", - "1680612926599", - null, - ) + val naddr = + ATag( + 30023, + "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", + "1680612926599", + null, + ) - val account = Account(KeyPair()) - withContext(Dispatchers.Main) { - val user = account.userProfile().live() - } + val account = Account(KeyPair()) + withContext(Dispatchers.Main) { + val user = account.userProfile().live() + } - val filter = ThreadFeedFilter(account, naddr.toTag()) - val calculatedFeed = filter.feed() + val filter = ThreadFeedFilter(account, naddr.toTag()) + val calculatedFeed = filter.feed() - val expecteedOrder = - listOf( - "30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599", - "e15b386824fbfdcbf1b50b8860f03062cef534a3ea5339cc837536fb2a58465e", - "ba9a8a1a8afb0b53fb5d4fa3f5130fe557a8d8d56fac7af9ad3443531d2a2933", - "b0425132a3dd4142a0f78986166aaa28021cc8fb440c95c321a95afce3d5e056", - "e9bb4e2d56bd2be2952570bd52b102c23444a62ada5b78ba086f086d9147651a", - "4d7b21c462f3fbf27a1882dbaaec4e99e5a5d18a2c18d1f2d1f0736684ad157c", - "9cdbced750e6b1e1274b7df47cb433f565414dd08c897167eba761eadee841cc", - "5799aac7a9b06f3cae3d3791b79df29d14173515cfdbf34398aab73ed4a44121", - "741f5367a9415f4d6f19c0f57a1e4647c8ed8309b53b0da2d82fc4ebfba03b2c", - "22323fc72b4c37f93ea21f6069684339ce5f63111161c81f2aa3de4a21bfe83b", - "14525fcaae530a029f782fd361dd0cd66634c3e23020bd19e66fe11c1e254e32", - "98ae0d6d10e494ed0bf70feb577e8225c6a6732c7af3d29f88bfe1b87d4439e6", - "36e262e71b7e8bcae946f69885b8c3614e318e82864437342cf50e8b9ab7229d", - "37725c2924ca267d66c2c27c2dae65550c07e7b883034cf1ea69671883430642", - "8ba54cfb6375270e8ae97a7e0992c1a0dbaa4cd46af8309d67a839e86789fde6", - "53410bc6d47e87f3f18ecbc93c716b5a6ef8ee3805516b2ff4d155154a685b7c", - "a3b3825af621727f9af3bd77392fe38c04d71658024916af7fe4c5867ef73eaa", - "e383476cb1ce5accde11d4b1338424fa32c3724cf96e6214af8e5e852981728a", - "e2d8aaed336d3c0f73a9ca46a89fdb2da62a6d172936a91b0067a68797b3bcb8", - "b92e4d6a5d0e8d1d2d2421044b84a4d11f2188261c55145d782b1b6bf0995009", - "da13e14cc8bcc243e0373dde14533d3829b8b621e214ca3c99c90f3dd9e11b8a", - "b5234d90a1543ba60765c57ac3fc7140129a4ac28bbd013531ec9b85e256ea55", - "c5ad64b1b72776a068c39f4549d089032432814a146849eba0650d1b329fb285", - "fc4e4a1230b002e4ae08251a0b26107de7f518800188b7a504f5de62d8b07996", - "6a58f8315af5badb1bdaeb5489417b94621a4d8e192ae2fedcca0c5dcf0c9cd4", - "6e9bb03c7c40d67fec0d0bb872548ec207ba0ac4533efa137d7bcaca9fb4b191", - "e2ae784b239cac4bad38136e4bd758b87dd261b659ef460450064bf9073edcb3", - "674c62f84afdc045bc3623ea132d90afdfe4b64249807f65302231115af5406d", - "d54761f672669ea4f4b7592f3b0a30ee28de340b0a7e46b91af94e66905171c9", - "00813a18ac9084cd0948c27027a980e34039a3011f30279a8b52ad87da5a3031", - "87a5bd25aa084cefb3357fc9c2a5b327254fab35fdd7b2d4bd0acddc63d0abe8", - "512962dbada5fd5015fc727a107d5c3f569662de67eab8e5da5a8065012cf11e", - "ce6e32e3e17b6901d2cc70b60f3743e24f885bb6e9da6d88cff516079eac1883", - "7a4a2419824669f07081abe2132f8cc0027efbce066ccdf187c897bb7ffa5dc3", - "45d4fc726f2cc5b524be862c14fdadc1a24b25b8c6c011eedf2d2909589263e7", - "d4a0b4f08d98d82a04292654ec132723cc2cf3fa24ffb6c0833426cb9372f4d5", - "8cdc4676aca93bbafcfbe6784f9b2df54e8ca20fbe69ba55fda487736bfdb7f6", - "7a18dda355525d468b31bba4fa947cba98cc19048d4a3099d5e9ba045d878c26", - ) + val expecteedOrder = + listOf( + "30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599", + "e15b386824fbfdcbf1b50b8860f03062cef534a3ea5339cc837536fb2a58465e", + "ba9a8a1a8afb0b53fb5d4fa3f5130fe557a8d8d56fac7af9ad3443531d2a2933", + "b0425132a3dd4142a0f78986166aaa28021cc8fb440c95c321a95afce3d5e056", + "e9bb4e2d56bd2be2952570bd52b102c23444a62ada5b78ba086f086d9147651a", + "4d7b21c462f3fbf27a1882dbaaec4e99e5a5d18a2c18d1f2d1f0736684ad157c", + "9cdbced750e6b1e1274b7df47cb433f565414dd08c897167eba761eadee841cc", + "5799aac7a9b06f3cae3d3791b79df29d14173515cfdbf34398aab73ed4a44121", + "741f5367a9415f4d6f19c0f57a1e4647c8ed8309b53b0da2d82fc4ebfba03b2c", + "22323fc72b4c37f93ea21f6069684339ce5f63111161c81f2aa3de4a21bfe83b", + "14525fcaae530a029f782fd361dd0cd66634c3e23020bd19e66fe11c1e254e32", + "98ae0d6d10e494ed0bf70feb577e8225c6a6732c7af3d29f88bfe1b87d4439e6", + "36e262e71b7e8bcae946f69885b8c3614e318e82864437342cf50e8b9ab7229d", + "37725c2924ca267d66c2c27c2dae65550c07e7b883034cf1ea69671883430642", + "8ba54cfb6375270e8ae97a7e0992c1a0dbaa4cd46af8309d67a839e86789fde6", + "53410bc6d47e87f3f18ecbc93c716b5a6ef8ee3805516b2ff4d155154a685b7c", + "a3b3825af621727f9af3bd77392fe38c04d71658024916af7fe4c5867ef73eaa", + "e383476cb1ce5accde11d4b1338424fa32c3724cf96e6214af8e5e852981728a", + "e2d8aaed336d3c0f73a9ca46a89fdb2da62a6d172936a91b0067a68797b3bcb8", + "b92e4d6a5d0e8d1d2d2421044b84a4d11f2188261c55145d782b1b6bf0995009", + "da13e14cc8bcc243e0373dde14533d3829b8b621e214ca3c99c90f3dd9e11b8a", + "b5234d90a1543ba60765c57ac3fc7140129a4ac28bbd013531ec9b85e256ea55", + "c5ad64b1b72776a068c39f4549d089032432814a146849eba0650d1b329fb285", + "fc4e4a1230b002e4ae08251a0b26107de7f518800188b7a504f5de62d8b07996", + "6a58f8315af5badb1bdaeb5489417b94621a4d8e192ae2fedcca0c5dcf0c9cd4", + "6e9bb03c7c40d67fec0d0bb872548ec207ba0ac4533efa137d7bcaca9fb4b191", + "e2ae784b239cac4bad38136e4bd758b87dd261b659ef460450064bf9073edcb3", + "674c62f84afdc045bc3623ea132d90afdfe4b64249807f65302231115af5406d", + "d54761f672669ea4f4b7592f3b0a30ee28de340b0a7e46b91af94e66905171c9", + "00813a18ac9084cd0948c27027a980e34039a3011f30279a8b52ad87da5a3031", + "87a5bd25aa084cefb3357fc9c2a5b327254fab35fdd7b2d4bd0acddc63d0abe8", + "512962dbada5fd5015fc727a107d5c3f569662de67eab8e5da5a8065012cf11e", + "ce6e32e3e17b6901d2cc70b60f3743e24f885bb6e9da6d88cff516079eac1883", + "7a4a2419824669f07081abe2132f8cc0027efbce066ccdf187c897bb7ffa5dc3", + "45d4fc726f2cc5b524be862c14fdadc1a24b25b8c6c011eedf2d2909589263e7", + "d4a0b4f08d98d82a04292654ec132723cc2cf3fa24ffb6c0833426cb9372f4d5", + "8cdc4676aca93bbafcfbe6784f9b2df54e8ca20fbe69ba55fda487736bfdb7f6", + "7a18dda355525d468b31bba4fa947cba98cc19048d4a3099d5e9ba045d878c26", + ) - for (i in expecteedOrder.indices) { - assertEquals(expecteedOrder[i], calculatedFeed[i].idHex) - } - } + for (i in expecteedOrder.indices) { + assertEquals(expecteedOrder[i], calculatedFeed[i].idHex) + } + } } diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt index dc83ecab2..3fa4da42a 100644 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt @@ -40,92 +40,92 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) class UrlUserTagTransformationTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug")) - } + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug")) + } - @Test - fun transformationText() { - val user = - LocalCache.getOrCreateUser( - decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z") - .toHexKey(), - ) - user.info = UserMetadata() - user.info?.displayName = "Vitor Pamplona" + @Test + fun transformationText() { + val user = + LocalCache.getOrCreateUser( + decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z") + .toHexKey(), + ) + user.info = UserMetadata() + user.info?.displayName = "Vitor Pamplona" - val transformedText = - buildAnnotatedStringWithUrlHighlighting( - AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"), - Color.Red, - ) + val transformedText = + buildAnnotatedStringWithUrlHighlighting( + AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"), + Color.Red, + ) - assertEquals("New Hey @Vitor Pamplona", transformedText.text.text) + assertEquals("New Hey @Vitor Pamplona", transformedText.text.text) - assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) // Before N - assertEquals(4, transformedText.offsetMapping.originalToTransformed(4)) // Before H - assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @ - assertEquals(8, transformedText.offsetMapping.originalToTransformed(9)) // Before n - assertEquals(8, transformedText.offsetMapping.originalToTransformed(10)) // Before p - assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) // Before u - assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) // Before b - assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) // Before 1 + assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) // Before N + assertEquals(4, transformedText.offsetMapping.originalToTransformed(4)) // Before H + assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @ + assertEquals(8, transformedText.offsetMapping.originalToTransformed(9)) // Before n + assertEquals(8, transformedText.offsetMapping.originalToTransformed(10)) // Before p + assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) // Before u + assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) // Before b + assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) // Before 1 - assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) - assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) + assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) + assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) - assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0)) - assertEquals(4, transformedText.offsetMapping.transformedToOriginal(4)) - assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8)) - assertEquals(12, transformedText.offsetMapping.transformedToOriginal(9)) + assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0)) + assertEquals(4, transformedText.offsetMapping.transformedToOriginal(4)) + assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8)) + assertEquals(12, transformedText.offsetMapping.transformedToOriginal(9)) - assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) - assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) - } + assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) + assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) + } - @Test - fun transformationTextTwoKeys() { - val user = - LocalCache.getOrCreateUser( - decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z") - .toHexKey(), - ) - user.info = UserMetadata() - user.info?.displayName = "Vitor Pamplona" + @Test + fun transformationTextTwoKeys() { + val user = + LocalCache.getOrCreateUser( + decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z") + .toHexKey(), + ) + user.info = UserMetadata() + user.info?.displayName = "Vitor Pamplona" - val transformedText = - buildAnnotatedStringWithUrlHighlighting( - AnnotatedString( - "New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", - ), - Color.Red, - ) + val transformedText = + buildAnnotatedStringWithUrlHighlighting( + AnnotatedString( + "New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", + ), + Color.Red, + ) - assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text) + assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text) - assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) - assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) - assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) + assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) + assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) + assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) - assertEquals(23, transformedText.offsetMapping.originalToTransformed(70)) // Before 5 - assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z - assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before - assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a - assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n - assertEquals(26, transformedText.offsetMapping.originalToTransformed(75)) // Before d - assertEquals(27, transformedText.offsetMapping.originalToTransformed(76)) // Before - assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @ - assertEquals(28, transformedText.offsetMapping.originalToTransformed(78)) // Before n + assertEquals(23, transformedText.offsetMapping.originalToTransformed(70)) // Before 5 + assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z + assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before + assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a + assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n + assertEquals(26, transformedText.offsetMapping.originalToTransformed(75)) // Before d + assertEquals(27, transformedText.offsetMapping.originalToTransformed(76)) // Before + assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @ + assertEquals(28, transformedText.offsetMapping.originalToTransformed(78)) // Before n - assertEquals(68, transformedText.offsetMapping.transformedToOriginal(22)) // Before a - assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before - assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a - assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n - assertEquals(75, transformedText.offsetMapping.transformedToOriginal(26)) // Before d - assertEquals(76, transformedText.offsetMapping.transformedToOriginal(27)) // Before - assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @ - } + assertEquals(68, transformedText.offsetMapping.transformedToOriginal(22)) // Before a + assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before + assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a + assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n + assertEquals(75, transformedText.offsetMapping.transformedToOriginal(26)) // Before d + assertEquals(76, transformedText.offsetMapping.transformedToOriginal(27)) // Before + assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @ + } } diff --git a/app/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt b/app/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt index 1337c7078..ae00af4db 100644 --- a/app/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt +++ b/app/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt @@ -30,136 +30,136 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class TranslationsTest { - fun translateTo( - text: String, - translateTo: String, - ): String? { - val task = LanguageTranslatorService.autoTranslate(text, emptySet(), translateTo) - return Tasks.await(task).result - } + fun translateTo( + text: String, + translateTo: String, + ): String? { + val task = LanguageTranslatorService.autoTranslate(text, emptySet(), translateTo) + return Tasks.await(task).result + } - fun assertTranslate( - expected: String, - input: String, - translateTo: String, - ) { - assertEquals(null, expected, translateTo(input, translateTo)) - } + fun assertTranslate( + expected: String, + input: String, + translateTo: String, + ) { + assertEquals(null, expected, translateTo(input, translateTo)) + } - fun assertTranslateContains( - expected: String, - input: String, - translateTo: String, - ) { - val translated = translateTo(input, translateTo)!! - assertTrue("'$translated' does not contain '$expected'", translated.contains(expected)) - } + fun assertTranslateContains( + expected: String, + input: String, + translateTo: String, + ) { + val translated = translateTo(input, translateTo)!! + assertTrue("'$translated' does not contain '$expected'", translated.contains(expected)) + } - @Test - fun testTranslation() { - assertTranslate("Olรก mundo", "Hello World", "pt") - } + @Test + fun testTranslation() { + assertTranslate("Olรก mundo", "Hello World", "pt") + } - @Test - fun testTranslationName() { - assertTranslate("Olรก Vitor, como vocรช estรก?", "Hello Vitor, how are you doing?", "pt") - } + @Test + fun testTranslationName() { + assertTranslate("Olรก Vitor, como vocรช estรก?", "Hello Vitor, how are you doing?", "pt") + } - @Test - fun testTranslationTag() { - assertTranslate("Vocรช jรก viu isso, #[0]", "Have you seen this, #[0]", "pt") - } + @Test + fun testTranslationTag() { + assertTranslate("Vocรช jรก viu isso, #[0]", "Have you seen this, #[0]", "pt") + } - @Test - fun testTranslationUrl() { - assertTranslateContains("https://t.me/mygroup", "Have you seen this https://t.me/mygroup", "pt") - assertTranslateContains("http://bananas.com", "Have you seen this http://bananas.com", "pt") - assertTranslateContains( - "http://bananas.com/myimage.jpg", - "Have you seen this http://bananas.com/myimage.jpg", - "pt", - ) - assertTranslateContains( - "http://bananas.com?search=true&image=myimage.jpg", - "Have you seen this http://bananas.com?search=true&image=myimage.jpg", - "pt", - ) - assertTranslate("https://i.imgur.com/EZ3QPsw.jpg", "https://i.imgur.com/EZ3QPsw.jpg", "pt") - assertTranslate("https://HaveYouSeenThis.com", "https://HaveYouSeenThis.com", "pt") - assertTranslate("https://haveyouseenthis.com", "https://haveyouseenthis.com", "pt") - assertTranslate( - "https://i.imgur.com/asdEZ3QPsw.jpg", - "https://i.imgur.com/asdEZ3QPsw.jpg", - "pt", - ) - assertTranslateContains( - "https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", - "Hi there! \n How are you doing? \n https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", - "pt", - ) - } + @Test + fun testTranslationUrl() { + assertTranslateContains("https://t.me/mygroup", "Have you seen this https://t.me/mygroup", "pt") + assertTranslateContains("http://bananas.com", "Have you seen this http://bananas.com", "pt") + assertTranslateContains( + "http://bananas.com/myimage.jpg", + "Have you seen this http://bananas.com/myimage.jpg", + "pt", + ) + assertTranslateContains( + "http://bananas.com?search=true&image=myimage.jpg", + "Have you seen this http://bananas.com?search=true&image=myimage.jpg", + "pt", + ) + assertTranslate("https://i.imgur.com/EZ3QPsw.jpg", "https://i.imgur.com/EZ3QPsw.jpg", "pt") + assertTranslate("https://HaveYouSeenThis.com", "https://HaveYouSeenThis.com", "pt") + assertTranslate("https://haveyouseenthis.com", "https://haveyouseenthis.com", "pt") + assertTranslate( + "https://i.imgur.com/asdEZ3QPsw.jpg", + "https://i.imgur.com/asdEZ3QPsw.jpg", + "pt", + ) + assertTranslateContains( + "https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", + "Hi there! \n How are you doing? \n https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", + "pt", + ) + } - @Test - fun testChineseWithUrlDetector() { - assertTranslate( - "I entered your home page is very carton, perhaps your attention or other data is too much, and the homepage of others is not so carton. From aMethyst client", - "ๆˆ‘่ฟ›ๅ…ฅไฝ ็š„ไธป้กตๅพˆๅก้กฟ๏ผŒไนŸ่ฎธๆ˜ฏไฝ ็š„ๅ…ณๆณจไบบๆ•ฐๆˆ–่€…ๅ…ถไป–ๆ•ฐๆฎๅคชๅคšไบ†๏ผŒๅ…ถไป–ไบบไธป้กตๆฒกๆœ‰่ฟ™ไนˆๅก้กฟใ€‚ๆฅ่‡ชamethystๅฎขๆˆท็ซฏ", - "en", - ) - } + @Test + fun testChineseWithUrlDetector() { + assertTranslate( + "I entered your home page is very carton, perhaps your attention or other data is too much, and the homepage of others is not so carton. From aMethyst client", + "ๆˆ‘่ฟ›ๅ…ฅไฝ ็š„ไธป้กตๅพˆๅก้กฟ๏ผŒไนŸ่ฎธๆ˜ฏไฝ ็š„ๅ…ณๆณจไบบๆ•ฐๆˆ–่€…ๅ…ถไป–ๆ•ฐๆฎๅคชๅคšไบ†๏ผŒๅ…ถไป–ไบบไธป้กตๆฒกๆœ‰่ฟ™ไนˆๅก้กฟใ€‚ๆฅ่‡ชamethystๅฎขๆˆท็ซฏ", + "en", + ) + } - @Test - fun testTranslationEmail() { - assertTranslateContains( - "vitor@amethyst.social", - "Have you seen this vitor@amethyst.social", - "pt", - ) - } + @Test + fun testTranslationEmail() { + assertTranslateContains( + "vitor@amethyst.social", + "Have you seen this vitor@amethyst.social", + "pt", + ) + } - @Test - fun testTranslationLnInvoice() { - assertTranslateContains( - "lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn", - "Have you seen this: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn I think I have to pay", - "pt", - ) + @Test + fun testTranslationLnInvoice() { + assertTranslateContains( + "lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn", + "Have you seen this: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn I think I have to pay", + "pt", + ) - assertTranslateContains( - "lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v", - "Test lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v", - "pt", - ) - } + assertTranslateContains( + "lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v", + "Test lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v", + "pt", + ) + } - @Test - fun testNostrEvents() { - assertTranslateContains( - "nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy", - "sure, nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy", - "en", - ) - } + @Test + fun testNostrEvents() { + assertTranslateContains( + "nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy", + "sure, nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy", + "en", + ) + } - @Test - fun testJapaneseTranslationsOfUrl() { - assertTranslateContains( - "https://youtu.be/wMYFmCDy_Eg", - "ใ†ใกใฎไผš็คพใฎๅฐใ•ใ„ๅ…ˆ่ผฉใฎ่ฉฑ ็ฌฌ1่ฉฑใ€Œใ†ใกใฎไผš็คพใฎๅ…ˆ่ผฉใฏๅฐใ•ใใฆๅฏๆ„›ใ„ใ€\n" + - "\n" + - "https://youtu.be/wMYFmCDy_Eg\n" + - "\n" + - "ๅ…ˆ่ผฉใŒใ†ใ–ใ„ๅพŒ่ผฉใฎ่ฉฑใจไผผใŸใ‚ˆใ†ใช่ฉฑใ‹ใจๆ€ใฃใŸใ‘ใฉใ€ใ‚‚ใฃใจใ‚ชใ‚ฟใ‚ฏใฎๅฆ„ๆƒณใ‚ใ‚‹ใ‚ใ‚‹็š„ใชใ‚‚ใฎใ‚’่ฉฐใ‚่พผใ‚“ใ ใ‚„ใคใ ใ€‚ใƒฏใƒผใƒ‰ใจใ‹ใ‚ทใƒใƒฅใ‚จใƒผใ‚ทใƒงใƒณใจใ‹ใ€ใƒ’ใƒญใ‚คใƒณใฎใ‚ตใ‚คใ‚บๆ„Ÿใจใ‹ใ€‚็Ÿฅใ‚‰ใ‚“ใ‘ใฉ", - "en", - ) - } + @Test + fun testJapaneseTranslationsOfUrl() { + assertTranslateContains( + "https://youtu.be/wMYFmCDy_Eg", + "ใ†ใกใฎไผš็คพใฎๅฐใ•ใ„ๅ…ˆ่ผฉใฎ่ฉฑ ็ฌฌ1่ฉฑใ€Œใ†ใกใฎไผš็คพใฎๅ…ˆ่ผฉใฏๅฐใ•ใใฆๅฏๆ„›ใ„ใ€\n" + + "\n" + + "https://youtu.be/wMYFmCDy_Eg\n" + + "\n" + + "ๅ…ˆ่ผฉใŒใ†ใ–ใ„ๅพŒ่ผฉใฎ่ฉฑใจไผผใŸใ‚ˆใ†ใช่ฉฑใ‹ใจๆ€ใฃใŸใ‘ใฉใ€ใ‚‚ใฃใจใ‚ชใ‚ฟใ‚ฏใฎๅฆ„ๆƒณใ‚ใ‚‹ใ‚ใ‚‹็š„ใชใ‚‚ใฎใ‚’่ฉฐใ‚่พผใ‚“ใ ใ‚„ใคใ ใ€‚ใƒฏใƒผใƒ‰ใจใ‹ใ‚ทใƒใƒฅใ‚จใƒผใ‚ทใƒงใƒณใจใ‹ใ€ใƒ’ใƒญใ‚คใƒณใฎใ‚ตใ‚คใ‚บๆ„Ÿใจใ‹ใ€‚็Ÿฅใ‚‰ใ‚“ใ‘ใฉ", + "en", + ) + } - @Test - fun testEmoji() { - assertTranslateContains( - "https://cdn.nostr.build/i/df3783dcdf7dd289ba02ba538dc039c8fe1d4db055e580b81604ed88c6af4ee0.jpg", - "\uD83E\uDD23 https://cdn.nostr.build/i/df3783dcdf7dd289ba02ba538dc039c8fe1d4db055e580b81604ed88c6af4ee0.jpg ", - "pt", - ) - } + @Test + fun testEmoji() { + assertTranslateContains( + "https://cdn.nostr.build/i/df3783dcdf7dd289ba02ba538dc039c8fe1d4db055e580b81604ed88c6af4ee0.jpg", + "\uD83E\uDD23 https://cdn.nostr.build/i/df3783dcdf7dd289ba02ba538dc039c8fe1d4db055e580b81604ed88c6af4ee0.jpg ", + "pt", + ) + } } diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt index bc008efd5..8c12f0172 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt @@ -21,5 +21,5 @@ package com.vitorpamplona.amethyst.service.lang object LanguageTranslatorService { - fun clear() {} + fun clear() {} } diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt index f8e8e3fc8..f08af58f9 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt @@ -28,80 +28,80 @@ import com.vitorpamplona.amethyst.Amethyst import org.unifiedpush.android.connector.UnifiedPush interface PushDistributorActions { - fun getSavedDistributor(): String + fun getSavedDistributor(): String - fun getInstalledDistributors(): List + fun getInstalledDistributors(): List - fun saveDistributor(distributor: String) + fun saveDistributor(distributor: String) - fun removeSavedDistributor() + fun removeSavedDistributor() } object PushDistributorHandler : PushDistributorActions { - private val appContext = Amethyst.instance.applicationContext - private val unifiedPush: UnifiedPush = UnifiedPush + private val appContext = Amethyst.instance.applicationContext + private val unifiedPush: UnifiedPush = UnifiedPush - private var endpointInternal = "" - val endpoint = endpointInternal + private var endpointInternal = "" + val endpoint = endpointInternal - fun getSavedEndpoint() = endpoint + fun getSavedEndpoint() = endpoint - fun setEndpoint(newEndpoint: String) { - endpointInternal = newEndpoint - Log.d("PushHandler", "New endpoint saved : $endpointInternal") - } + fun setEndpoint(newEndpoint: String) { + endpointInternal = newEndpoint + Log.d("PushHandler", "New endpoint saved : $endpointInternal") + } - fun removeEndpoint() { - endpointInternal = "" - } + fun removeEndpoint() { + endpointInternal = "" + } - override fun getSavedDistributor(): String { - return unifiedPush.getDistributor(appContext) - } + override fun getSavedDistributor(): String { + return unifiedPush.getDistributor(appContext) + } - fun savedDistributorExists(): Boolean = getSavedDistributor().isNotEmpty() + fun savedDistributorExists(): Boolean = getSavedDistributor().isNotEmpty() - override fun getInstalledDistributors(): List { - return unifiedPush.getDistributors(appContext) - } + override fun getInstalledDistributors(): List { + return unifiedPush.getDistributors(appContext) + } - fun formattedDistributorNames(): List { - val distributorsArray = getInstalledDistributors().toTypedArray() - val distributorsNameArray = - distributorsArray - .map { - try { - val ai = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - appContext.packageManager.getApplicationInfo( - it, - PackageManager.ApplicationInfoFlags.of( - PackageManager.GET_META_DATA.toLong(), - ), - ) - } else { - appContext.packageManager.getApplicationInfo(it, 0) - } - appContext.packageManager.getApplicationLabel(ai) - } catch (e: PackageManager.NameNotFoundException) { - it - } - as String - } - .toTypedArray() - return distributorsNameArray.toList() - } + fun formattedDistributorNames(): List { + val distributorsArray = getInstalledDistributors().toTypedArray() + val distributorsNameArray = + distributorsArray + .map { + try { + val ai = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + appContext.packageManager.getApplicationInfo( + it, + PackageManager.ApplicationInfoFlags.of( + PackageManager.GET_META_DATA.toLong(), + ), + ) + } else { + appContext.packageManager.getApplicationInfo(it, 0) + } + appContext.packageManager.getApplicationLabel(ai) + } catch (e: PackageManager.NameNotFoundException) { + it + } + as String + } + .toTypedArray() + return distributorsNameArray.toList() + } - override fun saveDistributor(distributor: String) { - unifiedPush.saveDistributor(appContext, distributor) - unifiedPush.registerApp(appContext) - } + override fun saveDistributor(distributor: String) { + unifiedPush.saveDistributor(appContext, distributor) + unifiedPush.registerApp(appContext) + } - override fun removeSavedDistributor() { - unifiedPush.safeRemoveDistributor(appContext) - } + override fun removeSavedDistributor() { + unifiedPush.safeRemoveDistributor(appContext) + } - fun forceRemoveDistributor(context: Context) { - unifiedPush.forceRemoveDistributor(context) - } + fun forceRemoveDistributor(context: Context) { + unifiedPush.forceRemoveDistributor(context) + } } diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt index 0953db0a8..951f6e8c6 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt @@ -40,92 +40,92 @@ import kotlinx.coroutines.launch import org.unifiedpush.android.connector.MessagingReceiver class PushMessageReceiver : MessagingReceiver() { - companion object { - private val TAG = "Amethyst-OSSPushReceiver" - } - - private val appContext = Amethyst.instance.applicationContext - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val eventCache = LruCache(100) - private val pushHandler = PushDistributorHandler - - override fun onMessage( - context: Context, - message: ByteArray, - instance: String, - ) { - val messageStr = String(message) - Log.d(TAG, "New message ${message.decodeToString()} for Instance: $instance") - scope.launch { - try { - parseMessage(messageStr)?.let { receiveIfNew(it) } - } catch (e: Exception) { - Log.d(TAG, "Message could not be parsed: ${e.message}") - } + companion object { + private val TAG = "Amethyst-OSSPushReceiver" } - } - private suspend fun parseMessage(message: String): GiftWrapEvent? { - (Event.fromJson(message) as? GiftWrapEvent)?.let { - return it + private val appContext = Amethyst.instance.applicationContext + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val eventCache = LruCache(100) + private val pushHandler = PushDistributorHandler + + override fun onMessage( + context: Context, + message: ByteArray, + instance: String, + ) { + val messageStr = String(message) + Log.d(TAG, "New message ${message.decodeToString()} for Instance: $instance") + scope.launch { + try { + parseMessage(messageStr)?.let { receiveIfNew(it) } + } catch (e: Exception) { + Log.d(TAG, "Message could not be parsed: ${e.message}") + } + } } - return null - } - private suspend fun receiveIfNew(event: GiftWrapEvent) { - if (eventCache.get(event.id) == null) { - eventCache.put(event.id, event.id) - EventNotificationConsumer(appContext).consume(event) + private suspend fun parseMessage(message: String): GiftWrapEvent? { + (Event.fromJson(message) as? GiftWrapEvent)?.let { + return it + } + return null } - } - override fun onNewEndpoint( - context: Context, - endpoint: String, - instance: String, - ) { - Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance") - val sanitizedEndpoint = endpoint.dropLast(5) - pushHandler.setEndpoint(sanitizedEndpoint) - scope.launch(Dispatchers.IO) { - RegisterAccounts(LocalPreferences.allSavedAccounts()).go(sanitizedEndpoint) - notificationManager().getOrCreateZapChannel(appContext) - notificationManager().getOrCreateDMChannel(appContext) + private suspend fun receiveIfNew(event: GiftWrapEvent) { + if (eventCache.get(event.id) == null) { + eventCache.put(event.id, event.id) + EventNotificationConsumer(appContext).consume(event) + } } - } - override fun onReceive( - context: Context, - intent: Intent, - ) { - val intentData = intent.dataString - val intentAction = intent.action.toString() - Log.d(TAG, "Intent Data:- $intentData Intent Action: $intentAction") - super.onReceive(context, intent) - } + override fun onNewEndpoint( + context: Context, + endpoint: String, + instance: String, + ) { + Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance") + val sanitizedEndpoint = endpoint.dropLast(5) + pushHandler.setEndpoint(sanitizedEndpoint) + scope.launch(Dispatchers.IO) { + RegisterAccounts(LocalPreferences.allSavedAccounts()).go(sanitizedEndpoint) + notificationManager().getOrCreateZapChannel(appContext) + notificationManager().getOrCreateDMChannel(appContext) + } + } - override fun onRegistrationFailed( - context: Context, - instance: String, - ) { - Log.d(TAG, "Registration failed for Instance: $instance") - scope.cancel() - pushHandler.forceRemoveDistributor(context) - } + override fun onReceive( + context: Context, + intent: Intent, + ) { + val intentData = intent.dataString + val intentAction = intent.action.toString() + Log.d(TAG, "Intent Data:- $intentData Intent Action: $intentAction") + super.onReceive(context, intent) + } - override fun onUnregistered( - context: Context, - instance: String, - ) { - val removedEndpoint = pushHandler.endpoint - Log.d(TAG, "Endpoint: $removedEndpoint removed for Instance: $instance") - Log.d(TAG, "App is unregistered. ") - pushHandler.forceRemoveDistributor(context) - pushHandler.removeEndpoint() - } + override fun onRegistrationFailed( + context: Context, + instance: String, + ) { + Log.d(TAG, "Registration failed for Instance: $instance") + scope.cancel() + pushHandler.forceRemoveDistributor(context) + } - fun notificationManager(): NotificationManager { - return ContextCompat.getSystemService(appContext, NotificationManager::class.java) - as NotificationManager - } + override fun onUnregistered( + context: Context, + instance: String, + ) { + val removedEndpoint = pushHandler.endpoint + Log.d(TAG, "Endpoint: $removedEndpoint removed for Instance: $instance") + Log.d(TAG, "App is unregistered. ") + pushHandler.forceRemoveDistributor(context) + pushHandler.removeEndpoint() + } + + fun notificationManager(): NotificationManager { + return ContextCompat.getSystemService(appContext, NotificationManager::class.java) + as NotificationManager + } } diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt index 72fb737bc..6a25767b0 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -24,19 +24,19 @@ import android.util.Log import com.vitorpamplona.amethyst.AccountInfo object PushNotificationUtils { - var hasInit: Boolean = false - private val pushHandler = PushDistributorHandler + var hasInit: Boolean = false + private val pushHandler = PushDistributorHandler - suspend fun init(accounts: List) { - if (hasInit || pushHandler.savedDistributorExists()) { - return + suspend fun init(accounts: List) { + if (hasInit || pushHandler.savedDistributorExists()) { + return + } + try { + if (pushHandler.savedDistributorExists()) { + RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint()) + } + } catch (e: Exception) { + Log.d("Amethyst-OSSPushUtils", "Failed to get endpoint.") + } } - try { - if (pushHandler.savedDistributorExists()) { - RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint()) - } - } catch (e: Exception) { - Log.d("Amethyst-OSSPushUtils", "Failed to get endpoint.") - } - } } diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt index 39153244d..6fba6039e 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt @@ -64,143 +64,142 @@ import kotlinx.collections.immutable.toImmutableList @OptIn(ExperimentalPermissionsApi::class) @Composable fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesViewModel) { - val notificationPermissionState = - CheckifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel) + val notificationPermissionState = + CheckifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel) - if (notificationPermissionState.status.isGranted) { - if (!sharedPreferencesViewModel.sharedPrefs.dontShowPushNotificationSelector) { - val context = LocalContext.current - var distributorPresent by remember { - mutableStateOf(PushDistributorHandler.savedDistributorExists()) - } - if (!distributorPresent) { - LoadDistributors { currentDistributor, list, readableListWithExplainer -> - if (readableListWithExplainer.size > 1) { - SpinnerSelectionDialog( - title = stringResource(id = R.string.select_push_server), - options = readableListWithExplainer, - onSelect = { index -> - if (list[index] == "None") { - PushDistributorHandler.forceRemoveDistributor(context) - sharedPreferencesViewModel.dontAskForNotificationPermissions() - sharedPreferencesViewModel.dontShowPushNotificationSelector() - } else { - val fullDistributorName = list[index] - PushDistributorHandler.saveDistributor(fullDistributorName) - } - distributorPresent = true - Log.d("Amethyst", "NotificationScreen: Distributor registered.") - }, - onDismiss = { - distributorPresent = true - Log.d("Amethyst", "NotificationScreen: Distributor dialog dismissed.") - }, - ) - } else { - AlertDialog( - onDismissRequest = { distributorPresent = true }, - title = { Text(stringResource(R.string.push_server_install_app)) }, - text = { - Material3RichText( - style = RichTextStyle().resolveDefaults(), - ) { - Markdown( - content = stringResource(R.string.push_server_install_app_description), - ) - } - }, - confirmButton = { - Row( - modifier = Modifier.padding(all = 8.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - TextButton( - onClick = { - distributorPresent = true - sharedPreferencesViewModel.dontShowPushNotificationSelector() - }, - ) { - Text(stringResource(R.string.quick_action_dont_show_again_button)) - } - Button( - onClick = { distributorPresent = true }, - contentPadding = PaddingValues(horizontal = 16.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - ) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.error_dialog_button_ok)) + if (notificationPermissionState.status.isGranted) { + if (!sharedPreferencesViewModel.sharedPrefs.dontShowPushNotificationSelector) { + val context = LocalContext.current + var distributorPresent by remember { + mutableStateOf(PushDistributorHandler.savedDistributorExists()) + } + if (!distributorPresent) { + LoadDistributors { currentDistributor, list, readableListWithExplainer -> + if (readableListWithExplainer.size > 1) { + SpinnerSelectionDialog( + title = stringResource(id = R.string.select_push_server), + options = readableListWithExplainer, + onSelect = { index -> + if (list[index] == "None") { + PushDistributorHandler.forceRemoveDistributor(context) + sharedPreferencesViewModel.dontAskForNotificationPermissions() + sharedPreferencesViewModel.dontShowPushNotificationSelector() + } else { + val fullDistributorName = list[index] + PushDistributorHandler.saveDistributor(fullDistributorName) + } + distributorPresent = true + Log.d("Amethyst", "NotificationScreen: Distributor registered.") + }, + onDismiss = { + distributorPresent = true + Log.d("Amethyst", "NotificationScreen: Distributor dialog dismissed.") + }, + ) + } else { + AlertDialog( + onDismissRequest = { distributorPresent = true }, + title = { Text(stringResource(R.string.push_server_install_app)) }, + text = { + Material3RichText( + style = RichTextStyle().resolveDefaults(), + ) { + Markdown( + content = stringResource(R.string.push_server_install_app_description), + ) + } + }, + confirmButton = { + Row( + modifier = Modifier.padding(all = 8.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextButton( + onClick = { + distributorPresent = true + sharedPreferencesViewModel.dontShowPushNotificationSelector() + }, + ) { + Text(stringResource(R.string.quick_action_dont_show_again_button)) + } + Button( + onClick = { distributorPresent = true }, + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.error_dialog_button_ok)) + } + } + } + }, + ) } - } } - }, - ) - } + } else { + val currentDistributor = PushDistributorHandler.getSavedDistributor() + PushDistributorHandler.saveDistributor(currentDistributor) + } } - } else { - val currentDistributor = PushDistributorHandler.getSavedDistributor() - PushDistributorHandler.saveDistributor(currentDistributor) - } } - } } @Composable -fun LoadDistributors( - onInner: @Composable (String, ImmutableList, ImmutableList) -> Unit -) { - val currentDistributor = PushDistributorHandler.getSavedDistributor().ifBlank { null } ?: "None" +fun LoadDistributors(onInner: @Composable (String, ImmutableList, ImmutableList) -> Unit) { + val currentDistributor = PushDistributorHandler.getSavedDistributor().ifBlank { null } ?: "None" - val list = remember { - PushDistributorHandler.getInstalledDistributors().plus("None").toImmutableList() - } + val list = + remember { + PushDistributorHandler.getInstalledDistributors().plus("None").toImmutableList() + } - val readableListWithExplainer = - PushDistributorHandler.formattedDistributorNames() - .mapIndexed { index, name -> - TitleExplainer( - name, - stringResource(id = R.string.push_server_uses_app_explainer, list[index]), - ) - } - .plus( - TitleExplainer( - stringResource(id = R.string.push_server_none), - stringResource(id = R.string.push_server_none_explainer), - ), - ) - .toImmutableList() + val readableListWithExplainer = + PushDistributorHandler.formattedDistributorNames() + .mapIndexed { index, name -> + TitleExplainer( + name, + stringResource(id = R.string.push_server_uses_app_explainer, list[index]), + ) + } + .plus( + TitleExplainer( + stringResource(id = R.string.push_server_none), + stringResource(id = R.string.push_server_none_explainer), + ), + ) + .toImmutableList() - onInner( - currentDistributor, - list, - readableListWithExplainer, - ) + onInner( + currentDistributor, + list, + readableListWithExplainer, + ) } @Composable fun PushNotificationSettingsRow(sharedPreferencesViewModel: SharedPreferencesViewModel) { - val context = LocalContext.current + val context = LocalContext.current - LoadDistributors { currentDistributor, list, readableListWithExplainer -> - SettingsRow( - R.string.push_server_title, - R.string.push_server_explainer, - selectedItens = readableListWithExplainer, - selectedIndex = list.indexOf(currentDistributor), - ) { index -> - if (list[index] == "None") { - sharedPreferencesViewModel.dontAskForNotificationPermissions() - sharedPreferencesViewModel.dontShowPushNotificationSelector() - PushDistributorHandler.forceRemoveDistributor(context) - } else { - PushDistributorHandler.saveDistributor(list[index]) - } + LoadDistributors { currentDistributor, list, readableListWithExplainer -> + SettingsRow( + R.string.push_server_title, + R.string.push_server_explainer, + selectedItens = readableListWithExplainer, + selectedIndex = list.indexOf(currentDistributor), + ) { index -> + if (list[index] == "None") { + sharedPreferencesViewModel.dontAskForNotificationPermissions() + sharedPreferencesViewModel.dontShowPushNotificationSelector() + PushDistributorHandler.forceRemoveDistributor(context) + } else { + PushDistributorHandler.saveDistributor(list[index]) + } + } } - } } diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index b91df7aeb..7d211335a 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -29,15 +29,14 @@ import com.vitorpamplona.quartz.events.ImmutableListOfLists @Composable fun TranslatableRichTextViewer( - content: String, - canPreview: Boolean, - modifier: Modifier = Modifier, - tags: ImmutableListOfLists, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) = - ExpandableRichTextViewer( + content: String, + canPreview: Boolean, + modifier: Modifier = Modifier, + tags: ImmutableListOfLists, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) = ExpandableRichTextViewer( content, canPreview, modifier, @@ -45,4 +44,4 @@ fun TranslatableRichTextViewer( backgroundColor, accountViewModel, nav, - ) +) diff --git a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefresh.kt b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefresh.kt index 0454af354..c308bab1a 100644 --- a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefresh.kt +++ b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefresh.kt @@ -42,19 +42,18 @@ import androidx.compose.ui.unit.Velocity * @sample androidx.compose.material.samples.PullRefreshSample */ fun Modifier.pullRefresh( - state: PullRefreshState, - enabled: Boolean = true, -) = - inspectable( + state: PullRefreshState, + enabled: Boolean = true, +) = inspectable( inspectorInfo = - debugInspectorInfo { - name = "pullRefresh" - properties["state"] = state - properties["enabled"] = enabled - }, - ) { + debugInspectorInfo { + name = "pullRefresh" + properties["state"] = state + properties["enabled"] = enabled + }, +) { Modifier.pullRefresh(state::onPull, state::onRelease, enabled) - } +} /** * A nested scroll modifier that provides [onPull] and [onRelease] callbacks to aid building custom @@ -78,49 +77,48 @@ fun Modifier.pullRefresh( * @sample androidx.compose.material.samples.CustomPullRefreshSample */ fun Modifier.pullRefresh( - onPull: (pullDelta: Float) -> Float, - onRelease: suspend (flingVelocity: Float) -> Float, - enabled: Boolean = true, -) = - inspectable( + onPull: (pullDelta: Float) -> Float, + onRelease: suspend (flingVelocity: Float) -> Float, + enabled: Boolean = true, +) = inspectable( inspectorInfo = - debugInspectorInfo { - name = "pullRefresh" - properties["onPull"] = onPull - properties["onRelease"] = onRelease - properties["enabled"] = enabled - }, - ) { + debugInspectorInfo { + name = "pullRefresh" + properties["onPull"] = onPull + properties["onRelease"] = onRelease + properties["enabled"] = enabled + }, +) { Modifier.nestedScroll(PullRefreshNestedScrollConnection(onPull, onRelease, enabled)) - } +} private class PullRefreshNestedScrollConnection( - private val onPull: (pullDelta: Float) -> Float, - private val onRelease: suspend (flingVelocity: Float) -> Float, - private val enabled: Boolean, + private val onPull: (pullDelta: Float) -> Float, + private val onRelease: suspend (flingVelocity: Float) -> Float, + private val enabled: Boolean, ) : NestedScrollConnection { - override fun onPreScroll( - available: Offset, - source: NestedScrollSource, - ): Offset = - when { - !enabled -> Offset.Zero - source == Drag && available.y < 0 -> Offset(0f, onPull(available.y)) // Swiping up - else -> Offset.Zero - } + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset = + when { + !enabled -> Offset.Zero + source == Drag && available.y < 0 -> Offset(0f, onPull(available.y)) // Swiping up + else -> Offset.Zero + } - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource, - ): Offset = - when { - !enabled -> Offset.Zero - source == Drag && available.y > 0 -> Offset(0f, onPull(available.y)) // Pulling down - else -> Offset.Zero - } + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset = + when { + !enabled -> Offset.Zero + source == Drag && available.y > 0 -> Offset(0f, onPull(available.y)) // Pulling down + else -> Offset.Zero + } - override suspend fun onPreFling(available: Velocity): Velocity { - return Velocity(0f, onRelease(available.y)) - } + override suspend fun onPreFling(available: Velocity): Velocity { + return Velocity(0f, onRelease(available.y)) + } } diff --git a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt index f62eaed9e..8283d1735 100644 --- a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt +++ b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt @@ -70,148 +70,148 @@ import kotlin.math.pow */ @Composable fun PullRefreshIndicator( - refreshing: Boolean, - state: PullRefreshState, - modifier: Modifier = Modifier, - backgroundColor: Color = MaterialTheme.colorScheme.surface, - contentColor: Color = contentColorFor(backgroundColor), - scale: Boolean = false, + refreshing: Boolean, + state: PullRefreshState, + modifier: Modifier = Modifier, + backgroundColor: Color = MaterialTheme.colorScheme.surface, + contentColor: Color = contentColorFor(backgroundColor), + scale: Boolean = false, ) { - val showElevation by - remember(refreshing, state) { derivedStateOf { refreshing || state.position > 0.5f } } + val showElevation by + remember(refreshing, state) { derivedStateOf { refreshing || state.position > 0.5f } } - Surface( - modifier = modifier.size(IndicatorSize).pullRefreshIndicatorTransform(state, scale), - shape = SpinnerShape, - color = backgroundColor, - shadowElevation = if (showElevation) Elevation else 0.dp, - ) { - Crossfade( - targetState = refreshing, - animationSpec = tween(durationMillis = CROSSFADE_DURATION_MS), - ) { refreshing -> - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - val spinnerSize = (ArcRadius + StrokeWidth).times(2) + Surface( + modifier = modifier.size(IndicatorSize).pullRefreshIndicatorTransform(state, scale), + shape = SpinnerShape, + color = backgroundColor, + shadowElevation = if (showElevation) Elevation else 0.dp, + ) { + Crossfade( + targetState = refreshing, + animationSpec = tween(durationMillis = CROSSFADE_DURATION_MS), + ) { refreshing -> + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + val spinnerSize = (ArcRadius + StrokeWidth).times(2) - if (refreshing) { - CircularProgressIndicator( - color = contentColor, - strokeWidth = StrokeWidth, - modifier = Modifier.size(spinnerSize), - ) - } else { - CircularArrowIndicator(state, contentColor, Modifier.size(spinnerSize)) + if (refreshing) { + CircularProgressIndicator( + color = contentColor, + strokeWidth = StrokeWidth, + modifier = Modifier.size(spinnerSize), + ) + } else { + CircularArrowIndicator(state, contentColor, Modifier.size(spinnerSize)) + } + } } - } } - } } /** Modifier.size MUST be specified. */ @Composable private fun CircularArrowIndicator( - state: PullRefreshState, - color: Color, - modifier: Modifier, + state: PullRefreshState, + color: Color, + modifier: Modifier, ) { - val path = remember { Path().apply { fillType = PathFillType.EvenOdd } } + val path = remember { Path().apply { fillType = PathFillType.EvenOdd } } - val targetAlpha by - remember(state) { derivedStateOf { if (state.progress >= 1f) MAX_ALPHA else MIN_ALPHA } } + val targetAlpha by + remember(state) { derivedStateOf { if (state.progress >= 1f) MAX_ALPHA else MIN_ALPHA } } - val alphaState = animateFloatAsState(targetValue = targetAlpha, animationSpec = AlphaTween) + val alphaState = animateFloatAsState(targetValue = targetAlpha, animationSpec = AlphaTween) - // Empty semantics for tests - Canvas(modifier.semantics {}) { - val values = ArrowValues(state.progress) - val alpha = alphaState.value + // Empty semantics for tests + Canvas(modifier.semantics {}) { + val values = ArrowValues(state.progress) + val alpha = alphaState.value - rotate(degrees = values.rotation) { - val arcRadius = ArcRadius.toPx() + StrokeWidth.toPx() / 2f - val arcBounds = - Rect( - size.center.x - arcRadius, - size.center.y - arcRadius, - size.center.x + arcRadius, - size.center.y + arcRadius, - ) - drawArc( - color = color, - alpha = alpha, - startAngle = values.startAngle, - sweepAngle = values.endAngle - values.startAngle, - useCenter = false, - topLeft = arcBounds.topLeft, - size = arcBounds.size, - style = - Stroke( - width = StrokeWidth.toPx(), - cap = StrokeCap.Square, - ), - ) - drawArrow(path, arcBounds, color, alpha, values) + rotate(degrees = values.rotation) { + val arcRadius = ArcRadius.toPx() + StrokeWidth.toPx() / 2f + val arcBounds = + Rect( + size.center.x - arcRadius, + size.center.y - arcRadius, + size.center.x + arcRadius, + size.center.y + arcRadius, + ) + drawArc( + color = color, + alpha = alpha, + startAngle = values.startAngle, + sweepAngle = values.endAngle - values.startAngle, + useCenter = false, + topLeft = arcBounds.topLeft, + size = arcBounds.size, + style = + Stroke( + width = StrokeWidth.toPx(), + cap = StrokeCap.Square, + ), + ) + drawArrow(path, arcBounds, color, alpha, values) + } } - } } @Immutable private class ArrowValues( - val rotation: Float, - val startAngle: Float, - val endAngle: Float, - val scale: Float, + val rotation: Float, + val startAngle: Float, + val endAngle: Float, + val scale: Float, ) private fun ArrowValues(progress: Float): ArrowValues { - // Discard first 40% of progress. Scale remaining progress to full range between 0 and 100%. - val adjustedPercent = max(min(1f, progress) - 0.4f, 0f) * 5 / 3 - // How far beyond the threshold pull has gone, as a percentage of the threshold. - val overshootPercent = abs(progress) - 1.0f - // Limit the overshoot to 200%. Linear between 0 and 200. - val linearTension = overshootPercent.coerceIn(0f, 2f) - // Non-linear tension. Increases with linearTension, but at a decreasing rate. - val tensionPercent = linearTension - linearTension.pow(2) / 4 + // Discard first 40% of progress. Scale remaining progress to full range between 0 and 100%. + val adjustedPercent = max(min(1f, progress) - 0.4f, 0f) * 5 / 3 + // How far beyond the threshold pull has gone, as a percentage of the threshold. + val overshootPercent = abs(progress) - 1.0f + // Limit the overshoot to 200%. Linear between 0 and 200. + val linearTension = overshootPercent.coerceIn(0f, 2f) + // Non-linear tension. Increases with linearTension, but at a decreasing rate. + val tensionPercent = linearTension - linearTension.pow(2) / 4 - // Calculations based on SwipeRefreshLayout specification. - val endTrim = adjustedPercent * MAX_PROGRESS_ARC - val rotation = (-0.25f + 0.4f * adjustedPercent + tensionPercent) * 0.5f - val startAngle = rotation * 360 - val endAngle = (rotation + endTrim) * 360 - val scale = min(1f, adjustedPercent) + // Calculations based on SwipeRefreshLayout specification. + val endTrim = adjustedPercent * MAX_PROGRESS_ARC + val rotation = (-0.25f + 0.4f * adjustedPercent + tensionPercent) * 0.5f + val startAngle = rotation * 360 + val endAngle = (rotation + endTrim) * 360 + val scale = min(1f, adjustedPercent) - return ArrowValues(rotation, startAngle, endAngle, scale) + return ArrowValues(rotation, startAngle, endAngle, scale) } private fun DrawScope.drawArrow( - arrow: Path, - bounds: Rect, - color: Color, - alpha: Float, - values: ArrowValues, + arrow: Path, + bounds: Rect, + color: Color, + alpha: Float, + values: ArrowValues, ) { - arrow.reset() - arrow.moveTo(0f, 0f) // Move to left corner - arrow.lineTo(x = ArrowWidth.toPx() * values.scale, y = 0f) // Line to right corner + arrow.reset() + arrow.moveTo(0f, 0f) // Move to left corner + arrow.lineTo(x = ArrowWidth.toPx() * values.scale, y = 0f) // Line to right corner - // Line to tip of arrow - arrow.lineTo( - x = ArrowWidth.toPx() * values.scale / 2, - y = ArrowHeight.toPx() * values.scale, - ) + // Line to tip of arrow + arrow.lineTo( + x = ArrowWidth.toPx() * values.scale / 2, + y = ArrowHeight.toPx() * values.scale, + ) - val radius = min(bounds.width, bounds.height) / 2f - val inset = ArrowWidth.toPx() * values.scale / 2f - arrow.translate( - Offset( - x = radius + bounds.center.x - inset, - y = bounds.center.y + StrokeWidth.toPx() / 2f, - ), - ) - arrow.close() - rotate(degrees = values.endAngle) { drawPath(path = arrow, color = color, alpha = alpha) } + val radius = min(bounds.width, bounds.height) / 2f + val inset = ArrowWidth.toPx() * values.scale / 2f + arrow.translate( + Offset( + x = radius + bounds.center.x - inset, + y = bounds.center.y + StrokeWidth.toPx() / 2f, + ), + ) + arrow.close() + rotate(degrees = values.endAngle) { drawPath(path = arrow, color = color, alpha = alpha) } } private const val CROSSFADE_DURATION_MS = 100 diff --git a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt index 8a0bbeefb..7bf737ad9 100644 --- a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt +++ b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt @@ -37,42 +37,41 @@ import androidx.compose.ui.platform.inspectable * @sample androidx.compose.material.samples.PullRefreshIndicatorTransformSample */ fun Modifier.pullRefreshIndicatorTransform( - state: PullRefreshState, - scale: Boolean = false, -) = - inspectable( + state: PullRefreshState, + scale: Boolean = false, +) = inspectable( inspectorInfo = - debugInspectorInfo { - name = "pullRefreshIndicatorTransform" - properties["state"] = state - properties["scale"] = scale - }, - ) { + debugInspectorInfo { + name = "pullRefreshIndicatorTransform" + properties["state"] = state + properties["scale"] = scale + }, +) { Modifier - // Essentially we only want to clip the at the top, so the indicator will not appear when - // the position is 0. It is preferable to clip the indicator as opposed to the layout that - // contains the indicator, as this would also end up clipping shadows drawn by items in a - // list for example - so we leave the clipping to the scrolling container. We use MAX_VALUE - // for the other dimensions to allow for more room for elevation / arbitrary indicators - we - // only ever really want to clip at the top edge. - .drawWithContent { - clipRect( - top = 0f, - left = -Float.MAX_VALUE, - right = Float.MAX_VALUE, - bottom = Float.MAX_VALUE, - ) { - this@drawWithContent.drawContent() + // Essentially we only want to clip the at the top, so the indicator will not appear when + // the position is 0. It is preferable to clip the indicator as opposed to the layout that + // contains the indicator, as this would also end up clipping shadows drawn by items in a + // list for example - so we leave the clipping to the scrolling container. We use MAX_VALUE + // for the other dimensions to allow for more room for elevation / arbitrary indicators - we + // only ever really want to clip at the top edge. + .drawWithContent { + clipRect( + top = 0f, + left = -Float.MAX_VALUE, + right = Float.MAX_VALUE, + bottom = Float.MAX_VALUE, + ) { + this@drawWithContent.drawContent() + } } - } - .graphicsLayer { - translationY = state.position - size.height + .graphicsLayer { + translationY = state.position - size.height - if (scale && !state.refreshing) { - val scaleFraction = - LinearOutSlowInEasing.transform(state.position / state.threshold).coerceIn(0f, 1f) - scaleX = scaleFraction - scaleY = scaleFraction + if (scale && !state.refreshing) { + val scaleFraction = + LinearOutSlowInEasing.transform(state.position / state.threshold).coerceIn(0f, 1f) + scaleX = scaleFraction + scaleY = scaleFraction + } } - } - } +} diff --git a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshState.kt b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshState.kt index 97687d053..cfadc039f 100644 --- a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshState.kt +++ b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshState.kt @@ -35,10 +35,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import kotlin.math.abs -import kotlin.math.pow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.pow /** * Creates a [PullRefreshState] that is remembered across compositions. @@ -55,33 +55,33 @@ import kotlinx.coroutines.launch */ @Composable fun rememberPullRefreshState( - refreshing: Boolean, - onRefresh: () -> Unit, - refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold, - refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset, + refreshing: Boolean, + onRefresh: () -> Unit, + refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold, + refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset, ): PullRefreshState { - require(refreshThreshold > 0.dp) { "The refresh trigger must be greater than zero!" } + require(refreshThreshold > 0.dp) { "The refresh trigger must be greater than zero!" } - val scope = rememberCoroutineScope() - val onRefreshState = rememberUpdatedState(onRefresh) - val thresholdPx: Float - val refreshingOffsetPx: Float + val scope = rememberCoroutineScope() + val onRefreshState = rememberUpdatedState(onRefresh) + val thresholdPx: Float + val refreshingOffsetPx: Float - with(LocalDensity.current) { - thresholdPx = refreshThreshold.toPx() - refreshingOffsetPx = refreshingOffset.toPx() - } + with(LocalDensity.current) { + thresholdPx = refreshThreshold.toPx() + refreshingOffsetPx = refreshingOffset.toPx() + } - val state = - remember(scope) { PullRefreshState(scope, onRefreshState, refreshingOffsetPx, thresholdPx) } + val state = + remember(scope) { PullRefreshState(scope, onRefreshState, refreshingOffsetPx, thresholdPx) } - SideEffect { - state.setRefreshing(refreshing) - state.setThreshold(thresholdPx) - state.setRefreshingOffset(refreshingOffsetPx) - } + SideEffect { + state.setRefreshing(refreshing) + state.setThreshold(thresholdPx) + state.setRefreshingOffset(refreshingOffsetPx) + } - return state + return state } /** @@ -98,131 +98,131 @@ fun rememberPullRefreshState( * Should be created using [rememberPullRefreshState]. */ class PullRefreshState -internal constructor( - private val animationScope: CoroutineScope, - private val onRefreshState: State<() -> Unit>, - refreshingOffset: Float, - threshold: Float, -) { - /** - * A float representing how far the user has pulled as a percentage of the refreshThreshold. - * - * If the component has not been pulled at all, progress is zero. If the pull has reached halfway - * to the threshold, progress is 0.5f. A value greater than 1 indicates that pull has gone beyond - * the refreshThreshold - e.g. a value of 2f indicates that the user has pulled to two times the - * refreshThreshold. - */ - val progress - get() = adjustedDistancePulled / threshold + internal constructor( + private val animationScope: CoroutineScope, + private val onRefreshState: State<() -> Unit>, + refreshingOffset: Float, + threshold: Float, + ) { + /** + * A float representing how far the user has pulled as a percentage of the refreshThreshold. + * + * If the component has not been pulled at all, progress is zero. If the pull has reached halfway + * to the threshold, progress is 0.5f. A value greater than 1 indicates that pull has gone beyond + * the refreshThreshold - e.g. a value of 2f indicates that the user has pulled to two times the + * refreshThreshold. + */ + val progress + get() = adjustedDistancePulled / threshold - val refreshing - get() = _refreshing + val refreshing + get() = _refreshing - val position - get() = _position + val position + get() = _position - val threshold - get() = _threshold + val threshold + get() = _threshold - private val adjustedDistancePulled by derivedStateOf { distancePulled * DRAG_MULTIPLIER } + private val adjustedDistancePulled by derivedStateOf { distancePulled * DRAG_MULTIPLIER } - private var _refreshing by mutableStateOf(false) - private var _position by mutableStateOf(0f) - private var distancePulled by mutableStateOf(0f) - private var _threshold by mutableStateOf(threshold) - private var refreshingOffsetState by mutableStateOf(refreshingOffset) + private var _refreshing by mutableStateOf(false) + private var _position by mutableStateOf(0f) + private var distancePulled by mutableStateOf(0f) + private var _threshold by mutableStateOf(threshold) + private var refreshingOffsetState by mutableStateOf(refreshingOffset) - internal fun onPull(pullDelta: Float): Float { - if (_refreshing) return 0f // Already refreshing, do nothing. + internal fun onPull(pullDelta: Float): Float { + if (_refreshing) return 0f // Already refreshing, do nothing. - val newOffset = (distancePulled + pullDelta).coerceAtLeast(0f) - val dragConsumed = newOffset - distancePulled - distancePulled = newOffset - _position = calculateIndicatorPosition() - return dragConsumed - } + val newOffset = (distancePulled + pullDelta).coerceAtLeast(0f) + val dragConsumed = newOffset - distancePulled + distancePulled = newOffset + _position = calculateIndicatorPosition() + return dragConsumed + } - internal fun onRelease(velocity: Float): Float { - if (refreshing) return 0f // Already refreshing, do nothing + internal fun onRelease(velocity: Float): Float { + if (refreshing) return 0f // Already refreshing, do nothing - if (adjustedDistancePulled > threshold) { - onRefreshState.value() + if (adjustedDistancePulled > threshold) { + onRefreshState.value() + } + animateIndicatorTo(0f) + val consumed = + when { + // We are flinging without having dragged the pull refresh (for example a fling inside + // a list) - don't consume + distancePulled == 0f -> 0f + // If the velocity is negative, the fling is upwards, and we don't want to prevent the + // the list from scrolling + velocity < 0f -> 0f + // We are showing the indicator, and the fling is downwards - consume everything + else -> velocity + } + distancePulled = 0f + return consumed + } + + internal fun setRefreshing(refreshing: Boolean) { + if (_refreshing != refreshing) { + _refreshing = refreshing + distancePulled = 0f + animateIndicatorTo(if (refreshing) refreshingOffsetState else 0f) + } + } + + internal fun setThreshold(threshold: Float) { + _threshold = threshold + } + + internal fun setRefreshingOffset(refreshingOffset: Float) { + if (refreshingOffsetState != refreshingOffset) { + refreshingOffsetState = refreshingOffset + if (refreshing) animateIndicatorTo(refreshingOffset) + } + } + + // Make sure to cancel any existing animations when we launch a new one. We use this instead of + // Animatable as calling snapTo() on every drag delta has a one frame delay, and some extra + // overhead of running through the animation pipeline instead of directly mutating the state. + private val mutatorMutex = MutatorMutex() + + private fun animateIndicatorTo(offset: Float) = + animationScope.launch { + mutatorMutex.mutate { + animate(initialValue = _position, targetValue = offset) { value, _ -> _position = value } + } + } + + private fun calculateIndicatorPosition(): Float = + when { + // If drag hasn't gone past the threshold, the position is the adjustedDistancePulled. + adjustedDistancePulled <= threshold -> adjustedDistancePulled + else -> { + // How far beyond the threshold pull has gone, as a percentage of the threshold. + val overshootPercent = abs(progress) - 1.0f + // Limit the overshoot to 200%. Linear between 0 and 200. + val linearTension = overshootPercent.coerceIn(0f, 2f) + // Non-linear tension. Increases with linearTension, but at a decreasing rate. + val tensionPercent = linearTension - linearTension.pow(2) / 4 + // The additional offset beyond the threshold. + val extraOffset = threshold * tensionPercent + threshold + extraOffset + } + } } - animateIndicatorTo(0f) - val consumed = - when { - // We are flinging without having dragged the pull refresh (for example a fling inside - // a list) - don't consume - distancePulled == 0f -> 0f - // If the velocity is negative, the fling is upwards, and we don't want to prevent the - // the list from scrolling - velocity < 0f -> 0f - // We are showing the indicator, and the fling is downwards - consume everything - else -> velocity - } - distancePulled = 0f - return consumed - } - - internal fun setRefreshing(refreshing: Boolean) { - if (_refreshing != refreshing) { - _refreshing = refreshing - distancePulled = 0f - animateIndicatorTo(if (refreshing) refreshingOffsetState else 0f) - } - } - - internal fun setThreshold(threshold: Float) { - _threshold = threshold - } - - internal fun setRefreshingOffset(refreshingOffset: Float) { - if (refreshingOffsetState != refreshingOffset) { - refreshingOffsetState = refreshingOffset - if (refreshing) animateIndicatorTo(refreshingOffset) - } - } - - // Make sure to cancel any existing animations when we launch a new one. We use this instead of - // Animatable as calling snapTo() on every drag delta has a one frame delay, and some extra - // overhead of running through the animation pipeline instead of directly mutating the state. - private val mutatorMutex = MutatorMutex() - - private fun animateIndicatorTo(offset: Float) = - animationScope.launch { - mutatorMutex.mutate { - animate(initialValue = _position, targetValue = offset) { value, _ -> _position = value } - } - } - - private fun calculateIndicatorPosition(): Float = - when { - // If drag hasn't gone past the threshold, the position is the adjustedDistancePulled. - adjustedDistancePulled <= threshold -> adjustedDistancePulled - else -> { - // How far beyond the threshold pull has gone, as a percentage of the threshold. - val overshootPercent = abs(progress) - 1.0f - // Limit the overshoot to 200%. Linear between 0 and 200. - val linearTension = overshootPercent.coerceIn(0f, 2f) - // Non-linear tension. Increases with linearTension, but at a decreasing rate. - val tensionPercent = linearTension - linearTension.pow(2) / 4 - // The additional offset beyond the threshold. - val extraOffset = threshold * tensionPercent - threshold + extraOffset - } - } -} /** Default parameter values for [rememberPullRefreshState]. */ object PullRefreshDefaults { - /** - * If the indicator is below this threshold offset when it is released, a refresh will be - * triggered. - */ - val RefreshThreshold = 80.dp + /** + * If the indicator is below this threshold offset when it is released, a refresh will be + * triggered. + */ + val RefreshThreshold = 80.dp - /** The offset at which the indicator should be rendered whilst a refresh is occurring. */ - val RefreshingOffset = 56.dp + /** The offset at which the indicator should be rendered whilst a refresh is occurring. */ + val RefreshingOffset = 56.dp } /** diff --git a/app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index dcf63a76e..a16fde1b4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -29,72 +29,72 @@ import android.util.Log import coil.ImageLoader import coil.disk.DiskCache import com.vitorpamplona.amethyst.service.playback.VideoCache -import java.io.File -import kotlin.time.measureTimedValue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import java.io.File +import kotlin.time.measureTimedValue class Amethyst : Application() { - val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - override fun onTerminate() { - super.onTerminate() - applicationIOScope.cancel() - } - - val videoCache: VideoCache by lazy { - val newCache = VideoCache() - newCache.initFileCache(this) - newCache - } - - private val imageCache: DiskCache by lazy { - DiskCache.Builder() - .directory(applicationContext.safeCacheDir.resolve("image_cache")) - .maxSizePercent(0.2) - .maximumMaxSizeBytes(500L * 1024 * 1024) // 250MB - .build() - } - - override fun onCreate() { - super.onCreate() - instance = this - - if (BuildConfig.DEBUG) { - StrictMode.setThreadPolicy( - ThreadPolicy.Builder().detectAll().penaltyLog().build(), - ) - StrictMode.setVmPolicy( - VmPolicy.Builder().detectAll().penaltyLog().build(), - ) + override fun onTerminate() { + super.onTerminate() + applicationIOScope.cancel() } - GlobalScope.launch(Dispatchers.IO) { - val (value, elapsed) = - measureTimedValue { - // initializes the video cache in a thread - videoCache + val videoCache: VideoCache by lazy { + val newCache = VideoCache() + newCache.initFileCache(this) + newCache + } + + private val imageCache: DiskCache by lazy { + DiskCache.Builder() + .directory(applicationContext.safeCacheDir.resolve("image_cache")) + .maxSizePercent(0.2) + .maximumMaxSizeBytes(500L * 1024 * 1024) // 250MB + .build() + } + + override fun onCreate() { + super.onCreate() + instance = this + + if (BuildConfig.DEBUG) { + StrictMode.setThreadPolicy( + ThreadPolicy.Builder().detectAll().penaltyLog().build(), + ) + StrictMode.setVmPolicy( + VmPolicy.Builder().detectAll().penaltyLog().build(), + ) + } + + GlobalScope.launch(Dispatchers.IO) { + val (value, elapsed) = + measureTimedValue { + // initializes the video cache in a thread + videoCache + } + Log.d("Rendering Metrics", "VideoCache initialized in $elapsed") } - Log.d("Rendering Metrics", "VideoCache initialized in $elapsed") } - } - fun imageLoaderBuilder(): ImageLoader.Builder { - return ImageLoader.Builder(applicationContext).diskCache { imageCache } - } + fun imageLoaderBuilder(): ImageLoader.Builder { + return ImageLoader.Builder(applicationContext).diskCache { imageCache } + } - companion object { - lateinit var instance: Amethyst - private set - } + companion object { + lateinit var instance: Amethyst + private set + } } internal val Context.safeCacheDir: File - get() { - val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" } - return cacheDir.apply { mkdirs() } - } + get() { + val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" } + return cacheDir.apply { mkdirs() } + } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt b/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt index e243c751b..9d6b15dad 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt @@ -24,28 +24,28 @@ import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey object EncryptedStorage { - private const val PREFERENCES_NAME = "secret_keeper" + private const val PREFERENCES_NAME = "secret_keeper" - // returns the preferences for each account or a global file if null. - fun prefsFileName(npub: String? = null): String { - return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub" - } + // returns the preferences for each account or a global file if null. + fun prefsFileName(npub: String? = null): String { + return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub" + } - fun preferences(npub: String? = null): EncryptedSharedPreferences { - val context = Amethyst.instance - val masterKey: MasterKey = - MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() + fun preferences(npub: String? = null): EncryptedSharedPreferences { + val context = Amethyst.instance + val masterKey: MasterKey = + MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() - val preferencesName = prefsFileName(npub) + val preferencesName = prefsFileName(npub) - return EncryptedSharedPreferences.create( - context, - preferencesName, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) as EncryptedSharedPreferences - } + return EncryptedSharedPreferences.create( + context, + preferencesName, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) as EncryptedSharedPreferences + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 844598014..60275baff 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -53,13 +53,13 @@ import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.signers.ExternalSignerLauncher import com.vitorpamplona.quartz.signers.NostrSignerExternal import com.vitorpamplona.quartz.signers.NostrSignerInternal -import java.io.File -import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import java.io.File +import java.util.Locale // Release mode (!BuildConfig.DEBUG) always uses encrypted preferences // To use plaintext SharedPreferences for debugging, set this to true @@ -69,583 +69,582 @@ private const val DEBUG_PREFERENCES_NAME = "debug_prefs" @Immutable data class AccountInfo( - val npub: String, - val hasPrivKey: Boolean, - val loggedInWithExternalSigner: Boolean, + val npub: String, + val hasPrivKey: Boolean, + val loggedInWithExternalSigner: Boolean, ) private object PrefKeys { - const val CURRENT_ACCOUNT = "currently_logged_in_account" - const val SAVED_ACCOUNTS = "all_saved_accounts" - const val NOSTR_PRIVKEY = "nostr_privkey" - const val NOSTR_PUBKEY = "nostr_pubkey" - const val RELAYS = "relays" - const val DONT_TRANSLATE_FROM = "dontTranslateFrom" - const val LANGUAGE_PREFS = "languagePreferences" - const val TRANSLATE_TO = "translateTo" - const val ZAP_AMOUNTS = "zapAmounts" - const val REACTION_CHOICES = "reactionChoices" - const val DEFAULT_ZAPTYPE = "defaultZapType" - const val DEFAULT_FILE_SERVER = "defaultFileServer" - const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" - const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList" - const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList" - const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList" - const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" - const val LATEST_CONTACT_LIST = "latestContactList" - const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" - const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog" - const val HIDE_NIP_24_WARNING_DIALOG = "hide_nip24_warning_dialog" - const val USE_PROXY = "use_proxy" - const val PROXY_PORT = "proxy_port" - const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content" - const val WARN_ABOUT_REPORTS = "warn_about_reports" - const val FILTER_SPAM_FROM_STRANGERS = "filter_spam_from_strangers" - const val LAST_READ_PER_ROUTE = "last_read_route_per_route" - const val AUTOMATICALLY_SHOW_IMAGES = "automatically_show_images" - const val AUTOMATICALLY_START_PLAYBACK = "automatically_start_playback" - const val THEME = "theme" - const val PREFERRED_LANGUAGE = "preferred_Language" - const val AUTOMATICALLY_LOAD_URL_PREVIEW = "automatically_load_url_preview" - const val AUTOMATICALLY_HIDE_NAV_BARS = "automatically_hide_nav_bars" - const val LOGIN_WITH_EXTERNAL_SIGNER = "login_with_external_signer" - const val AUTOMATICALLY_SHOW_PROFILE_PICTURE = "automatically_show_profile_picture" - const val SIGNER_PACKAGE_NAME = "signer_package_name" + const val CURRENT_ACCOUNT = "currently_logged_in_account" + const val SAVED_ACCOUNTS = "all_saved_accounts" + const val NOSTR_PRIVKEY = "nostr_privkey" + const val NOSTR_PUBKEY = "nostr_pubkey" + const val RELAYS = "relays" + const val DONT_TRANSLATE_FROM = "dontTranslateFrom" + const val LANGUAGE_PREFS = "languagePreferences" + const val TRANSLATE_TO = "translateTo" + const val ZAP_AMOUNTS = "zapAmounts" + const val REACTION_CHOICES = "reactionChoices" + const val DEFAULT_ZAPTYPE = "defaultZapType" + const val DEFAULT_FILE_SERVER = "defaultFileServer" + const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" + const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList" + const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList" + const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList" + const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" + const val LATEST_CONTACT_LIST = "latestContactList" + const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" + const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog" + const val HIDE_NIP_24_WARNING_DIALOG = "hide_nip24_warning_dialog" + const val USE_PROXY = "use_proxy" + const val PROXY_PORT = "proxy_port" + const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content" + const val WARN_ABOUT_REPORTS = "warn_about_reports" + const val FILTER_SPAM_FROM_STRANGERS = "filter_spam_from_strangers" + const val LAST_READ_PER_ROUTE = "last_read_route_per_route" + const val AUTOMATICALLY_SHOW_IMAGES = "automatically_show_images" + const val AUTOMATICALLY_START_PLAYBACK = "automatically_start_playback" + const val THEME = "theme" + const val PREFERRED_LANGUAGE = "preferred_Language" + const val AUTOMATICALLY_LOAD_URL_PREVIEW = "automatically_load_url_preview" + const val AUTOMATICALLY_HIDE_NAV_BARS = "automatically_hide_nav_bars" + const val LOGIN_WITH_EXTERNAL_SIGNER = "login_with_external_signer" + const val AUTOMATICALLY_SHOW_PROFILE_PICTURE = "automatically_show_profile_picture" + const val SIGNER_PACKAGE_NAME = "signer_package_name" - const val ALL_ACCOUNT_INFO = "all_saved_accounts_info" - const val SHARED_SETTINGS = "shared_settings" + const val ALL_ACCOUNT_INFO = "all_saved_accounts_info" + const val SHARED_SETTINGS = "shared_settings" } object LocalPreferences { - private const val COMMA = "," + private const val COMMA = "," - private var currentAccount: String? = null - private var savedAccounts: List? = null - private var cachedAccounts: MutableMap = mutableMapOf() + private var currentAccount: String? = null + private var savedAccounts: List? = null + private var cachedAccounts: MutableMap = mutableMapOf() - suspend fun currentAccount(): String? { - if (currentAccount == null) { - currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null) + suspend fun currentAccount(): String? { + if (currentAccount == null) { + currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null) + } + return currentAccount } - return currentAccount - } - private suspend fun updateCurrentAccount(npub: String) { - if (currentAccount != npub) { - currentAccount = npub + private suspend fun updateCurrentAccount(npub: String) { + if (currentAccount != npub) { + currentAccount = npub - encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, npub) }.apply() + encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, npub) }.apply() + } } - } - private fun savedAccounts(): List { - if (savedAccounts == null) { - with(encryptedPreferences()) { - val newSystemOfAccounts = - getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let { - Event.mapper.readValue>(it) - } + private fun savedAccounts(): List { + if (savedAccounts == null) { + with(encryptedPreferences()) { + val newSystemOfAccounts = + getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let { + Event.mapper.readValue>(it) + } - if (newSystemOfAccounts != null && newSystemOfAccounts.isNotEmpty()) { - savedAccounts = newSystemOfAccounts - } else { - val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf() + if (newSystemOfAccounts != null && newSystemOfAccounts.isNotEmpty()) { + savedAccounts = newSystemOfAccounts + } else { + val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf() - val migrated = - oldAccounts.map { npub -> - AccountInfo( - npub, - encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false), - (encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "") - .isNotBlank(), - ) + val migrated = + oldAccounts.map { npub -> + AccountInfo( + npub, + encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false), + (encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "") + .isNotBlank(), + ) + } + + savedAccounts = migrated + } } - - savedAccounts = migrated } - } - } - return savedAccounts!! - } - - private suspend fun updateSavedAccounts(accounts: List) = - withContext(Dispatchers.IO) { - if (savedAccounts != accounts) { - savedAccounts = accounts - - encryptedPreferences() - .edit() - .apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(accounts)) } - .apply() - } + return savedAccounts!! } - private val prefsDirPath: String - get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/" + private suspend fun updateSavedAccounts(accounts: List) = + withContext(Dispatchers.IO) { + if (savedAccounts != accounts) { + savedAccounts = accounts - private suspend fun addAccount(accInfo: AccountInfo) { - val accounts = savedAccounts().filter { it.npub != accInfo.npub }.plus(accInfo) - updateSavedAccounts(accounts) - } - - private suspend fun setCurrentAccount(account: Account) = - withContext(Dispatchers.IO) { - val npub = account.userProfile().pubkeyNpub() - val accInfo = - AccountInfo( - npub, - account.isWriteable(), - account.signer is NostrSignerExternal, - ) - updateCurrentAccount(npub) - addAccount(accInfo) - } - - suspend fun switchToAccount(accountInfo: AccountInfo) = - withContext(Dispatchers.IO) { updateCurrentAccount(accountInfo.npub) } - - /** Removes the account from the app level shared preferences */ - private suspend fun removeAccount(accountInfo: AccountInfo) { - val accounts = savedAccounts().filter { it.npub != accountInfo.npub } - updateSavedAccounts(accounts) - } - - /** Deletes the npub-specific shared preference file */ - private fun deleteUserPreferenceFile(npub: String) { - checkNotInMainThread() - - val prefsDir = File(prefsDirPath) - prefsDir.list()?.forEach { - if (it.contains(npub)) { - File(prefsDir, it).delete() - } - } - } - - private fun encryptedPreferences(npub: String? = null): SharedPreferences { - checkNotInMainThread() - - return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) { - val preferenceFile = - if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub" - Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE) - } else { - return EncryptedStorage.preferences(npub) - } - } - - /** - * Clears the preferences for a given npub, deletes the preferences xml file, and switches the - * user to the first account in the list if it exists - * - * We need to use `commit()` to write changes to disk and release the file lock so that it can be - * deleted. If we use `apply()` there is a race condition and the file will probably not be - * deleted - */ - @SuppressLint("ApplySharedPref") - suspend fun updatePrefsForLogout(accountInfo: AccountInfo) = - withContext(Dispatchers.IO) { - val userPrefs = encryptedPreferences(accountInfo.npub) - userPrefs.edit().clear().commit() - removeAccount(accountInfo) - deleteUserPreferenceFile(accountInfo.npub) - - if (savedAccounts().isEmpty()) { - encryptedPreferences().edit().clear().apply() - } else if (currentAccount() == accountInfo.npub) { - updateCurrentAccount(savedAccounts().elementAt(0).npub) - } - } - - suspend fun updatePrefsForLogin(account: Account) { - setCurrentAccount(account) - saveToEncryptedStorage(account) - } - - fun allSavedAccounts(): List { - return savedAccounts() - } - - suspend fun saveToEncryptedStorage(account: Account) = - withContext(Dispatchers.IO) { - checkNotInMainThread() - - val prefs = encryptedPreferences(account.userProfile().pubkeyNpub()) - prefs - .edit() - .apply { - putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, account.signer is NostrSignerExternal) - if (account.signer is NostrSignerExternal) { - remove(PrefKeys.NOSTR_PRIVKEY) - putString(PrefKeys.SIGNER_PACKAGE_NAME, account.signer.launcher.signerPackageName) - } else { - account.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) } - } - account.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) } - putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(account.localRelays)) - putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom) - putString( - PrefKeys.LANGUAGE_PREFS, - Event.mapper.writeValueAsString(account.languagePreferences), - ) - putString(PrefKeys.TRANSLATE_TO, account.translateTo) - putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(account.zapAmountChoices)) - putString( - PrefKeys.REACTION_CHOICES, - Event.mapper.writeValueAsString(account.reactionChoices), - ) - putString(PrefKeys.DEFAULT_ZAPTYPE, account.defaultZapType.name) - putString( - PrefKeys.DEFAULT_FILE_SERVER, - Event.mapper.writeValueAsString(account.defaultFileServer), - ) - putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList.value) - putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList.value) - putString( - PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, - account.defaultNotificationFollowList.value, - ) - putString( - PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, - account.defaultDiscoveryFollowList.value, - ) - putString( - PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, - Event.mapper.writeValueAsString(account.zapPaymentRequest), - ) - putString( - PrefKeys.LATEST_CONTACT_LIST, - Event.mapper.writeValueAsString(account.backupContactList), - ) - putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog) - putBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, account.hideNIP24WarningDialog) - putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog) - putBoolean(PrefKeys.USE_PROXY, account.proxy != null) - putInt(PrefKeys.PROXY_PORT, account.proxyPort) - putBoolean(PrefKeys.WARN_ABOUT_REPORTS, account.warnAboutPostsWithReports) - putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, account.filterSpamFromStrangers) - putString( - PrefKeys.LAST_READ_PER_ROUTE, - Event.mapper.writeValueAsString(account.lastReadPerRoute), - ) - - if (account.showSensitiveContent == null) { - remove(PrefKeys.SHOW_SENSITIVE_CONTENT) - } else { - putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!) - } + encryptedPreferences() + .edit() + .apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(accounts)) } + .apply() + } } - .apply() + + private val prefsDirPath: String + get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/" + + private suspend fun addAccount(accInfo: AccountInfo) { + val accounts = savedAccounts().filter { it.npub != accInfo.npub }.plus(accInfo) + updateSavedAccounts(accounts) } - suspend fun loadCurrentAccountFromEncryptedStorage(): Account? { - return currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) } - } + private suspend fun setCurrentAccount(account: Account) = + withContext(Dispatchers.IO) { + val npub = account.userProfile().pubkeyNpub() + val accInfo = + AccountInfo( + npub, + account.isWriteable(), + account.signer is NostrSignerExternal, + ) + updateCurrentAccount(npub) + addAccount(accInfo) + } - suspend fun migrateOldSharedSettings(): Settings? { - val prefs = encryptedPreferences() - loadOldSharedSettings(prefs)?.let { - saveSharedSettings(it, prefs) - return it + suspend fun switchToAccount(accountInfo: AccountInfo) = withContext(Dispatchers.IO) { updateCurrentAccount(accountInfo.npub) } + + /** Removes the account from the app level shared preferences */ + private suspend fun removeAccount(accountInfo: AccountInfo) { + val accounts = savedAccounts().filter { it.npub != accountInfo.npub } + updateSavedAccounts(accounts) } - return null - } - suspend fun saveSharedSettings( - sharedSettings: Settings, - prefs: SharedPreferences = encryptedPreferences(), - ) { - with(prefs.edit()) { - putString(PrefKeys.SHARED_SETTINGS, Event.mapper.writeValueAsString(sharedSettings)) - apply() + /** Deletes the npub-specific shared preference file */ + private fun deleteUserPreferenceFile(npub: String) { + checkNotInMainThread() + + val prefsDir = File(prefsDirPath) + prefsDir.list()?.forEach { + if (it.contains(npub)) { + File(prefsDir, it).delete() + } + } } - } - suspend fun loadSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? { - with(prefs) { - return try { - getString(PrefKeys.SHARED_SETTINGS, "{}")?.let { Event.mapper.readValue(it) } - } catch (e: Throwable) { - Log.w( - "LocalPreferences", - "Unable to decode shared preferences: ${getString(PrefKeys.SHARED_SETTINGS, null)}", - e, - ) - e.printStackTrace() - null - } + private fun encryptedPreferences(npub: String? = null): SharedPreferences { + checkNotInMainThread() + + return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) { + val preferenceFile = + if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub" + Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE) + } else { + return EncryptedStorage.preferences(npub) + } } - } - @Deprecated("Turned into a single JSON object") - suspend fun loadOldSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? { - with(prefs) { - if (!contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) { + /** + * Clears the preferences for a given npub, deletes the preferences xml file, and switches the + * user to the first account in the list if it exists + * + * We need to use `commit()` to write changes to disk and release the file lock so that it can be + * deleted. If we use `apply()` there is a race condition and the file will probably not be + * deleted + */ + @SuppressLint("ApplySharedPref") + suspend fun updatePrefsForLogout(accountInfo: AccountInfo) = + withContext(Dispatchers.IO) { + val userPrefs = encryptedPreferences(accountInfo.npub) + userPrefs.edit().clear().commit() + removeAccount(accountInfo) + deleteUserPreferenceFile(accountInfo.npub) + + if (savedAccounts().isEmpty()) { + encryptedPreferences().edit().clear().apply() + } else if (currentAccount() == accountInfo.npub) { + updateCurrentAccount(savedAccounts().elementAt(0).npub) + } + } + + suspend fun updatePrefsForLogin(account: Account) { + setCurrentAccount(account) + saveToEncryptedStorage(account) + } + + fun allSavedAccounts(): List { + return savedAccounts() + } + + suspend fun saveToEncryptedStorage(account: Account) = + withContext(Dispatchers.IO) { + checkNotInMainThread() + + val prefs = encryptedPreferences(account.userProfile().pubkeyNpub()) + prefs + .edit() + .apply { + putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, account.signer is NostrSignerExternal) + if (account.signer is NostrSignerExternal) { + remove(PrefKeys.NOSTR_PRIVKEY) + putString(PrefKeys.SIGNER_PACKAGE_NAME, account.signer.launcher.signerPackageName) + } else { + account.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) } + } + account.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) } + putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(account.localRelays)) + putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom) + putString( + PrefKeys.LANGUAGE_PREFS, + Event.mapper.writeValueAsString(account.languagePreferences), + ) + putString(PrefKeys.TRANSLATE_TO, account.translateTo) + putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(account.zapAmountChoices)) + putString( + PrefKeys.REACTION_CHOICES, + Event.mapper.writeValueAsString(account.reactionChoices), + ) + putString(PrefKeys.DEFAULT_ZAPTYPE, account.defaultZapType.name) + putString( + PrefKeys.DEFAULT_FILE_SERVER, + Event.mapper.writeValueAsString(account.defaultFileServer), + ) + putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList.value) + putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList.value) + putString( + PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, + account.defaultNotificationFollowList.value, + ) + putString( + PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, + account.defaultDiscoveryFollowList.value, + ) + putString( + PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, + Event.mapper.writeValueAsString(account.zapPaymentRequest), + ) + putString( + PrefKeys.LATEST_CONTACT_LIST, + Event.mapper.writeValueAsString(account.backupContactList), + ) + putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog) + putBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, account.hideNIP24WarningDialog) + putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog) + putBoolean(PrefKeys.USE_PROXY, account.proxy != null) + putInt(PrefKeys.PROXY_PORT, account.proxyPort) + putBoolean(PrefKeys.WARN_ABOUT_REPORTS, account.warnAboutPostsWithReports) + putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, account.filterSpamFromStrangers) + putString( + PrefKeys.LAST_READ_PER_ROUTE, + Event.mapper.writeValueAsString(account.lastReadPerRoute), + ) + + if (account.showSensitiveContent == null) { + remove(PrefKeys.SHOW_SENSITIVE_CONTENT) + } else { + putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!) + } + } + .apply() + } + + suspend fun loadCurrentAccountFromEncryptedStorage(): Account? { + return currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) } + } + + suspend fun migrateOldSharedSettings(): Settings? { + val prefs = encryptedPreferences() + loadOldSharedSettings(prefs)?.let { + saveSharedSettings(it, prefs) + return it + } return null - } - - val automaticallyShowImages = - if (contains(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)) { - parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false)) - } else { - ConnectivityType.ALWAYS - } - - val automaticallyStartPlayback = - if (contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) { - parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false)) - } else { - ConnectivityType.ALWAYS - } - val automaticallyShowUrlPreview = - if (contains(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)) { - parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false)) - } else { - ConnectivityType.ALWAYS - } - val automaticallyHideNavigationBars = - if (contains(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS)) { - parseBooleanType(getBoolean(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS, false)) - } else { - BooleanType.ALWAYS - } - - val automaticallyShowProfilePictures = - if (contains(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE)) { - parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE, false)) - } else { - ConnectivityType.ALWAYS - } - - val themeType = - if (contains(PrefKeys.THEME)) { - parseThemeType(getInt(PrefKeys.THEME, ThemeType.SYSTEM.screenCode)) - } else { - ThemeType.SYSTEM - } - - return Settings( - themeType, - getString(PrefKeys.PREFERRED_LANGUAGE, null)?.ifBlank { null }, - automaticallyShowImages, - automaticallyStartPlayback, - automaticallyShowUrlPreview, - automaticallyHideNavigationBars, - automaticallyShowProfilePictures, - false, - false, - ) - } - } - - val mutex = Mutex() - - suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): Account? = - withContext(Dispatchers.IO) { - mutex.withLock { - if (cachedAccounts.containsKey(npub)) { - return@withContext cachedAccounts.get(npub) - } - - val account = innerLoadCurrentAccountFromEncryptedStorage(npub) - account?.registerObservers() - - cachedAccounts.put(npub, account) - - return@withContext account - } } - suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): Account? = - withContext(Dispatchers.IO) { - checkNotInMainThread() + suspend fun saveSharedSettings( + sharedSettings: Settings, + prefs: SharedPreferences = encryptedPreferences(), + ) { + with(prefs.edit()) { + putString(PrefKeys.SHARED_SETTINGS, Event.mapper.writeValueAsString(sharedSettings)) + apply() + } + } - return@withContext with(encryptedPreferences(npub)) { - val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return@with null - val loginWithExternalSigner = getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false) - val privKey = if (loginWithExternalSigner) null else getString(PrefKeys.NOSTR_PRIVKEY, null) - - val localRelays = - getString(PrefKeys.RELAYS, "[]")?.let { - println("LocalRelays: $it") - Event.mapper.readValue?>(it) - } - ?: setOf() - - val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf() - val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language - val defaultHomeFollowList = - getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS - val defaultStoriesFollowList = - getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS - val defaultNotificationFollowList = - getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS - val defaultDiscoveryFollowList = - getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS - - val zapAmountChoices = - getString(PrefKeys.ZAP_AMOUNTS, "[]") - ?.let { Event.mapper.readValue?>(it) } - ?.ifEmpty { DefaultZapAmounts } - ?: DefaultZapAmounts - - val reactionChoices = - getString(PrefKeys.REACTION_CHOICES, "[]") - ?.let { Event.mapper.readValue?>(it) } - ?.ifEmpty { DefaultReactions } - ?: DefaultReactions - - val defaultZapType = - getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName -> - LnZapEvent.ZapType.values().firstOrNull { it.name == serverName } - } - ?: LnZapEvent.ZapType.PUBLIC - - val defaultFileServer = - try { - getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName -> - Event.mapper.readValue(serverName) - } - ?: Nip96MediaServers.DEFAULT[0] - } catch (e: Exception) { - Log.w("LocalPreferences", "Failed to decode saved File Server", e) - e.printStackTrace() - Nip96MediaServers.DEFAULT[0] - } - - val zapPaymentRequestServer = - try { - getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let { - Event.mapper.readValue(it) - } - } catch (e: Throwable) { - Log.w( - "LocalPreferences", - "Error Decoding Zap Payment Request Server ${getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)}", - e, - ) - e.printStackTrace() - null - } - - val latestContactList = - try { - getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let { - println("Decoding Contact List: " + it) - if (it != null) { - Event.fromJson(it) as ContactListEvent? - } else { + suspend fun loadSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? { + with(prefs) { + return try { + getString(PrefKeys.SHARED_SETTINGS, "{}")?.let { Event.mapper.readValue(it) } + } catch (e: Throwable) { + Log.w( + "LocalPreferences", + "Unable to decode shared preferences: ${getString(PrefKeys.SHARED_SETTINGS, null)}", + e, + ) + e.printStackTrace() null - } } - } catch (e: Throwable) { - Log.w( - "LocalPreferences", - "Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}", - e, - ) - null - } + } + } - val languagePreferences = - try { - getString(PrefKeys.LANGUAGE_PREFS, null)?.let { - Event.mapper.readValue?>(it) + @Deprecated("Turned into a single JSON object") + suspend fun loadOldSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? { + with(prefs) { + if (!contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) { + return null } - ?: mapOf() - } catch (e: Throwable) { - Log.w( - "LocalPreferences", - "Error Decoding Language Preferences ${getString(PrefKeys.LANGUAGE_PREFS, null)}", - e, + + val automaticallyShowImages = + if (contains(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)) { + parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false)) + } else { + ConnectivityType.ALWAYS + } + + val automaticallyStartPlayback = + if (contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) { + parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false)) + } else { + ConnectivityType.ALWAYS + } + val automaticallyShowUrlPreview = + if (contains(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)) { + parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false)) + } else { + ConnectivityType.ALWAYS + } + val automaticallyHideNavigationBars = + if (contains(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS)) { + parseBooleanType(getBoolean(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS, false)) + } else { + BooleanType.ALWAYS + } + + val automaticallyShowProfilePictures = + if (contains(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE)) { + parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE, false)) + } else { + ConnectivityType.ALWAYS + } + + val themeType = + if (contains(PrefKeys.THEME)) { + parseThemeType(getInt(PrefKeys.THEME, ThemeType.SYSTEM.screenCode)) + } else { + ThemeType.SYSTEM + } + + return Settings( + themeType, + getString(PrefKeys.PREFERRED_LANGUAGE, null)?.ifBlank { null }, + automaticallyShowImages, + automaticallyStartPlayback, + automaticallyShowUrlPreview, + automaticallyHideNavigationBars, + automaticallyShowProfilePictures, + false, + false, ) - e.printStackTrace() - mapOf() - } + } + } - val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) - val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) - val hideNIP24WarningDialog = getBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, false) - val useProxy = getBoolean(PrefKeys.USE_PROXY, false) - val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050) - val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) + val mutex = Mutex() - val showSensitiveContent = - if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) { - getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false) - } else { - null - } - val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true) - val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true) + suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): Account? = + withContext(Dispatchers.IO) { + mutex.withLock { + if (cachedAccounts.containsKey(npub)) { + return@withContext cachedAccounts.get(npub) + } - val lastReadPerRoute = - try { - getString(PrefKeys.LAST_READ_PER_ROUTE, null)?.let { - Event.mapper.readValue?>(it) + val account = innerLoadCurrentAccountFromEncryptedStorage(npub) + account?.registerObservers() + + cachedAccounts.put(npub, account) + + return@withContext account } - ?: mapOf() - } catch (e: Throwable) { - Log.w( - "LocalPreferences", - "Error Decoding Last Read per route ${getString(PrefKeys.LAST_READ_PER_ROUTE, null)}", - e, - ) - e.printStackTrace() - mapOf() - } - - val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()) - val signer = - if (loginWithExternalSigner) { - val packageName = - getString(PrefKeys.SIGNER_PACKAGE_NAME, null) ?: "com.greenart7c3.nostrsigner" - NostrSignerExternal( - pubKey, - ExternalSignerLauncher(pubKey.hexToByteArray().toNpub(), packageName), - ) - } else { - NostrSignerInternal(keyPair) - } - - val account = - Account( - keyPair = keyPair, - signer = signer, - localRelays = localRelays, - dontTranslateFrom = dontTranslateFrom, - languagePreferences = languagePreferences, - translateTo = translateTo, - zapAmountChoices = zapAmountChoices, - reactionChoices = reactionChoices, - defaultZapType = defaultZapType, - defaultFileServer = defaultFileServer, - defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList), - defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList), - defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList), - defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList), - zapPaymentRequest = zapPaymentRequestServer, - hideDeleteRequestDialog = hideDeleteRequestDialog, - hideBlockAlertDialog = hideBlockAlertDialog, - hideNIP24WarningDialog = hideNIP24WarningDialog, - backupContactList = latestContactList, - proxy = proxy, - proxyPort = proxyPort, - showSensitiveContent = showSensitiveContent, - warnAboutPostsWithReports = warnAboutReports, - filterSpamFromStrangers = filterSpam, - lastReadPerRoute = lastReadPerRoute, - ) - - // Loads from DB - account.userProfile() - - withContext(Dispatchers.Main) { - // Loads Live Objects - account.userProfile().live() } - return@with account - } - } + suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): Account? = + withContext(Dispatchers.IO) { + checkNotInMainThread() + + return@withContext with(encryptedPreferences(npub)) { + val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return@with null + val loginWithExternalSigner = getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false) + val privKey = if (loginWithExternalSigner) null else getString(PrefKeys.NOSTR_PRIVKEY, null) + + val localRelays = + getString(PrefKeys.RELAYS, "[]")?.let { + println("LocalRelays: $it") + Event.mapper.readValue?>(it) + } + ?: setOf() + + val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf() + val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language + val defaultHomeFollowList = + getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS + val defaultStoriesFollowList = + getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS + val defaultNotificationFollowList = + getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS + val defaultDiscoveryFollowList = + getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS + + val zapAmountChoices = + getString(PrefKeys.ZAP_AMOUNTS, "[]") + ?.let { Event.mapper.readValue?>(it) } + ?.ifEmpty { DefaultZapAmounts } + ?: DefaultZapAmounts + + val reactionChoices = + getString(PrefKeys.REACTION_CHOICES, "[]") + ?.let { Event.mapper.readValue?>(it) } + ?.ifEmpty { DefaultReactions } + ?: DefaultReactions + + val defaultZapType = + getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName -> + LnZapEvent.ZapType.values().firstOrNull { it.name == serverName } + } + ?: LnZapEvent.ZapType.PUBLIC + + val defaultFileServer = + try { + getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName -> + Event.mapper.readValue(serverName) + } + ?: Nip96MediaServers.DEFAULT[0] + } catch (e: Exception) { + Log.w("LocalPreferences", "Failed to decode saved File Server", e) + e.printStackTrace() + Nip96MediaServers.DEFAULT[0] + } + + val zapPaymentRequestServer = + try { + getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let { + Event.mapper.readValue(it) + } + } catch (e: Throwable) { + Log.w( + "LocalPreferences", + "Error Decoding Zap Payment Request Server ${getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)}", + e, + ) + e.printStackTrace() + null + } + + val latestContactList = + try { + getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let { + println("Decoding Contact List: " + it) + if (it != null) { + Event.fromJson(it) as ContactListEvent? + } else { + null + } + } + } catch (e: Throwable) { + Log.w( + "LocalPreferences", + "Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}", + e, + ) + null + } + + val languagePreferences = + try { + getString(PrefKeys.LANGUAGE_PREFS, null)?.let { + Event.mapper.readValue?>(it) + } + ?: mapOf() + } catch (e: Throwable) { + Log.w( + "LocalPreferences", + "Error Decoding Language Preferences ${getString(PrefKeys.LANGUAGE_PREFS, null)}", + e, + ) + e.printStackTrace() + mapOf() + } + + val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) + val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) + val hideNIP24WarningDialog = getBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, false) + val useProxy = getBoolean(PrefKeys.USE_PROXY, false) + val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050) + val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) + + val showSensitiveContent = + if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) { + getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false) + } else { + null + } + val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true) + val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true) + + val lastReadPerRoute = + try { + getString(PrefKeys.LAST_READ_PER_ROUTE, null)?.let { + Event.mapper.readValue?>(it) + } + ?: mapOf() + } catch (e: Throwable) { + Log.w( + "LocalPreferences", + "Error Decoding Last Read per route ${getString(PrefKeys.LAST_READ_PER_ROUTE, null)}", + e, + ) + e.printStackTrace() + mapOf() + } + + val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()) + val signer = + if (loginWithExternalSigner) { + val packageName = + getString(PrefKeys.SIGNER_PACKAGE_NAME, null) ?: "com.greenart7c3.nostrsigner" + NostrSignerExternal( + pubKey, + ExternalSignerLauncher(pubKey.hexToByteArray().toNpub(), packageName), + ) + } else { + NostrSignerInternal(keyPair) + } + + val account = + Account( + keyPair = keyPair, + signer = signer, + localRelays = localRelays, + dontTranslateFrom = dontTranslateFrom, + languagePreferences = languagePreferences, + translateTo = translateTo, + zapAmountChoices = zapAmountChoices, + reactionChoices = reactionChoices, + defaultZapType = defaultZapType, + defaultFileServer = defaultFileServer, + defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList), + defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList), + defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList), + defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList), + zapPaymentRequest = zapPaymentRequestServer, + hideDeleteRequestDialog = hideDeleteRequestDialog, + hideBlockAlertDialog = hideBlockAlertDialog, + hideNIP24WarningDialog = hideNIP24WarningDialog, + backupContactList = latestContactList, + proxy = proxy, + proxyPort = proxyPort, + showSensitiveContent = showSensitiveContent, + warnAboutPostsWithReports = warnAboutReports, + filterSpamFromStrangers = filterSpam, + lastReadPerRoute = lastReadPerRoute, + ) + + // Loads from DB + account.userProfile() + + withContext(Dispatchers.Main) { + // Loads Live Objects + account.userProfile().live() + } + + return@with account + } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index 233081208..d5114aa90 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -62,188 +62,188 @@ import kotlinx.coroutines.launch @Stable class ServiceManager { - private var isStarted: Boolean = - false // to not open amber in a loop trying to use auth relays and registering for notifications - private var account: Account? = null + private var isStarted: Boolean = + false // to not open amber in a loop trying to use auth relays and registering for notifications + private var account: Account? = null - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private var collectorJob: Job? = null + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var collectorJob: Job? = null - private fun start(account: Account) { - this.account = account - start() - } - - private fun start() { - Log.d("ServiceManager", "Pre Starting Relay Services $isStarted $account") - if (isStarted && account != null) { - return - } - Log.d("ServiceManager", "Starting Relay Services") - - val myAccount = account - - // Resets Proxy Use - HttpClient.start(account?.proxy) - LocalCache.antiSpam.active = account?.filterSpamFromStrangers ?: true - Coil.setImageLoader { - Amethyst.instance - .imageLoaderBuilder() - .components { - if (Build.VERSION.SDK_INT >= 28) { - add(ImageDecoderDecoder.Factory()) - } else { - add(GifDecoder.Factory()) - } - add(SvgDecoder.Factory()) - } // .logger(DebugLogger()) - .okHttpClient { HttpClient.getHttpClient() } - .precision(Precision.INEXACT) - .respectCacheHeaders(false) - .build() - } - - if (myAccount != null) { - val relaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays() - Log.d("Relay", "Service Manager Connect Connecting ${relaySet.size}") - Client.reconnect(relaySet) - - collectorJob?.cancel() - collectorJob = null - collectorJob = - scope.launch { - myAccount.userProfile().flow().relays.stateFlow.collect { - if (isStarted) { - val newRelaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays() - Client.reconnect(newRelaySet, onlyIfChanged = true) - } - } - } - - // start services - NostrAccountDataSource.account = myAccount - NostrAccountDataSource.otherAccounts = - LocalPreferences.allSavedAccounts().mapNotNull { - try { - it.npub.bechToBytes().toHexKey() - } catch (e: Exception) { - null - } - } - NostrHomeDataSource.account = myAccount - NostrChatroomListDataSource.account = myAccount - NostrVideoDataSource.account = myAccount - NostrDiscoveryDataSource.account = myAccount - - // Notification Elements - NostrHomeDataSource.start() - NostrAccountDataSource.start() - GlobalScope.launch(Dispatchers.IO) { - delay(3000) - NostrChatroomListDataSource.start() - NostrDiscoveryDataSource.start() - NostrVideoDataSource.start() - } - - // More Info Data Sources - NostrSingleEventDataSource.start() - NostrSingleChannelDataSource.start() - NostrSingleUserDataSource.start() - isStarted = true - } - } - - private fun pause() { - Log.d("ServiceManager", "Pausing Relay Services") - - collectorJob?.cancel() - collectorJob = null - - NostrAccountDataSource.stopSync() - NostrHomeDataSource.stopSync() - NostrChannelDataSource.stopSync() - NostrChatroomDataSource.stopSync() - NostrChatroomListDataSource.stopSync() - NostrDiscoveryDataSource.stopSync() - - NostrCommunityDataSource.stopSync() - NostrHashtagDataSource.stopSync() - NostrGeohashDataSource.stopSync() - NostrSearchEventOrUserDataSource.stopSync() - NostrSingleChannelDataSource.stopSync() - NostrSingleEventDataSource.stopSync() - NostrSingleUserDataSource.stopSync() - NostrThreadDataSource.stopSync() - NostrUserProfileDataSource.stopSync() - NostrVideoDataSource.stopSync() - - Client.reconnect(null) - isStarted = false - } - - fun cleanObservers() { - LocalCache.cleanObservers() - } - - fun trimMemory() { - LocalCache.cleanObservers() - - val accounts = - LocalPreferences.allSavedAccounts().mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet() - - account?.let { - LocalCache.pruneOldAndHiddenMessages(it) - NostrChatroomDataSource.clearEOSEs(it) - - LocalCache.pruneHiddenMessages(it) - LocalCache.pruneContactLists(accounts) - LocalCache.pruneRepliesAndReactions(accounts) - LocalCache.prunePastVersionsOfReplaceables() - LocalCache.pruneExpiredEvents() - } - } - - // This method keeps the pause/start in a Syncronized block to - // avoid concurrent pauses and starts. - @Synchronized - fun forceRestart( - account: Account? = null, - start: Boolean = true, - pause: Boolean = true, - ) { - if (pause) { - pause() - } - - if (start) { - if (account != null) { - start(account) - } else { + private fun start(account: Account) { + this.account = account start() - } } - } - fun restartIfDifferentAccount(account: Account) { - if (this.account != account) { - forceRestart(account, true, true) + private fun start() { + Log.d("ServiceManager", "Pre Starting Relay Services $isStarted $account") + if (isStarted && account != null) { + return + } + Log.d("ServiceManager", "Starting Relay Services") + + val myAccount = account + + // Resets Proxy Use + HttpClient.start(account?.proxy) + LocalCache.antiSpam.active = account?.filterSpamFromStrangers ?: true + Coil.setImageLoader { + Amethyst.instance + .imageLoaderBuilder() + .components { + if (Build.VERSION.SDK_INT >= 28) { + add(ImageDecoderDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } + add(SvgDecoder.Factory()) + } // .logger(DebugLogger()) + .okHttpClient { HttpClient.getHttpClient() } + .precision(Precision.INEXACT) + .respectCacheHeaders(false) + .build() + } + + if (myAccount != null) { + val relaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays() + Log.d("Relay", "Service Manager Connect Connecting ${relaySet.size}") + Client.reconnect(relaySet) + + collectorJob?.cancel() + collectorJob = null + collectorJob = + scope.launch { + myAccount.userProfile().flow().relays.stateFlow.collect { + if (isStarted) { + val newRelaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays() + Client.reconnect(newRelaySet, onlyIfChanged = true) + } + } + } + + // start services + NostrAccountDataSource.account = myAccount + NostrAccountDataSource.otherAccounts = + LocalPreferences.allSavedAccounts().mapNotNull { + try { + it.npub.bechToBytes().toHexKey() + } catch (e: Exception) { + null + } + } + NostrHomeDataSource.account = myAccount + NostrChatroomListDataSource.account = myAccount + NostrVideoDataSource.account = myAccount + NostrDiscoveryDataSource.account = myAccount + + // Notification Elements + NostrHomeDataSource.start() + NostrAccountDataSource.start() + GlobalScope.launch(Dispatchers.IO) { + delay(3000) + NostrChatroomListDataSource.start() + NostrDiscoveryDataSource.start() + NostrVideoDataSource.start() + } + + // More Info Data Sources + NostrSingleEventDataSource.start() + NostrSingleChannelDataSource.start() + NostrSingleUserDataSource.start() + isStarted = true + } } - } - fun forceRestart() { - forceRestart(null, true, true) - } + private fun pause() { + Log.d("ServiceManager", "Pausing Relay Services") - fun justStart() { - forceRestart(null, true, false) - } + collectorJob?.cancel() + collectorJob = null - fun pauseForGood() { - forceRestart(null, false, true) - } + NostrAccountDataSource.stopSync() + NostrHomeDataSource.stopSync() + NostrChannelDataSource.stopSync() + NostrChatroomDataSource.stopSync() + NostrChatroomListDataSource.stopSync() + NostrDiscoveryDataSource.stopSync() - fun pauseForGoodAndClearAccount() { - account = null - forceRestart(null, false, true) - } + NostrCommunityDataSource.stopSync() + NostrHashtagDataSource.stopSync() + NostrGeohashDataSource.stopSync() + NostrSearchEventOrUserDataSource.stopSync() + NostrSingleChannelDataSource.stopSync() + NostrSingleEventDataSource.stopSync() + NostrSingleUserDataSource.stopSync() + NostrThreadDataSource.stopSync() + NostrUserProfileDataSource.stopSync() + NostrVideoDataSource.stopSync() + + Client.reconnect(null) + isStarted = false + } + + fun cleanObservers() { + LocalCache.cleanObservers() + } + + fun trimMemory() { + LocalCache.cleanObservers() + + val accounts = + LocalPreferences.allSavedAccounts().mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet() + + account?.let { + LocalCache.pruneOldAndHiddenMessages(it) + NostrChatroomDataSource.clearEOSEs(it) + + LocalCache.pruneHiddenMessages(it) + LocalCache.pruneContactLists(accounts) + LocalCache.pruneRepliesAndReactions(accounts) + LocalCache.prunePastVersionsOfReplaceables() + LocalCache.pruneExpiredEvents() + } + } + + // This method keeps the pause/start in a Syncronized block to + // avoid concurrent pauses and starts. + @Synchronized + fun forceRestart( + account: Account? = null, + start: Boolean = true, + pause: Boolean = true, + ) { + if (pause) { + pause() + } + + if (start) { + if (account != null) { + start(account) + } else { + start() + } + } + } + + fun restartIfDifferentAccount(account: Account) { + if (this.account != account) { + forceRestart(account, true, true) + } + } + + fun forceRestart() { + forceRestart(null, true, true) + } + + fun justStart() { + forceRestart(null, true, false) + } + + fun pauseForGood() { + forceRestart(null, false, true) + } + + fun pauseForGoodAndClearAccount() { + account = null + forceRestart(null, false, true) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 13fcf7876..279e488d5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -93,11 +93,6 @@ import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.signers.NostrSignerExternal import com.vitorpamplona.quartz.signers.NostrSignerInternal import com.vitorpamplona.quartz.utils.DualCase -import java.math.BigDecimal -import java.net.Proxy -import java.util.Locale -import java.util.UUID -import kotlin.coroutines.resume import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableSet @@ -117,2243 +112,2254 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull +import java.math.BigDecimal +import java.net.Proxy +import java.util.Locale +import java.util.UUID +import kotlin.coroutines.resume val DefaultChannels = - setOf( - // Anigma's Nostr - "25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", - // Amethyst's Group - "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", - ) + setOf( + // Anigma's Nostr + "25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", + // Amethyst's Group + "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", + ) val DefaultReactions = - listOf( - "\uD83D\uDE80", - "\uD83E\uDEC2", - "\uD83D\uDC40", - "\uD83D\uDE02", - "\uD83C\uDF89", - "\uD83E\uDD14", - "\uD83D\uDE31", - ) + listOf( + "\uD83D\uDE80", + "\uD83E\uDEC2", + "\uD83D\uDC40", + "\uD83D\uDE02", + "\uD83C\uDF89", + "\uD83E\uDD14", + "\uD83D\uDE31", + ) val DefaultZapAmounts = listOf(500L, 1000L, 5000L) fun getLanguagesSpokenByUser(): Set { - val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()) - val codedList = mutableSetOf() - for (i in 0 until languageList.size()) { - languageList.get(i)?.let { codedList.add(it.language) } - } - return codedList + val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()) + val codedList = mutableSetOf() + for (i in 0 until languageList.size()) { + languageList.get(i)?.let { codedList.add(it.language) } + } + return codedList } val GLOBAL_FOLLOWS = - " Global " // This has spaces to avoid mixing with a potential NIP-51 list with the same name. + " Global " // This has spaces to avoid mixing with a potential NIP-51 list with the same name. val KIND3_FOLLOWS = - " All Follows " // This has spaces to avoid mixing with a potential NIP-51 list with the same + " All Follows " // This has spaces to avoid mixing with a potential NIP-51 list with the same // name. @OptIn(DelicateCoroutinesApi::class) @Stable class Account( - val keyPair: KeyPair, - val signer: NostrSigner = NostrSignerInternal(keyPair), - var localRelays: Set = Constants.defaultRelays.toSet(), - var dontTranslateFrom: Set = getLanguagesSpokenByUser(), - var languagePreferences: Map = mapOf(), - var translateTo: String = Locale.getDefault().language, - var zapAmountChoices: List = DefaultZapAmounts, - var reactionChoices: List = DefaultReactions, - var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, - var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0], - var defaultHomeFollowList: MutableStateFlow = MutableStateFlow(KIND3_FOLLOWS), - var defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), - var defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), - var defaultDiscoveryFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), - var zapPaymentRequest: Nip47URI? = null, - var hideDeleteRequestDialog: Boolean = false, - var hideBlockAlertDialog: Boolean = false, - var hideNIP24WarningDialog: Boolean = false, - var backupContactList: ContactListEvent? = null, - var proxy: Proxy? = null, - var proxyPort: Int = 9050, - var showSensitiveContent: Boolean? = null, - var warnAboutPostsWithReports: Boolean = true, - var filterSpamFromStrangers: Boolean = true, - var lastReadPerRoute: Map = mapOf(), + val keyPair: KeyPair, + val signer: NostrSigner = NostrSignerInternal(keyPair), + var localRelays: Set = Constants.defaultRelays.toSet(), + var dontTranslateFrom: Set = getLanguagesSpokenByUser(), + var languagePreferences: Map = mapOf(), + var translateTo: String = Locale.getDefault().language, + var zapAmountChoices: List = DefaultZapAmounts, + var reactionChoices: List = DefaultReactions, + var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0], + var defaultHomeFollowList: MutableStateFlow = MutableStateFlow(KIND3_FOLLOWS), + var defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), + var defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), + var defaultDiscoveryFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), + var zapPaymentRequest: Nip47URI? = null, + var hideDeleteRequestDialog: Boolean = false, + var hideBlockAlertDialog: Boolean = false, + var hideNIP24WarningDialog: Boolean = false, + var backupContactList: ContactListEvent? = null, + var proxy: Proxy? = null, + var proxyPort: Int = 9050, + var showSensitiveContent: Boolean? = null, + var warnAboutPostsWithReports: Boolean = true, + var filterSpamFromStrangers: Boolean = true, + var lastReadPerRoute: Map = mapOf(), ) { - // Uses a single scope for the entire application. - val scope = Amethyst.instance.applicationIOScope + // Uses a single scope for the entire application. + val scope = Amethyst.instance.applicationIOScope - var transientHiddenUsers: ImmutableSet = persistentSetOf() + var transientHiddenUsers: ImmutableSet = persistentSetOf() - data class PaymentRequest( - val relayUrl: String, - val description: String, - ) - - var transientPaymentRequestDismissals: Set = emptySet() - val transientPaymentRequests: MutableStateFlow> = MutableStateFlow(emptySet()) - - // Observers line up here. - val live: AccountLiveData = AccountLiveData(this) - val liveLanguages: AccountLiveData = AccountLiveData(this) - val saveable: AccountLiveData = AccountLiveData(this) - - @Immutable - data class LiveFollowLists( - val users: ImmutableSet = persistentSetOf(), - val hashtags: ImmutableSet = persistentSetOf(), - val geotags: ImmutableSet = persistentSetOf(), - val communities: ImmutableSet = persistentSetOf(), - ) - - @OptIn(ExperimentalCoroutinesApi::class) - val liveKind3Follows: StateFlow by lazy { - userProfile() - .live() - .follows - .asFlow() - .transformLatest { - emit( - LiveFollowLists( - userProfile().cachedFollowingKeySet().toImmutableSet(), - userProfile().cachedFollowingTagSet().toImmutableSet(), - userProfile().cachedFollowingGeohashSet().toImmutableSet(), - userProfile().cachedFollowingCommunitiesSet().toImmutableSet(), - ), - ) - } - .stateIn(scope, SharingStarted.Eagerly, LiveFollowLists()) - } - - @OptIn(ExperimentalCoroutinesApi::class) - private val liveHomeList: StateFlow by lazy { - defaultHomeFollowList - .transformLatest { - LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let { - emit(it) - } - } - .flattenMerge() - .stateIn(scope, SharingStarted.Eagerly, null) - } - - val liveHomeFollowLists: StateFlow by lazy { - combineTransform(defaultHomeFollowList, liveKind3Follows, liveHomeList) { - listName, - kind3Follows, - peopleListFollows, - -> - if (listName == GLOBAL_FOLLOWS) { - emit(null) - } else if (listName == KIND3_FOLLOWS) { - emit(kind3Follows) - } else { - val result = - withTimeoutOrNull(1000) { - suspendCancellableCoroutine { continuation -> - decryptLiveFollows(peopleListFollows) { continuation.resume(it) } - } - } - result?.let { emit(it) } ?: run { emit(LiveFollowLists()) } - } - } - .stateIn(scope, SharingStarted.Eagerly, LiveFollowLists()) - } - - @OptIn(ExperimentalCoroutinesApi::class) - private val liveNotificationList: StateFlow by lazy { - defaultNotificationFollowList - .transformLatest { - LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let { - emit(it) - } - } - .flattenMerge() - .stateIn(scope, SharingStarted.Eagerly, null) - } - - val liveNotificationFollowLists: StateFlow by lazy { - combineTransform(defaultNotificationFollowList, liveKind3Follows, liveNotificationList) { - listName, - kind3Follows, - peopleListFollows, - -> - if (listName == GLOBAL_FOLLOWS) { - emit(null) - } else if (listName == KIND3_FOLLOWS) { - emit(kind3Follows) - } else { - val result = - withTimeoutOrNull(1000) { - suspendCancellableCoroutine { continuation -> - decryptLiveFollows(peopleListFollows) { continuation.resume(it) } - } - } - result?.let { emit(it) } ?: run { emit(LiveFollowLists()) } - } - } - .stateIn(scope, SharingStarted.Eagerly, LiveFollowLists()) - } - - @OptIn(ExperimentalCoroutinesApi::class) - private val liveStoriesList: StateFlow by lazy { - defaultStoriesFollowList - .transformLatest { - LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let { - emit(it) - } - } - .flattenMerge() - .stateIn(scope, SharingStarted.Eagerly, null) - } - - val liveStoriesFollowLists: StateFlow by lazy { - combineTransform(defaultStoriesFollowList, liveKind3Follows, liveStoriesList) { - listName, - kind3Follows, - peopleListFollows, - -> - if (listName == GLOBAL_FOLLOWS) { - emit(null) - } else if (listName == KIND3_FOLLOWS) { - emit(kind3Follows) - } else { - val result = - withTimeoutOrNull(1000) { - suspendCancellableCoroutine { continuation -> - decryptLiveFollows(peopleListFollows) { continuation.resume(it) } - } - } - result?.let { emit(it) } ?: run { emit(LiveFollowLists()) } - } - } - .stateIn(scope, SharingStarted.Eagerly, LiveFollowLists()) - } - - @OptIn(ExperimentalCoroutinesApi::class) - private val liveDiscoveryList: StateFlow by lazy { - defaultDiscoveryFollowList - .transformLatest { - LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let { - emit(it) - } - } - .flattenMerge() - .stateIn(scope, SharingStarted.Eagerly, null) - } - - val liveDiscoveryFollowLists: StateFlow by lazy { - combineTransform(defaultDiscoveryFollowList, liveKind3Follows, liveDiscoveryList) { - listName, - kind3Follows, - peopleListFollows, - -> - if (listName == GLOBAL_FOLLOWS) { - emit(null) - } else if (listName == KIND3_FOLLOWS) { - emit(kind3Follows) - } else { - val result = - withTimeoutOrNull(1000) { - suspendCancellableCoroutine { continuation -> - decryptLiveFollows(peopleListFollows) { continuation.resume(it) } - } - } - result?.let { emit(it) } ?: run { emit(LiveFollowLists()) } - } - } - .stateIn(scope, SharingStarted.Eagerly, LiveFollowLists()) - } - - private fun decryptLiveFollows( - peopleListFollows: NoteState?, - onReady: (LiveFollowLists) -> Unit, - ) { - val listEvent = (peopleListFollows?.note?.event as? GeneralListEvent) - listEvent?.privateTags(signer) { privateTagList -> - onReady( - LiveFollowLists( - users = - (listEvent.bookmarkedPeople() + listEvent.filterUsers(privateTagList)).toImmutableSet(), - hashtags = - (listEvent.hashtags() + listEvent.filterHashtags(privateTagList)).toImmutableSet(), - geotags = - (listEvent.geohashes() + listEvent.filterGeohashes(privateTagList)).toImmutableSet(), - communities = - (listEvent.taggedAddresses() + listEvent.filterAddresses(privateTagList)) - .map { it.toTag() } - .toImmutableSet(), - ), - ) - } - } - - @Immutable - data class LiveHiddenUsers( - val hiddenUsers: ImmutableSet, - val spammers: ImmutableSet, - val hiddenWords: ImmutableSet, - val hiddenWordsCase: List, - val showSensitiveContent: Boolean?, - ) - - val flowHiddenUsers: StateFlow by lazy { - combineTransform( - live.asFlow(), - getBlockListNote().flow().metadata.stateFlow, - getMuteListNote().flow().metadata.stateFlow, - ) { localLive, blockList, muteList -> - checkNotInMainThread() - - val resultBlockList = - (blockList.note.event as? PeopleListEvent)?.let { - withTimeoutOrNull(1000) { - suspendCancellableCoroutine { continuation -> - it.publicAndPrivateUsersAndWords(signer) { continuation.resume(it) } - } - } - } - ?: PeopleListEvent.UsersAndWords() - - val resultMuteList = - (muteList.note.event as? MuteListEvent)?.let { - withTimeoutOrNull(1000) { - suspendCancellableCoroutine { continuation -> - it.publicAndPrivateUsersAndWords(signer) { continuation.resume(it) } - } - } - } - ?: PeopleListEvent.UsersAndWords() - - val hiddenWords = resultBlockList.words + resultMuteList.words - - emit( - LiveHiddenUsers( - hiddenUsers = (resultBlockList.users + resultMuteList.users).toPersistentSet(), - hiddenWords = hiddenWords.toPersistentSet(), - hiddenWordsCase = hiddenWords.map { DualCase(it.lowercase(), it.uppercase()) }, - spammers = localLive.account.transientHiddenUsers, - showSensitiveContent = localLive.account.showSensitiveContent, - ), - ) - } - .stateIn( - scope, - SharingStarted.Eagerly, - LiveHiddenUsers( - hiddenUsers = persistentSetOf(), - hiddenWords = persistentSetOf(), - hiddenWordsCase = emptyList(), - spammers = transientHiddenUsers, - showSensitiveContent = showSensitiveContent, - ), - ) - } - - val liveHiddenUsers = flowHiddenUsers.asLiveData() - - val decryptBookmarks: LiveData by lazy { - userProfile().live().innerBookmarks.switchMap { userState -> - liveData(Dispatchers.IO) { - userState.user.latestBookmarkList?.privateTags(signer) { - scope.launch(Dispatchers.IO) { userState.user.latestBookmarkList?.let { emit(it) } } - } - } - } - } - - fun addPaymentRequestIfNew(paymentRequest: PaymentRequest) { - if ( - !this.transientPaymentRequests.value.contains(paymentRequest) && - !this.transientPaymentRequestDismissals.contains(paymentRequest) - ) { - this.transientPaymentRequests.value = transientPaymentRequests.value + paymentRequest - } - } - - fun dismissPaymentRequest(request: PaymentRequest) { - if (this.transientPaymentRequests.value.contains(request)) { - this.transientPaymentRequests.value = transientPaymentRequests.value - request - this.transientPaymentRequestDismissals = transientPaymentRequestDismissals + request - } - } - - var userProfileCache: User? = null - - fun updateOptOutOptions( - warnReports: Boolean, - filterSpam: Boolean, - ) { - warnAboutPostsWithReports = warnReports - filterSpamFromStrangers = filterSpam - LocalCache.antiSpam.active = filterSpamFromStrangers - if (!filterSpamFromStrangers) { - transientHiddenUsers = persistentSetOf() - } - live.invalidateData() - saveable.invalidateData() - } - - fun userProfile(): User { - return userProfileCache - ?: run { - val myUser: User = LocalCache.getOrCreateUser(keyPair.pubKey.toHexKey()) - userProfileCache = myUser - myUser - } - } - - fun isWriteable(): Boolean { - return keyPair.privKey != null || signer is NostrSignerExternal - } - - fun sendNewRelayList(relays: Map) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.updateRelayList( - earlierVersion = contactList, - relayUse = relays, - signer = signer, - ) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } else { - ContactListEvent.createFromScratch( - followUsers = listOf(), - followTags = listOf(), - followGeohashes = listOf(), - followCommunities = listOf(), - followEvents = DefaultChannels.toList(), - relayUse = relays, - signer = signer, - ) { - // Keep this local to avoid erasing a good contact list. - // Client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - suspend fun sendNewUserMetadata( - toString: String, - newName: String, - identities: List, - ) { - if (!isWriteable()) return - - MetadataEvent.create(toString, newName, identities, signer) { - Client.send(it) - LocalCache.justConsume(it, null) - } - - return - } - - fun reactionTo( - note: Note, - reaction: String, - ): List { - return note.reactedBy(userProfile(), reaction) - } - - fun hasBoosted(note: Note): Boolean { - return boostsTo(note).isNotEmpty() - } - - fun boostsTo(note: Note): List { - return note.boostedBy(userProfile()) - } - - fun hasReacted( - note: Note, - reaction: String, - ): Boolean { - return note.hasReacted(userProfile(), reaction) - } - - suspend fun reactTo( - note: Note, - reaction: String, - ) { - if (!isWriteable()) return - - if (hasReacted(note, reaction)) { - // has already liked this note - return - } - - if (note.event is ChatMessageEvent) { - val event = note.event as ChatMessageEvent - val users = event.recipientsPubKey().plus(event.pubKey).toSet().toList() - - if (reaction.startsWith(":")) { - val emojiUrl = EmojiUrl.decode(reaction) - if (emojiUrl != null) { - note.event?.let { - NIP24Factory().createReactionWithinGroup( - emojiUrl = emojiUrl, - originalNote = it, - to = users, - signer = signer, - ) { - broadcastPrivately(it) - } - } - - return - } - } - - note.event?.let { - NIP24Factory().createReactionWithinGroup( - content = reaction, - originalNote = it, - to = users, - signer = signer, - ) { - broadcastPrivately(it) - } - } - return - } else { - if (reaction.startsWith(":")) { - val emojiUrl = EmojiUrl.decode(reaction) - if (emojiUrl != null) { - note.event?.let { - ReactionEvent.create(emojiUrl, it, signer) { - Client.send(it) - LocalCache.consume(it) - } - } - - return - } - } - - note.event?.let { - ReactionEvent.create(reaction, it, signer) { - Client.send(it) - LocalCache.consume(it) - } - } - } - } - - fun createZapRequestFor( - note: Note, - pollOption: Int?, - message: String = "", - zapType: LnZapEvent.ZapType, - toUser: User?, - onReady: (LnZapRequestEvent) -> Unit, - ) { - if (!isWriteable()) return - - note.event?.let { event -> - LnZapRequestEvent.create( - event, - userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } - ?: localRelays.map { it.url }.toSet(), - signer, - pollOption, - message, - zapType, - toUser?.pubkeyHex, - onReady = onReady, - ) - } - } - - fun hasWalletConnectSetup(): Boolean { - return zapPaymentRequest != null - } - - fun isNIP47Author(pubkeyHex: String?): Boolean { - return (getNIP47Signer().pubKey == pubkeyHex) - } - - fun getNIP47Signer(): NostrSigner { - return zapPaymentRequest?.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) } - ?: signer - } - - fun decryptZapPaymentResponseEvent( - zapResponseEvent: LnZapPaymentResponseEvent, - onReady: (Response) -> Unit, - ) { - val myNip47 = zapPaymentRequest ?: return - - val signer = - myNip47.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) } ?: signer - - zapResponseEvent.response(signer, onReady) - } - - fun calculateIfNoteWasZappedByAccount( - zappedNote: Note?, - onWasZapped: () -> Unit, - ) { - zappedNote?.isZappedBy(userProfile(), this, onWasZapped) - } - - fun calculateZappedAmount( - zappedNote: Note?, - onReady: (BigDecimal) -> Unit, - ) { - zappedNote?.zappedAmountWithNWCPayments(getNIP47Signer(), onReady) - } - - fun sendZapPaymentRequestFor( - bolt11: String, - zappedNote: Note?, - onResponse: (Response?) -> Unit, - ) { - if (!isWriteable()) return - - zapPaymentRequest?.let { nip47 -> - val signer = - nip47.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) } ?: signer - - LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, signer) { event -> - val wcListener = - NostrLnZapPaymentResponseDataSource( - fromServiceHex = nip47.pubKeyHex, - toUserHex = event.pubKey, - replyingToHex = event.id, - authSigner = signer, - ) - wcListener.start() - - LocalCache.consume(event, zappedNote) { it.response(signer) { onResponse(it) } } - - Client.send(event, nip47.relayUri, wcListener.feedTypes) { wcListener.destroy() } - } - } - } - - fun createZapRequestFor( - userPubKeyHex: String, - message: String = "", - zapType: LnZapEvent.ZapType, - onReady: (LnZapRequestEvent) -> Unit, - ) { - LnZapRequestEvent.create( - userPubKeyHex, - userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } - ?: localRelays.map { it.url }.toSet(), - signer, - message, - zapType, - onReady = onReady, + data class PaymentRequest( + val relayUrl: String, + val description: String, ) - } - suspend fun report( - note: Note, - type: ReportEvent.ReportType, - content: String = "", - ) { - if (!isWriteable()) return + var transientPaymentRequestDismissals: Set = emptySet() + val transientPaymentRequests: MutableStateFlow> = MutableStateFlow(emptySet()) - if (note.hasReacted(userProfile(), "โš ๏ธ")) { - // has already liked this note - return - } + // Observers line up here. + val live: AccountLiveData = AccountLiveData(this) + val liveLanguages: AccountLiveData = AccountLiveData(this) + val saveable: AccountLiveData = AccountLiveData(this) - note.event?.let { - ReactionEvent.createWarning(it, signer) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - - note.event?.let { - ReportEvent.create(it, type, signer, content) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - suspend fun report( - user: User, - type: ReportEvent.ReportType, - ) { - if (!isWriteable()) return - - if (user.hasReport(userProfile(), type)) { - // has already reported this note - return - } - - ReportEvent.create(user.pubkeyHex, type, signer) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - - suspend fun delete(note: Note) { - return delete(listOf(note)) - } - - suspend fun delete(notes: List) { - if (!isWriteable()) return - - val myNotes = notes.filter { it.author == userProfile() }.mapNotNull { it.event?.id() } - - if (myNotes.isNotEmpty()) { - DeletionEvent.create(myNotes, signer) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - fun createHTTPAuthorization( - url: String, - method: String, - body: ByteArray? = null, - onReady: (HTTPAuthorizationEvent) -> Unit, - ) { - if (!isWriteable()) return - - HTTPAuthorizationEvent.create(url, method, body, signer, onReady = onReady) - } - - suspend fun boost(note: Note) { - if (!isWriteable()) return - - if (note.hasBoostedInTheLast5Minutes(userProfile())) { - // has already bosted in the past 5mins - return - } - - note.event?.let { - if (it.kind() == 1) { - RepostEvent.create(it, signer) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } else { - GenericRepostEvent.create(it, signer) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - } - } - - fun broadcast(note: Note) { - note.event?.let { - if (it is WrappedEvent && it.host != null) { - it.host?.let { hostEvent -> Client.send(hostEvent) } - } else { - Client.send(it) - } - } - } - - fun follow(user: User) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null) { - ContactListEvent.followUser(contactList, user.pubkeyHex, signer) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } else { - ContactListEvent.createFromScratch( - followUsers = listOf(Contact(user.pubkeyHex, null)), - followTags = emptyList(), - followGeohashes = emptyList(), - followCommunities = emptyList(), - followEvents = DefaultChannels.toList(), - relayUse = - Constants.defaultRelays.associate { - it.url to ContactListEvent.ReadWrite(it.read, it.write) - }, - signer = signer, - ) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - fun follow(channel: Channel) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null) { - ContactListEvent.followEvent(contactList, channel.idHex, signer) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } else { - ContactListEvent.createFromScratch( - followUsers = emptyList(), - followTags = emptyList(), - followGeohashes = emptyList(), - followCommunities = emptyList(), - followEvents = DefaultChannels.toList().plus(channel.idHex), - relayUse = - Constants.defaultRelays.associate { - it.url to ContactListEvent.ReadWrite(it.read, it.write) - }, - signer = signer, - ) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - fun follow(community: AddressableNote) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null) { - ContactListEvent.followAddressableEvent(contactList, community.address, signer) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } else { - val relays = - Constants.defaultRelays.associate { - it.url to ContactListEvent.ReadWrite(it.read, it.write) - } - ContactListEvent.createFromScratch( - followUsers = emptyList(), - followTags = emptyList(), - followGeohashes = emptyList(), - followCommunities = listOf(community.address), - followEvents = DefaultChannels.toList(), - relayUse = relays, - signer = signer, - ) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - fun followHashtag(tag: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null) { - ContactListEvent.followHashtag( - contactList, - tag, - signer, - ) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } else { - ContactListEvent.createFromScratch( - followUsers = emptyList(), - followTags = listOf(tag), - followGeohashes = emptyList(), - followCommunities = emptyList(), - followEvents = DefaultChannels.toList(), - relayUse = - Constants.defaultRelays.associate { - it.url to ContactListEvent.ReadWrite(it.read, it.write) - }, - signer = signer, - ) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - fun followGeohash(geohash: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null) { - ContactListEvent.followGeohash( - contactList, - geohash, - signer, - onReady = this::onNewEventCreated, - ) - } else { - ContactListEvent.createFromScratch( - followUsers = emptyList(), - followTags = emptyList(), - followGeohashes = listOf(geohash), - followCommunities = emptyList(), - followEvents = DefaultChannels.toList(), - relayUse = - Constants.defaultRelays.associate { - it.url to ContactListEvent.ReadWrite(it.read, it.write) - }, - signer = signer, - onReady = this::onNewEventCreated, - ) - } - } - - fun onNewEventCreated(event: Event) { - Client.send(event) - LocalCache.justConsume(event, null) - } - - fun unfollow(user: User) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowUser( - contactList, - user.pubkeyHex, - signer, - onReady = this::onNewEventCreated, - ) - } - } - - suspend fun unfollowHashtag(tag: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowHashtag( - contactList, - tag, - signer, - onReady = this::onNewEventCreated, - ) - } - } - - suspend fun unfollowGeohash(geohash: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowGeohash( - contactList, - geohash, - signer, - onReady = this::onNewEventCreated, - ) - } - } - - suspend fun unfollow(channel: Channel) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowEvent( - contactList, - channel.idHex, - signer, - onReady = this::onNewEventCreated, - ) - } - } - - suspend fun unfollow(community: AddressableNote) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowAddressableEvent( - contactList, - community.address, - signer, - onReady = this::onNewEventCreated, - ) - } - } - - fun createNip95( - byteArray: ByteArray, - headerInfo: FileHeader, - alt: String?, - sensitiveContent: Boolean, - onReady: (Pair) -> Unit, - ) { - if (!isWriteable()) return - - FileStorageEvent.create( - mimeType = headerInfo.mimeType ?: "", - data = byteArray, - signer = signer, - ) { data -> - FileStorageHeaderEvent.create( - data, - mimeType = headerInfo.mimeType, - hash = headerInfo.hash, - size = headerInfo.size.toString(), - dimensions = headerInfo.dim, - blurhash = headerInfo.blurHash, - alt = alt, - sensitiveContent = sensitiveContent, - signer = signer, - ) { signedEvent -> - onReady( - Pair(data, signedEvent), - ) - } - } - } - - fun consumeAndSendNip95( - data: FileStorageEvent, - signedEvent: FileStorageHeaderEvent, - relayList: List? = null, - ): Note? { - if (!isWriteable()) return null - - Client.send(data, relayList = relayList) - LocalCache.consume(data, null) - - Client.send(signedEvent, relayList = relayList) - LocalCache.consume(signedEvent, null) - - return LocalCache.notes[signedEvent.id] - } - - fun consumeNip95( - data: FileStorageEvent, - signedEvent: FileStorageHeaderEvent, - ): Note? { - LocalCache.consume(data, null) - LocalCache.consume(signedEvent, null) - - return LocalCache.notes[signedEvent.id] - } - - fun sendNip95( - data: FileStorageEvent, - signedEvent: FileStorageHeaderEvent, - relayList: List? = null, - ) { - Client.send(data, relayList = relayList) - Client.send(signedEvent, relayList = relayList) - } - - fun sendHeader( - signedEvent: FileHeaderEvent, - relayList: List? = null, - onReady: (Note) -> Unit, - ) { - Client.send(signedEvent, relayList = relayList) - LocalCache.consume(signedEvent, null) - - LocalCache.notes[signedEvent.id]?.let { onReady(it) } - } - - fun createHeader( - imageUrl: String, - magnetUri: String?, - headerInfo: FileHeader, - alt: String?, - sensitiveContent: Boolean, - originalHash: String? = null, - onReady: (FileHeaderEvent) -> Unit, - ) { - if (!isWriteable()) return - - FileHeaderEvent.create( - url = imageUrl, - magnetUri = magnetUri, - mimeType = headerInfo.mimeType, - hash = headerInfo.hash, - size = headerInfo.size.toString(), - dimensions = headerInfo.dim, - blurhash = headerInfo.blurHash, - alt = alt, - originalHash = originalHash, - sensitiveContent = sensitiveContent, - signer = signer, - ) { event -> - onReady(event) - } - } - - fun sendHeader( - imageUrl: String, - magnetUri: String?, - headerInfo: FileHeader, - alt: String?, - sensitiveContent: Boolean, - originalHash: String? = null, - relayList: List? = null, - onReady: (Note) -> Unit, - ) { - if (!isWriteable()) return - - FileHeaderEvent.create( - url = imageUrl, - magnetUri = magnetUri, - mimeType = headerInfo.mimeType, - hash = headerInfo.hash, - size = headerInfo.size.toString(), - dimensions = headerInfo.dim, - blurhash = headerInfo.blurHash, - alt = alt, - originalHash = originalHash, - sensitiveContent = sensitiveContent, - signer = signer, - ) { event -> - sendHeader(event, relayList = relayList, onReady) - } - } - - fun sendClassifieds( - title: String, - price: Price, - condition: ClassifiedsEvent.CONDITION, - location: String, - category: String, - message: String, - replyTo: List?, - mentions: List?, - directMentions: Set, - zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean, - zapRaiserAmount: Long? = null, - relayList: List? = null, - geohash: String? = null, - nip94attachments: List? = null, - ) { - if (!isWriteable()) return - - val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } - val mentionsHex = mentions?.map { it.pubkeyHex } - val addresses = replyTo?.mapNotNull { it.address() } - - ClassifiedsEvent.create( - dTag = UUID.randomUUID().toString(), - title = title, - price = price, - condition = condition, - summary = message, - image = null, - location = location, - category = category, - message = message, - replyTos = repliesToHex, - mentions = mentionsHex, - addresses = addresses, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - directMentions = directMentions, - geohash = geohash, - nip94attachments = nip94attachments, - signer = signer, - ) { - Client.send(it, relayList = relayList) - LocalCache.justConsume(it, null) - - replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } } - addresses?.forEach { - LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { - Client.send(it, relayList = relayList) - } - } - } - } - - fun sendPost( - message: String, - replyTo: List?, - mentions: List?, - tags: List? = null, - zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean, - zapRaiserAmount: Long? = null, - replyingTo: String?, - root: String?, - directMentions: Set, - relayList: List? = null, - geohash: String? = null, - nip94attachments: List? = null, - ) { - if (!isWriteable()) return - - val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } - val mentionsHex = mentions?.map { it.pubkeyHex } - val addresses = replyTo?.mapNotNull { it.address() } - - TextNoteEvent.create( - msg = message, - replyTos = repliesToHex, - mentions = mentionsHex, - addresses = addresses, - extraTags = tags, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - replyingTo = replyingTo, - root = root, - directMentions = directMentions, - geohash = geohash, - nip94attachments = nip94attachments, - signer = signer, - ) { - Client.send(it, relayList = relayList) - LocalCache.justConsume(it, null) - - // broadcast replied notes - replyingTo?.let { - LocalCache.getNoteIfExists(replyingTo)?.event?.let { - Client.send(it, relayList = relayList) - } - } - replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } } - addresses?.forEach { - LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { - Client.send(it, relayList = relayList) - } - } - } - } - - fun sendPoll( - message: String, - replyTo: List?, - mentions: List?, - pollOptions: Map, - valueMaximum: Int?, - valueMinimum: Int?, - consensusThreshold: Int?, - closedAt: Int?, - zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean, - zapRaiserAmount: Long? = null, - relayList: List? = null, - geohash: String? = null, - nip94attachments: List? = null, - ) { - if (!isWriteable()) return - - val repliesToHex = replyTo?.map { it.idHex } - val mentionsHex = mentions?.map { it.pubkeyHex } - val addresses = replyTo?.mapNotNull { it.address() } - - PollNoteEvent.create( - msg = message, - replyTos = repliesToHex, - mentions = mentionsHex, - addresses = addresses, - signer = signer, - pollOptions = pollOptions, - valueMaximum = valueMaximum, - valueMinimum = valueMinimum, - consensusThreshold = consensusThreshold, - closedAt = closedAt, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - geohash = geohash, - nip94attachments = nip94attachments, - ) { - Client.send(it, relayList = relayList) - LocalCache.justConsume(it, null) - - // Rebroadcast replies and tags to the current relay set - replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } } - addresses?.forEach { - LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { - Client.send(it, relayList = relayList) - } - } - } - } - - fun sendChannelMessage( - message: String, - toChannel: String, - replyTo: List?, - mentions: List?, - zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean, - zapRaiserAmount: Long? = null, - geohash: String? = null, - nip94attachments: List? = null, - ) { - if (!isWriteable()) return - - val repliesToHex = replyTo?.map { it.idHex } - val mentionsHex = mentions?.map { it.pubkeyHex } - - ChannelMessageEvent.create( - message = message, - channel = toChannel, - replyTos = repliesToHex, - mentions = mentionsHex, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - geohash = geohash, - nip94attachments = nip94attachments, - signer = signer, - ) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - - fun sendLiveMessage( - message: String, - toChannel: ATag, - replyTo: List?, - mentions: List?, - zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean, - zapRaiserAmount: Long? = null, - geohash: String? = null, - nip94attachments: List? = null, - ) { - if (!isWriteable()) return - - // val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } - val repliesToHex = replyTo?.map { it.idHex } - val mentionsHex = mentions?.map { it.pubkeyHex } - - LiveActivitiesChatMessageEvent.create( - message = message, - activity = toChannel, - replyTos = repliesToHex, - mentions = mentionsHex, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - geohash = geohash, - nip94attachments = nip94attachments, - signer = signer, - ) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - - fun sendPrivateMessage( - message: String, - toUser: User, - replyingTo: Note? = null, - mentions: List?, - zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean, - zapRaiserAmount: Long? = null, - geohash: String? = null, - ) { - sendPrivateMessage( - message, - toUser.pubkeyHex, - replyingTo, - mentions, - zapReceiver, - wantsToMarkAsSensitive, - zapRaiserAmount, - geohash, + @Immutable + data class LiveFollowLists( + val users: ImmutableSet = persistentSetOf(), + val hashtags: ImmutableSet = persistentSetOf(), + val geotags: ImmutableSet = persistentSetOf(), + val communities: ImmutableSet = persistentSetOf(), ) - } - fun sendPrivateMessage( - message: String, - toUser: HexKey, - replyingTo: Note? = null, - mentions: List?, - zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean, - zapRaiserAmount: Long? = null, - geohash: String? = null, - ) { - if (!isWriteable()) return - - val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } - val mentionsHex = mentions?.map { it.pubkeyHex } - - PrivateDmEvent.create( - recipientPubKey = toUser, - publishedRecipientPubKey = toUser, - msg = message, - replyTos = repliesToHex, - mentions = mentionsHex, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - geohash = geohash, - signer = signer, - advertiseNip18 = false, - ) { - Client.send(it) - LocalCache.consume(it, null) + @OptIn(ExperimentalCoroutinesApi::class) + val liveKind3Follows: StateFlow by lazy { + userProfile() + .live() + .follows + .asFlow() + .transformLatest { + emit( + LiveFollowLists( + userProfile().cachedFollowingKeySet().toImmutableSet(), + userProfile().cachedFollowingTagSet().toImmutableSet(), + userProfile().cachedFollowingGeohashSet().toImmutableSet(), + userProfile().cachedFollowingCommunitiesSet().toImmutableSet(), + ), + ) + } + .stateIn(scope, SharingStarted.Eagerly, LiveFollowLists()) } - } - fun sendNIP24PrivateMessage( - message: String, - toUsers: List, - subject: String? = null, - replyingTo: Note? = null, - mentions: List?, - zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean, - zapRaiserAmount: Long? = null, - geohash: String? = null, - ) { - if (!isWriteable()) return - - val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } - val mentionsHex = mentions?.map { it.pubkeyHex } - - NIP24Factory().createMsgNIP24( - msg = message, - to = toUsers, - subject = subject, - replyTos = repliesToHex, - mentions = mentionsHex, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - geohash = geohash, - signer = signer, - ) { - broadcastPrivately(it) + @OptIn(ExperimentalCoroutinesApi::class) + private val liveHomeList: StateFlow by lazy { + defaultHomeFollowList + .transformLatest { + LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let { + emit(it) + } + } + .flattenMerge() + .stateIn(scope, SharingStarted.Eagerly, null) } - } - fun broadcastPrivately(signedEvents: NIP24Factory.Result) { - val mine = signedEvents.wraps.filter { (it.recipientPubKey() == signer.pubKey) } - - mine.forEach { giftWrap -> - giftWrap.cachedGift(signer) { gift -> - if (gift is SealedGossipEvent) { - gift.cachedGossip(signer) { gossip -> LocalCache.justConsume(gossip, null) } - } else { - LocalCache.justConsume(gift, null) + val liveHomeFollowLists: StateFlow by lazy { + combineTransform(defaultHomeFollowList, liveKind3Follows, liveHomeList) { + listName, + kind3Follows, + peopleListFollows, + -> + if (listName == GLOBAL_FOLLOWS) { + emit(null) + } else if (listName == KIND3_FOLLOWS) { + emit(kind3Follows) + } else { + val result = + withTimeoutOrNull(1000) { + suspendCancellableCoroutine { continuation -> + decryptLiveFollows(peopleListFollows) { continuation.resume(it) } + } + } + result?.let { emit(it) } ?: run { emit(LiveFollowLists()) } + } } - } - - LocalCache.consume(giftWrap, null) + .stateIn(scope, SharingStarted.Eagerly, LiveFollowLists()) } - val id = mine.firstOrNull()?.id - val mineNote = if (id == null) null else LocalCache.getNoteIfExists(id) - - signedEvents.wraps.forEach { - // Creates an alias - if (mineNote != null && it.recipientPubKey() != keyPair.pubKey.toHexKey()) { - LocalCache.getOrAddAliasNote(it.id, mineNote) - } - - Client.send(it) - } - } - - fun sendCreateNewChannel( - name: String, - about: String, - picture: String, - ) { - if (!isWriteable()) return - - ChannelCreateEvent.create( - name = name, - about = about, - picture = picture, - signer = signer, - ) { - Client.send(it) - LocalCache.justConsume(it, null) - - LocalCache.getChannelIfExists(it.id)?.let { follow(it) } - } - } - - fun updateStatus( - oldStatus: AddressableNote, - newStatus: String, - ) { - if (!isWriteable()) return - val oldEvent = oldStatus.event as? StatusEvent ?: return - - StatusEvent.update(oldEvent, newStatus, signer) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - - fun createStatus(newStatus: String) { - if (!isWriteable()) return - - StatusEvent.create(newStatus, "general", expiration = null, signer) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - - fun deleteStatus(oldStatus: AddressableNote) { - if (!isWriteable()) return - val oldEvent = oldStatus.event as? StatusEvent ?: return - - StatusEvent.clear(oldEvent, signer) { event -> - Client.send(event) - LocalCache.justConsume(event, null) - - DeletionEvent.create(listOf(event.id), signer) { event2 -> - Client.send(event2) - LocalCache.justConsume(event2, null) - } - } - } - - fun removeEmojiPack( - usersEmojiList: Note, - emojiList: Note, - ) { - if (!isWriteable()) return - - val noteEvent = usersEmojiList.event - if (noteEvent !is EmojiPackSelectionEvent) return - val emojiListEvent = emojiList.event - if (emojiListEvent !is EmojiPackEvent) return - - EmojiPackSelectionEvent.create( - noteEvent.taggedAddresses().filter { it != emojiListEvent.address() }, - signer, - ) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - - fun addEmojiPack( - usersEmojiList: Note, - emojiList: Note, - ) { - if (!isWriteable()) return - val emojiListEvent = emojiList.event - if (emojiListEvent !is EmojiPackEvent) return - - if (usersEmojiList.event == null) { - EmojiPackSelectionEvent.create( - listOf(emojiListEvent.address()), - signer, - ) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } else { - val noteEvent = usersEmojiList.event - if (noteEvent !is EmojiPackSelectionEvent) return - - if (noteEvent.taggedAddresses().any { it == emojiListEvent.address() }) { - return - } - - EmojiPackSelectionEvent.create( - noteEvent.taggedAddresses().plus(emojiListEvent.address()), - signer, - ) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - fun addBookmark( - note: Note, - isPrivate: Boolean, - ) { - if (!isWriteable()) return - - if (note is AddressableNote) { - BookmarkListEvent.addReplaceable( - userProfile().latestBookmarkList, - note.address, - isPrivate, - signer, - ) { - Client.send(it) - LocalCache.consume(it) - } - } else { - BookmarkListEvent.addEvent( - userProfile().latestBookmarkList, - note.idHex, - isPrivate, - signer, - ) { - Client.send(it) - LocalCache.consume(it) - } - } - } - - fun removeBookmark( - note: Note, - isPrivate: Boolean, - ) { - if (!isWriteable()) return - - val bookmarks = userProfile().latestBookmarkList ?: return - - if (note is AddressableNote) { - BookmarkListEvent.removeReplaceable( - bookmarks, - note.address, - isPrivate, - signer, - ) { - Client.send(it) - LocalCache.consume(it) - } - } else { - BookmarkListEvent.removeEvent( - bookmarks, - note.idHex, - isPrivate, - signer, - ) { - Client.send(it) - LocalCache.consume(it) - } - } - } - - fun createAuthEvent( - relay: Relay, - challenge: String, - onReady: (RelayAuthEvent) -> Unit, - ) { - return createAuthEvent(relay.url, challenge, onReady = onReady) - } - - fun createAuthEvent( - relayUrl: String, - challenge: String, - onReady: (RelayAuthEvent) -> Unit, - ) { - if (!isWriteable()) return - - RelayAuthEvent.create(relayUrl, challenge, signer, onReady = onReady) - } - - fun isInPrivateBookmarks( - note: Note, - onReady: (Boolean) -> Unit, - ) { - if (!isWriteable()) return - - if (note is AddressableNote) { - userProfile().latestBookmarkList?.privateTaggedAddresses(signer) { - onReady(it.contains(note.address)) - } - } else { - userProfile().latestBookmarkList?.privateTaggedEvents(signer) { - onReady(it.contains(note.idHex)) - } - } - } - - fun isInPublicBookmarks(note: Note): Boolean { - if (!isWriteable()) return false - - if (note is AddressableNote) { - return userProfile().latestBookmarkList?.taggedAddresses()?.contains(note.address) == true - } else { - return userProfile().latestBookmarkList?.taggedEvents()?.contains(note.idHex) == true - } - } - - fun getBlockListNote(): AddressableNote { - val aTag = - ATag( - PeopleListEvent.KIND, - userProfile().pubkeyHex, - PeopleListEvent.BLOCK_LIST_D_TAG, - null, - ) - return LocalCache.getOrCreateAddressableNote(aTag) - } - - fun getMuteListNote(): AddressableNote { - val aTag = - ATag( - MuteListEvent.KIND, - userProfile().pubkeyHex, - "", - null, - ) - return LocalCache.getOrCreateAddressableNote(aTag) - } - - fun getFileServersNote(): AddressableNote { - val aTag = - ATag( - FileServersEvent.KIND, - userProfile().pubkeyHex, - "", - null, - ) - return LocalCache.getOrCreateAddressableNote(aTag) - } - - fun getBlockList(): PeopleListEvent? { - return getBlockListNote().event as? PeopleListEvent - } - - fun getMuteList(): MuteListEvent? { - return getMuteListNote().event as? MuteListEvent - } - - fun getFileServersList(): FileServersEvent? { - return getFileServersNote().event as? FileServersEvent - } - - fun hideWord(word: String) { - val muteList = getMuteList() - - if (muteList != null) { - MuteListEvent.addWord( - earlierVersion = muteList, - word = word, - isPrivate = true, - signer = signer, - ) { - Client.send(it) - LocalCache.consume(it, null) - } - } else { - MuteListEvent.createListWithWord( - word = word, - isPrivate = true, - signer = signer, - ) { - Client.send(it) - LocalCache.consume(it, null) - } - } - } - - fun showWord(word: String) { - val blockList = getBlockList() - - if (blockList != null) { - PeopleListEvent.removeWord( - earlierVersion = blockList, - word = word, - isPrivate = true, - signer = signer, - ) { - Client.send(it) - LocalCache.consume(it, null) - } + @OptIn(ExperimentalCoroutinesApi::class) + private val liveNotificationList: StateFlow by lazy { + defaultNotificationFollowList + .transformLatest { + LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let { + emit(it) + } + } + .flattenMerge() + .stateIn(scope, SharingStarted.Eagerly, null) } - val muteList = getMuteList() - - if (muteList != null) { - MuteListEvent.removeWord( - earlierVersion = muteList, - word = word, - isPrivate = true, - signer = signer, - ) { - Client.send(it) - LocalCache.consume(it, null) - } - } - } - - fun hideUser(pubkeyHex: String) { - val muteList = getMuteList() - - if (muteList != null) { - MuteListEvent.addUser( - earlierVersion = muteList, - pubKeyHex = pubkeyHex, - isPrivate = true, - signer = signer, - ) { - Client.send(it) - LocalCache.consume(it, null) - } - } else { - MuteListEvent.createListWithUser( - pubKeyHex = pubkeyHex, - isPrivate = true, - signer = signer, - ) { - Client.send(it) - LocalCache.consume(it, null) - } - } - } - - fun showUser(pubkeyHex: String) { - val blockList = getBlockList() - - if (blockList != null) { - PeopleListEvent.removeUser( - earlierVersion = blockList, - pubKeyHex = pubkeyHex, - isPrivate = true, - signer = signer, - ) { - Client.send(it) - LocalCache.consume(it, null) - } - } - - val muteList = getMuteList() - - if (muteList != null) { - MuteListEvent.removeUser( - earlierVersion = muteList, - pubKeyHex = pubkeyHex, - isPrivate = true, - signer = signer, - ) { - Client.send(it) - LocalCache.consume(it, null) - } - } - - transientHiddenUsers = (transientHiddenUsers - pubkeyHex).toImmutableSet() - live.invalidateData() - saveable.invalidateData() - } - - fun changeDefaultZapType(zapType: LnZapEvent.ZapType) { - defaultZapType = zapType - live.invalidateData() - saveable.invalidateData() - } - - fun changeDefaultFileServer(server: Nip96MediaServers.ServerName) { - defaultFileServer = server - live.invalidateData() - saveable.invalidateData() - } - - fun changeDefaultHomeFollowList(name: String) { - defaultHomeFollowList.tryEmit(name) - live.invalidateData() - saveable.invalidateData() - } - - fun changeDefaultStoriesFollowList(name: String) { - defaultStoriesFollowList.tryEmit(name) - live.invalidateData() - saveable.invalidateData() - } - - fun changeDefaultNotificationFollowList(name: String) { - defaultNotificationFollowList.tryEmit(name) - live.invalidateData() - saveable.invalidateData() - } - - fun changeDefaultDiscoveryFollowList(name: String) { - defaultDiscoveryFollowList.tryEmit(name) - live.invalidateData() - saveable.invalidateData() - } - - fun changeZapAmounts(newAmounts: List) { - zapAmountChoices = newAmounts - live.invalidateData() - saveable.invalidateData() - } - - fun changeReactionTypes(newTypes: List) { - reactionChoices = newTypes - live.invalidateData() - saveable.invalidateData() - } - - fun changeZapPaymentRequest(newServer: Nip47URI?) { - zapPaymentRequest = newServer - live.invalidateData() - saveable.invalidateData() - } - - fun selectedChatsFollowList(): Set { - val contactList = userProfile().latestContactList - return contactList?.taggedEvents()?.toSet() ?: DefaultChannels - } - - fun sendChangeChannel( - name: String, - about: String, - picture: String, - channel: Channel, - ) { - if (!isWriteable()) return - - ChannelMetadataEvent.create( - name, - about, - picture, - originalChannelIdHex = channel.idHex, - signer = signer, - ) { - Client.send(it) - LocalCache.justConsume(it, null) - - follow(channel) - } - } - - fun unwrap( - event: GiftWrapEvent, - onReady: (Event) -> Unit, - ) { - if (!isWriteable()) return - - return event.cachedGift(signer, onReady) - } - - fun unseal( - event: SealedGossipEvent, - onReady: (Event) -> Unit, - ) { - if (!isWriteable()) return - - return event.cachedGossip(signer, onReady) - } - - fun cachedDecryptContent(note: Note): String? { - val event = note.event - return if (event is PrivateDmEvent && isWriteable()) { - event.cachedContentFor(signer) - } else if (event is LnZapRequestEvent && event.isPrivateZap() && isWriteable()) { - event.cachedPrivateZap()?.content - } else { - event?.content() - } - } - - fun decryptContent( - note: Note, - onReady: (String) -> Unit, - ) { - val event = note.event - if (event is PrivateDmEvent && isWriteable()) { - event.plainContent(signer, onReady) - } else if (event is LnZapRequestEvent) { - decryptZapContentAuthor(note) { onReady(it.content) } - } else { - event?.content()?.let { onReady(it) } - } - } - - fun decryptZapContentAuthor( - note: Note, - onReady: (Event) -> Unit, - ) { - val event = note.event - if (event is LnZapRequestEvent) { - if (event.isPrivateZap()) { - if (isWriteable()) { - event.decryptPrivateZap(signer) { onReady(it) } + val liveNotificationFollowLists: StateFlow by lazy { + combineTransform(defaultNotificationFollowList, liveKind3Follows, liveNotificationList) { + listName, + kind3Follows, + peopleListFollows, + -> + if (listName == GLOBAL_FOLLOWS) { + emit(null) + } else if (listName == KIND3_FOLLOWS) { + emit(kind3Follows) + } else { + val result = + withTimeoutOrNull(1000) { + suspendCancellableCoroutine { continuation -> + decryptLiveFollows(peopleListFollows) { continuation.resume(it) } + } + } + result?.let { emit(it) } ?: run { emit(LiveFollowLists()) } + } } - } else { - onReady(event) - } + .stateIn(scope, SharingStarted.Eagerly, LiveFollowLists()) } - } - fun addDontTranslateFrom(languageCode: String) { - dontTranslateFrom = dontTranslateFrom.plus(languageCode) - liveLanguages.invalidateData() - - saveable.invalidateData() - } - - fun updateTranslateTo(languageCode: String) { - translateTo = languageCode - liveLanguages.invalidateData() - - saveable.invalidateData() - } - - fun prefer( - source: String, - target: String, - preference: String, - ) { - languagePreferences = languagePreferences + Pair("$source,$target", preference) - saveable.invalidateData() - } - - fun preferenceBetween( - source: String, - target: String, - ): String? { - return languagePreferences.get("$source,$target") - } - - private fun updateContactListTo(newContactList: ContactListEvent?) { - if (newContactList == null || newContactList.tags.isEmpty()) return - - // Events might be different objects, we have to compare their ids. - if (backupContactList?.id != newContactList.id) { - backupContactList = newContactList - saveable.invalidateData() + @OptIn(ExperimentalCoroutinesApi::class) + private val liveStoriesList: StateFlow by lazy { + defaultStoriesFollowList + .transformLatest { + LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let { + emit(it) + } + } + .flattenMerge() + .stateIn(scope, SharingStarted.Eagerly, null) } - } - // Takes a User's relay list and adds the types of feeds they are active for. - fun activeRelays(): Array? { - var usersRelayList = - userProfile().latestContactList?.relays()?.map { - val localFeedTypes = - localRelays.firstOrNull { localRelay -> localRelay.url == it.key }?.feedTypes - ?: Constants.defaultRelays - .filter { defaultRelay -> defaultRelay.url == it.key } - .firstOrNull() - ?.feedTypes - ?: FeedType.values().toSet() + val liveStoriesFollowLists: StateFlow by lazy { + combineTransform(defaultStoriesFollowList, liveKind3Follows, liveStoriesList) { + listName, + kind3Follows, + peopleListFollows, + -> + if (listName == GLOBAL_FOLLOWS) { + emit(null) + } else if (listName == KIND3_FOLLOWS) { + emit(kind3Follows) + } else { + val result = + withTimeoutOrNull(1000) { + suspendCancellableCoroutine { continuation -> + decryptLiveFollows(peopleListFollows) { continuation.resume(it) } + } + } + result?.let { emit(it) } ?: run { emit(LiveFollowLists()) } + } + } + .stateIn(scope, SharingStarted.Eagerly, LiveFollowLists()) + } - Relay(it.key, it.value.read, it.value.write, localFeedTypes) - } - ?: return null + @OptIn(ExperimentalCoroutinesApi::class) + private val liveDiscoveryList: StateFlow by lazy { + defaultDiscoveryFollowList + .transformLatest { + LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let { + emit(it) + } + } + .flattenMerge() + .stateIn(scope, SharingStarted.Eagerly, null) + } - // Ugly, but forces nostr.band as the only search-supporting relay today. - // TODO: Remove when search becomes more available. - val searchRelays = - usersRelayList.filter { it.url.removeSuffix("/") in Constants.forcedRelaysForSearchSet } - val hasSearchRelay = usersRelayList.any { it.activeTypes.contains(FeedType.SEARCH) } - if (!hasSearchRelay && searchRelays.isEmpty()) { - usersRelayList = - usersRelayList + - Constants.forcedRelayForSearch.map { - Relay( - it.url, - it.read, - it.write, - it.feedTypes, + val liveDiscoveryFollowLists: StateFlow by lazy { + combineTransform(defaultDiscoveryFollowList, liveKind3Follows, liveDiscoveryList) { + listName, + kind3Follows, + peopleListFollows, + -> + if (listName == GLOBAL_FOLLOWS) { + emit(null) + } else if (listName == KIND3_FOLLOWS) { + emit(kind3Follows) + } else { + val result = + withTimeoutOrNull(1000) { + suspendCancellableCoroutine { continuation -> + decryptLiveFollows(peopleListFollows) { continuation.resume(it) } + } + } + result?.let { emit(it) } ?: run { emit(LiveFollowLists()) } + } + } + .stateIn(scope, SharingStarted.Eagerly, LiveFollowLists()) + } + + private fun decryptLiveFollows( + peopleListFollows: NoteState?, + onReady: (LiveFollowLists) -> Unit, + ) { + val listEvent = (peopleListFollows?.note?.event as? GeneralListEvent) + listEvent?.privateTags(signer) { privateTagList -> + onReady( + LiveFollowLists( + users = + (listEvent.bookmarkedPeople() + listEvent.filterUsers(privateTagList)).toImmutableSet(), + hashtags = + (listEvent.hashtags() + listEvent.filterHashtags(privateTagList)).toImmutableSet(), + geotags = + (listEvent.geohashes() + listEvent.filterGeohashes(privateTagList)).toImmutableSet(), + communities = + (listEvent.taggedAddresses() + listEvent.filterAddresses(privateTagList)) + .map { it.toTag() } + .toImmutableSet(), + ), ) - } - } - - return usersRelayList.toTypedArray() - } - - fun convertLocalRelays(): Array { - return localRelays.map { Relay(it.url, it.read, it.write, it.feedTypes) }.toTypedArray() - } - - fun activeGlobalRelays(): Array { - return (activeRelays() ?: convertLocalRelays()) - .filter { it.activeTypes.contains(FeedType.GLOBAL) } - .map { it.url } - .toTypedArray() - } - - fun activeWriteRelays(): List { - return (activeRelays() ?: convertLocalRelays()).filter { it.write } - } - - fun isAllHidden(users: Set): Boolean { - return users.all { isHidden(it) } - } - - fun isHidden(user: User) = isHidden(user.pubkeyHex) - - fun isHidden(userHex: String): Boolean { - return flowHiddenUsers.value.hiddenUsers.contains(userHex) || - flowHiddenUsers.value.spammers.contains(userHex) - } - - fun followingKeySet(): Set { - return userProfile().cachedFollowingKeySet() - } - - fun followingTagSet(): Set { - return userProfile().cachedFollowingTagSet() - } - - fun isAcceptable(user: User): Boolean { - if (userProfile().pubkeyHex == user.pubkeyHex) { - return true - } - - if (user.pubkeyHex in followingKeySet()) { - return true - } - - if (!warnAboutPostsWithReports) { - return !isHidden(user) && // if user hasn't hided this author - user.reportsBy(userProfile()).isEmpty() // if user has not reported this post - } - return !isHidden(user) && // if user hasn't hided this author - user.reportsBy(userProfile()).isEmpty() && // if user has not reported this post - user.countReportAuthorsBy(followingKeySet()) < 5 - } - - private fun isAcceptableDirect(note: Note): Boolean { - if (!warnAboutPostsWithReports) { - return !note.hasReportsBy(userProfile()) - } - return !note.hasReportsBy(userProfile()) && // if user has not reported this post - note.countReportAuthorsBy(followingKeySet()) < 5 // if it has 5 reports by reliable users - } - - fun isFollowing(user: User): Boolean { - return user.pubkeyHex in followingKeySet() - } - - fun isFollowing(user: HexKey): Boolean { - return user in followingKeySet() - } - - fun isAcceptable(note: Note): Boolean { - return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author - isAcceptableDirect(note) && - ((note.event !is RepostEvent && note.event !is GenericRepostEvent) || - (note.replyTo?.firstOrNull { isAcceptableDirect(it) } != - null)) // is not a reaction about a blocked post - } - - fun getRelevantReports(note: Note): Set { - val followsPlusMe = userProfile().latestContactList?.verifiedFollowKeySetAndMe ?: emptySet() - - val innerReports = - if (note.event is RepostEvent || note.event is GenericRepostEvent) { - note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList() - } else { - emptyList() - } - - return (note.reportsBy(followsPlusMe) + - (note.author?.reportsBy(followsPlusMe) ?: emptyList()) + - innerReports) - .toSet() - } - - fun saveRelayList(value: List) { - try { - localRelays = value.toSet() - return sendNewRelayList( - value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }, - ) - } finally { - saveable.invalidateData() - } - } - - fun setHideDeleteRequestDialog() { - hideDeleteRequestDialog = true - saveable.invalidateData() - } - - fun setHideNIP24WarningDialog() { - hideNIP24WarningDialog = true - saveable.invalidateData() - } - - fun setHideBlockAlertDialog() { - hideBlockAlertDialog = true - saveable.invalidateData() - } - - fun updateShowSensitiveContent(show: Boolean?) { - showSensitiveContent = show - saveable.invalidateData() - live.invalidateData() - } - - fun markAsRead( - route: String, - timestampInSecs: Long, - ): Boolean { - val lastTime = lastReadPerRoute[route] - return if (lastTime == null || timestampInSecs > lastTime) { - lastReadPerRoute = lastReadPerRoute + Pair(route, timestampInSecs) - saveable.invalidateData() - true - } else { - false - } - } - - fun loadLastRead(route: String): Long { - return lastReadPerRoute[route] ?: 0 - } - - suspend fun registerObservers() = - withContext(Dispatchers.Main) { - // saves contact list for the next time. - userProfile().live().follows.observeForever { - GlobalScope.launch(Dispatchers.IO) { updateContactListTo(userProfile().latestContactList) } - } - - // imports transient blocks due to spam. - LocalCache.antiSpam.liveSpam.observeForever { - GlobalScope.launch(Dispatchers.IO) { - it.cache.spamMessages.snapshot().values.forEach { - if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) { - if (it.pubkeyHex != userProfile().pubkeyHex && it.pubkeyHex !in followingKeySet()) { - transientHiddenUsers = (transientHiddenUsers + it.pubkeyHex).toImmutableSet() - live.invalidateData() - } - } - } } - } } - init { - Log.d("Init", "Account") - backupContactList?.let { - println("Loading saved contacts ${it.toJson()}") + @Immutable + data class LiveHiddenUsers( + val hiddenUsers: ImmutableSet, + val spammers: ImmutableSet, + val hiddenWords: ImmutableSet, + val hiddenWordsCase: List, + val showSensitiveContent: Boolean?, + ) - if (userProfile().latestContactList == null) { - GlobalScope.launch(Dispatchers.IO) { LocalCache.consume(it) } - } + val flowHiddenUsers: StateFlow by lazy { + combineTransform( + live.asFlow(), + getBlockListNote().flow().metadata.stateFlow, + getMuteListNote().flow().metadata.stateFlow, + ) { localLive, blockList, muteList -> + checkNotInMainThread() + + val resultBlockList = + (blockList.note.event as? PeopleListEvent)?.let { + withTimeoutOrNull(1000) { + suspendCancellableCoroutine { continuation -> + it.publicAndPrivateUsersAndWords(signer) { continuation.resume(it) } + } + } + } + ?: PeopleListEvent.UsersAndWords() + + val resultMuteList = + (muteList.note.event as? MuteListEvent)?.let { + withTimeoutOrNull(1000) { + suspendCancellableCoroutine { continuation -> + it.publicAndPrivateUsersAndWords(signer) { continuation.resume(it) } + } + } + } + ?: PeopleListEvent.UsersAndWords() + + val hiddenWords = resultBlockList.words + resultMuteList.words + + emit( + LiveHiddenUsers( + hiddenUsers = (resultBlockList.users + resultMuteList.users).toPersistentSet(), + hiddenWords = hiddenWords.toPersistentSet(), + hiddenWordsCase = hiddenWords.map { DualCase(it.lowercase(), it.uppercase()) }, + spammers = localLive.account.transientHiddenUsers, + showSensitiveContent = localLive.account.showSensitiveContent, + ), + ) + } + .stateIn( + scope, + SharingStarted.Eagerly, + LiveHiddenUsers( + hiddenUsers = persistentSetOf(), + hiddenWords = persistentSetOf(), + hiddenWordsCase = emptyList(), + spammers = transientHiddenUsers, + showSensitiveContent = showSensitiveContent, + ), + ) + } + + val liveHiddenUsers = flowHiddenUsers.asLiveData() + + val decryptBookmarks: LiveData by lazy { + userProfile().live().innerBookmarks.switchMap { userState -> + liveData(Dispatchers.IO) { + userState.user.latestBookmarkList?.privateTags(signer) { + scope.launch(Dispatchers.IO) { userState.user.latestBookmarkList?.let { emit(it) } } + } + } + } + } + + fun addPaymentRequestIfNew(paymentRequest: PaymentRequest) { + if ( + !this.transientPaymentRequests.value.contains(paymentRequest) && + !this.transientPaymentRequestDismissals.contains(paymentRequest) + ) { + this.transientPaymentRequests.value = transientPaymentRequests.value + paymentRequest + } + } + + fun dismissPaymentRequest(request: PaymentRequest) { + if (this.transientPaymentRequests.value.contains(request)) { + this.transientPaymentRequests.value = transientPaymentRequests.value - request + this.transientPaymentRequestDismissals = transientPaymentRequestDismissals + request + } + } + + var userProfileCache: User? = null + + fun updateOptOutOptions( + warnReports: Boolean, + filterSpam: Boolean, + ) { + warnAboutPostsWithReports = warnReports + filterSpamFromStrangers = filterSpam + LocalCache.antiSpam.active = filterSpamFromStrangers + if (!filterSpamFromStrangers) { + transientHiddenUsers = persistentSetOf() + } + live.invalidateData() + saveable.invalidateData() + } + + fun userProfile(): User { + return userProfileCache + ?: run { + val myUser: User = LocalCache.getOrCreateUser(keyPair.pubKey.toHexKey()) + userProfileCache = myUser + myUser + } + } + + fun isWriteable(): Boolean { + return keyPair.privKey != null || signer is NostrSignerExternal + } + + fun sendNewRelayList(relays: Map) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + + if (contactList != null && contactList.tags.isNotEmpty()) { + ContactListEvent.updateRelayList( + earlierVersion = contactList, + relayUse = relays, + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } else { + ContactListEvent.createFromScratch( + followUsers = listOf(), + followTags = listOf(), + followGeohashes = listOf(), + followCommunities = listOf(), + followEvents = DefaultChannels.toList(), + relayUse = relays, + signer = signer, + ) { + // Keep this local to avoid erasing a good contact list. + // Client.send(it) + LocalCache.justConsume(it, null) + } + } + } + + suspend fun sendNewUserMetadata( + toString: String, + newName: String, + identities: List, + ) { + if (!isWriteable()) return + + MetadataEvent.create(toString, newName, identities, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + + return + } + + fun reactionTo( + note: Note, + reaction: String, + ): List { + return note.reactedBy(userProfile(), reaction) + } + + fun hasBoosted(note: Note): Boolean { + return boostsTo(note).isNotEmpty() + } + + fun boostsTo(note: Note): List { + return note.boostedBy(userProfile()) + } + + fun hasReacted( + note: Note, + reaction: String, + ): Boolean { + return note.hasReacted(userProfile(), reaction) + } + + suspend fun reactTo( + note: Note, + reaction: String, + ) { + if (!isWriteable()) return + + if (hasReacted(note, reaction)) { + // has already liked this note + return + } + + if (note.event is ChatMessageEvent) { + val event = note.event as ChatMessageEvent + val users = event.recipientsPubKey().plus(event.pubKey).toSet().toList() + + if (reaction.startsWith(":")) { + val emojiUrl = EmojiUrl.decode(reaction) + if (emojiUrl != null) { + note.event?.let { + NIP24Factory().createReactionWithinGroup( + emojiUrl = emojiUrl, + originalNote = it, + to = users, + signer = signer, + ) { + broadcastPrivately(it) + } + } + + return + } + } + + note.event?.let { + NIP24Factory().createReactionWithinGroup( + content = reaction, + originalNote = it, + to = users, + signer = signer, + ) { + broadcastPrivately(it) + } + } + return + } else { + if (reaction.startsWith(":")) { + val emojiUrl = EmojiUrl.decode(reaction) + if (emojiUrl != null) { + note.event?.let { + ReactionEvent.create(emojiUrl, it, signer) { + Client.send(it) + LocalCache.consume(it) + } + } + + return + } + } + + note.event?.let { + ReactionEvent.create(reaction, it, signer) { + Client.send(it) + LocalCache.consume(it) + } + } + } + } + + fun createZapRequestFor( + note: Note, + pollOption: Int?, + message: String = "", + zapType: LnZapEvent.ZapType, + toUser: User?, + onReady: (LnZapRequestEvent) -> Unit, + ) { + if (!isWriteable()) return + + note.event?.let { event -> + LnZapRequestEvent.create( + event, + userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } + ?: localRelays.map { it.url }.toSet(), + signer, + pollOption, + message, + zapType, + toUser?.pubkeyHex, + onReady = onReady, + ) + } + } + + fun hasWalletConnectSetup(): Boolean { + return zapPaymentRequest != null + } + + fun isNIP47Author(pubkeyHex: String?): Boolean { + return (getNIP47Signer().pubKey == pubkeyHex) + } + + fun getNIP47Signer(): NostrSigner { + return zapPaymentRequest?.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) } + ?: signer + } + + fun decryptZapPaymentResponseEvent( + zapResponseEvent: LnZapPaymentResponseEvent, + onReady: (Response) -> Unit, + ) { + val myNip47 = zapPaymentRequest ?: return + + val signer = + myNip47.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) } ?: signer + + zapResponseEvent.response(signer, onReady) + } + + fun calculateIfNoteWasZappedByAccount( + zappedNote: Note?, + onWasZapped: () -> Unit, + ) { + zappedNote?.isZappedBy(userProfile(), this, onWasZapped) + } + + fun calculateZappedAmount( + zappedNote: Note?, + onReady: (BigDecimal) -> Unit, + ) { + zappedNote?.zappedAmountWithNWCPayments(getNIP47Signer(), onReady) + } + + fun sendZapPaymentRequestFor( + bolt11: String, + zappedNote: Note?, + onResponse: (Response?) -> Unit, + ) { + if (!isWriteable()) return + + zapPaymentRequest?.let { nip47 -> + val signer = + nip47.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) } ?: signer + + LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, signer) { event -> + val wcListener = + NostrLnZapPaymentResponseDataSource( + fromServiceHex = nip47.pubKeyHex, + toUserHex = event.pubKey, + replyingToHex = event.id, + authSigner = signer, + ) + wcListener.start() + + LocalCache.consume(event, zappedNote) { it.response(signer) { onResponse(it) } } + + Client.send(event, nip47.relayUri, wcListener.feedTypes) { wcListener.destroy() } + } + } + } + + fun createZapRequestFor( + userPubKeyHex: String, + message: String = "", + zapType: LnZapEvent.ZapType, + onReady: (LnZapRequestEvent) -> Unit, + ) { + LnZapRequestEvent.create( + userPubKeyHex, + userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } + ?: localRelays.map { it.url }.toSet(), + signer, + message, + zapType, + onReady = onReady, + ) + } + + suspend fun report( + note: Note, + type: ReportEvent.ReportType, + content: String = "", + ) { + if (!isWriteable()) return + + if (note.hasReacted(userProfile(), "โš ๏ธ")) { + // has already liked this note + return + } + + note.event?.let { + ReactionEvent.createWarning(it, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + + note.event?.let { + ReportEvent.create(it, type, signer, content) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + } + + suspend fun report( + user: User, + type: ReportEvent.ReportType, + ) { + if (!isWriteable()) return + + if (user.hasReport(userProfile(), type)) { + // has already reported this note + return + } + + ReportEvent.create(user.pubkeyHex, type, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + + suspend fun delete(note: Note) { + return delete(listOf(note)) + } + + suspend fun delete(notes: List) { + if (!isWriteable()) return + + val myNotes = notes.filter { it.author == userProfile() }.mapNotNull { it.event?.id() } + + if (myNotes.isNotEmpty()) { + DeletionEvent.create(myNotes, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + } + + fun createHTTPAuthorization( + url: String, + method: String, + body: ByteArray? = null, + onReady: (HTTPAuthorizationEvent) -> Unit, + ) { + if (!isWriteable()) return + + HTTPAuthorizationEvent.create(url, method, body, signer, onReady = onReady) + } + + suspend fun boost(note: Note) { + if (!isWriteable()) return + + if (note.hasBoostedInTheLast5Minutes(userProfile())) { + // has already bosted in the past 5mins + return + } + + note.event?.let { + if (it.kind() == 1) { + RepostEvent.create(it, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } else { + GenericRepostEvent.create(it, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + } + } + + fun broadcast(note: Note) { + note.event?.let { + if (it is WrappedEvent && it.host != null) { + it.host?.let { hostEvent -> Client.send(hostEvent) } + } else { + Client.send(it) + } + } + } + + fun follow(user: User) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + + if (contactList != null) { + ContactListEvent.followUser(contactList, user.pubkeyHex, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } else { + ContactListEvent.createFromScratch( + followUsers = listOf(Contact(user.pubkeyHex, null)), + followTags = emptyList(), + followGeohashes = emptyList(), + followCommunities = emptyList(), + followEvents = DefaultChannels.toList(), + relayUse = + Constants.defaultRelays.associate { + it.url to ContactListEvent.ReadWrite(it.read, it.write) + }, + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + } + + fun follow(channel: Channel) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + + if (contactList != null) { + ContactListEvent.followEvent(contactList, channel.idHex, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } else { + ContactListEvent.createFromScratch( + followUsers = emptyList(), + followTags = emptyList(), + followGeohashes = emptyList(), + followCommunities = emptyList(), + followEvents = DefaultChannels.toList().plus(channel.idHex), + relayUse = + Constants.defaultRelays.associate { + it.url to ContactListEvent.ReadWrite(it.read, it.write) + }, + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + } + + fun follow(community: AddressableNote) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + + if (contactList != null) { + ContactListEvent.followAddressableEvent(contactList, community.address, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } else { + val relays = + Constants.defaultRelays.associate { + it.url to ContactListEvent.ReadWrite(it.read, it.write) + } + ContactListEvent.createFromScratch( + followUsers = emptyList(), + followTags = emptyList(), + followGeohashes = emptyList(), + followCommunities = listOf(community.address), + followEvents = DefaultChannels.toList(), + relayUse = relays, + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + } + + fun followHashtag(tag: String) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + + if (contactList != null) { + ContactListEvent.followHashtag( + contactList, + tag, + signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } else { + ContactListEvent.createFromScratch( + followUsers = emptyList(), + followTags = listOf(tag), + followGeohashes = emptyList(), + followCommunities = emptyList(), + followEvents = DefaultChannels.toList(), + relayUse = + Constants.defaultRelays.associate { + it.url to ContactListEvent.ReadWrite(it.read, it.write) + }, + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + } + + fun followGeohash(geohash: String) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + + if (contactList != null) { + ContactListEvent.followGeohash( + contactList, + geohash, + signer, + onReady = this::onNewEventCreated, + ) + } else { + ContactListEvent.createFromScratch( + followUsers = emptyList(), + followTags = emptyList(), + followGeohashes = listOf(geohash), + followCommunities = emptyList(), + followEvents = DefaultChannels.toList(), + relayUse = + Constants.defaultRelays.associate { + it.url to ContactListEvent.ReadWrite(it.read, it.write) + }, + signer = signer, + onReady = this::onNewEventCreated, + ) + } + } + + fun onNewEventCreated(event: Event) { + Client.send(event) + LocalCache.justConsume(event, null) + } + + fun unfollow(user: User) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + + if (contactList != null && contactList.tags.isNotEmpty()) { + ContactListEvent.unfollowUser( + contactList, + user.pubkeyHex, + signer, + onReady = this::onNewEventCreated, + ) + } + } + + suspend fun unfollowHashtag(tag: String) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + + if (contactList != null && contactList.tags.isNotEmpty()) { + ContactListEvent.unfollowHashtag( + contactList, + tag, + signer, + onReady = this::onNewEventCreated, + ) + } + } + + suspend fun unfollowGeohash(geohash: String) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + + if (contactList != null && contactList.tags.isNotEmpty()) { + ContactListEvent.unfollowGeohash( + contactList, + geohash, + signer, + onReady = this::onNewEventCreated, + ) + } + } + + suspend fun unfollow(channel: Channel) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + + if (contactList != null && contactList.tags.isNotEmpty()) { + ContactListEvent.unfollowEvent( + contactList, + channel.idHex, + signer, + onReady = this::onNewEventCreated, + ) + } + } + + suspend fun unfollow(community: AddressableNote) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + + if (contactList != null && contactList.tags.isNotEmpty()) { + ContactListEvent.unfollowAddressableEvent( + contactList, + community.address, + signer, + onReady = this::onNewEventCreated, + ) + } + } + + fun createNip95( + byteArray: ByteArray, + headerInfo: FileHeader, + alt: String?, + sensitiveContent: Boolean, + onReady: (Pair) -> Unit, + ) { + if (!isWriteable()) return + + FileStorageEvent.create( + mimeType = headerInfo.mimeType ?: "", + data = byteArray, + signer = signer, + ) { data -> + FileStorageHeaderEvent.create( + data, + mimeType = headerInfo.mimeType, + hash = headerInfo.hash, + size = headerInfo.size.toString(), + dimensions = headerInfo.dim, + blurhash = headerInfo.blurHash, + alt = alt, + sensitiveContent = sensitiveContent, + signer = signer, + ) { signedEvent -> + onReady( + Pair(data, signedEvent), + ) + } + } + } + + fun consumeAndSendNip95( + data: FileStorageEvent, + signedEvent: FileStorageHeaderEvent, + relayList: List? = null, + ): Note? { + if (!isWriteable()) return null + + Client.send(data, relayList = relayList) + LocalCache.consume(data, null) + + Client.send(signedEvent, relayList = relayList) + LocalCache.consume(signedEvent, null) + + return LocalCache.notes[signedEvent.id] + } + + fun consumeNip95( + data: FileStorageEvent, + signedEvent: FileStorageHeaderEvent, + ): Note? { + LocalCache.consume(data, null) + LocalCache.consume(signedEvent, null) + + return LocalCache.notes[signedEvent.id] + } + + fun sendNip95( + data: FileStorageEvent, + signedEvent: FileStorageHeaderEvent, + relayList: List? = null, + ) { + Client.send(data, relayList = relayList) + Client.send(signedEvent, relayList = relayList) + } + + fun sendHeader( + signedEvent: FileHeaderEvent, + relayList: List? = null, + onReady: (Note) -> Unit, + ) { + Client.send(signedEvent, relayList = relayList) + LocalCache.consume(signedEvent, null) + + LocalCache.notes[signedEvent.id]?.let { onReady(it) } + } + + fun createHeader( + imageUrl: String, + magnetUri: String?, + headerInfo: FileHeader, + alt: String?, + sensitiveContent: Boolean, + originalHash: String? = null, + onReady: (FileHeaderEvent) -> Unit, + ) { + if (!isWriteable()) return + + FileHeaderEvent.create( + url = imageUrl, + magnetUri = magnetUri, + mimeType = headerInfo.mimeType, + hash = headerInfo.hash, + size = headerInfo.size.toString(), + dimensions = headerInfo.dim, + blurhash = headerInfo.blurHash, + alt = alt, + originalHash = originalHash, + sensitiveContent = sensitiveContent, + signer = signer, + ) { event -> + onReady(event) + } + } + + fun sendHeader( + imageUrl: String, + magnetUri: String?, + headerInfo: FileHeader, + alt: String?, + sensitiveContent: Boolean, + originalHash: String? = null, + relayList: List? = null, + onReady: (Note) -> Unit, + ) { + if (!isWriteable()) return + + FileHeaderEvent.create( + url = imageUrl, + magnetUri = magnetUri, + mimeType = headerInfo.mimeType, + hash = headerInfo.hash, + size = headerInfo.size.toString(), + dimensions = headerInfo.dim, + blurhash = headerInfo.blurHash, + alt = alt, + originalHash = originalHash, + sensitiveContent = sensitiveContent, + signer = signer, + ) { event -> + sendHeader(event, relayList = relayList, onReady) + } + } + + fun sendClassifieds( + title: String, + price: Price, + condition: ClassifiedsEvent.CONDITION, + location: String, + category: String, + message: String, + replyTo: List?, + mentions: List?, + directMentions: Set, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean, + zapRaiserAmount: Long? = null, + relayList: List? = null, + geohash: String? = null, + nip94attachments: List? = null, + ) { + if (!isWriteable()) return + + val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } + val mentionsHex = mentions?.map { it.pubkeyHex } + val addresses = replyTo?.mapNotNull { it.address() } + + ClassifiedsEvent.create( + dTag = UUID.randomUUID().toString(), + title = title, + price = price, + condition = condition, + summary = message, + image = null, + location = location, + category = category, + message = message, + replyTos = repliesToHex, + mentions = mentionsHex, + addresses = addresses, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + directMentions = directMentions, + geohash = geohash, + nip94attachments = nip94attachments, + signer = signer, + ) { + Client.send(it, relayList = relayList) + LocalCache.justConsume(it, null) + + replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } } + addresses?.forEach { + LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { + Client.send(it, relayList = relayList) + } + } + } + } + + fun sendPost( + message: String, + replyTo: List?, + mentions: List?, + tags: List? = null, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean, + zapRaiserAmount: Long? = null, + replyingTo: String?, + root: String?, + directMentions: Set, + relayList: List? = null, + geohash: String? = null, + nip94attachments: List? = null, + ) { + if (!isWriteable()) return + + val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } + val mentionsHex = mentions?.map { it.pubkeyHex } + val addresses = replyTo?.mapNotNull { it.address() } + + TextNoteEvent.create( + msg = message, + replyTos = repliesToHex, + mentions = mentionsHex, + addresses = addresses, + extraTags = tags, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + replyingTo = replyingTo, + root = root, + directMentions = directMentions, + geohash = geohash, + nip94attachments = nip94attachments, + signer = signer, + ) { + Client.send(it, relayList = relayList) + LocalCache.justConsume(it, null) + + // broadcast replied notes + replyingTo?.let { + LocalCache.getNoteIfExists(replyingTo)?.event?.let { + Client.send(it, relayList = relayList) + } + } + replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } } + addresses?.forEach { + LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { + Client.send(it, relayList = relayList) + } + } + } + } + + fun sendPoll( + message: String, + replyTo: List?, + mentions: List?, + pollOptions: Map, + valueMaximum: Int?, + valueMinimum: Int?, + consensusThreshold: Int?, + closedAt: Int?, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean, + zapRaiserAmount: Long? = null, + relayList: List? = null, + geohash: String? = null, + nip94attachments: List? = null, + ) { + if (!isWriteable()) return + + val repliesToHex = replyTo?.map { it.idHex } + val mentionsHex = mentions?.map { it.pubkeyHex } + val addresses = replyTo?.mapNotNull { it.address() } + + PollNoteEvent.create( + msg = message, + replyTos = repliesToHex, + mentions = mentionsHex, + addresses = addresses, + signer = signer, + pollOptions = pollOptions, + valueMaximum = valueMaximum, + valueMinimum = valueMinimum, + consensusThreshold = consensusThreshold, + closedAt = closedAt, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + geohash = geohash, + nip94attachments = nip94attachments, + ) { + Client.send(it, relayList = relayList) + LocalCache.justConsume(it, null) + + // Rebroadcast replies and tags to the current relay set + replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } } + addresses?.forEach { + LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { + Client.send(it, relayList = relayList) + } + } + } + } + + fun sendChannelMessage( + message: String, + toChannel: String, + replyTo: List?, + mentions: List?, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean, + zapRaiserAmount: Long? = null, + geohash: String? = null, + nip94attachments: List? = null, + ) { + if (!isWriteable()) return + + val repliesToHex = replyTo?.map { it.idHex } + val mentionsHex = mentions?.map { it.pubkeyHex } + + ChannelMessageEvent.create( + message = message, + channel = toChannel, + replyTos = repliesToHex, + mentions = mentionsHex, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + geohash = geohash, + nip94attachments = nip94attachments, + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + + fun sendLiveMessage( + message: String, + toChannel: ATag, + replyTo: List?, + mentions: List?, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean, + zapRaiserAmount: Long? = null, + geohash: String? = null, + nip94attachments: List? = null, + ) { + if (!isWriteable()) return + + // val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } + val repliesToHex = replyTo?.map { it.idHex } + val mentionsHex = mentions?.map { it.pubkeyHex } + + LiveActivitiesChatMessageEvent.create( + message = message, + activity = toChannel, + replyTos = repliesToHex, + mentions = mentionsHex, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + geohash = geohash, + nip94attachments = nip94attachments, + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + + fun sendPrivateMessage( + message: String, + toUser: User, + replyingTo: Note? = null, + mentions: List?, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean, + zapRaiserAmount: Long? = null, + geohash: String? = null, + ) { + sendPrivateMessage( + message, + toUser.pubkeyHex, + replyingTo, + mentions, + zapReceiver, + wantsToMarkAsSensitive, + zapRaiserAmount, + geohash, + ) + } + + fun sendPrivateMessage( + message: String, + toUser: HexKey, + replyingTo: Note? = null, + mentions: List?, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean, + zapRaiserAmount: Long? = null, + geohash: String? = null, + ) { + if (!isWriteable()) return + + val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } + val mentionsHex = mentions?.map { it.pubkeyHex } + + PrivateDmEvent.create( + recipientPubKey = toUser, + publishedRecipientPubKey = toUser, + msg = message, + replyTos = repliesToHex, + mentions = mentionsHex, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + geohash = geohash, + signer = signer, + advertiseNip18 = false, + ) { + Client.send(it) + LocalCache.consume(it, null) + } + } + + fun sendNIP24PrivateMessage( + message: String, + toUsers: List, + subject: String? = null, + replyingTo: Note? = null, + mentions: List?, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean, + zapRaiserAmount: Long? = null, + geohash: String? = null, + ) { + if (!isWriteable()) return + + val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } + val mentionsHex = mentions?.map { it.pubkeyHex } + + NIP24Factory().createMsgNIP24( + msg = message, + to = toUsers, + subject = subject, + replyTos = repliesToHex, + mentions = mentionsHex, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + geohash = geohash, + signer = signer, + ) { + broadcastPrivately(it) + } + } + + fun broadcastPrivately(signedEvents: NIP24Factory.Result) { + val mine = signedEvents.wraps.filter { (it.recipientPubKey() == signer.pubKey) } + + mine.forEach { giftWrap -> + giftWrap.cachedGift(signer) { gift -> + if (gift is SealedGossipEvent) { + gift.cachedGossip(signer) { gossip -> LocalCache.justConsume(gossip, null) } + } else { + LocalCache.justConsume(gift, null) + } + } + + LocalCache.consume(giftWrap, null) + } + + val id = mine.firstOrNull()?.id + val mineNote = if (id == null) null else LocalCache.getNoteIfExists(id) + + signedEvents.wraps.forEach { + // Creates an alias + if (mineNote != null && it.recipientPubKey() != keyPair.pubKey.toHexKey()) { + LocalCache.getOrAddAliasNote(it.id, mineNote) + } + + Client.send(it) + } + } + + fun sendCreateNewChannel( + name: String, + about: String, + picture: String, + ) { + if (!isWriteable()) return + + ChannelCreateEvent.create( + name = name, + about = about, + picture = picture, + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + + LocalCache.getChannelIfExists(it.id)?.let { follow(it) } + } + } + + fun updateStatus( + oldStatus: AddressableNote, + newStatus: String, + ) { + if (!isWriteable()) return + val oldEvent = oldStatus.event as? StatusEvent ?: return + + StatusEvent.update(oldEvent, newStatus, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + + fun createStatus(newStatus: String) { + if (!isWriteable()) return + + StatusEvent.create(newStatus, "general", expiration = null, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + + fun deleteStatus(oldStatus: AddressableNote) { + if (!isWriteable()) return + val oldEvent = oldStatus.event as? StatusEvent ?: return + + StatusEvent.clear(oldEvent, signer) { event -> + Client.send(event) + LocalCache.justConsume(event, null) + + DeletionEvent.create(listOf(event.id), signer) { event2 -> + Client.send(event2) + LocalCache.justConsume(event2, null) + } + } + } + + fun removeEmojiPack( + usersEmojiList: Note, + emojiList: Note, + ) { + if (!isWriteable()) return + + val noteEvent = usersEmojiList.event + if (noteEvent !is EmojiPackSelectionEvent) return + val emojiListEvent = emojiList.event + if (emojiListEvent !is EmojiPackEvent) return + + EmojiPackSelectionEvent.create( + noteEvent.taggedAddresses().filter { it != emojiListEvent.address() }, + signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + + fun addEmojiPack( + usersEmojiList: Note, + emojiList: Note, + ) { + if (!isWriteable()) return + val emojiListEvent = emojiList.event + if (emojiListEvent !is EmojiPackEvent) return + + if (usersEmojiList.event == null) { + EmojiPackSelectionEvent.create( + listOf(emojiListEvent.address()), + signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } else { + val noteEvent = usersEmojiList.event + if (noteEvent !is EmojiPackSelectionEvent) return + + if (noteEvent.taggedAddresses().any { it == emojiListEvent.address() }) { + return + } + + EmojiPackSelectionEvent.create( + noteEvent.taggedAddresses().plus(emojiListEvent.address()), + signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + } + + fun addBookmark( + note: Note, + isPrivate: Boolean, + ) { + if (!isWriteable()) return + + if (note is AddressableNote) { + BookmarkListEvent.addReplaceable( + userProfile().latestBookmarkList, + note.address, + isPrivate, + signer, + ) { + Client.send(it) + LocalCache.consume(it) + } + } else { + BookmarkListEvent.addEvent( + userProfile().latestBookmarkList, + note.idHex, + isPrivate, + signer, + ) { + Client.send(it) + LocalCache.consume(it) + } + } + } + + fun removeBookmark( + note: Note, + isPrivate: Boolean, + ) { + if (!isWriteable()) return + + val bookmarks = userProfile().latestBookmarkList ?: return + + if (note is AddressableNote) { + BookmarkListEvent.removeReplaceable( + bookmarks, + note.address, + isPrivate, + signer, + ) { + Client.send(it) + LocalCache.consume(it) + } + } else { + BookmarkListEvent.removeEvent( + bookmarks, + note.idHex, + isPrivate, + signer, + ) { + Client.send(it) + LocalCache.consume(it) + } + } + } + + fun createAuthEvent( + relay: Relay, + challenge: String, + onReady: (RelayAuthEvent) -> Unit, + ) { + return createAuthEvent(relay.url, challenge, onReady = onReady) + } + + fun createAuthEvent( + relayUrl: String, + challenge: String, + onReady: (RelayAuthEvent) -> Unit, + ) { + if (!isWriteable()) return + + RelayAuthEvent.create(relayUrl, challenge, signer, onReady = onReady) + } + + fun isInPrivateBookmarks( + note: Note, + onReady: (Boolean) -> Unit, + ) { + if (!isWriteable()) return + + if (note is AddressableNote) { + userProfile().latestBookmarkList?.privateTaggedAddresses(signer) { + onReady(it.contains(note.address)) + } + } else { + userProfile().latestBookmarkList?.privateTaggedEvents(signer) { + onReady(it.contains(note.idHex)) + } + } + } + + fun isInPublicBookmarks(note: Note): Boolean { + if (!isWriteable()) return false + + if (note is AddressableNote) { + return userProfile().latestBookmarkList?.taggedAddresses()?.contains(note.address) == true + } else { + return userProfile().latestBookmarkList?.taggedEvents()?.contains(note.idHex) == true + } + } + + fun getBlockListNote(): AddressableNote { + val aTag = + ATag( + PeopleListEvent.KIND, + userProfile().pubkeyHex, + PeopleListEvent.BLOCK_LIST_D_TAG, + null, + ) + return LocalCache.getOrCreateAddressableNote(aTag) + } + + fun getMuteListNote(): AddressableNote { + val aTag = + ATag( + MuteListEvent.KIND, + userProfile().pubkeyHex, + "", + null, + ) + return LocalCache.getOrCreateAddressableNote(aTag) + } + + fun getFileServersNote(): AddressableNote { + val aTag = + ATag( + FileServersEvent.KIND, + userProfile().pubkeyHex, + "", + null, + ) + return LocalCache.getOrCreateAddressableNote(aTag) + } + + fun getBlockList(): PeopleListEvent? { + return getBlockListNote().event as? PeopleListEvent + } + + fun getMuteList(): MuteListEvent? { + return getMuteListNote().event as? MuteListEvent + } + + fun getFileServersList(): FileServersEvent? { + return getFileServersNote().event as? FileServersEvent + } + + fun hideWord(word: String) { + val muteList = getMuteList() + + if (muteList != null) { + MuteListEvent.addWord( + earlierVersion = muteList, + word = word, + isPrivate = true, + signer = signer, + ) { + Client.send(it) + LocalCache.consume(it, null) + } + } else { + MuteListEvent.createListWithWord( + word = word, + isPrivate = true, + signer = signer, + ) { + Client.send(it) + LocalCache.consume(it, null) + } + } + } + + fun showWord(word: String) { + val blockList = getBlockList() + + if (blockList != null) { + PeopleListEvent.removeWord( + earlierVersion = blockList, + word = word, + isPrivate = true, + signer = signer, + ) { + Client.send(it) + LocalCache.consume(it, null) + } + } + + val muteList = getMuteList() + + if (muteList != null) { + MuteListEvent.removeWord( + earlierVersion = muteList, + word = word, + isPrivate = true, + signer = signer, + ) { + Client.send(it) + LocalCache.consume(it, null) + } + } + } + + fun hideUser(pubkeyHex: String) { + val muteList = getMuteList() + + if (muteList != null) { + MuteListEvent.addUser( + earlierVersion = muteList, + pubKeyHex = pubkeyHex, + isPrivate = true, + signer = signer, + ) { + Client.send(it) + LocalCache.consume(it, null) + } + } else { + MuteListEvent.createListWithUser( + pubKeyHex = pubkeyHex, + isPrivate = true, + signer = signer, + ) { + Client.send(it) + LocalCache.consume(it, null) + } + } + } + + fun showUser(pubkeyHex: String) { + val blockList = getBlockList() + + if (blockList != null) { + PeopleListEvent.removeUser( + earlierVersion = blockList, + pubKeyHex = pubkeyHex, + isPrivate = true, + signer = signer, + ) { + Client.send(it) + LocalCache.consume(it, null) + } + } + + val muteList = getMuteList() + + if (muteList != null) { + MuteListEvent.removeUser( + earlierVersion = muteList, + pubKeyHex = pubkeyHex, + isPrivate = true, + signer = signer, + ) { + Client.send(it) + LocalCache.consume(it, null) + } + } + + transientHiddenUsers = (transientHiddenUsers - pubkeyHex).toImmutableSet() + live.invalidateData() + saveable.invalidateData() + } + + fun changeDefaultZapType(zapType: LnZapEvent.ZapType) { + defaultZapType = zapType + live.invalidateData() + saveable.invalidateData() + } + + fun changeDefaultFileServer(server: Nip96MediaServers.ServerName) { + defaultFileServer = server + live.invalidateData() + saveable.invalidateData() + } + + fun changeDefaultHomeFollowList(name: String) { + defaultHomeFollowList.tryEmit(name) + live.invalidateData() + saveable.invalidateData() + } + + fun changeDefaultStoriesFollowList(name: String) { + defaultStoriesFollowList.tryEmit(name) + live.invalidateData() + saveable.invalidateData() + } + + fun changeDefaultNotificationFollowList(name: String) { + defaultNotificationFollowList.tryEmit(name) + live.invalidateData() + saveable.invalidateData() + } + + fun changeDefaultDiscoveryFollowList(name: String) { + defaultDiscoveryFollowList.tryEmit(name) + live.invalidateData() + saveable.invalidateData() + } + + fun changeZapAmounts(newAmounts: List) { + zapAmountChoices = newAmounts + live.invalidateData() + saveable.invalidateData() + } + + fun changeReactionTypes(newTypes: List) { + reactionChoices = newTypes + live.invalidateData() + saveable.invalidateData() + } + + fun changeZapPaymentRequest(newServer: Nip47URI?) { + zapPaymentRequest = newServer + live.invalidateData() + saveable.invalidateData() + } + + fun selectedChatsFollowList(): Set { + val contactList = userProfile().latestContactList + return contactList?.taggedEvents()?.toSet() ?: DefaultChannels + } + + fun sendChangeChannel( + name: String, + about: String, + picture: String, + channel: Channel, + ) { + if (!isWriteable()) return + + ChannelMetadataEvent.create( + name, + about, + picture, + originalChannelIdHex = channel.idHex, + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + + follow(channel) + } + } + + fun unwrap( + event: GiftWrapEvent, + onReady: (Event) -> Unit, + ) { + if (!isWriteable()) return + + return event.cachedGift(signer, onReady) + } + + fun unseal( + event: SealedGossipEvent, + onReady: (Event) -> Unit, + ) { + if (!isWriteable()) return + + return event.cachedGossip(signer, onReady) + } + + fun cachedDecryptContent(note: Note): String? { + val event = note.event + return if (event is PrivateDmEvent && isWriteable()) { + event.cachedContentFor(signer) + } else if (event is LnZapRequestEvent && event.isPrivateZap() && isWriteable()) { + event.cachedPrivateZap()?.content + } else { + event?.content() + } + } + + fun decryptContent( + note: Note, + onReady: (String) -> Unit, + ) { + val event = note.event + if (event is PrivateDmEvent && isWriteable()) { + event.plainContent(signer, onReady) + } else if (event is LnZapRequestEvent) { + decryptZapContentAuthor(note) { onReady(it.content) } + } else { + event?.content()?.let { onReady(it) } + } + } + + fun decryptZapContentAuthor( + note: Note, + onReady: (Event) -> Unit, + ) { + val event = note.event + if (event is LnZapRequestEvent) { + if (event.isPrivateZap()) { + if (isWriteable()) { + event.decryptPrivateZap(signer) { onReady(it) } + } + } else { + onReady(event) + } + } + } + + fun addDontTranslateFrom(languageCode: String) { + dontTranslateFrom = dontTranslateFrom.plus(languageCode) + liveLanguages.invalidateData() + + saveable.invalidateData() + } + + fun updateTranslateTo(languageCode: String) { + translateTo = languageCode + liveLanguages.invalidateData() + + saveable.invalidateData() + } + + fun prefer( + source: String, + target: String, + preference: String, + ) { + languagePreferences = languagePreferences + Pair("$source,$target", preference) + saveable.invalidateData() + } + + fun preferenceBetween( + source: String, + target: String, + ): String? { + return languagePreferences.get("$source,$target") + } + + private fun updateContactListTo(newContactList: ContactListEvent?) { + if (newContactList == null || newContactList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupContactList?.id != newContactList.id) { + backupContactList = newContactList + saveable.invalidateData() + } + } + + // Takes a User's relay list and adds the types of feeds they are active for. + fun activeRelays(): Array? { + var usersRelayList = + userProfile().latestContactList?.relays()?.map { + val localFeedTypes = + localRelays.firstOrNull { localRelay -> localRelay.url == it.key }?.feedTypes + ?: Constants.defaultRelays + .filter { defaultRelay -> defaultRelay.url == it.key } + .firstOrNull() + ?.feedTypes + ?: FeedType.values().toSet() + + Relay(it.key, it.value.read, it.value.write, localFeedTypes) + } + ?: return null + + // Ugly, but forces nostr.band as the only search-supporting relay today. + // TODO: Remove when search becomes more available. + val searchRelays = + usersRelayList.filter { it.url.removeSuffix("/") in Constants.forcedRelaysForSearchSet } + val hasSearchRelay = usersRelayList.any { it.activeTypes.contains(FeedType.SEARCH) } + if (!hasSearchRelay && searchRelays.isEmpty()) { + usersRelayList = + usersRelayList + + Constants.forcedRelayForSearch.map { + Relay( + it.url, + it.read, + it.write, + it.feedTypes, + ) + } + } + + return usersRelayList.toTypedArray() + } + + fun convertLocalRelays(): Array { + return localRelays.map { Relay(it.url, it.read, it.write, it.feedTypes) }.toTypedArray() + } + + fun activeGlobalRelays(): Array { + return (activeRelays() ?: convertLocalRelays()) + .filter { it.activeTypes.contains(FeedType.GLOBAL) } + .map { it.url } + .toTypedArray() + } + + fun activeWriteRelays(): List { + return (activeRelays() ?: convertLocalRelays()).filter { it.write } + } + + fun isAllHidden(users: Set): Boolean { + return users.all { isHidden(it) } + } + + fun isHidden(user: User) = isHidden(user.pubkeyHex) + + fun isHidden(userHex: String): Boolean { + return flowHiddenUsers.value.hiddenUsers.contains(userHex) || + flowHiddenUsers.value.spammers.contains(userHex) + } + + fun followingKeySet(): Set { + return userProfile().cachedFollowingKeySet() + } + + fun followingTagSet(): Set { + return userProfile().cachedFollowingTagSet() + } + + fun isAcceptable(user: User): Boolean { + if (userProfile().pubkeyHex == user.pubkeyHex) { + return true + } + + if (user.pubkeyHex in followingKeySet()) { + return true + } + + if (!warnAboutPostsWithReports) { + return !isHidden(user) && // if user hasn't hided this author + user.reportsBy(userProfile()).isEmpty() // if user has not reported this post + } + return !isHidden(user) && // if user hasn't hided this author + user.reportsBy(userProfile()).isEmpty() && // if user has not reported this post + user.countReportAuthorsBy(followingKeySet()) < 5 + } + + private fun isAcceptableDirect(note: Note): Boolean { + if (!warnAboutPostsWithReports) { + return !note.hasReportsBy(userProfile()) + } + return !note.hasReportsBy(userProfile()) && // if user has not reported this post + note.countReportAuthorsBy(followingKeySet()) < 5 // if it has 5 reports by reliable users + } + + fun isFollowing(user: User): Boolean { + return user.pubkeyHex in followingKeySet() + } + + fun isFollowing(user: HexKey): Boolean { + return user in followingKeySet() + } + + fun isAcceptable(note: Note): Boolean { + return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author + isAcceptableDirect(note) && + ( + (note.event !is RepostEvent && note.event !is GenericRepostEvent) || + ( + note.replyTo?.firstOrNull { isAcceptableDirect(it) } != + null + ) + ) // is not a reaction about a blocked post + } + + fun getRelevantReports(note: Note): Set { + val followsPlusMe = userProfile().latestContactList?.verifiedFollowKeySetAndMe ?: emptySet() + + val innerReports = + if (note.event is RepostEvent || note.event is GenericRepostEvent) { + note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList() + } else { + emptyList() + } + + return ( + note.reportsBy(followsPlusMe) + + (note.author?.reportsBy(followsPlusMe) ?: emptyList()) + + innerReports + ) + .toSet() + } + + fun saveRelayList(value: List) { + try { + localRelays = value.toSet() + return sendNewRelayList( + value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }, + ) + } finally { + saveable.invalidateData() + } + } + + fun setHideDeleteRequestDialog() { + hideDeleteRequestDialog = true + saveable.invalidateData() + } + + fun setHideNIP24WarningDialog() { + hideNIP24WarningDialog = true + saveable.invalidateData() + } + + fun setHideBlockAlertDialog() { + hideBlockAlertDialog = true + saveable.invalidateData() + } + + fun updateShowSensitiveContent(show: Boolean?) { + showSensitiveContent = show + saveable.invalidateData() + live.invalidateData() + } + + fun markAsRead( + route: String, + timestampInSecs: Long, + ): Boolean { + val lastTime = lastReadPerRoute[route] + return if (lastTime == null || timestampInSecs > lastTime) { + lastReadPerRoute = lastReadPerRoute + Pair(route, timestampInSecs) + saveable.invalidateData() + true + } else { + false + } + } + + fun loadLastRead(route: String): Long { + return lastReadPerRoute[route] ?: 0 + } + + suspend fun registerObservers() = + withContext(Dispatchers.Main) { + // saves contact list for the next time. + userProfile().live().follows.observeForever { + GlobalScope.launch(Dispatchers.IO) { updateContactListTo(userProfile().latestContactList) } + } + + // imports transient blocks due to spam. + LocalCache.antiSpam.liveSpam.observeForever { + GlobalScope.launch(Dispatchers.IO) { + it.cache.spamMessages.snapshot().values.forEach { + if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) { + if (it.pubkeyHex != userProfile().pubkeyHex && it.pubkeyHex !in followingKeySet()) { + transientHiddenUsers = (transientHiddenUsers + it.pubkeyHex).toImmutableSet() + live.invalidateData() + } + } + } + } + } + } + + init { + Log.d("Init", "Account") + backupContactList?.let { + println("Loading saved contacts ${it.toJson()}") + + if (userProfile().latestContactList == null) { + GlobalScope.launch(Dispatchers.IO) { LocalCache.consume(it) } + } + } } - } } class AccountLiveData(private val account: Account) : - LiveData(AccountState(account)) { - // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.Default) + LiveData(AccountState(account)) { + // Refreshes observers in batches. + private val bundler = BundledUpdate(300, Dispatchers.Default) - fun invalidateData() { - bundler.invalidate { - if (hasActiveObservers()) { - refresh() - } + fun invalidateData() { + bundler.invalidate { + if (hasActiveObservers()) { + refresh() + } + } } - } - fun refresh() { - postValue(AccountState(account)) - } + fun refresh() { + postValue(AccountState(account)) + } } @Immutable class AccountState(val account: Account) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt index 44ec79075..31dcb1924 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt @@ -35,94 +35,94 @@ import kotlinx.coroutines.Dispatchers data class Spammer(val pubkeyHex: HexKey, var duplicatedMessages: Set) class AntiSpamFilter { - val recentMessages = LruCache(1000) - val spamMessages = LruCache(1000) + val recentMessages = LruCache(1000) + val spamMessages = LruCache(1000) - var active: Boolean = true + var active: Boolean = true - fun isSpam( - event: Event, - relay: Relay?, - ): Boolean { - checkNotInMainThread() + fun isSpam( + event: Event, + relay: Relay?, + ): Boolean { + checkNotInMainThread() - if (!active) return false + if (!active) return false - val idHex = event.id + val idHex = event.id - // if short message, ok - // The idea here is to avoid considering repeated "GM" messages spam. - if (event.content.length < 50) return false + // if short message, ok + // The idea here is to avoid considering repeated "GM" messages spam. + if (event.content.length < 50) return false - // if the message is actually short but because it cites a user/event, the nostr: string is - // really long, make it ok. - // The idea here is to avoid considering repeated "@Bot, command" messages spam, while still - // blocking repeated "lnbc..." invoices or fishing urls - if (event.content.length < 180 && Nip19.nip19regex.matcher(event.content).find()) return false + // if the message is actually short but because it cites a user/event, the nostr: string is + // really long, make it ok. + // The idea here is to avoid considering repeated "@Bot, command" messages spam, while still + // blocking repeated "lnbc..." invoices or fishing urls + if (event.content.length < 180 && Nip19.nip19regex.matcher(event.content).find()) return false - // double list strategy: - // if duplicated, it goes into spam. 1000 spam messages are saved into the spam list. + // double list strategy: + // if duplicated, it goes into spam. 1000 spam messages are saved into the spam list. - // Considers tags so that same replies to different people don't count. - val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode() + // Considers tags so that same replies to different people don't count. + val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode() - if ( - (recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null + if ( + (recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null + ) { + Log.w( + "Potential SPAM Message for sharing", + "${Nip19.createNEvent(event.id, event.pubKey, event.kind, null)}", + ) + Log.w( + "Potential SPAM Message", + "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}", + ) + + // Log down offenders + logOffender(hash, event) + + liveSpam.invalidateData() + + return true + } + + recentMessages.put(hash, idHex) + + return false + } + + @Synchronized + private fun logOffender( + hashCode: Int, + event: Event, ) { - Log.w( - "Potential SPAM Message for sharing", - "${Nip19.createNEvent(event.id, event.pubKey, event.kind, null)}", - ) - Log.w( - "Potential SPAM Message", - "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}", - ) - - // Log down offenders - logOffender(hash, event) - - liveSpam.invalidateData() - - return true + if (spamMessages.get(hashCode) == null) { + spamMessages.put(hashCode, Spammer(event.pubKey, setOf(recentMessages[hashCode], event.id))) + } else { + val spammer = spamMessages.get(hashCode) + spammer.duplicatedMessages = spammer.duplicatedMessages + event.id + } } - recentMessages.put(hash, idHex) - - return false - } - - @Synchronized - private fun logOffender( - hashCode: Int, - event: Event, - ) { - if (spamMessages.get(hashCode) == null) { - spamMessages.put(hashCode, Spammer(event.pubKey, setOf(recentMessages[hashCode], event.id))) - } else { - val spammer = spamMessages.get(hashCode) - spammer.duplicatedMessages = spammer.duplicatedMessages + event.id - } - } - - val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this) + val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this) } @Stable class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData(AntiSpamState(cache)) { - // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.IO) + // Refreshes observers in batches. + private val bundler = BundledUpdate(300, Dispatchers.IO) - fun invalidateData() { - checkNotInMainThread() + fun invalidateData() { + checkNotInMainThread() - bundler.invalidate { - checkNotInMainThread() + bundler.invalidate { + checkNotInMainThread() - if (hasActiveObservers()) { - postValue(AntiSpamState(cache)) - } + if (hasActiveObservers()) { + postValue(AntiSpamState(cache)) + } + } } - } } class AntiSpamState(val cache: AntiSpamFilter) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt index 6da9d0a02..1d211d28b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt @@ -32,176 +32,176 @@ import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.toNote import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent -import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.Dispatchers +import java.util.concurrent.ConcurrentHashMap @Stable class PublicChatChannel(idHex: String) : Channel(idHex) { - var info = ChannelCreateEvent.ChannelData(null, null, null) + var info = ChannelCreateEvent.ChannelData(null, null, null) - fun updateChannelInfo( - creator: User, - channelInfo: ChannelCreateEvent.ChannelData, - updatedAt: Long, - ) { - this.info = channelInfo - super.updateChannelInfo(creator, updatedAt) - } + fun updateChannelInfo( + creator: User, + channelInfo: ChannelCreateEvent.ChannelData, + updatedAt: Long, + ) { + this.info = channelInfo + super.updateChannelInfo(creator, updatedAt) + } - override fun toBestDisplayName(): String { - return info.name ?: super.toBestDisplayName() - } + override fun toBestDisplayName(): String { + return info.name ?: super.toBestDisplayName() + } - override fun summary(): String? { - return info.about - } + override fun summary(): String? { + return info.about + } - override fun profilePicture(): String? { - if (info.picture.isNullOrBlank()) return super.profilePicture() - return info.picture ?: super.profilePicture() - } + override fun profilePicture(): String? { + if (info.picture.isNullOrBlank()) return super.profilePicture() + return info.picture ?: super.profilePicture() + } - override fun anyNameStartsWith(prefix: String): Boolean { - return listOfNotNull(info.name, info.about).filter { it.contains(prefix, true) }.isNotEmpty() - } + override fun anyNameStartsWith(prefix: String): Boolean { + return listOfNotNull(info.name, info.about).filter { it.contains(prefix, true) }.isNotEmpty() + } } @Stable class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) { - var info: LiveActivitiesEvent? = null + var info: LiveActivitiesEvent? = null - override fun idNote() = address.toNAddr() + override fun idNote() = address.toNAddr() - override fun idDisplayNote() = idNote().toShortenHex() + override fun idDisplayNote() = idNote().toShortenHex() - fun address() = address + fun address() = address - fun updateChannelInfo( - creator: User, - channelInfo: LiveActivitiesEvent, - updatedAt: Long, - ) { - this.info = channelInfo - super.updateChannelInfo(creator, updatedAt) - } + fun updateChannelInfo( + creator: User, + channelInfo: LiveActivitiesEvent, + updatedAt: Long, + ) { + this.info = channelInfo + super.updateChannelInfo(creator, updatedAt) + } - override fun toBestDisplayName(): String { - return info?.title() ?: super.toBestDisplayName() - } + override fun toBestDisplayName(): String { + return info?.title() ?: super.toBestDisplayName() + } - override fun summary(): String? { - return info?.summary() - } + override fun summary(): String? { + return info?.summary() + } - override fun profilePicture(): String? { - return info?.image()?.ifBlank { null } - } + override fun profilePicture(): String? { + return info?.image()?.ifBlank { null } + } - override fun anyNameStartsWith(prefix: String): Boolean { - return listOfNotNull(info?.title(), info?.summary()) - .filter { it.contains(prefix, true) } - .isNotEmpty() - } + override fun anyNameStartsWith(prefix: String): Boolean { + return listOfNotNull(info?.title(), info?.summary()) + .filter { it.contains(prefix, true) } + .isNotEmpty() + } } @Stable abstract class Channel(val idHex: String) { - var creator: User? = null + var creator: User? = null - var updatedMetadataAt: Long = 0 + var updatedMetadataAt: Long = 0 - val notes = ConcurrentHashMap() + val notes = ConcurrentHashMap() - open fun id() = Hex.decode(idHex) + open fun id() = Hex.decode(idHex) - open fun idNote() = id().toNote() + open fun idNote() = id().toNote() - open fun idDisplayNote() = idNote().toShortenHex() + open fun idDisplayNote() = idNote().toShortenHex() - open fun toBestDisplayName(): String { - return idDisplayNote() - } + open fun toBestDisplayName(): String { + return idDisplayNote() + } - open fun summary(): String? { - return null - } + open fun summary(): String? { + return null + } - open fun creatorName(): String? { - return creator?.toBestDisplayName() - } + open fun creatorName(): String? { + return creator?.toBestDisplayName() + } - open fun profilePicture(): String? { - return creator?.profilePicture() - } + open fun profilePicture(): String? { + return creator?.profilePicture() + } - open fun updateChannelInfo( - creator: User, - updatedAt: Long, - ) { - this.creator = creator - this.updatedMetadataAt = updatedAt + open fun updateChannelInfo( + creator: User, + updatedAt: Long, + ) { + this.creator = creator + this.updatedMetadataAt = updatedAt - live.invalidateData() - } + live.invalidateData() + } - fun addNote(note: Note) { - notes[note.idHex] = note - } + fun addNote(note: Note) { + notes[note.idHex] = note + } - fun removeNote(note: Note) { - notes.remove(note.idHex) - } + fun removeNote(note: Note) { + notes.remove(note.idHex) + } - fun removeNote(noteHex: String) { - notes.remove(noteHex) - } + fun removeNote(noteHex: String) { + notes.remove(noteHex) + } - abstract fun anyNameStartsWith(prefix: String): Boolean + abstract fun anyNameStartsWith(prefix: String): Boolean - // Observers line up here. - val live: ChannelLiveData = ChannelLiveData(this) + // Observers line up here. + val live: ChannelLiveData = ChannelLiveData(this) - fun pruneOldAndHiddenMessages(account: Account): Set { - val important = - notes.values - .filter { it.author?.let { it1 -> account.isHidden(it1) } == false } - .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .reversed() - .take(1000) - .toSet() + fun pruneOldAndHiddenMessages(account: Account): Set { + val important = + notes.values + .filter { it.author?.let { it1 -> account.isHidden(it1) } == false } + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + .reversed() + .take(1000) + .toSet() - val toBeRemoved = notes.values.filter { it !in important }.toSet() + val toBeRemoved = notes.values.filter { it !in important }.toSet() - toBeRemoved.forEach { notes.remove(it.idHex) } + toBeRemoved.forEach { notes.remove(it.idHex) } - return toBeRemoved - } + return toBeRemoved + } } class ChannelLiveData(val channel: Channel) : LiveData(ChannelState(channel)) { - // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.IO) + // Refreshes observers in batches. + private val bundler = BundledUpdate(300, Dispatchers.IO) - fun invalidateData() { - checkNotInMainThread() + fun invalidateData() { + checkNotInMainThread() - bundler.invalidate { - checkNotInMainThread() - if (hasActiveObservers()) { - postValue(ChannelState(channel)) - } + bundler.invalidate { + checkNotInMainThread() + if (hasActiveObservers()) { + postValue(ChannelState(channel)) + } + } } - } - override fun onActive() { - super.onActive() - NostrSingleChannelDataSource.add(channel) - } + override fun onActive() { + super.onActive() + NostrSingleChannelDataSource.add(channel) + } - override fun onInactive() { - super.onInactive() - NostrSingleChannelDataSource.remove(channel) - } + override fun onInactive() { + super.onInactive() + NostrSingleChannelDataSource.remove(channel) + } } class ChannelState(val channel: Channel) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt index 18c7c1f7f..a331fece9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt @@ -27,62 +27,62 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Stable class Chatroom() { - var roomMessages: Set = setOf() - var subject: String? = null - var subjectCreatedAt: Long? = null + var roomMessages: Set = setOf() + var subject: String? = null + var subjectCreatedAt: Long? = null - @Synchronized - fun addMessageSync(msg: Note) { - checkNotInMainThread() + @Synchronized + fun addMessageSync(msg: Note) { + checkNotInMainThread() - if (msg !in roomMessages) { - roomMessages = roomMessages + msg + if (msg !in roomMessages) { + roomMessages = roomMessages + msg - val newSubject = msg.event?.subject() + val newSubject = msg.event?.subject() - if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) { - subject = newSubject - subjectCreatedAt = msg.createdAt() - } - } - } - - @Synchronized - fun removeMessageSync(msg: Note) { - checkNotInMainThread() - - if (msg !in roomMessages) { - roomMessages = roomMessages + msg - - roomMessages - .filter { it.event?.subject() != null } - .sortedBy { it.createdAt() } - .lastOrNull() - ?.let { - subject = it.event?.subject() - subjectCreatedAt = it.createdAt() + if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) { + subject = newSubject + subjectCreatedAt = msg.createdAt() + } } } - } - fun senderIntersects(keySet: Set): Boolean { - return roomMessages.any { it.author?.pubkeyHex in keySet } - } + @Synchronized + fun removeMessageSync(msg: Note) { + checkNotInMainThread() - fun pruneMessagesToTheLatestOnly(): Set { - val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + if (msg !in roomMessages) { + roomMessages = roomMessages + msg - val toKeep = - if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) { - // Recent messages, keep last 100 - sorted.take(100).toSet() - } else { - // Old messages, keep the last one. - sorted.take(1).toSet() - } + sorted.filter { it.liveSet?.isInUse() ?: false } + roomMessages + .filter { it.event?.subject() != null } + .sortedBy { it.createdAt() } + .lastOrNull() + ?.let { + subject = it.event?.subject() + subjectCreatedAt = it.createdAt() + } + } + } - val toRemove = roomMessages.minus(toKeep) - roomMessages = toKeep - return toRemove - } + fun senderIntersects(keySet: Set): Boolean { + return roomMessages.any { it.author?.pubkeyHex in keySet } + } + + fun pruneMessagesToTheLatestOnly(): Set { + val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + + val toKeep = + if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) { + // Recent messages, keep last 100 + sorted.take(100).toSet() + } else { + // Old messages, keep the last one. + sorted.take(1).toSet() + } + sorted.filter { it.liveSet?.isInUse() ?: false } + + val toRemove = roomMessages.minus(toKeep) + roomMessages = toKeep + return toRemove + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt index ff580a27c..fb40ae0d2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt @@ -28,144 +28,154 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R fun checkForHashtagWithIcon( - tag: String, - primary: Color, + tag: String, + primary: Color, ): HashtagIcon? { - return when (tag.lowercase()) { - "bitcoin", - "btc", - "timechain", - "bitcoiner", - "bitcoiners", -> - HashtagIcon( - R.drawable.ht_btc, - "Bitcoin", - Color.Unspecified, - Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp), - ) - "nostr", - "nostrich", - "nostriches", - "thenostr", -> - HashtagIcon( - R.drawable.ht_nostr, - "Nostr", - Color.Unspecified, - Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp), - ) - "lightning", - "lightningnetwork", -> - HashtagIcon( - R.drawable.ht_lightning, - "Lightning", - Color.Unspecified, - Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp), - ) - "zap", - "zaps", - "zapper", - "zappers", - "zapping", - "zapped", - "zapathon", - "zapraiser", - "zaplife", - "zapchain", -> - HashtagIcon( - R.drawable.zap, - "Zap", - Color.Unspecified, - Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp), - ) - "amethyst" -> - HashtagIcon( - R.drawable.amethyst, - "Amethyst", - Color.Unspecified, - Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp), - ) - "onyx" -> - HashtagIcon( - R.drawable.black_heart, - "Onyx", - Color.Unspecified, - Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp), - ) - "cashu", - "ecash", - "nut", - "nuts", - "deeznuts", -> - HashtagIcon( - R.drawable.cashu, - "Cashu", - Color.Unspecified, - Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp), - ) - "plebs", - "pleb", - "plebchain", -> - HashtagIcon( - R.drawable.plebs, - "Pleb", - Color.Unspecified, - Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp), - ) - "coffee", - "coffeechain", - "cafe", -> - HashtagIcon( - R.drawable.coffee, - "Coffee", - Color.Unspecified, - Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp), - ) - "skullofsatoshi" -> - HashtagIcon( - R.drawable.skull, - "SkullofSatoshi", - Color.Unspecified, - Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp), - ) - "grownostr", - "gardening", - "garden", -> - HashtagIcon( - R.drawable.grownostr, - "GrowNostr", - Color.Unspecified, - Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp), - ) - "footstr" -> - HashtagIcon( - R.drawable.footstr, - "Footstr", - Color.Unspecified, - Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp), - ) - "tunestr", - "music", - "nowplaying", -> - HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(0.dp, 3.dp, 0.dp, 1.dp)) - "weed", - "weedstr", - "420", - "cannabis", - "marijuana", -> - HashtagIcon( - R.drawable.weed, - "Weed", - Color.Unspecified, - Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp), - ) - else -> null - } + return when (tag.lowercase()) { + "bitcoin", + "btc", + "timechain", + "bitcoiner", + "bitcoiners", + -> + HashtagIcon( + R.drawable.ht_btc, + "Bitcoin", + Color.Unspecified, + Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp), + ) + "nostr", + "nostrich", + "nostriches", + "thenostr", + -> + HashtagIcon( + R.drawable.ht_nostr, + "Nostr", + Color.Unspecified, + Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp), + ) + "lightning", + "lightningnetwork", + -> + HashtagIcon( + R.drawable.ht_lightning, + "Lightning", + Color.Unspecified, + Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp), + ) + "zap", + "zaps", + "zapper", + "zappers", + "zapping", + "zapped", + "zapathon", + "zapraiser", + "zaplife", + "zapchain", + -> + HashtagIcon( + R.drawable.zap, + "Zap", + Color.Unspecified, + Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp), + ) + "amethyst" -> + HashtagIcon( + R.drawable.amethyst, + "Amethyst", + Color.Unspecified, + Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp), + ) + "onyx" -> + HashtagIcon( + R.drawable.black_heart, + "Onyx", + Color.Unspecified, + Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp), + ) + "cashu", + "ecash", + "nut", + "nuts", + "deeznuts", + -> + HashtagIcon( + R.drawable.cashu, + "Cashu", + Color.Unspecified, + Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp), + ) + "plebs", + "pleb", + "plebchain", + -> + HashtagIcon( + R.drawable.plebs, + "Pleb", + Color.Unspecified, + Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp), + ) + "coffee", + "coffeechain", + "cafe", + -> + HashtagIcon( + R.drawable.coffee, + "Coffee", + Color.Unspecified, + Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp), + ) + "skullofsatoshi" -> + HashtagIcon( + R.drawable.skull, + "SkullofSatoshi", + Color.Unspecified, + Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp), + ) + "grownostr", + "gardening", + "garden", + -> + HashtagIcon( + R.drawable.grownostr, + "GrowNostr", + Color.Unspecified, + Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp), + ) + "footstr" -> + HashtagIcon( + R.drawable.footstr, + "Footstr", + Color.Unspecified, + Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp), + ) + "tunestr", + "music", + "nowplaying", + -> + HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(0.dp, 3.dp, 0.dp, 1.dp)) + "weed", + "weedstr", + "420", + "cannabis", + "marijuana", + -> + HashtagIcon( + R.drawable.weed, + "Weed", + Color.Unspecified, + Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp), + ) + else -> null + } } @Immutable class HashtagIcon( - val icon: Int, - val description: String, - val color: Color, - val modifier: Modifier, + val icon: Int, + val description: String, + val color: Color, + val modifier: Modifier, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 52feee0ec..2122b7a99 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -94,13 +94,6 @@ import com.vitorpamplona.quartz.events.StatusEvent import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.VideoHorizontalEvent import com.vitorpamplona.quartz.events.VideoVerticalEvent -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.util.concurrent.ConcurrentHashMap import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList @@ -109,1724 +102,1749 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.concurrent.ConcurrentHashMap object LocalCache { - val antiSpam = AntiSpamFilter() + val antiSpam = AntiSpamFilter() - val users = ConcurrentHashMap(5000) - val notes = ConcurrentHashMap(5000) - val channels = ConcurrentHashMap() - val addressables = ConcurrentHashMap(100) + val users = ConcurrentHashMap(5000) + val notes = ConcurrentHashMap(5000) + val channels = ConcurrentHashMap() + val addressables = ConcurrentHashMap(100) - val awaitingPaymentRequests = - ConcurrentHashMap Unit>>(10) + val awaitingPaymentRequests = + ConcurrentHashMap Unit>>(10) - fun checkGetOrCreateUser(key: String): User? { - // checkNotInMainThread() + fun checkGetOrCreateUser(key: String): User? { + // checkNotInMainThread() - if (isValidHex(key)) { - return getOrCreateUser(key) - } - return null - } - - fun getOrCreateUser(key: HexKey): User { - // checkNotInMainThread() - - return users[key] - ?: run { - require(isValidHex(key = key)) { "$key is not a valid hex" } - - val newObject = User(key) - users.putIfAbsent(key, newObject) ?: newObject - } - } - - fun getUserIfExists(key: String): User? { - if (key.isEmpty()) return null - return users[key] - } - - fun getAddressableNoteIfExists(key: String): AddressableNote? { - return addressables[key] - } - - fun getNoteIfExists(key: String): Note? { - return addressables[key] ?: notes[key] - } - - fun getChannelIfExists(key: String): Channel? { - return channels[key] - } - - fun checkGetOrCreateNote(key: String): Note? { - checkNotInMainThread() - - if (ATag.isATag(key)) { - return checkGetOrCreateAddressableNote(key) - } - if (isValidHex(key)) { - val note = getOrCreateNote(key) - val noteEvent = note.event - return if (noteEvent is AddressableEvent) { - // upgrade to the latest - val newNote = checkGetOrCreateAddressableNote(noteEvent.address().toTag()) - - if (newNote != null && noteEvent is Event && newNote.event == null) { - val author = note.author ?: getOrCreateUser(noteEvent.pubKey) - newNote.loadEvent(noteEvent as Event, author, emptyList()) - note.moveAllReferencesTo(newNote) + if (isValidHex(key)) { + return getOrCreateUser(key) } - - newNote - } else { - note - } - } - return null - } - - fun getOrAddAliasNote( - idHex: String, - note: Note, - ): Note { - checkNotInMainThread() - - return notes.get(idHex) - ?: run { - require(isValidHex(idHex)) { "$idHex is not a valid hex" } - - notes.putIfAbsent(idHex, note) ?: note - } - } - - fun getOrCreateNote(idHex: String): Note { - checkNotInMainThread() - - return notes.get(idHex) - ?: run { - require(isValidHex(idHex)) { "$idHex is not a valid hex" } - - val newObject = Note(idHex) - notes.putIfAbsent(idHex, newObject) ?: newObject - } - } - - fun checkGetOrCreateChannel(key: String): Channel? { - checkNotInMainThread() - - if (isValidHex(key)) { - return getOrCreateChannel(key) { PublicChatChannel(key) } - } - val aTag = ATag.parse(key, null) - if (aTag != null) { - return getOrCreateChannel(aTag.toTag()) { LiveActivitiesChannel(aTag) } - } - return null - } - - private fun isValidHex(key: String): Boolean { - if (key.isBlank()) return false - if (key.contains(":")) return false - - return HexValidator.isHex(key) - } - - fun getOrCreateChannel( - key: String, - channelFactory: (String) -> Channel, - ): Channel { - checkNotInMainThread() - - return channels[key] - ?: run { - val newObject = channelFactory(key) - channels.putIfAbsent(key, newObject) ?: newObject - } - } - - fun checkGetOrCreateAddressableNote(key: String): AddressableNote? { - return try { - val addr = ATag.parse(key, null) // relay doesn't matter for the index. - if (addr != null) { - getOrCreateAddressableNote(addr) - } else { - null - } - } catch (e: IllegalArgumentException) { - Log.e("LocalCache", "Invalid Key to create channel: $key", e) - null - } - } - - fun getOrCreateAddressableNoteInternal(key: ATag): AddressableNote { - // checkNotInMainThread() - - // we can't use naddr here because naddr might include relay info and - // the preferred relay should not be part of the index. - return addressables[key.toTag()] - ?: run { - val newObject = AddressableNote(key) - addressables.putIfAbsent(key.toTag(), newObject) ?: newObject - } - } - - fun getOrCreateAddressableNote(key: ATag): AddressableNote { - val note = getOrCreateAddressableNoteInternal(key) - // Loads the user outside a Syncronized block to avoid blocking - if (note.author == null) { - note.author = checkGetOrCreateUser(key.pubKeyHex) - } - return note - } - - fun consume(event: MetadataEvent) { - // new event - val oldUser = getOrCreateUser(event.pubKey) - if (oldUser.info == null || event.createdAt > oldUser.info!!.updatedMetadataAt) { - val newUserMetadata = event.contactMetaData() - if (newUserMetadata != null) { - oldUser.updateUserInfo(newUserMetadata, event) - } - // Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}") - } else { - // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} - // ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") - } - } - - fun consume(event: ContactListEvent) { - val user = getOrCreateUser(event.pubKey) - - // avoids processing empty contact lists. - if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty()) { - user.updateContactList(event) - // Log.d("CL", "Consumed contact list ${user.toNostrUri()} ${event.relays()?.size}") - } - } - - fun consume(event: BookmarkListEvent) { - val user = getOrCreateUser(event.pubKey) - if (user.latestBookmarkList == null || event.createdAt > user.latestBookmarkList!!.createdAt) { - if (event.dTag() == "bookmark") { - user.updateBookmark(event) - } - // Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}") - } else { - // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} - // ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") - } - } - - fun formattedDateTime(timestamp: Long): String { - return Instant.ofEpochSecond(timestamp) - .atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a")) - } - - fun consume( - event: TextNoteEvent, - relay: Relay? = null, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) + return null } - // Already processed this event. - if (note.event != null) return + fun getOrCreateUser(key: HexKey): User { + // checkNotInMainThread() - if (antiSpam.isSpam(event, relay)) { - relay?.let { it.spamCounter++ } - return - } + return users[key] + ?: run { + require(isValidHex(key = key)) { "$key is not a valid hex" } - val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } - - note.loadEvent(event, author, replyTo) - - // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } - - fun consume( - event: LongTextNoteEvent, - relay: Relay?, - ) { - val version = getOrCreateNote(event.id) - val note = getOrCreateAddressableNote(event.address()) - val author = getOrCreateUser(event.pubKey) - - if (version.event == null) { - version.loadEvent(event, author, emptyList()) - version.moveAllReferencesTo(note) - } - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event?.id() == event.id()) return - - if (antiSpam.isSpam(event, relay)) { - relay?.let { it.spamCounter++ } - return - } - - val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } - - if (event.createdAt > (note.createdAt() ?: 0)) { - note.loadEvent(event, author, replyTo) - - refreshObservers(note) - } - } - - fun consume( - event: PollNoteEvent, - relay: Relay? = null, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - if (antiSpam.isSpam(event, relay)) { - relay?.let { it.spamCounter++ } - return - } - - val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } - - note.loadEvent(event, author, replyTo) - - // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } - - private fun consume( - event: LiveActivitiesEvent, - relay: Relay?, - ) { - val version = getOrCreateNote(event.id) - val note = getOrCreateAddressableNote(event.address()) - val author = getOrCreateUser(event.pubKey) - - if (version.event == null) { - version.loadEvent(event, author, emptyList()) - version.moveAllReferencesTo(note) - } - - if (note.event?.id() == event.id()) return - - if (event.createdAt > (note.createdAt() ?: 0)) { - note.loadEvent(event, author, emptyList()) - - val channel = - getOrCreateChannel(note.idHex) { LiveActivitiesChannel(note.address) } - as? LiveActivitiesChannel - - val creator = event.host()?.ifBlank { null }?.let { checkGetOrCreateUser(it) } ?: author - - channel?.updateChannelInfo(creator, event, event.createdAt) - - refreshObservers(note) - } - } - - fun consume( - event: MuteListEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } - - fun consume( - event: FileServersEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } - - fun consume( - event: PeopleListEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } - - private fun consume( - event: AdvertisedRelayListEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } - - private fun consume( - event: CommunityDefinitionEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } - - fun consume( - event: EmojiPackSelectionEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } - - private fun consume( - event: EmojiPackEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } - - private fun consume( - event: ClassifiedsEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } - - private fun consume( - event: PinListEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } - - private fun consume( - event: RelaySetEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } - - private fun consume( - event: AudioTrackEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } - - private fun consume( - event: VideoVerticalEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } - - private fun consume( - event: VideoHorizontalEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } - - fun consume( - event: StatusEvent, - relay: Relay?, - ) { - val version = getOrCreateNote(event.id) - val note = getOrCreateAddressableNote(event.address()) - val author = getOrCreateUser(event.pubKey) - - if (version.event == null) { - version.loadEvent(event, author, emptyList()) - version.moveAllReferencesTo(note) - } - - // Already processed this event. - if (note.event?.id() == event.id()) return - - if (event.createdAt > (note.createdAt() ?: 0)) { - note.loadEvent(event, author, emptyList()) - - author.liveSet?.innerStatuses?.invalidateData() - - refreshObservers(note) - } - } - - fun consume( - event: BadgeDefinitionEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } - - fun consume(event: BadgeProfilesEvent) { - val version = getOrCreateNote(event.id) - val note = getOrCreateAddressableNote(event.address()) - val author = getOrCreateUser(event.pubKey) - - if (version.event == null) { - version.loadEvent(event, author, emptyList()) - version.moveAllReferencesTo(note) - } - - // Already processed this event. - if (note.event?.id() == event.id()) return - - val replyTo = - event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } + - event.badgeAwardDefinitions().map { getOrCreateAddressableNote(it) } - - if (event.createdAt > (note.createdAt() ?: 0)) { - note.loadEvent(event, author, replyTo) - - refreshObservers(note) - } - } - - fun consume(event: BadgeAwardEvent) { - val note = getOrCreateNote(event.id) - - // Already processed this event. - if (note.event != null) return - - // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${formattedDateTime(event.createdAt)}") - - val author = getOrCreateUser(event.pubKey) - val awardDefinition = event.awardDefinition().map { getOrCreateAddressableNote(it) } - - note.loadEvent(event, author, awardDefinition) - - // Replies of an Badge Definition are Award Events - awardDefinition.forEach { it.addReply(note) } - - refreshObservers(note) - } - - private fun comsume( - event: NNSEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } - - fun consume( - event: AppDefinitionEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } - - private fun consume( - event: CalendarEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } - - private fun consume( - event: CalendarDateSlotEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } - - private fun consume( - event: CalendarTimeSlotEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } - - private fun consume( - event: CalendarRSVPEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } - - private fun consumeBaseReplaceable( - event: BaseAddressableEvent, - relay: Relay?, - ) { - val version = getOrCreateNote(event.id) - val note = getOrCreateAddressableNote(event.address()) - val author = getOrCreateUser(event.pubKey) - - if (version.event == null) { - version.loadEvent(event, author, emptyList()) - version.moveAllReferencesTo(note) - } - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event?.id() == event.id()) return - - if (event.createdAt > (note.createdAt() ?: 0)) { - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } - } - - fun consume( - event: AppRecommendationEvent, - relay: Relay?, - ) { - consumeBaseReplaceable(event, relay) - } - - @Suppress("UNUSED_PARAMETER") - fun consume(event: RecommendRelayEvent) { - // // Log.d("RR", event.toJson()) - } - - fun consume( - event: PrivateDmEvent, - relay: Relay?, - ): Note { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return note - - val recipient = event.verifiedRecipientPubKey()?.let { getOrCreateUser(it) } - - // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") - - val repliesTo = event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } - - note.loadEvent(event, author, repliesTo) - - if (recipient != null) { - author.addMessage(recipient, note) - recipient.addMessage(author, note) - } - - refreshObservers(note) - - return note - } - - fun consume(event: DeletionEvent) { - var deletedAtLeastOne = false - - event - .deleteEvents() - .mapNotNull { notes[it] } - .forEach { deleteNote -> - // must be the same author - if (deleteNote.author?.pubkeyHex == event.pubKey) { - // reverts the add - val mentions = - deleteNote.event - ?.tags() - ?.filter { it.firstOrNull() == "p" } - ?.mapNotNull { it.getOrNull(1) } - ?.mapNotNull { checkGetOrCreateUser(it) } - - mentions?.forEach { user -> user.removeReport(deleteNote) } - - // Counts the replies - deleteNote.replyTo?.forEach { masterNote -> - masterNote.removeReply(deleteNote) - masterNote.removeBoost(deleteNote) - masterNote.removeReaction(deleteNote) - masterNote.removeZap(deleteNote) - masterNote.removeZapPayment(deleteNote) - masterNote.removeReport(deleteNote) - } - - deleteNote.channelHex()?.let { channels[it]?.removeNote(deleteNote) } - - (deleteNote.event as? LiveActivitiesChatMessageEvent)?.activity()?.let { - channels[it.toTag()]?.removeNote(deleteNote) - } - - if (deleteNote.event is PrivateDmEvent) { - val author = deleteNote.author - val recipient = - (deleteNote.event as? PrivateDmEvent)?.verifiedRecipientPubKey()?.let { - checkGetOrCreateUser(it) - } - - if (recipient != null && author != null) { - author.removeMessage(recipient, deleteNote) - recipient.removeMessage(author, deleteNote) + val newObject = User(key) + users.putIfAbsent(key, newObject) ?: newObject } - } + } - notes.remove(deleteNote.idHex) + fun getUserIfExists(key: String): User? { + if (key.isEmpty()) return null + return users[key] + } - deletedAtLeastOne = true + fun getAddressableNoteIfExists(key: String): AddressableNote? { + return addressables[key] + } + + fun getNoteIfExists(key: String): Note? { + return addressables[key] ?: notes[key] + } + + fun getChannelIfExists(key: String): Channel? { + return channels[key] + } + + fun checkGetOrCreateNote(key: String): Note? { + checkNotInMainThread() + + if (ATag.isATag(key)) { + return checkGetOrCreateAddressableNote(key) } - } - - if (deletedAtLeastOne) { - // refreshObservers() - } - } - - fun consume(event: RepostEvent) { - val note = getOrCreateNote(event.id) - - // Already processed this event. - if (note.event != null) return - - // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${formattedDateTime(event.createdAt)}") - - val author = getOrCreateUser(event.pubKey) - val repliesTo = - event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().map { getOrCreateAddressableNote(it) } - - note.loadEvent(event, author, repliesTo) - - // Counts the replies - repliesTo.forEach { it.addBoost(note) } - - refreshObservers(note) - } - - fun consume(event: GenericRepostEvent) { - val note = getOrCreateNote(event.id) - - // Already processed this event. - if (note.event != null) return - - // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${formattedDateTime(event.createdAt)}") - - val author = getOrCreateUser(event.pubKey) - val repliesTo = - event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().map { getOrCreateAddressableNote(it) } - - note.loadEvent(event, author, repliesTo) - - // Counts the replies - repliesTo.forEach { it.addBoost(note) } - - refreshObservers(note) - } - - fun consume(event: CommunityPostApprovalEvent) { - val note = getOrCreateNote(event.id) - - // Already processed this event. - if (note.event != null) return - - // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} - // ${formattedDateTime(event.createdAt)}") - - val author = getOrCreateUser(event.pubKey) - - val communities = event.communities() - val eventsApproved = event.approvedEvents().mapNotNull { checkGetOrCreateNote(it) } - - val repliesTo = communities.map { getOrCreateAddressableNote(it) } - - note.loadEvent(event, author, eventsApproved) - - // Counts the replies - repliesTo.forEach { it.addBoost(note) } - - refreshObservers(note) - } - - fun consume(event: ReactionEvent) { - val note = getOrCreateNote(event.id) - - // Already processed this event. - if (note.event != null) return - - val author = getOrCreateUser(event.pubKey) - val repliesTo = - event.originalPost().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().map { getOrCreateAddressableNote(it) } - - note.loadEvent(event, author, repliesTo) - - // Log.d("RE", "New Reaction ${event.content} (${notes.size},${users.size}) - // ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") - - repliesTo.forEach { it.addReaction(note) } - - refreshObservers(note) - } - - fun consume( - event: ReportEvent, - relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - val mentions = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.key) } - val repliesTo = - event.reportedPost().mapNotNull { checkGetOrCreateNote(it.key) } + - event.taggedAddresses().map { getOrCreateAddressableNote(it) } - - note.loadEvent(event, author, repliesTo) - - // Log.d("RP", "New Report ${event.content} by ${note.author?.toBestDisplayName()} - // ${formattedDateTime(event.createdAt)}") - // Adds notifications to users. - if (repliesTo.isEmpty()) { - mentions.forEach { it.addReport(note) } - } else { - repliesTo.forEach { it.addReport(note) } - - mentions.forEach { - // doesn't add to reports, but triggers recounts - it.liveSet?.innerReports?.invalidateData() - } - } - - refreshObservers(note) - } - - fun consume(event: ChannelCreateEvent) { - // Log.d("MT", "New Event ${event.content} ${event.id.toHex()}") - val oldChannel = getOrCreateChannel(event.id) { PublicChatChannel(it) } - val author = getOrCreateUser(event.pubKey) - - val note = getOrCreateNote(event.id) - if (note.event == null) { - oldChannel.addNote(note) - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } - - if (event.createdAt <= oldChannel.updatedMetadataAt) { - return // older data, does nothing - } - if (oldChannel.creator == null || oldChannel.creator == author) { - if (oldChannel is PublicChatChannel) { - oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) - } - } - } - - fun consume(event: ChannelMetadataEvent) { - val channelId = event.channel() - // Log.d("MT", "New PublicChatMetadata ${event.channelInfo()}") - if (channelId.isNullOrBlank()) return - - // new event - val oldChannel = checkGetOrCreateChannel(channelId) ?: return - - val author = getOrCreateUser(event.pubKey) - if (event.createdAt > oldChannel.updatedMetadataAt) { - if (oldChannel is PublicChatChannel) { - oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) - } - } else { - // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} - // ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") - } - - val note = getOrCreateNote(event.id) - if (note.event == null) { - oldChannel.addNote(note) - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } - } - - fun consume( - event: ChannelMessageEvent, - relay: Relay?, - ) { - val channelId = event.channel() - - if (channelId.isNullOrBlank()) return - - val channel = checkGetOrCreateChannel(channelId) ?: return - - val note = getOrCreateNote(event.id) - channel.addNote(note) - - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - if (antiSpam.isSpam(event, relay)) { - relay?.let { it.spamCounter++ } - return - } - - val replyTo = - event - .tagsWithoutCitations() - .filter { it != event.channel() } - .mapNotNull { checkGetOrCreateNote(it) } - - note.loadEvent(event, author, replyTo) - - // Log.d("CM", "New Chat Note (${note.author?.toBestDisplayName()} ${note.event?.content()} - // ${formattedDateTime(event.createdAt)}") - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } - - fun consume( - event: LiveActivitiesChatMessageEvent, - relay: Relay?, - ) { - val activityId = event.activity() ?: return - - val channel = getOrCreateChannel(activityId.toTag()) { LiveActivitiesChannel(activityId) } - - val note = getOrCreateNote(event.id) - channel.addNote(note) - - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - if (antiSpam.isSpam(event, relay)) { - relay?.let { it.spamCounter++ } - return - } - - val replyTo = - event - .tagsWithoutCitations() - .filter { it != event.activity()?.toTag() } - .mapNotNull { checkGetOrCreateNote(it) } - - note.loadEvent(event, author, replyTo) - - // Counts the replies - replyTo.forEach { it.addReply(note) } - - refreshObservers(note) - } - - @Suppress("UNUSED_PARAMETER") fun consume(event: ChannelHideMessageEvent) {} - - @Suppress("UNUSED_PARAMETER") fun consume(event: ChannelMuteUserEvent) {} - - fun consume(event: LnZapEvent) { - val note = getOrCreateNote(event.id) - // Already processed this event. - if (note.event != null) return - - val zapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) } - - if (zapRequest == null || zapRequest.event !is LnZapRequestEvent) { - Log.e("ZP", "Zap Request not found. Unable to process Zap {${event.toJson()}}") - return - } - - val author = getOrCreateUser(event.pubKey) - val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } - val repliesTo = - event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().map { getOrCreateAddressableNote(it) } + - ((zapRequest.event as? LnZapRequestEvent)?.taggedAddresses()?.map { - getOrCreateAddressableNote(it) + if (isValidHex(key)) { + val note = getOrCreateNote(key) + val noteEvent = note.event + return if (noteEvent is AddressableEvent) { + // upgrade to the latest + val newNote = checkGetOrCreateAddressableNote(noteEvent.address().toTag()) + + if (newNote != null && noteEvent is Event && newNote.event == null) { + val author = note.author ?: getOrCreateUser(noteEvent.pubKey) + newNote.loadEvent(noteEvent as Event, author, emptyList()) + note.moveAllReferencesTo(newNote) + } + + newNote + } else { + note + } } - ?: emptySet()) - - note.loadEvent(event, author, repliesTo) - - // Log.d("ZP", "New ZapEvent ${event.content} (${notes.size},${users.size}) - // ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") - - repliesTo.forEach { it.addZap(zapRequest, note) } - mentions.forEach { it.addZap(zapRequest, note) } - - refreshObservers(note) - } - - fun consume(event: LnZapRequestEvent) { - val note = getOrCreateNote(event.id) - - // Already processed this event. - if (note.event != null) return - - val author = getOrCreateUser(event.pubKey) - val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } - val repliesTo = - event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().map { getOrCreateAddressableNote(it) } - - note.loadEvent(event, author, repliesTo) - - // Log.d("ZP", "New Zap Request ${event.content} (${notes.size},${users.size}) - // ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") - - repliesTo.forEach { it.addZap(note, null) } - mentions.forEach { it.addZap(note, null) } - - refreshObservers(note) - } - - fun consume( - event: AudioHeaderEvent, - relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) + return null } - // Already processed this event. - if (note.event != null) return + fun getOrAddAliasNote( + idHex: String, + note: Note, + ): Note { + checkNotInMainThread() - note.loadEvent(event, author, emptyList()) + return notes.get(idHex) + ?: run { + require(isValidHex(idHex)) { "$idHex is not a valid hex" } - refreshObservers(note) - } - - fun consume( - event: FileHeaderEvent, - relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) + notes.putIfAbsent(idHex, note) ?: note + } } - // Already processed this event. - if (note.event != null) return + fun getOrCreateNote(idHex: String): Note { + checkNotInMainThread() - note.loadEvent(event, author, emptyList()) + return notes.get(idHex) + ?: run { + require(isValidHex(idHex)) { "$idHex is not a valid hex" } - refreshObservers(note) - } - - fun consume( - event: FileStorageHeaderEvent, - relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) + val newObject = Note(idHex) + notes.putIfAbsent(idHex, newObject) ?: newObject + } } - // Already processed this event. - if (note.event != null) return + fun checkGetOrCreateChannel(key: String): Channel? { + checkNotInMainThread() - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } - - fun consume( - event: HighlightEvent, - relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) + if (isValidHex(key)) { + return getOrCreateChannel(key) { PublicChatChannel(key) } + } + val aTag = ATag.parse(key, null) + if (aTag != null) { + return getOrCreateChannel(aTag.toTag()) { LiveActivitiesChannel(aTag) } + } + return null } - // Already processed this event. - if (note.event != null) return + private fun isValidHex(key: String): Boolean { + if (key.isBlank()) return false + if (key.contains(":")) return false - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } - - fun consume( - event: FileStorageEvent, - relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) + return HexValidator.isHex(key) } - try { - val cachePath = File(Amethyst.instance.applicationContext.cacheDir, "NIP95") - cachePath.mkdirs() - val file = File(cachePath, event.id) - if (!file.exists()) { - val stream = FileOutputStream(file) - stream.write(event.decode()) - stream.close() - Log.i( - "FileStorageEvent", - "NIP95 File received from ${relay?.url} and saved to disk as $file", - ) - } - } catch (e: IOException) { - Log.e("FileStorageEvent", "FileStorageEvent save to disk error: " + event.id, e) + fun getOrCreateChannel( + key: String, + channelFactory: (String) -> Channel, + ): Channel { + checkNotInMainThread() + + return channels[key] + ?: run { + val newObject = channelFactory(key) + channels.putIfAbsent(key, newObject) ?: newObject + } } - // Already processed this event. - if (note.event != null) return - - // this is an invalid event. But we don't need to keep the data in memory. - val eventNoData = - FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig) - - note.loadEvent(eventNoData, author, emptyList()) - - refreshObservers(note) - } - - private fun consume( - event: ChatMessageEvent, - relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) + fun checkGetOrCreateAddressableNote(key: String): AddressableNote? { + return try { + val addr = ATag.parse(key, null) // relay doesn't matter for the index. + if (addr != null) { + getOrCreateAddressableNote(addr) + } else { + null + } + } catch (e: IllegalArgumentException) { + Log.e("LocalCache", "Invalid Key to create channel: $key", e) + null + } } - // Already processed this event. - if (note.event != null) return + fun getOrCreateAddressableNoteInternal(key: ATag): AddressableNote { + // checkNotInMainThread() - val recipientsHex = event.recipientsPubKey().plus(event.pubKey).toSet() - val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() - - // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") - - val repliesTo = event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } - - note.loadEvent(event, author, repliesTo) - - if (recipients.isNotEmpty()) { - recipients.forEach { - val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) - - val authorGroup = - if (groupMinusRecipient.isEmpty()) { - // note to self - ChatroomKey(persistentSetOf(it.pubkeyHex)) - } else { - ChatroomKey(groupMinusRecipient.toImmutableSet()) - } - - it.addMessage(authorGroup, note) - } + // we can't use naddr here because naddr might include relay info and + // the preferred relay should not be part of the index. + return addressables[key.toTag()] + ?: run { + val newObject = AddressableNote(key) + addressables.putIfAbsent(key.toTag(), newObject) ?: newObject + } } - refreshObservers(note) - } - - private fun consume( - event: SealedGossipEvent, - relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) + fun getOrCreateAddressableNote(key: ATag): AddressableNote { + val note = getOrCreateAddressableNoteInternal(key) + // Loads the user outside a Syncronized block to avoid blocking + if (note.author == null) { + note.author = checkGetOrCreateUser(key.pubKeyHex) + } + return note } - // Already processed this event. - if (note.event != null) return - - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } - - fun consume( - event: GiftWrapEvent, - relay: Relay?, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) + fun consume(event: MetadataEvent) { + // new event + val oldUser = getOrCreateUser(event.pubKey) + if (oldUser.info == null || event.createdAt > oldUser.info!!.updatedMetadataAt) { + val newUserMetadata = event.contactMetaData() + if (newUserMetadata != null) { + oldUser.updateUserInfo(newUserMetadata, event) + } + // Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}") + } else { + // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} + // ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") + } } - // Already processed this event. - if (note.event != null) return + fun consume(event: ContactListEvent) { + val user = getOrCreateUser(event.pubKey) - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } - - fun consume(event: LnZapPaymentRequestEvent) { - // Does nothing without a response callback. - } - - fun consume( - event: LnZapPaymentRequestEvent, - zappedNote: Note?, - onResponse: (LnZapPaymentResponseEvent) -> Unit, - ) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - // Already processed this event. - if (note.event != null) return - - note.loadEvent(event, author, emptyList()) - - zappedNote?.addZapPayment(note, null) - - awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse)) - - refreshObservers(note) - } - - fun consume(event: LnZapPaymentResponseEvent) { - val requestId = event.requestId() - val pair = awaitingPaymentRequests[requestId] ?: return - - val (zappedNote, responseCallback) = pair - - val requestNote = requestId?.let { checkGetOrCreateNote(requestId) } - - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - // Already processed this event. - if (note.event != null) return - - note.loadEvent(event, author, emptyList()) - - requestNote?.let { request -> zappedNote?.addZapPayment(request, note) } - - if (responseCallback != null) { - responseCallback(event) - } - } - - fun findUsersStartingWith(username: String): List { - checkNotInMainThread() - - val key = decodePublicKeyAsHexOrNull(username) - - if (key != null && users[key] != null) { - return listOfNotNull(users[key]) + // avoids processing empty contact lists. + if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty()) { + user.updateContactList(event) + // Log.d("CL", "Consumed contact list ${user.toNostrUri()} ${event.relays()?.size}") + } } - return users.values.filter { - (it.anyNameStartsWith(username)) || - it.pubkeyHex.startsWith(username, true) || - it.pubkeyNpub().startsWith(username, true) - } - } - - fun findNotesStartingWith(text: String): List { - checkNotInMainThread() - - val key = - try { - Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey() - } catch (e: Exception) { - null - } - - if (key != null && (notes[key] ?: addressables[key]) != null) { - return listOfNotNull(notes[key] ?: addressables[key]) + fun consume(event: BookmarkListEvent) { + val user = getOrCreateUser(event.pubKey) + if (user.latestBookmarkList == null || event.createdAt > user.latestBookmarkList!!.createdAt) { + if (event.dTag() == "bookmark") { + user.updateBookmark(event) + } + // Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}") + } else { + // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} + // ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") + } } - return notes.values.filter { - (it.event !is GenericRepostEvent && - it.event !is RepostEvent && - it.event !is CommunityPostApprovalEvent && - it.event !is ReactionEvent && - it.event !is GiftWrapEvent && - it.event !is LnZapEvent && - it.event !is LnZapRequestEvent) && - (it.event?.content()?.contains(text, true) - ?: false || - it.event?.matchTag1With(text) ?: false || - it.idHex.startsWith(text, true) || - it.idNote().startsWith(text, true)) - } + - addressables.values.filter { - (it.event !is GenericRepostEvent && - it.event !is RepostEvent && - it.event !is CommunityPostApprovalEvent && - it.event !is ReactionEvent && - it.event !is GiftWrapEvent && - it.event !is LnZapEvent && - it.event !is LnZapRequestEvent) && - (it.event?.content()?.contains(text, true) - ?: false || it.event?.matchTag1With(text) ?: false || it.idHex.startsWith(text, true)) - } - } - - fun findChannelsStartingWith(text: String): List { - checkNotInMainThread() - - val key = - try { - Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey() - } catch (e: Exception) { - null - } - - if (key != null && channels[key] != null) { - return listOfNotNull(channels[key]) + fun formattedDateTime(timestamp: Long): String { + return Instant.ofEpochSecond(timestamp) + .atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a")) } - return channels.values.filter { - it.anyNameStartsWith(text) || - it.idHex.startsWith(text, true) || - it.idNote().startsWith(text, true) - } - } + fun consume( + event: TextNoteEvent, + relay: Relay? = null, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) - suspend fun findStatusesForUser(user: User): ImmutableList { - checkNotInMainThread() + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } - return addressables - .filter { - val noteEvent = it.value.event - (noteEvent is StatusEvent && - noteEvent.pubKey == user.pubkeyHex && - !noteEvent.isExpired() && - noteEvent.content.isNotBlank()) - } - .values - .sortedWith(compareBy({ it.event?.expiration() ?: it.event?.createdAt() }, { it.idHex })) - .reversed() - .toImmutableList() - } + // Already processed this event. + if (note.event != null) return - fun cleanObservers() { - notes.forEach { it.value.clearLive() } + if (antiSpam.isSpam(event, relay)) { + relay?.let { it.spamCounter++ } + return + } - addressables.forEach { it.value.clearLive() } + val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } - users.forEach { it.value.clearLive() } - } + note.loadEvent(event, author, replyTo) - fun pruneOldAndHiddenMessages(account: Account) { - checkNotInMainThread() + // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} + // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") - channels.forEach { it -> - val toBeRemoved = it.value.pruneOldAndHiddenMessages(account) + // Counts the replies + replyTo.forEach { it.addReply(note) } - val childrenToBeRemoved = mutableListOf() - - toBeRemoved.forEach { - removeFromCache(it) - - childrenToBeRemoved.addAll(it.removeAllChildNotes()) - } - - removeFromCache(childrenToBeRemoved) - - if (toBeRemoved.size > 100 || it.value.notes.size > 100) { - println( - "PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}. ${it.value.notes.size} kept", - ) - } + refreshObservers(note) } - users.forEach { userPair -> - userPair.value.privateChatrooms.values.map { - val toBeRemoved = it.pruneMessagesToTheLatestOnly() + fun consume( + event: LongTextNoteEvent, + relay: Relay?, + ) { + val version = getOrCreateNote(event.id) + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + + if (version.event == null) { + version.loadEvent(event, author, emptyList()) + version.moveAllReferencesTo(note) + } + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event?.id() == event.id()) return + + if (antiSpam.isSpam(event, relay)) { + relay?.let { it.spamCounter++ } + return + } + + val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + + if (event.createdAt > (note.createdAt() ?: 0)) { + note.loadEvent(event, author, replyTo) + + refreshObservers(note) + } + } + + fun consume( + event: PollNoteEvent, + relay: Relay? = null, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + + if (antiSpam.isSpam(event, relay)) { + relay?.let { it.spamCounter++ } + return + } + + val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + + note.loadEvent(event, author, replyTo) + + // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} + // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") + + // Counts the replies + replyTo.forEach { it.addReply(note) } + + refreshObservers(note) + } + + private fun consume( + event: LiveActivitiesEvent, + relay: Relay?, + ) { + val version = getOrCreateNote(event.id) + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + + if (version.event == null) { + version.loadEvent(event, author, emptyList()) + version.moveAllReferencesTo(note) + } + + if (note.event?.id() == event.id()) return + + if (event.createdAt > (note.createdAt() ?: 0)) { + note.loadEvent(event, author, emptyList()) + + val channel = + getOrCreateChannel(note.idHex) { LiveActivitiesChannel(note.address) } + as? LiveActivitiesChannel + + val creator = event.host()?.ifBlank { null }?.let { checkGetOrCreateUser(it) } ?: author + + channel?.updateChannelInfo(creator, event, event.createdAt) + + refreshObservers(note) + } + } + + fun consume( + event: MuteListEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + fun consume( + event: FileServersEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + fun consume( + event: PeopleListEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: AdvertisedRelayListEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: CommunityDefinitionEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + fun consume( + event: EmojiPackSelectionEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: EmojiPackEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: ClassifiedsEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: PinListEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: RelaySetEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: AudioTrackEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: VideoVerticalEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: VideoHorizontalEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + fun consume( + event: StatusEvent, + relay: Relay?, + ) { + val version = getOrCreateNote(event.id) + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + + if (version.event == null) { + version.loadEvent(event, author, emptyList()) + version.moveAllReferencesTo(note) + } + + // Already processed this event. + if (note.event?.id() == event.id()) return + + if (event.createdAt > (note.createdAt() ?: 0)) { + note.loadEvent(event, author, emptyList()) + + author.liveSet?.innerStatuses?.invalidateData() + + refreshObservers(note) + } + } + + fun consume( + event: BadgeDefinitionEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + fun consume(event: BadgeProfilesEvent) { + val version = getOrCreateNote(event.id) + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + + if (version.event == null) { + version.loadEvent(event, author, emptyList()) + version.moveAllReferencesTo(note) + } + + // Already processed this event. + if (note.event?.id() == event.id()) return + + val replyTo = + event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } + + event.badgeAwardDefinitions().map { getOrCreateAddressableNote(it) } + + if (event.createdAt > (note.createdAt() ?: 0)) { + note.loadEvent(event, author, replyTo) + + refreshObservers(note) + } + } + + fun consume(event: BadgeAwardEvent) { + val note = getOrCreateNote(event.id) + + // Already processed this event. + if (note.event != null) return + + // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} + // ${formattedDateTime(event.createdAt)}") + + val author = getOrCreateUser(event.pubKey) + val awardDefinition = event.awardDefinition().map { getOrCreateAddressableNote(it) } + + note.loadEvent(event, author, awardDefinition) + + // Replies of an Badge Definition are Award Events + awardDefinition.forEach { it.addReply(note) } + + refreshObservers(note) + } + + private fun comsume( + event: NNSEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + fun consume( + event: AppDefinitionEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: CalendarEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: CalendarDateSlotEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: CalendarTimeSlotEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: CalendarRSVPEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consumeBaseReplaceable( + event: BaseAddressableEvent, + relay: Relay?, + ) { + val version = getOrCreateNote(event.id) + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + + if (version.event == null) { + version.loadEvent(event, author, emptyList()) + version.moveAllReferencesTo(note) + } + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event?.id() == event.id()) return + + if (event.createdAt > (note.createdAt() ?: 0)) { + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + } + + fun consume( + event: AppRecommendationEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + @Suppress("UNUSED_PARAMETER") + fun consume(event: RecommendRelayEvent) { + // // Log.d("RR", event.toJson()) + } + + fun consume( + event: PrivateDmEvent, + relay: Relay?, + ): Note { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return note + + val recipient = event.verifiedRecipientPubKey()?.let { getOrCreateUser(it) } + + // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") + + val repliesTo = event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } + + note.loadEvent(event, author, repliesTo) + + if (recipient != null) { + author.addMessage(recipient, note) + recipient.addMessage(author, note) + } + + refreshObservers(note) + + return note + } + + fun consume(event: DeletionEvent) { + var deletedAtLeastOne = false + + event + .deleteEvents() + .mapNotNull { notes[it] } + .forEach { deleteNote -> + // must be the same author + if (deleteNote.author?.pubkeyHex == event.pubKey) { + // reverts the add + val mentions = + deleteNote.event + ?.tags() + ?.filter { it.firstOrNull() == "p" } + ?.mapNotNull { it.getOrNull(1) } + ?.mapNotNull { checkGetOrCreateUser(it) } + + mentions?.forEach { user -> user.removeReport(deleteNote) } + + // Counts the replies + deleteNote.replyTo?.forEach { masterNote -> + masterNote.removeReply(deleteNote) + masterNote.removeBoost(deleteNote) + masterNote.removeReaction(deleteNote) + masterNote.removeZap(deleteNote) + masterNote.removeZapPayment(deleteNote) + masterNote.removeReport(deleteNote) + } + + deleteNote.channelHex()?.let { channels[it]?.removeNote(deleteNote) } + + (deleteNote.event as? LiveActivitiesChatMessageEvent)?.activity()?.let { + channels[it.toTag()]?.removeNote(deleteNote) + } + + if (deleteNote.event is PrivateDmEvent) { + val author = deleteNote.author + val recipient = + (deleteNote.event as? PrivateDmEvent)?.verifiedRecipientPubKey()?.let { + checkGetOrCreateUser(it) + } + + if (recipient != null && author != null) { + author.removeMessage(recipient, deleteNote) + recipient.removeMessage(author, deleteNote) + } + } + + notes.remove(deleteNote.idHex) + + deletedAtLeastOne = true + } + } + + if (deletedAtLeastOne) { + // refreshObservers() + } + } + + fun consume(event: RepostEvent) { + val note = getOrCreateNote(event.id) + + // Already processed this event. + if (note.event != null) return + + // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} + // ${formattedDateTime(event.createdAt)}") + + val author = getOrCreateUser(event.pubKey) + val repliesTo = + event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + + note.loadEvent(event, author, repliesTo) + + // Counts the replies + repliesTo.forEach { it.addBoost(note) } + + refreshObservers(note) + } + + fun consume(event: GenericRepostEvent) { + val note = getOrCreateNote(event.id) + + // Already processed this event. + if (note.event != null) return + + // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} + // ${formattedDateTime(event.createdAt)}") + + val author = getOrCreateUser(event.pubKey) + val repliesTo = + event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + + note.loadEvent(event, author, repliesTo) + + // Counts the replies + repliesTo.forEach { it.addBoost(note) } + + refreshObservers(note) + } + + fun consume(event: CommunityPostApprovalEvent) { + val note = getOrCreateNote(event.id) + + // Already processed this event. + if (note.event != null) return + + // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} + // ${formattedDateTime(event.createdAt)}") + + val author = getOrCreateUser(event.pubKey) + + val communities = event.communities() + val eventsApproved = event.approvedEvents().mapNotNull { checkGetOrCreateNote(it) } + + val repliesTo = communities.map { getOrCreateAddressableNote(it) } + + note.loadEvent(event, author, eventsApproved) + + // Counts the replies + repliesTo.forEach { it.addBoost(note) } + + refreshObservers(note) + } + + fun consume(event: ReactionEvent) { + val note = getOrCreateNote(event.id) + + // Already processed this event. + if (note.event != null) return + + val author = getOrCreateUser(event.pubKey) + val repliesTo = + event.originalPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + + note.loadEvent(event, author, repliesTo) + + // Log.d("RE", "New Reaction ${event.content} (${notes.size},${users.size}) + // ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") + + repliesTo.forEach { it.addReaction(note) } + + refreshObservers(note) + } + + fun consume( + event: ReportEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + + val mentions = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.key) } + val repliesTo = + event.reportedPost().mapNotNull { checkGetOrCreateNote(it.key) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + + note.loadEvent(event, author, repliesTo) + + // Log.d("RP", "New Report ${event.content} by ${note.author?.toBestDisplayName()} + // ${formattedDateTime(event.createdAt)}") + // Adds notifications to users. + if (repliesTo.isEmpty()) { + mentions.forEach { it.addReport(note) } + } else { + repliesTo.forEach { it.addReport(note) } + + mentions.forEach { + // doesn't add to reports, but triggers recounts + it.liveSet?.innerReports?.invalidateData() + } + } + + refreshObservers(note) + } + + fun consume(event: ChannelCreateEvent) { + // Log.d("MT", "New Event ${event.content} ${event.id.toHex()}") + val oldChannel = getOrCreateChannel(event.id) { PublicChatChannel(it) } + val author = getOrCreateUser(event.pubKey) + + val note = getOrCreateNote(event.id) + if (note.event == null) { + oldChannel.addNote(note) + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + + if (event.createdAt <= oldChannel.updatedMetadataAt) { + return // older data, does nothing + } + if (oldChannel.creator == null || oldChannel.creator == author) { + if (oldChannel is PublicChatChannel) { + oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) + } + } + } + + fun consume(event: ChannelMetadataEvent) { + val channelId = event.channel() + // Log.d("MT", "New PublicChatMetadata ${event.channelInfo()}") + if (channelId.isNullOrBlank()) return + + // new event + val oldChannel = checkGetOrCreateChannel(channelId) ?: return + + val author = getOrCreateUser(event.pubKey) + if (event.createdAt > oldChannel.updatedMetadataAt) { + if (oldChannel is PublicChatChannel) { + oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) + } + } else { + // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} + // ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") + } + + val note = getOrCreateNote(event.id) + if (note.event == null) { + oldChannel.addNote(note) + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + } + + fun consume( + event: ChannelMessageEvent, + relay: Relay?, + ) { + val channelId = event.channel() + + if (channelId.isNullOrBlank()) return + + val channel = checkGetOrCreateChannel(channelId) ?: return + + val note = getOrCreateNote(event.id) + channel.addNote(note) + + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + + if (antiSpam.isSpam(event, relay)) { + relay?.let { it.spamCounter++ } + return + } + + val replyTo = + event + .tagsWithoutCitations() + .filter { it != event.channel() } + .mapNotNull { checkGetOrCreateNote(it) } + + note.loadEvent(event, author, replyTo) + + // Log.d("CM", "New Chat Note (${note.author?.toBestDisplayName()} ${note.event?.content()} + // ${formattedDateTime(event.createdAt)}") + + // Counts the replies + replyTo.forEach { it.addReply(note) } + + refreshObservers(note) + } + + fun consume( + event: LiveActivitiesChatMessageEvent, + relay: Relay?, + ) { + val activityId = event.activity() ?: return + + val channel = getOrCreateChannel(activityId.toTag()) { LiveActivitiesChannel(activityId) } + + val note = getOrCreateNote(event.id) + channel.addNote(note) + + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + + if (antiSpam.isSpam(event, relay)) { + relay?.let { it.spamCounter++ } + return + } + + val replyTo = + event + .tagsWithoutCitations() + .filter { it != event.activity()?.toTag() } + .mapNotNull { checkGetOrCreateNote(it) } + + note.loadEvent(event, author, replyTo) + + // Counts the replies + replyTo.forEach { it.addReply(note) } + + refreshObservers(note) + } + + @Suppress("UNUSED_PARAMETER") + fun consume(event: ChannelHideMessageEvent) {} + + @Suppress("UNUSED_PARAMETER") + fun consume(event: ChannelMuteUserEvent) {} + + fun consume(event: LnZapEvent) { + val note = getOrCreateNote(event.id) + // Already processed this event. + if (note.event != null) return + + val zapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) } + + if (zapRequest == null || zapRequest.event !is LnZapRequestEvent) { + Log.e("ZP", "Zap Request not found. Unable to process Zap {${event.toJson()}}") + return + } + + val author = getOrCreateUser(event.pubKey) + val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } + val repliesTo = + event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + + ( + (zapRequest.event as? LnZapRequestEvent)?.taggedAddresses()?.map { + getOrCreateAddressableNote(it) + } + ?: emptySet() + ) + + note.loadEvent(event, author, repliesTo) + + // Log.d("ZP", "New ZapEvent ${event.content} (${notes.size},${users.size}) + // ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") + + repliesTo.forEach { it.addZap(zapRequest, note) } + mentions.forEach { it.addZap(zapRequest, note) } + + refreshObservers(note) + } + + fun consume(event: LnZapRequestEvent) { + val note = getOrCreateNote(event.id) + + // Already processed this event. + if (note.event != null) return + + val author = getOrCreateUser(event.pubKey) + val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } + val repliesTo = + event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + + note.loadEvent(event, author, repliesTo) + + // Log.d("ZP", "New Zap Request ${event.content} (${notes.size},${users.size}) + // ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") + + repliesTo.forEach { it.addZap(note, null) } + mentions.forEach { it.addZap(note, null) } + + refreshObservers(note) + } + + fun consume( + event: AudioHeaderEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + + fun consume( + event: FileHeaderEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + + fun consume( + event: FileStorageHeaderEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + + fun consume( + event: HighlightEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + + fun consume( + event: FileStorageEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + try { + val cachePath = File(Amethyst.instance.applicationContext.cacheDir, "NIP95") + cachePath.mkdirs() + val file = File(cachePath, event.id) + if (!file.exists()) { + val stream = FileOutputStream(file) + stream.write(event.decode()) + stream.close() + Log.i( + "FileStorageEvent", + "NIP95 File received from ${relay?.url} and saved to disk as $file", + ) + } + } catch (e: IOException) { + Log.e("FileStorageEvent", "FileStorageEvent save to disk error: " + event.id, e) + } + + // Already processed this event. + if (note.event != null) return + + // this is an invalid event. But we don't need to keep the data in memory. + val eventNoData = + FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig) + + note.loadEvent(eventNoData, author, emptyList()) + + refreshObservers(note) + } + + private fun consume( + event: ChatMessageEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + + val recipientsHex = event.recipientsPubKey().plus(event.pubKey).toSet() + val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() + + // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") + + val repliesTo = event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } + + note.loadEvent(event, author, repliesTo) + + if (recipients.isNotEmpty()) { + recipients.forEach { + val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) + + val authorGroup = + if (groupMinusRecipient.isEmpty()) { + // note to self + ChatroomKey(persistentSetOf(it.pubkeyHex)) + } else { + ChatroomKey(groupMinusRecipient.toImmutableSet()) + } + + it.addMessage(authorGroup, note) + } + } + + refreshObservers(note) + } + + private fun consume( + event: SealedGossipEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + + fun consume( + event: GiftWrapEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + + fun consume(event: LnZapPaymentRequestEvent) { + // Does nothing without a response callback. + } + + fun consume( + event: LnZapPaymentRequestEvent, + zappedNote: Note?, + onResponse: (LnZapPaymentResponseEvent) -> Unit, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + // Already processed this event. + if (note.event != null) return + + note.loadEvent(event, author, emptyList()) + + zappedNote?.addZapPayment(note, null) + + awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse)) + + refreshObservers(note) + } + + fun consume(event: LnZapPaymentResponseEvent) { + val requestId = event.requestId() + val pair = awaitingPaymentRequests[requestId] ?: return + + val (zappedNote, responseCallback) = pair + + val requestNote = requestId?.let { checkGetOrCreateNote(requestId) } + + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + // Already processed this event. + if (note.event != null) return + + note.loadEvent(event, author, emptyList()) + + requestNote?.let { request -> zappedNote?.addZapPayment(request, note) } + + if (responseCallback != null) { + responseCallback(event) + } + } + + fun findUsersStartingWith(username: String): List { + checkNotInMainThread() + + val key = decodePublicKeyAsHexOrNull(username) + + if (key != null && users[key] != null) { + return listOfNotNull(users[key]) + } + + return users.values.filter { + (it.anyNameStartsWith(username)) || + it.pubkeyHex.startsWith(username, true) || + it.pubkeyNpub().startsWith(username, true) + } + } + + fun findNotesStartingWith(text: String): List { + checkNotInMainThread() + + val key = + try { + Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey() + } catch (e: Exception) { + null + } + + if (key != null && (notes[key] ?: addressables[key]) != null) { + return listOfNotNull(notes[key] ?: addressables[key]) + } + + return notes.values.filter { + ( + it.event !is GenericRepostEvent && + it.event !is RepostEvent && + it.event !is CommunityPostApprovalEvent && + it.event !is ReactionEvent && + it.event !is GiftWrapEvent && + it.event !is LnZapEvent && + it.event !is LnZapRequestEvent + ) && + ( + it.event?.content()?.contains(text, true) + ?: false || + it.event?.matchTag1With(text) ?: false || + it.idHex.startsWith(text, true) || + it.idNote().startsWith(text, true) + ) + } + + addressables.values.filter { + ( + it.event !is GenericRepostEvent && + it.event !is RepostEvent && + it.event !is CommunityPostApprovalEvent && + it.event !is ReactionEvent && + it.event !is GiftWrapEvent && + it.event !is LnZapEvent && + it.event !is LnZapRequestEvent + ) && + ( + it.event?.content()?.contains(text, true) + ?: false || it.event?.matchTag1With(text) ?: false || it.idHex.startsWith(text, true) + ) + } + } + + fun findChannelsStartingWith(text: String): List { + checkNotInMainThread() + + val key = + try { + Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey() + } catch (e: Exception) { + null + } + + if (key != null && channels[key] != null) { + return listOfNotNull(channels[key]) + } + + return channels.values.filter { + it.anyNameStartsWith(text) || + it.idHex.startsWith(text, true) || + it.idNote().startsWith(text, true) + } + } + + suspend fun findStatusesForUser(user: User): ImmutableList { + checkNotInMainThread() + + return addressables + .filter { + val noteEvent = it.value.event + ( + noteEvent is StatusEvent && + noteEvent.pubKey == user.pubkeyHex && + !noteEvent.isExpired() && + noteEvent.content.isNotBlank() + ) + } + .values + .sortedWith(compareBy({ it.event?.expiration() ?: it.event?.createdAt() }, { it.idHex })) + .reversed() + .toImmutableList() + } + + fun cleanObservers() { + notes.forEach { it.value.clearLive() } + + addressables.forEach { it.value.clearLive() } + + users.forEach { it.value.clearLive() } + } + + fun pruneOldAndHiddenMessages(account: Account) { + checkNotInMainThread() + + channels.forEach { it -> + val toBeRemoved = it.value.pruneOldAndHiddenMessages(account) + + val childrenToBeRemoved = mutableListOf() + + toBeRemoved.forEach { + removeFromCache(it) + + childrenToBeRemoved.addAll(it.removeAllChildNotes()) + } + + removeFromCache(childrenToBeRemoved) + + if (toBeRemoved.size > 100 || it.value.notes.size > 100) { + println( + "PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}. ${it.value.notes.size} kept", + ) + } + } + + users.forEach { userPair -> + userPair.value.privateChatrooms.values.map { + val toBeRemoved = it.pruneMessagesToTheLatestOnly() + + val childrenToBeRemoved = mutableListOf() + + toBeRemoved.forEach { + removeFromCache(it) + + childrenToBeRemoved.addAll(it.removeAllChildNotes()) + } + + removeFromCache(childrenToBeRemoved) + + if (toBeRemoved.size > 1) { + println( + "PRUNE: ${toBeRemoved.size} private messages with ${userPair.value.toBestDisplayName()} removed. ${it.roomMessages.size} kept", + ) + } + } + } + } + + fun prunePastVersionsOfReplaceables() { + val toBeRemoved = + notes + .filter { + val noteEvent = it.value.event + if (noteEvent is AddressableEvent) { + noteEvent.createdAt() < + (addressables[noteEvent.address().toTag()]?.event?.createdAt() ?: 0) + } else { + false + } + } + .values val childrenToBeRemoved = mutableListOf() toBeRemoved.forEach { - removeFromCache(it) + val newerVersion = addressables[(it.event as? AddressableEvent)?.address()?.toTag()] + if (newerVersion != null) { + it.moveAllReferencesTo(newerVersion) + } - childrenToBeRemoved.addAll(it.removeAllChildNotes()) + removeFromCache(it) + childrenToBeRemoved.addAll(it.removeAllChildNotes()) } removeFromCache(childrenToBeRemoved) if (toBeRemoved.size > 1) { - println( - "PRUNE: ${toBeRemoved.size} private messages with ${userPair.value.toBestDisplayName()} removed. ${it.roomMessages.size} kept", - ) + println("PRUNE: ${toBeRemoved.size} old version of addressables removed.") } - } } - } - fun prunePastVersionsOfReplaceables() { - val toBeRemoved = - notes - .filter { - val noteEvent = it.value.event - if (noteEvent is AddressableEvent) { - noteEvent.createdAt() < - (addressables[noteEvent.address().toTag()]?.event?.createdAt() ?: 0) - } else { + fun pruneRepliesAndReactions(accounts: Set) { + checkNotInMainThread() + + val toBeRemoved = + notes + .filter { + ( + (it.value.event is TextNoteEvent && !it.value.isNewThread()) || + it.value.event is ReactionEvent || + it.value.event is LnZapEvent || + it.value.event is LnZapRequestEvent || + it.value.event is ReportEvent || + it.value.event is GenericRepostEvent + ) && + it.value.replyTo?.any { it.liveSet?.isInUse() == true } != true && + it.value.liveSet?.isInUse() != true && // don't delete if observing. + it.value.author?.pubkeyHex !in + accounts && // don't delete if it is the logged in account + it.value.event?.isTaggedUsers(accounts) != + true // don't delete if it's a notification to the logged in user + } + .values + + val childrenToBeRemoved = mutableListOf() + + toBeRemoved.forEach { + removeFromCache(it) + childrenToBeRemoved.addAll(it.removeAllChildNotes()) + } + + removeFromCache(childrenToBeRemoved) + + toBeRemoved.forEach { + it.replyTo?.forEach { masterNote -> + masterNote.clearEOSE() // allows reloading of these events + } + } + + if (toBeRemoved.size > 1) { + println("PRUNE: ${toBeRemoved.size} thread replies removed.") + } + } + + private fun removeFromCache(note: Note) { + note.replyTo?.forEach { masterNote -> + masterNote.removeReply(note) + masterNote.removeBoost(note) + masterNote.removeReaction(note) + masterNote.removeZap(note) + masterNote.removeReport(note) + masterNote.clearEOSE() // allows reloading of these events if needed + } + + val noteEvent = note.event + + if (noteEvent is LnZapEvent) { + noteEvent.zappedAuthor().forEach { + val author = getUserIfExists(it) + author?.removeZap(note) + author?.clearEOSE() + } + } + if (noteEvent is LnZapRequestEvent) { + noteEvent.zappedAuthor().mapNotNull { + val author = getUserIfExists(it) + author?.removeZap(note) + author?.clearEOSE() + } + } + if (noteEvent is ReportEvent) { + noteEvent.reportedAuthor().mapNotNull { + val author = getUserIfExists(it.key) + author?.removeReport(note) + author?.clearEOSE() + } + } + + notes.remove(note.idHex) + } + + fun removeFromCache(nextToBeRemoved: List) { + nextToBeRemoved.forEach { note -> removeFromCache(note) } + } + + fun pruneExpiredEvents() { + checkNotInMainThread() + + val toBeRemoved = notes.filter { it.value.event?.isExpired() == true }.values + + val childrenToBeRemoved = mutableListOf() + + toBeRemoved.forEach { + removeFromCache(it) + childrenToBeRemoved.addAll(it.removeAllChildNotes()) + } + + removeFromCache(childrenToBeRemoved) + + if (toBeRemoved.size > 1) { + println("PRUNE: ${toBeRemoved.size} thread replies removed.") + } + } + + fun pruneHiddenMessages(account: Account) { + checkNotInMainThread() + + val childrenToBeRemoved = mutableListOf() + + val toBeRemoved = + account.liveHiddenUsers.value + ?.hiddenUsers + ?.map { userHex -> + ( + notes.values.filter { it.event?.pubKey() == userHex } + + addressables.values.filter { it.event?.pubKey() == userHex } + ) + .toSet() + } + ?.flatten() + ?: emptyList() + + toBeRemoved.forEach { + removeFromCache(it) + childrenToBeRemoved.addAll(it.removeAllChildNotes()) + } + + removeFromCache(childrenToBeRemoved) + + println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden") + } + + fun pruneContactLists(loggedIn: Set) { + checkNotInMainThread() + + var removingContactList = 0 + users.values.forEach { + if ( + it.pubkeyHex !in loggedIn && + (it.liveSet == null || it.liveSet?.isInUse() == false) && + it.latestContactList != null + ) { + it.latestContactList = null + removingContactList++ + } + } + + println("PRUNE: $removingContactList contact lists") + } + + // Observers line up here. + val live: LocalCacheLiveData = LocalCacheLiveData() + + private fun refreshObservers(newNote: Note) { + live.invalidateData(newNote) + } + + fun verifyAndConsume( + event: Event, + relay: Relay?, + ) { + if (justVerify(event)) { + justConsume(event, relay) + } + } + + fun justVerify(event: Event): Boolean { + checkNotInMainThread() + + return if (!event.hasValidSignature()) { + try { + event.checkSignature() + } catch (e: Exception) { + Log.w("Event failed retest ${event.kind}", (e.message ?: "") + event.toJson()) + } false - } + } else { + true } - .values - - val childrenToBeRemoved = mutableListOf() - - toBeRemoved.forEach { - val newerVersion = addressables[(it.event as? AddressableEvent)?.address()?.toTag()] - if (newerVersion != null) { - it.moveAllReferencesTo(newerVersion) - } - - removeFromCache(it) - childrenToBeRemoved.addAll(it.removeAllChildNotes()) } - removeFromCache(childrenToBeRemoved) + fun justConsume( + event: Event, + relay: Relay?, + ) { + checkNotInMainThread() - if (toBeRemoved.size > 1) { - println("PRUNE: ${toBeRemoved.size} old version of addressables removed.") - } - } - - fun pruneRepliesAndReactions(accounts: Set) { - checkNotInMainThread() - - val toBeRemoved = - notes - .filter { - ((it.value.event is TextNoteEvent && !it.value.isNewThread()) || - it.value.event is ReactionEvent || - it.value.event is LnZapEvent || - it.value.event is LnZapRequestEvent || - it.value.event is ReportEvent || - it.value.event is GenericRepostEvent) && - it.value.replyTo?.any { it.liveSet?.isInUse() == true } != true && - it.value.liveSet?.isInUse() != true && // don't delete if observing. - it.value.author?.pubkeyHex !in - accounts && // don't delete if it is the logged in account - it.value.event?.isTaggedUsers(accounts) != - true // don't delete if it's a notification to the logged in user + try { + when (event) { + is AdvertisedRelayListEvent -> consume(event, relay) + is AppDefinitionEvent -> consume(event, relay) + is AppRecommendationEvent -> consume(event, relay) + is AudioHeaderEvent -> consume(event, relay) + is AudioTrackEvent -> consume(event, relay) + is BadgeAwardEvent -> consume(event) + is BadgeDefinitionEvent -> consume(event, relay) + is BadgeProfilesEvent -> consume(event) + is BookmarkListEvent -> consume(event) + is CalendarEvent -> consume(event, relay) + is CalendarDateSlotEvent -> consume(event, relay) + is CalendarTimeSlotEvent -> consume(event, relay) + is CalendarRSVPEvent -> consume(event, relay) + is ChannelCreateEvent -> consume(event) + is ChannelHideMessageEvent -> consume(event) + is ChannelMessageEvent -> consume(event, relay) + is ChannelMetadataEvent -> consume(event) + is ChannelMuteUserEvent -> consume(event) + is ChatMessageEvent -> consume(event, relay) + is ClassifiedsEvent -> consume(event, relay) + is CommunityDefinitionEvent -> consume(event, relay) + is CommunityPostApprovalEvent -> { + event.containedPost()?.let { verifyAndConsume(it, relay) } + consume(event) + } + is ContactListEvent -> consume(event) + is DeletionEvent -> consume(event) + is EmojiPackEvent -> consume(event, relay) + is EmojiPackSelectionEvent -> consume(event, relay) + is SealedGossipEvent -> consume(event, relay) + is FileHeaderEvent -> consume(event, relay) + is FileServersEvent -> consume(event, relay) + is FileStorageEvent -> consume(event, relay) + is FileStorageHeaderEvent -> consume(event, relay) + is GiftWrapEvent -> consume(event, relay) + is HighlightEvent -> consume(event, relay) + is LiveActivitiesEvent -> consume(event, relay) + is LiveActivitiesChatMessageEvent -> consume(event, relay) + is LnZapEvent -> { + event.zapRequest?.let { + // must have a valid request + verifyAndConsume(it, relay) + consume(event) + } + } + is LnZapRequestEvent -> consume(event) + is LnZapPaymentRequestEvent -> consume(event) + is LnZapPaymentResponseEvent -> consume(event) + is LongTextNoteEvent -> consume(event, relay) + is MetadataEvent -> consume(event) + is MuteListEvent -> consume(event, relay) + is NNSEvent -> comsume(event, relay) + is PrivateDmEvent -> consume(event, relay) + is PinListEvent -> consume(event, relay) + is PeopleListEvent -> consume(event, relay) + is PollNoteEvent -> consume(event, relay) + is ReactionEvent -> consume(event) + is RecommendRelayEvent -> consume(event) + is RelaySetEvent -> consume(event, relay) + is ReportEvent -> consume(event, relay) + is RepostEvent -> { + event.containedPost()?.let { verifyAndConsume(it, relay) } + consume(event) + } + is GenericRepostEvent -> { + event.containedPost()?.let { verifyAndConsume(it, relay) } + consume(event) + } + is StatusEvent -> consume(event, relay) + is TextNoteEvent -> consume(event, relay) + is VideoHorizontalEvent -> consume(event, relay) + is VideoVerticalEvent -> consume(event, relay) + else -> { + Log.w("Event Not Supported", event.toJson()) + } + } + } catch (e: Exception) { + e.printStackTrace() } - .values - - val childrenToBeRemoved = mutableListOf() - - toBeRemoved.forEach { - removeFromCache(it) - childrenToBeRemoved.addAll(it.removeAllChildNotes()) } - - removeFromCache(childrenToBeRemoved) - - toBeRemoved.forEach { - it.replyTo?.forEach { masterNote -> - masterNote.clearEOSE() // allows reloading of these events - } - } - - if (toBeRemoved.size > 1) { - println("PRUNE: ${toBeRemoved.size} thread replies removed.") - } - } - - private fun removeFromCache(note: Note) { - note.replyTo?.forEach { masterNote -> - masterNote.removeReply(note) - masterNote.removeBoost(note) - masterNote.removeReaction(note) - masterNote.removeZap(note) - masterNote.removeReport(note) - masterNote.clearEOSE() // allows reloading of these events if needed - } - - val noteEvent = note.event - - if (noteEvent is LnZapEvent) { - noteEvent.zappedAuthor().forEach { - val author = getUserIfExists(it) - author?.removeZap(note) - author?.clearEOSE() - } - } - if (noteEvent is LnZapRequestEvent) { - noteEvent.zappedAuthor().mapNotNull { - val author = getUserIfExists(it) - author?.removeZap(note) - author?.clearEOSE() - } - } - if (noteEvent is ReportEvent) { - noteEvent.reportedAuthor().mapNotNull { - val author = getUserIfExists(it.key) - author?.removeReport(note) - author?.clearEOSE() - } - } - - notes.remove(note.idHex) - } - - fun removeFromCache(nextToBeRemoved: List) { - nextToBeRemoved.forEach { note -> removeFromCache(note) } - } - - fun pruneExpiredEvents() { - checkNotInMainThread() - - val toBeRemoved = notes.filter { it.value.event?.isExpired() == true }.values - - val childrenToBeRemoved = mutableListOf() - - toBeRemoved.forEach { - removeFromCache(it) - childrenToBeRemoved.addAll(it.removeAllChildNotes()) - } - - removeFromCache(childrenToBeRemoved) - - if (toBeRemoved.size > 1) { - println("PRUNE: ${toBeRemoved.size} thread replies removed.") - } - } - - fun pruneHiddenMessages(account: Account) { - checkNotInMainThread() - - val childrenToBeRemoved = mutableListOf() - - val toBeRemoved = - account.liveHiddenUsers.value - ?.hiddenUsers - ?.map { userHex -> - (notes.values.filter { it.event?.pubKey() == userHex } + - addressables.values.filter { it.event?.pubKey() == userHex }) - .toSet() - } - ?.flatten() - ?: emptyList() - - toBeRemoved.forEach { - removeFromCache(it) - childrenToBeRemoved.addAll(it.removeAllChildNotes()) - } - - removeFromCache(childrenToBeRemoved) - - println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden") - } - - fun pruneContactLists(loggedIn: Set) { - checkNotInMainThread() - - var removingContactList = 0 - users.values.forEach { - if ( - it.pubkeyHex !in loggedIn && - (it.liveSet == null || it.liveSet?.isInUse() == false) && - it.latestContactList != null - ) { - it.latestContactList = null - removingContactList++ - } - } - - println("PRUNE: $removingContactList contact lists") - } - - // Observers line up here. - val live: LocalCacheLiveData = LocalCacheLiveData() - - private fun refreshObservers(newNote: Note) { - live.invalidateData(newNote) - } - - fun verifyAndConsume( - event: Event, - relay: Relay?, - ) { - if (justVerify(event)) { - justConsume(event, relay) - } - } - - fun justVerify(event: Event): Boolean { - checkNotInMainThread() - - return if (!event.hasValidSignature()) { - try { - event.checkSignature() - } catch (e: Exception) { - Log.w("Event failed retest ${event.kind}", (e.message ?: "") + event.toJson()) - } - false - } else { - true - } - } - - fun justConsume( - event: Event, - relay: Relay?, - ) { - checkNotInMainThread() - - try { - when (event) { - is AdvertisedRelayListEvent -> consume(event, relay) - is AppDefinitionEvent -> consume(event, relay) - is AppRecommendationEvent -> consume(event, relay) - is AudioHeaderEvent -> consume(event, relay) - is AudioTrackEvent -> consume(event, relay) - is BadgeAwardEvent -> consume(event) - is BadgeDefinitionEvent -> consume(event, relay) - is BadgeProfilesEvent -> consume(event) - is BookmarkListEvent -> consume(event) - is CalendarEvent -> consume(event, relay) - is CalendarDateSlotEvent -> consume(event, relay) - is CalendarTimeSlotEvent -> consume(event, relay) - is CalendarRSVPEvent -> consume(event, relay) - is ChannelCreateEvent -> consume(event) - is ChannelHideMessageEvent -> consume(event) - is ChannelMessageEvent -> consume(event, relay) - is ChannelMetadataEvent -> consume(event) - is ChannelMuteUserEvent -> consume(event) - is ChatMessageEvent -> consume(event, relay) - is ClassifiedsEvent -> consume(event, relay) - is CommunityDefinitionEvent -> consume(event, relay) - is CommunityPostApprovalEvent -> { - event.containedPost()?.let { verifyAndConsume(it, relay) } - consume(event) - } - is ContactListEvent -> consume(event) - is DeletionEvent -> consume(event) - is EmojiPackEvent -> consume(event, relay) - is EmojiPackSelectionEvent -> consume(event, relay) - is SealedGossipEvent -> consume(event, relay) - is FileHeaderEvent -> consume(event, relay) - is FileServersEvent -> consume(event, relay) - is FileStorageEvent -> consume(event, relay) - is FileStorageHeaderEvent -> consume(event, relay) - is GiftWrapEvent -> consume(event, relay) - is HighlightEvent -> consume(event, relay) - is LiveActivitiesEvent -> consume(event, relay) - is LiveActivitiesChatMessageEvent -> consume(event, relay) - is LnZapEvent -> { - event.zapRequest?.let { - // must have a valid request - verifyAndConsume(it, relay) - consume(event) - } - } - is LnZapRequestEvent -> consume(event) - is LnZapPaymentRequestEvent -> consume(event) - is LnZapPaymentResponseEvent -> consume(event) - is LongTextNoteEvent -> consume(event, relay) - is MetadataEvent -> consume(event) - is MuteListEvent -> consume(event, relay) - is NNSEvent -> comsume(event, relay) - is PrivateDmEvent -> consume(event, relay) - is PinListEvent -> consume(event, relay) - is PeopleListEvent -> consume(event, relay) - is PollNoteEvent -> consume(event, relay) - is ReactionEvent -> consume(event) - is RecommendRelayEvent -> consume(event) - is RelaySetEvent -> consume(event, relay) - is ReportEvent -> consume(event, relay) - is RepostEvent -> { - event.containedPost()?.let { verifyAndConsume(it, relay) } - consume(event) - } - is GenericRepostEvent -> { - event.containedPost()?.let { verifyAndConsume(it, relay) } - consume(event) - } - is StatusEvent -> consume(event, relay) - is TextNoteEvent -> consume(event, relay) - is VideoHorizontalEvent -> consume(event, relay) - is VideoVerticalEvent -> consume(event, relay) - else -> { - Log.w("Event Not Supported", event.toJson()) - } - } - } catch (e: Exception) { - e.printStackTrace() - } - } } @Stable class LocalCacheLiveData { - private val _newEventBundles = MutableSharedFlow>(0, 10, BufferOverflow.DROP_OLDEST) - val newEventBundles = _newEventBundles.asSharedFlow() // read-only public view + private val _newEventBundles = MutableSharedFlow>(0, 10, BufferOverflow.DROP_OLDEST) + val newEventBundles = _newEventBundles.asSharedFlow() // read-only public view - // Refreshes observers in batches. - private val bundler = BundledInsert(1000, Dispatchers.IO) + // Refreshes observers in batches. + private val bundler = BundledInsert(1000, Dispatchers.IO) - fun invalidateData(newNote: Note) { - bundler.invalidateList(newNote) { bundledNewNotes -> _newEventBundles.emit(bundledNewNotes) } - } + fun invalidateData(newNote: Note) { + bundler.invalidateList(newNote) { bundledNewNotes -> _newEventBundles.emit(bundledNewNotes) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index af650623b..5d2a49f9a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -63,1014 +63,1020 @@ import com.vitorpamplona.quartz.events.WrappedEvent import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.containsAny +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import java.math.BigDecimal import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow @Stable class AddressableNote(val address: ATag) : Note(address.toTag()) { - override fun idNote() = address.toNAddr() + override fun idNote() = address.toNAddr() - override fun toNEvent() = address.toNAddr() + override fun toNEvent() = address.toNAddr() - override fun idDisplayNote() = idNote().toShortenHex() + override fun idDisplayNote() = idNote().toShortenHex() - override fun address() = address + override fun address() = address - override fun createdAt(): Long? { - if (event == null) return null + override fun createdAt(): Long? { + if (event == null) return null - val publishedAt = (event as? LongTextNoteEvent)?.publishedAt() ?: Long.MAX_VALUE - val lastCreatedAt = event?.createdAt() ?: Long.MAX_VALUE + val publishedAt = (event as? LongTextNoteEvent)?.publishedAt() ?: Long.MAX_VALUE + val lastCreatedAt = event?.createdAt() ?: Long.MAX_VALUE - return minOf(publishedAt, lastCreatedAt) - } + return minOf(publishedAt, lastCreatedAt) + } - fun dTag(): String? { - return (event as? AddressableEvent)?.dTag() - } + fun dTag(): String? { + return (event as? AddressableEvent)?.dTag() + } } @Stable open class Note(val idHex: String) { - // These fields are only available after the Text Note event is received. - // They are immutable after that. - var event: EventInterface? = null - var author: User? = null - var replyTo: List? = null + // These fields are only available after the Text Note event is received. + // They are immutable after that. + var event: EventInterface? = null + var author: User? = null + var replyTo: List? = null - // These fields are updated every time an event related to this note is received. - var replies = listOf() - private set + // These fields are updated every time an event related to this note is received. + var replies = listOf() + private set - var reactions = mapOf>() - private set + var reactions = mapOf>() + private set - var boosts = listOf() - private set + var boosts = listOf() + private set - var reports = mapOf>() - private set + var reports = mapOf>() + private set - var zaps = mapOf() - private set + var zaps = mapOf() + private set - var zapsAmount: BigDecimal = BigDecimal.ZERO + var zapsAmount: BigDecimal = BigDecimal.ZERO - var zapPayments = mapOf() - private set + var zapPayments = mapOf() + private set - var relays = listOf() - private set + var relays = listOf() + private set - var lastReactionsDownloadTime: Map = emptyMap() + var lastReactionsDownloadTime: Map = emptyMap() - fun id() = Hex.decode(idHex) + fun id() = Hex.decode(idHex) - open fun idNote() = id().toNote() + open fun idNote() = id().toNote() - open fun toNEvent(): String { - val myEvent = event - return if (myEvent is WrappedEvent) { - val host = myEvent.host - if (host != null) { - Nip19.createNEvent( - host.id, - host.pubKey, - host.kind(), - relays.firstOrNull()?.url, - ) - } else { - Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url) - } - } else { - Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url) - } - } - - fun toNostrUri(): String { - return "nostr:${toNEvent()}" - } - - open fun idDisplayNote() = idNote().toShortenHex() - - fun channelHex(): HexKey? { - return if ( - event is ChannelMessageEvent || - event is ChannelMetadataEvent || - event is ChannelCreateEvent || - event is LiveActivitiesChatMessageEvent || - event is LiveActivitiesEvent - ) { - (event as? ChannelMessageEvent)?.channel() - ?: (event as? ChannelMetadataEvent)?.channel() ?: (event as? ChannelCreateEvent)?.id - ?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag() - ?: (event as? LiveActivitiesEvent)?.address()?.toTag() - } else { - null - } - } - - open fun address(): ATag? = null - - open fun createdAt() = event?.createdAt() - - fun loadEvent( - event: Event, - author: User, - replyTo: List, - ) { - if (this.event?.id() != event.id()) { - this.event = event - this.author = author - this.replyTo = replyTo - - liveSet?.innerMetadata?.invalidateData() - flowSet?.metadata?.invalidateData() - } - } - - fun formattedDateTime(timestamp: Long): String { - return Instant.ofEpochSecond(timestamp) - .atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss")) - } - - data class LevelSignature(val signature: String, val createdAt: Long?, val author: User?) - - /** - * This method caches signatures during each execution to avoid recalculation in longer threads - */ - fun replyLevelSignature( - eventsToConsider: Set, - cachedSignatures: MutableMap, - account: User, - accountFollowingSet: Set, - now: Long, - ): LevelSignature { - val replyTo = replyTo - if ( - event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty() - ) { - return LevelSignature( - signature = "/" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) + ";", - createdAt = createdAt(), - author = author, - ) - } - - val parent = - (replyTo - .filter { - it.idHex in eventsToConsider - } // This forces the signature to be based on a branch, avoiding two roots - .map { - cachedSignatures[it] - ?: it - .replyLevelSignature( - eventsToConsider, - cachedSignatures, - account, - accountFollowingSet, - now, - ) - .apply { cachedSignatures.put(it, this) } + open fun toNEvent(): String { + val myEvent = event + return if (myEvent is WrappedEvent) { + val host = myEvent.host + if (host != null) { + Nip19.createNEvent( + host.id, + host.pubKey, + host.kind(), + relays.firstOrNull()?.url, + ) + } else { + Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url) + } + } else { + Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url) } - .maxByOrNull { it.signature.length }) - - val parentSignature = parent?.signature?.removeSuffix(";") ?: "" - - val threadOrder = - if (parent?.author == author && createdAt() != null) { - // author of the thread first, in **ascending** order - "9" + - formattedDateTime((parent?.createdAt ?: 0) + (now - (createdAt() ?: 0))) + - idHex.substring(0, 8) - } else if (author?.pubkeyHex == account.pubkeyHex) { - "8" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my replies - } else if (author?.pubkeyHex in accountFollowingSet) { - "7" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my follows replies. - } else { - "0" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // everyone else. - } - - val mySignature = - LevelSignature( - signature = parentSignature + "/" + threadOrder + ";", - createdAt = createdAt(), - author = author, - ) - - cachedSignatures[this] = mySignature - return mySignature - } - - fun replyLevel(cachedLevels: MutableMap = mutableMapOf()): Int { - val replyTo = replyTo - if ( - event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty() - ) { - return 0 } - return replyTo.maxOf { - cachedLevels[it] ?: it.replyLevel(cachedLevels).apply { cachedLevels.put(it, this) } - } + 1 - } - - fun addReply(note: Note) { - if (note !in replies) { - replies = replies + note - liveSet?.innerReplies?.invalidateData() - } - } - - fun removeReply(note: Note) { - if (note in replies) { - replies = replies - note - liveSet?.innerReplies?.invalidateData() - } - } - - fun removeBoost(note: Note) { - if (note in boosts) { - boosts = boosts - note - liveSet?.innerBoosts?.invalidateData() - } - } - - fun removeAllChildNotes(): List { - val toBeRemoved = - replies + - reactions.values.flatten() + - boosts + - reports.values.flatten() + - zaps.keys + - zaps.values.filterNotNull() + - zapPayments.keys + - zapPayments.values.filterNotNull() - - replies = listOf() - reactions = mapOf>() - boosts = listOf() - reports = mapOf>() - zaps = mapOf() - zapPayments = mapOf() - zapsAmount = BigDecimal.ZERO - relays = listOf() - lastReactionsDownloadTime = emptyMap() - - liveSet?.innerReplies?.invalidateData() - liveSet?.innerReactions?.invalidateData() - liveSet?.innerBoosts?.invalidateData() - liveSet?.innerReports?.invalidateData() - liveSet?.innerZaps?.invalidateData() - - return toBeRemoved - } - - fun removeReaction(note: Note) { - val tags = note.event?.tags() ?: emptyArray() - val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+" - - if (reactions[reaction]?.contains(note) == true) { - reactions[reaction]?.let { - if (note in it) { - val newList = it.minus(note) - if (newList.isEmpty()) { - reactions = reactions.minus(reaction) - } else { - reactions = reactions + Pair(reaction, newList) - } - - liveSet?.innerReactions?.invalidateData() - } - } - } - } - - fun removeReport(deleteNote: Note) { - val author = deleteNote.author ?: return - - if (reports[author]?.contains(deleteNote) == true) { - reports[author]?.let { - reports = reports + Pair(author, it.minus(deleteNote)) - liveSet?.innerReports?.invalidateData() - } - } - } - - fun removeZap(note: Note) { - if (zaps[note] != null) { - zaps = zaps.minus(note) - updateZapTotal() - liveSet?.innerZaps?.invalidateData() - } else if (zaps.containsValue(note)) { - zaps = zaps.filterValues { it != note } - updateZapTotal() - liveSet?.innerZaps?.invalidateData() - } - } - - fun removeZapPayment(note: Note) { - if (zapPayments.containsKey(note)) { - zapPayments = zapPayments.minus(note) - liveSet?.innerZaps?.invalidateData() - } else if (zapPayments.containsValue(note)) { - zapPayments = zapPayments.filterValues { it != note } - liveSet?.innerZaps?.invalidateData() - } - } - - fun addBoost(note: Note) { - if (note !in boosts) { - boosts = boosts + note - liveSet?.innerBoosts?.invalidateData() - } - } - - @Synchronized - private fun innerAddZap( - zapRequest: Note, - zap: Note?, - ): Boolean { - if (zaps[zapRequest] == null) { - zaps = zaps + Pair(zapRequest, zap) - return true + fun toNostrUri(): String { + return "nostr:${toNEvent()}" } - return false - } + open fun idDisplayNote() = idNote().toShortenHex() - fun addZap( - zapRequest: Note, - zap: Note?, - ) { - checkNotInMainThread() - - if (zaps[zapRequest] == null) { - val inserted = innerAddZap(zapRequest, zap) - if (inserted) { - updateZapTotal() - liveSet?.innerZaps?.invalidateData() - } - } - } - - @Synchronized - private fun innerAddZapPayment( - zapPaymentRequest: Note, - zapPayment: Note?, - ): Boolean { - if (zapPayments[zapPaymentRequest] == null) { - zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment) - return true - } - - return false - } - - fun addZapPayment( - zapPaymentRequest: Note, - zapPayment: Note?, - ) { - checkNotInMainThread() - if (zapPayments[zapPaymentRequest] == null) { - val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment) - if (inserted) { - liveSet?.innerZaps?.invalidateData() - } - } - } - - fun addReaction(note: Note) { - val tags = note.event?.tags() ?: emptyArray() - val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+" - - val listOfAuthors = reactions[reaction] - if (listOfAuthors == null) { - reactions = reactions + Pair(reaction, listOf(note)) - liveSet?.innerReactions?.invalidateData() - } else if (!listOfAuthors.contains(note)) { - reactions = reactions + Pair(reaction, listOfAuthors + note) - liveSet?.innerReactions?.invalidateData() - } - } - - fun addReport(note: Note) { - val author = note.author ?: return - - val reportsByAuthor = reports[author] - - if (reportsByAuthor == null) { - reports = reports + Pair(author, listOf(note)) - liveSet?.innerReports?.invalidateData() - } else if (!reportsByAuthor.contains(note)) { - reports = reports + Pair(author, reportsByAuthor + note) - liveSet?.innerReports?.invalidateData() - } - } - - @Synchronized - fun addRelaySync(briefInfo: RelayBriefInfoCache.RelayBriefInfo) { - if (briefInfo !in relays) { - relays = relays + briefInfo - } - } - - fun addRelay(relay: Relay) { - if (relay.brief !in relays) { - addRelaySync(relay.brief) - liveSet?.innerRelays?.invalidateData() - } - } - - private fun recursiveIsPaidByCalculation( - account: Account, - remainingZapPayments: List>, - onWasZappedByAuthor: () -> Unit, - ) { - if (remainingZapPayments.isEmpty()) { - return - } - - val next = remainingZapPayments.first() - - val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent - if (zapResponseEvent != null) { - account.decryptZapPaymentResponseEvent(zapResponseEvent) { response -> - if ( - response is PayInvoiceSuccessResponse && - account.isNIP47Author(zapResponseEvent.requestAuthor()) + fun channelHex(): HexKey? { + return if ( + event is ChannelMessageEvent || + event is ChannelMetadataEvent || + event is ChannelCreateEvent || + event is LiveActivitiesChatMessageEvent || + event is LiveActivitiesEvent ) { - onWasZappedByAuthor() + (event as? ChannelMessageEvent)?.channel() + ?: (event as? ChannelMetadataEvent)?.channel() ?: (event as? ChannelCreateEvent)?.id + ?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag() + ?: (event as? LiveActivitiesEvent)?.address()?.toTag() } else { - recursiveIsPaidByCalculation( - account, - remainingZapPayments.minus(next), - onWasZappedByAuthor, - ) + null } - } - } - } - - private fun recursiveIsZappedByCalculation( - option: Int?, - user: User, - account: Account, - remainingZapEvents: List>, - onWasZappedByAuthor: () -> Unit, - ) { - if (remainingZapEvents.isEmpty()) { - return } - val next = remainingZapEvents.first() + open fun address(): ATag? = null - if (next.first.author?.pubkeyHex == user.pubkeyHex) { - onWasZappedByAuthor() - } else { - account.decryptZapContentAuthor(next.first) { + open fun createdAt() = event?.createdAt() + + fun loadEvent( + event: Event, + author: User, + replyTo: List, + ) { + if (this.event?.id() != event.id()) { + this.event = event + this.author = author + this.replyTo = replyTo + + liveSet?.innerMetadata?.invalidateData() + flowSet?.metadata?.invalidateData() + } + } + + fun formattedDateTime(timestamp: Long): String { + return Instant.ofEpochSecond(timestamp) + .atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss")) + } + + data class LevelSignature(val signature: String, val createdAt: Long?, val author: User?) + + /** + * This method caches signatures during each execution to avoid recalculation in longer threads + */ + fun replyLevelSignature( + eventsToConsider: Set, + cachedSignatures: MutableMap, + account: User, + accountFollowingSet: Set, + now: Long, + ): LevelSignature { + val replyTo = replyTo if ( - it.pubKey == user.pubkeyHex && - (option == null || option == (it as? LnZapEvent)?.zappedPollOption()) + event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty() ) { - onWasZappedByAuthor() - } else { - recursiveIsZappedByCalculation( - option, - user, - account, - remainingZapEvents.minus(next), - onWasZappedByAuthor, - ) + return LevelSignature( + signature = "/" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) + ";", + createdAt = createdAt(), + author = author, + ) } - } - } - } - fun isZappedBy( - user: User, - account: Account, - onWasZappedByAuthor: () -> Unit, - ) { - recursiveIsZappedByCalculation(null, user, account, zaps.toList(), onWasZappedByAuthor) - if (account.userProfile() == user) { - recursiveIsPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor) - } - } + val parent = + ( + replyTo + .filter { + it.idHex in eventsToConsider + } // This forces the signature to be based on a branch, avoiding two roots + .map { + cachedSignatures[it] + ?: it + .replyLevelSignature( + eventsToConsider, + cachedSignatures, + account, + accountFollowingSet, + now, + ) + .apply { cachedSignatures.put(it, this) } + } + .maxByOrNull { it.signature.length } + ) - fun isZappedBy( - option: Int?, - user: User, - account: Account, - onWasZappedByAuthor: () -> Unit, - ) { - recursiveIsZappedByCalculation(option, user, account, zaps.toList(), onWasZappedByAuthor) - } + val parentSignature = parent?.signature?.removeSuffix(";") ?: "" - fun getReactionBy(user: User): String? { - return reactions.firstNotNullOfOrNull { - if (it.value.any { it.author?.pubkeyHex == user.pubkeyHex }) { - it.key - } else { - null - } - } - } - - fun isBoostedBy(user: User): Boolean { - return boosts.any { it.author?.pubkeyHex == user.pubkeyHex } - } - - fun hasReportsBy(user: User): Boolean { - return reports[user]?.isNotEmpty() ?: false - } - - fun countReportAuthorsBy(users: Set): Int { - return reports.count { it.key.pubkeyHex in users } - } - - fun reportsBy(users: Set): List { - return reports - .mapNotNull { - if (it.key.pubkeyHex in users) { - it.value - } else { - null - } - } - .flatten() - } - - private fun updateZapTotal() { - var sumOfAmounts = BigDecimal.ZERO - - // Regular Zap Receipts - zaps.forEach { - val noteEvent = it?.value?.event - if (noteEvent is LnZapEvent) { - sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO - } - } - - zapsAmount = sumOfAmounts - } - - private fun recursiveZappedAmountCalculation( - invoiceSet: LinkedHashSet, - remainingZapPayments: List>, - signer: NostrSigner, - output: BigDecimal, - onReady: (BigDecimal) -> Unit, - ) { - if (remainingZapPayments.isEmpty()) { - onReady(output) - return - } - - val next = remainingZapPayments.first() - - (next.second?.event as? LnZapPaymentResponseEvent)?.response(signer) { noteEvent -> - if (noteEvent is PayInvoiceSuccessResponse) { - (next.first.event as? LnZapPaymentRequestEvent)?.lnInvoice(signer) { invoice -> - val amount = - try { - LnInvoiceUtil.getAmountInSats(invoice) - } catch (e: java.lang.Exception) { - null + val threadOrder = + if (parent?.author == author && createdAt() != null) { + // author of the thread first, in **ascending** order + "9" + + formattedDateTime((parent?.createdAt ?: 0) + (now - (createdAt() ?: 0))) + + idHex.substring(0, 8) + } else if (author?.pubkeyHex == account.pubkeyHex) { + "8" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my replies + } else if (author?.pubkeyHex in accountFollowingSet) { + "7" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my follows replies. + } else { + "0" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // everyone else. } - var newAmount = output + val mySignature = + LevelSignature( + signature = parentSignature + "/" + threadOrder + ";", + createdAt = createdAt(), + author = author, + ) - if (amount != null && !invoiceSet.contains(invoice)) { - invoiceSet.add(invoice) - newAmount += amount - } + cachedSignatures[this] = mySignature + return mySignature + } - recursiveZappedAmountCalculation( + fun replyLevel(cachedLevels: MutableMap = mutableMapOf()): Int { + val replyTo = replyTo + if ( + event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty() + ) { + return 0 + } + + return replyTo.maxOf { + cachedLevels[it] ?: it.replyLevel(cachedLevels).apply { cachedLevels.put(it, this) } + } + 1 + } + + fun addReply(note: Note) { + if (note !in replies) { + replies = replies + note + liveSet?.innerReplies?.invalidateData() + } + } + + fun removeReply(note: Note) { + if (note in replies) { + replies = replies - note + liveSet?.innerReplies?.invalidateData() + } + } + + fun removeBoost(note: Note) { + if (note in boosts) { + boosts = boosts - note + liveSet?.innerBoosts?.invalidateData() + } + } + + fun removeAllChildNotes(): List { + val toBeRemoved = + replies + + reactions.values.flatten() + + boosts + + reports.values.flatten() + + zaps.keys + + zaps.values.filterNotNull() + + zapPayments.keys + + zapPayments.values.filterNotNull() + + replies = listOf() + reactions = mapOf>() + boosts = listOf() + reports = mapOf>() + zaps = mapOf() + zapPayments = mapOf() + zapsAmount = BigDecimal.ZERO + relays = listOf() + lastReactionsDownloadTime = emptyMap() + + liveSet?.innerReplies?.invalidateData() + liveSet?.innerReactions?.invalidateData() + liveSet?.innerBoosts?.invalidateData() + liveSet?.innerReports?.invalidateData() + liveSet?.innerZaps?.invalidateData() + + return toBeRemoved + } + + fun removeReaction(note: Note) { + val tags = note.event?.tags() ?: emptyArray() + val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+" + + if (reactions[reaction]?.contains(note) == true) { + reactions[reaction]?.let { + if (note in it) { + val newList = it.minus(note) + if (newList.isEmpty()) { + reactions = reactions.minus(reaction) + } else { + reactions = reactions + Pair(reaction, newList) + } + + liveSet?.innerReactions?.invalidateData() + } + } + } + } + + fun removeReport(deleteNote: Note) { + val author = deleteNote.author ?: return + + if (reports[author]?.contains(deleteNote) == true) { + reports[author]?.let { + reports = reports + Pair(author, it.minus(deleteNote)) + liveSet?.innerReports?.invalidateData() + } + } + } + + fun removeZap(note: Note) { + if (zaps[note] != null) { + zaps = zaps.minus(note) + updateZapTotal() + liveSet?.innerZaps?.invalidateData() + } else if (zaps.containsValue(note)) { + zaps = zaps.filterValues { it != note } + updateZapTotal() + liveSet?.innerZaps?.invalidateData() + } + } + + fun removeZapPayment(note: Note) { + if (zapPayments.containsKey(note)) { + zapPayments = zapPayments.minus(note) + liveSet?.innerZaps?.invalidateData() + } else if (zapPayments.containsValue(note)) { + zapPayments = zapPayments.filterValues { it != note } + liveSet?.innerZaps?.invalidateData() + } + } + + fun addBoost(note: Note) { + if (note !in boosts) { + boosts = boosts + note + liveSet?.innerBoosts?.invalidateData() + } + } + + @Synchronized + private fun innerAddZap( + zapRequest: Note, + zap: Note?, + ): Boolean { + if (zaps[zapRequest] == null) { + zaps = zaps + Pair(zapRequest, zap) + return true + } + + return false + } + + fun addZap( + zapRequest: Note, + zap: Note?, + ) { + checkNotInMainThread() + + if (zaps[zapRequest] == null) { + val inserted = innerAddZap(zapRequest, zap) + if (inserted) { + updateZapTotal() + liveSet?.innerZaps?.invalidateData() + } + } + } + + @Synchronized + private fun innerAddZapPayment( + zapPaymentRequest: Note, + zapPayment: Note?, + ): Boolean { + if (zapPayments[zapPaymentRequest] == null) { + zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment) + return true + } + + return false + } + + fun addZapPayment( + zapPaymentRequest: Note, + zapPayment: Note?, + ) { + checkNotInMainThread() + if (zapPayments[zapPaymentRequest] == null) { + val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment) + if (inserted) { + liveSet?.innerZaps?.invalidateData() + } + } + } + + fun addReaction(note: Note) { + val tags = note.event?.tags() ?: emptyArray() + val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+" + + val listOfAuthors = reactions[reaction] + if (listOfAuthors == null) { + reactions = reactions + Pair(reaction, listOf(note)) + liveSet?.innerReactions?.invalidateData() + } else if (!listOfAuthors.contains(note)) { + reactions = reactions + Pair(reaction, listOfAuthors + note) + liveSet?.innerReactions?.invalidateData() + } + } + + fun addReport(note: Note) { + val author = note.author ?: return + + val reportsByAuthor = reports[author] + + if (reportsByAuthor == null) { + reports = reports + Pair(author, listOf(note)) + liveSet?.innerReports?.invalidateData() + } else if (!reportsByAuthor.contains(note)) { + reports = reports + Pair(author, reportsByAuthor + note) + liveSet?.innerReports?.invalidateData() + } + } + + @Synchronized + fun addRelaySync(briefInfo: RelayBriefInfoCache.RelayBriefInfo) { + if (briefInfo !in relays) { + relays = relays + briefInfo + } + } + + fun addRelay(relay: Relay) { + if (relay.brief !in relays) { + addRelaySync(relay.brief) + liveSet?.innerRelays?.invalidateData() + } + } + + private fun recursiveIsPaidByCalculation( + account: Account, + remainingZapPayments: List>, + onWasZappedByAuthor: () -> Unit, + ) { + if (remainingZapPayments.isEmpty()) { + return + } + + val next = remainingZapPayments.first() + + val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent + if (zapResponseEvent != null) { + account.decryptZapPaymentResponseEvent(zapResponseEvent) { response -> + if ( + response is PayInvoiceSuccessResponse && + account.isNIP47Author(zapResponseEvent.requestAuthor()) + ) { + onWasZappedByAuthor() + } else { + recursiveIsPaidByCalculation( + account, + remainingZapPayments.minus(next), + onWasZappedByAuthor, + ) + } + } + } + } + + private fun recursiveIsZappedByCalculation( + option: Int?, + user: User, + account: Account, + remainingZapEvents: List>, + onWasZappedByAuthor: () -> Unit, + ) { + if (remainingZapEvents.isEmpty()) { + return + } + + val next = remainingZapEvents.first() + + if (next.first.author?.pubkeyHex == user.pubkeyHex) { + onWasZappedByAuthor() + } else { + account.decryptZapContentAuthor(next.first) { + if ( + it.pubKey == user.pubkeyHex && + (option == null || option == (it as? LnZapEvent)?.zappedPollOption()) + ) { + onWasZappedByAuthor() + } else { + recursiveIsZappedByCalculation( + option, + user, + account, + remainingZapEvents.minus(next), + onWasZappedByAuthor, + ) + } + } + } + } + + fun isZappedBy( + user: User, + account: Account, + onWasZappedByAuthor: () -> Unit, + ) { + recursiveIsZappedByCalculation(null, user, account, zaps.toList(), onWasZappedByAuthor) + if (account.userProfile() == user) { + recursiveIsPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor) + } + } + + fun isZappedBy( + option: Int?, + user: User, + account: Account, + onWasZappedByAuthor: () -> Unit, + ) { + recursiveIsZappedByCalculation(option, user, account, zaps.toList(), onWasZappedByAuthor) + } + + fun getReactionBy(user: User): String? { + return reactions.firstNotNullOfOrNull { + if (it.value.any { it.author?.pubkeyHex == user.pubkeyHex }) { + it.key + } else { + null + } + } + } + + fun isBoostedBy(user: User): Boolean { + return boosts.any { it.author?.pubkeyHex == user.pubkeyHex } + } + + fun hasReportsBy(user: User): Boolean { + return reports[user]?.isNotEmpty() ?: false + } + + fun countReportAuthorsBy(users: Set): Int { + return reports.count { it.key.pubkeyHex in users } + } + + fun reportsBy(users: Set): List { + return reports + .mapNotNull { + if (it.key.pubkeyHex in users) { + it.value + } else { + null + } + } + .flatten() + } + + private fun updateZapTotal() { + var sumOfAmounts = BigDecimal.ZERO + + // Regular Zap Receipts + zaps.forEach { + val noteEvent = it?.value?.event + if (noteEvent is LnZapEvent) { + sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO + } + } + + zapsAmount = sumOfAmounts + } + + private fun recursiveZappedAmountCalculation( + invoiceSet: LinkedHashSet, + remainingZapPayments: List>, + signer: NostrSigner, + output: BigDecimal, + onReady: (BigDecimal) -> Unit, + ) { + if (remainingZapPayments.isEmpty()) { + onReady(output) + return + } + + val next = remainingZapPayments.first() + + (next.second?.event as? LnZapPaymentResponseEvent)?.response(signer) { noteEvent -> + if (noteEvent is PayInvoiceSuccessResponse) { + (next.first.event as? LnZapPaymentRequestEvent)?.lnInvoice(signer) { invoice -> + val amount = + try { + LnInvoiceUtil.getAmountInSats(invoice) + } catch (e: java.lang.Exception) { + null + } + + var newAmount = output + + if (amount != null && !invoiceSet.contains(invoice)) { + invoiceSet.add(invoice) + newAmount += amount + } + + recursiveZappedAmountCalculation( + invoiceSet, + remainingZapPayments.minus(next), + signer, + newAmount, + onReady, + ) + } + } + } + } + + fun zappedAmountWithNWCPayments( + signer: NostrSigner, + onReady: (BigDecimal) -> Unit, + ) { + if (zapPayments.isEmpty()) { + onReady(zapsAmount) + } + + val invoiceSet = LinkedHashSet(zaps.size + zapPayments.size) + zaps.forEach { (it.value?.event as? LnZapEvent)?.lnInvoice()?.let { invoiceSet.add(it) } } + + recursiveZappedAmountCalculation( invoiceSet, - remainingZapPayments.minus(next), + zapPayments.toList(), signer, - newAmount, + zapsAmount, onReady, - ) + ) + } + + fun hasPledgeBy(user: User): Boolean { + return replies + .filter { it.event?.isTaggedHash("bounty-added-reward") ?: false } + .any { + val pledgeValue = + try { + BigDecimal(it.event?.content()) + } catch (e: Exception) { + null + // do nothing if it can't convert to bigdecimal + } + + pledgeValue != null && it.author == user + } + } + + fun pledgedAmountByOthers(): BigDecimal { + return replies + .filter { it.event?.isTaggedHash("bounty-added-reward") ?: false } + .mapNotNull { + try { + BigDecimal(it.event?.content()) + } catch (e: Exception) { + null + // do nothing if it can't convert to bigdecimal + } + } + .sumOf { it } + } + + fun hasAnyReports(): Boolean { + val dayAgo = TimeUtils.oneDayAgo() + return reports.isNotEmpty() || + ( + author?.reports?.any { it.value.firstOrNull { (it.createdAt() ?: 0) > dayAgo } != null } + ?: false + ) + } + + fun isNewThread(): Boolean { + return ( + event is RepostEvent || + event is GenericRepostEvent || + replyTo == null || + replyTo?.size == 0 + ) && + event !is ChannelMessageEvent && + event !is LiveActivitiesChatMessageEvent + } + + fun hasZapped(loggedIn: User): Boolean { + return zaps.any { it.key.author == loggedIn } + } + + fun hasReacted( + loggedIn: User, + content: String, + ): Boolean { + return reactedBy(loggedIn, content).isNotEmpty() + } + + fun reactedBy( + loggedIn: User, + content: String, + ): List { + return reactions[content]?.filter { it.author == loggedIn } ?: emptyList() + } + + fun reactedBy(loggedIn: User): List { + return reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key } + } + + fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean { + return boosts.firstOrNull { + it.author == loggedIn && (it.createdAt() ?: 0) > TimeUtils.fiveMinutesAgo() + } != null // 5 minute protection + } + + fun boostedBy(loggedIn: User): List { + return boosts.filter { it.author == loggedIn } + } + + fun moveAllReferencesTo(note: AddressableNote) { + // migrates these comments to a new version + replies.forEach { + note.addReply(it) + it.replyTo = it.replyTo?.updated(this, note) } - } - } - } - - fun zappedAmountWithNWCPayments( - signer: NostrSigner, - onReady: (BigDecimal) -> Unit, - ) { - if (zapPayments.isEmpty()) { - onReady(zapsAmount) - } - - val invoiceSet = LinkedHashSet(zaps.size + zapPayments.size) - zaps.forEach { (it.value?.event as? LnZapEvent)?.lnInvoice()?.let { invoiceSet.add(it) } } - - recursiveZappedAmountCalculation( - invoiceSet, - zapPayments.toList(), - signer, - zapsAmount, - onReady, - ) - } - - fun hasPledgeBy(user: User): Boolean { - return replies - .filter { it.event?.isTaggedHash("bounty-added-reward") ?: false } - .any { - val pledgeValue = - try { - BigDecimal(it.event?.content()) - } catch (e: Exception) { - null - // do nothing if it can't convert to bigdecimal - } - - pledgeValue != null && it.author == user - } - } - - fun pledgedAmountByOthers(): BigDecimal { - return replies - .filter { it.event?.isTaggedHash("bounty-added-reward") ?: false } - .mapNotNull { - try { - BigDecimal(it.event?.content()) - } catch (e: Exception) { - null - // do nothing if it can't convert to bigdecimal + reactions.forEach { + it.value.forEach { + note.addReaction(it) + it.replyTo = it.replyTo?.updated(this, note) + } } - } - .sumOf { it } - } - - fun hasAnyReports(): Boolean { - val dayAgo = TimeUtils.oneDayAgo() - return reports.isNotEmpty() || - (author?.reports?.any { it.value.firstOrNull { (it.createdAt() ?: 0) > dayAgo } != null } - ?: false) - } - - fun isNewThread(): Boolean { - return (event is RepostEvent || - event is GenericRepostEvent || - replyTo == null || - replyTo?.size == 0) && - event !is ChannelMessageEvent && - event !is LiveActivitiesChatMessageEvent - } - - fun hasZapped(loggedIn: User): Boolean { - return zaps.any { it.key.author == loggedIn } - } - - fun hasReacted( - loggedIn: User, - content: String, - ): Boolean { - return reactedBy(loggedIn, content).isNotEmpty() - } - - fun reactedBy( - loggedIn: User, - content: String, - ): List { - return reactions[content]?.filter { it.author == loggedIn } ?: emptyList() - } - - fun reactedBy(loggedIn: User): List { - return reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key } - } - - fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean { - return boosts.firstOrNull { - it.author == loggedIn && (it.createdAt() ?: 0) > TimeUtils.fiveMinutesAgo() - } != null // 5 minute protection - } - - fun boostedBy(loggedIn: User): List { - return boosts.filter { it.author == loggedIn } - } - - fun moveAllReferencesTo(note: AddressableNote) { - // migrates these comments to a new version - replies.forEach { - note.addReply(it) - it.replyTo = it.replyTo?.updated(this, note) - } - reactions.forEach { - it.value.forEach { - note.addReaction(it) - it.replyTo = it.replyTo?.updated(this, note) - } - } - boosts.forEach { - note.addBoost(it) - it.replyTo = it.replyTo?.updated(this, note) - } - reports.forEach { - it.value.forEach { - note.addReport(it) - it.replyTo = it.replyTo?.updated(this, note) - } - } - zaps.forEach { - note.addZap(it.key, it.value) - it.key.replyTo = it.key.replyTo?.updated(this, note) - it.value?.replyTo = it.value?.replyTo?.updated(this, note) - } - - replyTo = null - replies = emptyList() - reactions = emptyMap() - boosts = emptyList() - reports = emptyMap() - zaps = emptyMap() - zapsAmount = BigDecimal.ZERO - } - - fun clearEOSE() { - lastReactionsDownloadTime = emptyMap() - } - - fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean { - val thisEvent = event ?: return false - - val isBoostedNoteHidden = - if ( - thisEvent is GenericRepostEvent || - thisEvent is RepostEvent || - thisEvent is CommunityPostApprovalEvent - ) { - replyTo?.lastOrNull()?.isHiddenFor(accountChoices) ?: false - } else { - false - } - - val isHiddenByWord = - if (thisEvent is BaseTextNoteEvent) { - accountChoices.hiddenWords.any { - thisEvent.content.containsAny(accountChoices.hiddenWordsCase) + boosts.forEach { + note.addBoost(it) + it.replyTo = it.replyTo?.updated(this, note) + } + reports.forEach { + it.value.forEach { + note.addReport(it) + it.replyTo = it.replyTo?.updated(this, note) + } + } + zaps.forEach { + note.addZap(it.key, it.value) + it.key.replyTo = it.key.replyTo?.updated(this, note) + it.value?.replyTo = it.value?.replyTo?.updated(this, note) } - } else { - false - } - val isSensitive = thisEvent.isSensitive() - return isBoostedNoteHidden || - isHiddenByWord || - accountChoices.hiddenUsers.contains(author?.pubkeyHex) || - accountChoices.spammers.contains(author?.pubkeyHex) || - (isSensitive && accountChoices.showSensitiveContent == false) - } - - var liveSet: NoteLiveSet? = null - var flowSet: NoteFlowSet? = null - - @Synchronized - fun createOrDestroyLiveSync(create: Boolean) { - if (create) { - if (liveSet == null) { - liveSet = NoteLiveSet(this) - } - } else { - if (liveSet != null && liveSet?.isInUse() == false) { - liveSet?.destroy() - liveSet = null - } + replyTo = null + replies = emptyList() + reactions = emptyMap() + boosts = emptyList() + reports = emptyMap() + zaps = emptyMap() + zapsAmount = BigDecimal.ZERO } - } - fun live(): NoteLiveSet { - if (liveSet == null) { - createOrDestroyLiveSync(true) + fun clearEOSE() { + lastReactionsDownloadTime = emptyMap() } - return liveSet!! - } - fun clearLive() { - if (liveSet != null && liveSet?.isInUse() == false) { - createOrDestroyLiveSync(false) - } - } + fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean { + val thisEvent = event ?: return false - @Synchronized - fun createOrDestroyFlowSync(create: Boolean) { - if (create) { - if (flowSet == null) { - flowSet = NoteFlowSet(this) - } - } else { - if (flowSet != null && flowSet?.isInUse() == false) { - flowSet?.destroy() - flowSet = null - } - } - } + val isBoostedNoteHidden = + if ( + thisEvent is GenericRepostEvent || + thisEvent is RepostEvent || + thisEvent is CommunityPostApprovalEvent + ) { + replyTo?.lastOrNull()?.isHiddenFor(accountChoices) ?: false + } else { + false + } - fun flow(): NoteFlowSet { - if (flowSet == null) { - createOrDestroyFlowSync(true) - } - return flowSet!! - } + val isHiddenByWord = + if (thisEvent is BaseTextNoteEvent) { + accountChoices.hiddenWords.any { + thisEvent.content.containsAny(accountChoices.hiddenWordsCase) + } + } else { + false + } - fun clearFlow() { - if (flowSet != null && flowSet?.isInUse() == false) { - createOrDestroyFlowSync(false) + val isSensitive = thisEvent.isSensitive() + return isBoostedNoteHidden || + isHiddenByWord || + accountChoices.hiddenUsers.contains(author?.pubkeyHex) || + accountChoices.spammers.contains(author?.pubkeyHex) || + (isSensitive && accountChoices.showSensitiveContent == false) + } + + var liveSet: NoteLiveSet? = null + var flowSet: NoteFlowSet? = null + + @Synchronized + fun createOrDestroyLiveSync(create: Boolean) { + if (create) { + if (liveSet == null) { + liveSet = NoteLiveSet(this) + } + } else { + if (liveSet != null && liveSet?.isInUse() == false) { + liveSet?.destroy() + liveSet = null + } + } + } + + fun live(): NoteLiveSet { + if (liveSet == null) { + createOrDestroyLiveSync(true) + } + return liveSet!! + } + + fun clearLive() { + if (liveSet != null && liveSet?.isInUse() == false) { + createOrDestroyLiveSync(false) + } + } + + @Synchronized + fun createOrDestroyFlowSync(create: Boolean) { + if (create) { + if (flowSet == null) { + flowSet = NoteFlowSet(this) + } + } else { + if (flowSet != null && flowSet?.isInUse() == false) { + flowSet?.destroy() + flowSet = null + } + } + } + + fun flow(): NoteFlowSet { + if (flowSet == null) { + createOrDestroyFlowSync(true) + } + return flowSet!! + } + + fun clearFlow() { + if (flowSet != null && flowSet?.isInUse() == false) { + createOrDestroyFlowSync(false) + } } - } } @Stable class NoteFlowSet(u: Note) { - // Observers line up here. - val metadata = NoteBundledRefresherFlow(u) + // Observers line up here. + val metadata = NoteBundledRefresherFlow(u) - fun isInUse(): Boolean { - return metadata.stateFlow.subscriptionCount.value > 0 - } + fun isInUse(): Boolean { + return metadata.stateFlow.subscriptionCount.value > 0 + } - fun destroy() { - metadata.destroy() - } + fun destroy() { + metadata.destroy() + } } @Stable class NoteLiveSet(u: Note) { - // Observers line up here. - val innerMetadata = NoteBundledRefresherLiveData(u) - val innerReactions = NoteBundledRefresherLiveData(u) - val innerBoosts = NoteBundledRefresherLiveData(u) - val innerReplies = NoteBundledRefresherLiveData(u) - val innerReports = NoteBundledRefresherLiveData(u) - val innerRelays = NoteBundledRefresherLiveData(u) - val innerZaps = NoteBundledRefresherLiveData(u) + // Observers line up here. + val innerMetadata = NoteBundledRefresherLiveData(u) + val innerReactions = NoteBundledRefresherLiveData(u) + val innerBoosts = NoteBundledRefresherLiveData(u) + val innerReplies = NoteBundledRefresherLiveData(u) + val innerReports = NoteBundledRefresherLiveData(u) + val innerRelays = NoteBundledRefresherLiveData(u) + val innerZaps = NoteBundledRefresherLiveData(u) - val metadata = innerMetadata.map { it } - val reactions = innerReactions.map { it } - val boosts = innerBoosts.map { it } - val replies = innerReplies.map { it } - val reports = innerReports.map { it } - val relays = innerRelays.map { it } - val zaps = innerZaps.map { it } + val metadata = innerMetadata.map { it } + val reactions = innerReactions.map { it } + val boosts = innerBoosts.map { it } + val replies = innerReplies.map { it } + val reports = innerReports.map { it } + val relays = innerRelays.map { it } + val zaps = innerZaps.map { it } - val authorChanges = innerMetadata.map { it.note.author }.distinctUntilChanged() + val authorChanges = innerMetadata.map { it.note.author }.distinctUntilChanged() - val hasEvent = innerMetadata.map { it.note.event != null }.distinctUntilChanged() + val hasEvent = innerMetadata.map { it.note.event != null }.distinctUntilChanged() - val hasReactions = - innerZaps - .combineWith(innerBoosts, innerReactions) { zapState, boostState, reactionState -> - zapState?.note?.zaps?.isNotEmpty() - ?: false || - boostState?.note?.boosts?.isNotEmpty() ?: false || - reactionState?.note?.reactions?.isNotEmpty() ?: false - } - .distinctUntilChanged() + val hasReactions = + innerZaps + .combineWith(innerBoosts, innerReactions) { zapState, boostState, reactionState -> + zapState?.note?.zaps?.isNotEmpty() + ?: false || + boostState?.note?.boosts?.isNotEmpty() ?: false || + reactionState?.note?.reactions?.isNotEmpty() ?: false + } + .distinctUntilChanged() - val replyCount = innerReplies.map { it.note.replies.size }.distinctUntilChanged() + val replyCount = innerReplies.map { it.note.replies.size }.distinctUntilChanged() - val reactionCount = - innerReactions - .map { - var total = 0 - it.note.reactions.forEach { total += it.value.size } - total - } - .distinctUntilChanged() + val reactionCount = + innerReactions + .map { + var total = 0 + it.note.reactions.forEach { total += it.value.size } + total + } + .distinctUntilChanged() - val boostCount = innerBoosts.map { it.note.boosts.size }.distinctUntilChanged() + val boostCount = innerBoosts.map { it.note.boosts.size }.distinctUntilChanged() - val relayInfo = innerRelays.map { it.note.relays } + val relayInfo = innerRelays.map { it.note.relays } - val content = innerMetadata.map { it.note.event?.content() ?: "" } + val content = innerMetadata.map { it.note.event?.content() ?: "" } - fun isInUse(): Boolean { - return metadata.hasObservers() || - reactions.hasObservers() || - boosts.hasObservers() || - replies.hasObservers() || - reports.hasObservers() || - relays.hasObservers() || - zaps.hasObservers() || - authorChanges.hasObservers() || - hasEvent.hasObservers() || - hasReactions.hasObservers() || - replyCount.hasObservers() || - reactionCount.hasObservers() || - boostCount.hasObservers() - } + fun isInUse(): Boolean { + return metadata.hasObservers() || + reactions.hasObservers() || + boosts.hasObservers() || + replies.hasObservers() || + reports.hasObservers() || + relays.hasObservers() || + zaps.hasObservers() || + authorChanges.hasObservers() || + hasEvent.hasObservers() || + hasReactions.hasObservers() || + replyCount.hasObservers() || + reactionCount.hasObservers() || + boostCount.hasObservers() + } - fun destroy() { - innerMetadata.destroy() - innerReactions.destroy() - innerBoosts.destroy() - innerReplies.destroy() - innerReports.destroy() - innerRelays.destroy() - innerZaps.destroy() - } + fun destroy() { + innerMetadata.destroy() + innerReactions.destroy() + innerBoosts.destroy() + innerReplies.destroy() + innerReports.destroy() + innerRelays.destroy() + innerZaps.destroy() + } } @Stable class NoteBundledRefresherFlow(val note: Note) { - // Refreshes observers in batches. - private val bundler = BundledUpdate(500, Dispatchers.IO) - val stateFlow = MutableStateFlow(NoteState(note)) + // Refreshes observers in batches. + private val bundler = BundledUpdate(500, Dispatchers.IO) + val stateFlow = MutableStateFlow(NoteState(note)) - fun destroy() { - bundler.cancel() - } - - fun invalidateData() { - checkNotInMainThread() - - bundler.invalidate { - checkNotInMainThread() - - stateFlow.emit(NoteState(note)) + fun destroy() { + bundler.cancel() + } + + fun invalidateData() { + checkNotInMainThread() + + bundler.invalidate { + checkNotInMainThread() + + stateFlow.emit(NoteState(note)) + } } - } } @Stable class NoteBundledRefresherLiveData(val note: Note) : LiveData(NoteState(note)) { - // Refreshes observers in batches. - private val bundler = BundledUpdate(500, Dispatchers.IO) + // Refreshes observers in batches. + private val bundler = BundledUpdate(500, Dispatchers.IO) - fun destroy() { - bundler.cancel() - } - - fun invalidateData() { - checkNotInMainThread() - - bundler.invalidate { - checkNotInMainThread() - - postValue(NoteState(note)) + fun destroy() { + bundler.cancel() } - } - fun map(transform: (NoteState) -> Y): NoteLoadingLiveData { - val initialValue = this.value?.let { transform(it) } - val result = NoteLoadingLiveData(note, initialValue) - result.addSource(this) { x -> result.value = transform(x) } - return result - } + fun invalidateData() { + checkNotInMainThread() + + bundler.invalidate { + checkNotInMainThread() + + postValue(NoteState(note)) + } + } + + fun map(transform: (NoteState) -> Y): NoteLoadingLiveData { + val initialValue = this.value?.let { transform(it) } + val result = NoteLoadingLiveData(note, initialValue) + result.addSource(this) { x -> result.value = transform(x) } + return result + } } @Stable class NoteLoadingLiveData(val note: Note, initialValue: Y?) : MediatorLiveData(initialValue) { - override fun onActive() { - super.onActive() - if (note is AddressableNote) { - NostrSingleEventDataSource.addAddress(note) - } else { - NostrSingleEventDataSource.add(note) + override fun onActive() { + super.onActive() + if (note is AddressableNote) { + NostrSingleEventDataSource.addAddress(note) + } else { + NostrSingleEventDataSource.add(note) + } } - } - override fun onInactive() { - super.onInactive() - if (note is AddressableNote) { - NostrSingleEventDataSource.removeAddress(note) - } else { - NostrSingleEventDataSource.remove(note) + override fun onInactive() { + super.onInactive() + if (note is AddressableNote) { + NostrSingleEventDataSource.removeAddress(note) + } else { + NostrSingleEventDataSource.remove(note) + } } - } } @Immutable class NoteState(val note: Note) object RelayBriefInfoCache { - val cache = LruCache(50) + val cache = LruCache(50) - @Immutable - data class RelayBriefInfo( - val url: String, - val displayUrl: String = - url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(), - val favIcon: String = "https://$displayUrl/favicon.ico".intern(), - ) + @Immutable + data class RelayBriefInfo( + val url: String, + val displayUrl: String = + url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(), + val favIcon: String = "https://$displayUrl/favicon.ico".intern(), + ) - fun get(url: String): RelayBriefInfo { - val info = cache[url] - if (info != null) return info + fun get(url: String): RelayBriefInfo { + val info = cache[url] + if (info != null) return info - val newInfo = RelayBriefInfo(url) - cache.put(url, newInfo) - return newInfo - } + val newInfo = RelayBriefInfo(url) + cache.put(url, newInfo) + return newInfo + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt index 260ff7d6c..fd4a63b9a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt @@ -23,94 +23,94 @@ package com.vitorpamplona.amethyst.model import com.vitorpamplona.quartz.encoders.HexKey class ParticipantListBuilder { - private fun addFollowsThatDirectlyParticipateOnToSet( - baseNote: Note, - followingSet: Set?, - set: MutableSet, - ) { - baseNote.author?.let { author -> - if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) { - set.add(author) - } - } - - // Breaks these searchers down to avoid the memory use of creating multiple lists - baseNote.replies.forEach { reply -> - reply.author?.let { author -> - if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) { - set.add(author) + private fun addFollowsThatDirectlyParticipateOnToSet( + baseNote: Note, + followingSet: Set?, + set: MutableSet, + ) { + baseNote.author?.let { author -> + if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) { + set.add(author) + } } - } - } - baseNote.boosts.forEach { boost -> - boost.author?.let { author -> - if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) { - set.add(author) + // Breaks these searchers down to avoid the memory use of creating multiple lists + baseNote.replies.forEach { reply -> + reply.author?.let { author -> + if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) { + set.add(author) + } + } } - } - } - baseNote.zaps.forEach { zapPair -> - zapPair.key.author?.let { author -> - if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) { - set.add(author) + baseNote.boosts.forEach { boost -> + boost.author?.let { author -> + if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) { + set.add(author) + } + } } - } - } - baseNote.reactions.forEach { reactionSet -> - reactionSet.value.forEach { reaction -> - reaction.author?.let { author -> - if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) { - set.add(author) - } + baseNote.zaps.forEach { zapPair -> + zapPair.key.author?.let { author -> + if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) { + set.add(author) + } + } } - } - } - } - fun followsThatParticipateOnDirect( - baseNote: Note?, - followingSet: Set?, - ): Set { - if (baseNote == null) return mutableSetOf() - - val set = mutableSetOf() - addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, set) - return set - } - - fun followsThatParticipateOn( - baseNote: Note?, - followingSet: Set?, - ): Set { - if (baseNote == null) return mutableSetOf() - - val mySet = mutableSetOf() - addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, mySet) - - baseNote.replies.forEach { addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) } - - baseNote.boosts.forEach { - it.replyTo?.forEach { addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) } + baseNote.reactions.forEach { reactionSet -> + reactionSet.value.forEach { reaction -> + reaction.author?.let { author -> + if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) { + set.add(author) + } + } + } + } } - LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.values?.forEach { - addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) + fun followsThatParticipateOnDirect( + baseNote: Note?, + followingSet: Set?, + ): Set { + if (baseNote == null) return mutableSetOf() + + val set = mutableSetOf() + addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, set) + return set } - return mySet - } + fun followsThatParticipateOn( + baseNote: Note?, + followingSet: Set?, + ): Set { + if (baseNote == null) return mutableSetOf() - fun countFollowsThatParticipateOn( - baseNote: Note?, - followingSet: Set?, - ): Int { - if (baseNote == null) return 0 + val mySet = mutableSetOf() + addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, mySet) - val list = followsThatParticipateOn(baseNote, followingSet) + baseNote.replies.forEach { addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) } - return list.size - } + baseNote.boosts.forEach { + it.replyTo?.forEach { addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) } + } + + LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.values?.forEach { + addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) + } + + return mySet + } + + fun countFollowsThatParticipateOn( + baseNote: Note?, + followingSet: Set?, + ): Int { + if (baseNote == null) return 0 + + val list = followsThatParticipateOn(baseNote, followingSet) + + return list.size + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/RelayInformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/RelayInformation.kt index be9f35b22..a07b807f5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/RelayInformation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/RelayInformation.kt @@ -26,57 +26,56 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper @Stable class RelayInformation( - val id: String?, - val name: String?, - val description: String?, - val pubkey: String?, - val contact: String?, - val supported_nips: List?, - val supported_nip_extensions: List?, - val software: String?, - val version: String?, - val limitation: RelayInformationLimitation?, - val relay_countries: List?, - val language_tags: List?, - val tags: List?, - val posting_policy: String?, - val payments_url: String?, - val fees: RelayInformationFees?, + val id: String?, + val name: String?, + val description: String?, + val pubkey: String?, + val contact: String?, + val supported_nips: List?, + val supported_nip_extensions: List?, + val software: String?, + val version: String?, + val limitation: RelayInformationLimitation?, + val relay_countries: List?, + val language_tags: List?, + val tags: List?, + val posting_policy: String?, + val payments_url: String?, + val fees: RelayInformationFees?, ) { - companion object { - val mapper = - jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + companion object { + val mapper = + jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - fun fromJson(json: String): RelayInformation = - mapper.readValue(json, RelayInformation::class.java) - } + fun fromJson(json: String): RelayInformation = mapper.readValue(json, RelayInformation::class.java) + } } @Stable class RelayInformationFee( - val amount: Int?, - val unit: String?, - val period: Int?, - val kinds: List?, + val amount: Int?, + val unit: String?, + val period: Int?, + val kinds: List?, ) class RelayInformationFees( - val admission: List?, - val subscription: List?, - val publication: List?, - val retention: List?, + val admission: List?, + val subscription: List?, + val publication: List?, + val retention: List?, ) class RelayInformationLimitation( - val max_message_length: Int?, - val max_subscriptions: Int?, - val max_filters: Int?, - val max_limit: Int?, - val max_subid_length: Int?, - val min_prefix: Int?, - val max_event_tags: Int?, - val max_content_length: Int?, - val min_pow_difficulty: Int?, - val auth_required: Boolean?, - val payment_required: Boolean?, + val max_message_length: Int?, + val max_subscriptions: Int?, + val max_filters: Int?, + val max_limit: Int?, + val max_subid_length: Int?, + val min_prefix: Int?, + val max_event_tags: Int?, + val max_content_length: Int?, + val min_pow_difficulty: Int?, + val auth_required: Boolean?, + val payment_required: Boolean?, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt index 413266bb8..1aa95119c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt @@ -25,15 +25,15 @@ import com.vitorpamplona.amethyst.service.relays.FeedType @Immutable data class RelaySetupInfo( - val url: String, - val read: Boolean, - val write: Boolean, - val errorCount: Int = 0, - val downloadCountInBytes: Int = 0, - val uploadCountInBytes: Int = 0, - val spamCount: Int = 0, - val feedTypes: Set, - val paidRelay: Boolean = false, + val url: String, + val read: Boolean, + val write: Boolean, + val errorCount: Int = 0, + val downloadCountInBytes: Int = 0, + val uploadCountInBytes: Int = 0, + val spamCount: Int = 0, + val feedTypes: Set, + val paidRelay: Boolean = false, ) { - val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url) + val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Settings.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Settings.kt index 2dc0ece29..e1f0881ff 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Settings.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Settings.kt @@ -25,83 +25,83 @@ import com.vitorpamplona.amethyst.R @Stable data class Settings( - val theme: ThemeType = ThemeType.SYSTEM, - val preferredLanguage: String? = null, - val automaticallyShowImages: ConnectivityType = ConnectivityType.ALWAYS, - val automaticallyStartPlayback: ConnectivityType = ConnectivityType.ALWAYS, - val automaticallyShowUrlPreview: ConnectivityType = ConnectivityType.ALWAYS, - val automaticallyHideNavigationBars: BooleanType = BooleanType.ALWAYS, - val automaticallyShowProfilePictures: ConnectivityType = ConnectivityType.ALWAYS, - val dontShowPushNotificationSelector: Boolean = false, - val dontAskForNotificationPermissions: Boolean = false, + val theme: ThemeType = ThemeType.SYSTEM, + val preferredLanguage: String? = null, + val automaticallyShowImages: ConnectivityType = ConnectivityType.ALWAYS, + val automaticallyStartPlayback: ConnectivityType = ConnectivityType.ALWAYS, + val automaticallyShowUrlPreview: ConnectivityType = ConnectivityType.ALWAYS, + val automaticallyHideNavigationBars: BooleanType = BooleanType.ALWAYS, + val automaticallyShowProfilePictures: ConnectivityType = ConnectivityType.ALWAYS, + val dontShowPushNotificationSelector: Boolean = false, + val dontAskForNotificationPermissions: Boolean = false, ) enum class ThemeType(val screenCode: Int, val resourceId: Int) { - SYSTEM(0, R.string.system), - LIGHT(1, R.string.light), - DARK(2, R.string.dark), + SYSTEM(0, R.string.system), + LIGHT(1, R.string.light), + DARK(2, R.string.dark), } fun parseThemeType(code: Int?): ThemeType { - return when (code) { - ThemeType.SYSTEM.screenCode -> ThemeType.SYSTEM - ThemeType.LIGHT.screenCode -> ThemeType.LIGHT - ThemeType.DARK.screenCode -> ThemeType.DARK - else -> { - ThemeType.SYSTEM + return when (code) { + ThemeType.SYSTEM.screenCode -> ThemeType.SYSTEM + ThemeType.LIGHT.screenCode -> ThemeType.LIGHT + ThemeType.DARK.screenCode -> ThemeType.DARK + else -> { + ThemeType.SYSTEM + } } - } } enum class ConnectivityType(val prefCode: Boolean?, val screenCode: Int, val resourceId: Int) { - ALWAYS(null, 0, R.string.connectivity_type_always), - WIFI_ONLY(true, 1, R.string.connectivity_type_wifi_only), - NEVER(false, 2, R.string.connectivity_type_never), + ALWAYS(null, 0, R.string.connectivity_type_always), + WIFI_ONLY(true, 1, R.string.connectivity_type_wifi_only), + NEVER(false, 2, R.string.connectivity_type_never), } fun parseConnectivityType(code: Boolean?): ConnectivityType { - return when (code) { - ConnectivityType.ALWAYS.prefCode -> ConnectivityType.ALWAYS - ConnectivityType.WIFI_ONLY.prefCode -> ConnectivityType.WIFI_ONLY - ConnectivityType.NEVER.prefCode -> ConnectivityType.NEVER - else -> { - ConnectivityType.ALWAYS + return when (code) { + ConnectivityType.ALWAYS.prefCode -> ConnectivityType.ALWAYS + ConnectivityType.WIFI_ONLY.prefCode -> ConnectivityType.WIFI_ONLY + ConnectivityType.NEVER.prefCode -> ConnectivityType.NEVER + else -> { + ConnectivityType.ALWAYS + } } - } } fun parseConnectivityType(screenCode: Int): ConnectivityType { - return when (screenCode) { - ConnectivityType.ALWAYS.screenCode -> ConnectivityType.ALWAYS - ConnectivityType.WIFI_ONLY.screenCode -> ConnectivityType.WIFI_ONLY - ConnectivityType.NEVER.screenCode -> ConnectivityType.NEVER - else -> { - ConnectivityType.ALWAYS + return when (screenCode) { + ConnectivityType.ALWAYS.screenCode -> ConnectivityType.ALWAYS + ConnectivityType.WIFI_ONLY.screenCode -> ConnectivityType.WIFI_ONLY + ConnectivityType.NEVER.screenCode -> ConnectivityType.NEVER + else -> { + ConnectivityType.ALWAYS + } } - } } enum class BooleanType(val prefCode: Boolean?, val screenCode: Int, val reourceId: Int) { - ALWAYS(null, 0, R.string.connectivity_type_always), - NEVER(false, 1, R.string.connectivity_type_never), + ALWAYS(null, 0, R.string.connectivity_type_always), + NEVER(false, 1, R.string.connectivity_type_never), } fun parseBooleanType(code: Boolean?): BooleanType { - return when (code) { - BooleanType.ALWAYS.prefCode -> BooleanType.ALWAYS - BooleanType.NEVER.prefCode -> BooleanType.NEVER - else -> { - BooleanType.ALWAYS + return when (code) { + BooleanType.ALWAYS.prefCode -> BooleanType.ALWAYS + BooleanType.NEVER.prefCode -> BooleanType.NEVER + else -> { + BooleanType.ALWAYS + } } - } } fun parseBooleanType(screenCode: Int): BooleanType { - return when (screenCode) { - BooleanType.ALWAYS.screenCode -> BooleanType.ALWAYS - BooleanType.NEVER.screenCode -> BooleanType.NEVER - else -> { - BooleanType.ALWAYS + return when (screenCode) { + BooleanType.ALWAYS.screenCode -> BooleanType.ALWAYS + BooleanType.NEVER.screenCode -> BooleanType.NEVER + else -> { + BooleanType.ALWAYS + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt index 440f2a861..a24c2cfbe 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt @@ -26,86 +26,86 @@ import com.vitorpamplona.quartz.events.RepostEvent import kotlin.time.measureTimedValue class ThreadAssembler { - private fun searchRoot( - note: Note, - testedNotes: MutableSet = mutableSetOf(), - ): Note? { - if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note + private fun searchRoot( + note: Note, + testedNotes: MutableSet = mutableSetOf(), + ): Note? { + if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note - if (note.event is RepostEvent || note.event is GenericRepostEvent) return note + if (note.event is RepostEvent || note.event is GenericRepostEvent) return note - testedNotes.add(note) + testedNotes.add(note) - val markedAsRoot = - note.event - ?.tags() - ?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" } - ?.getOrNull(1) - if (markedAsRoot != null) { - // Check to ssee if there is an error in the tag and the root has replies - if (LocalCache.getNoteIfExists(markedAsRoot)?.replyTo?.isEmpty() == true) { - return LocalCache.checkGetOrCreateNote(markedAsRoot) - } - } - - val hasNoReplyTo = note.replyTo?.reversed()?.firstOrNull { it.replyTo?.isEmpty() == true } - if (hasNoReplyTo != null) return hasNoReplyTo - - // recursive - val roots = - note.replyTo - ?.map { - if (it !in testedNotes) { - searchRoot(it, testedNotes) - } else { - null - } + val markedAsRoot = + note.event + ?.tags() + ?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" } + ?.getOrNull(1) + if (markedAsRoot != null) { + // Check to ssee if there is an error in the tag and the root has replies + if (LocalCache.getNoteIfExists(markedAsRoot)?.replyTo?.isEmpty() == true) { + return LocalCache.checkGetOrCreateNote(markedAsRoot) + } } - ?.filterNotNull() - if (roots != null && roots.isNotEmpty()) { - return roots[0] - } + val hasNoReplyTo = note.replyTo?.reversed()?.firstOrNull { it.replyTo?.isEmpty() == true } + if (hasNoReplyTo != null) return hasNoReplyTo - return null - } + // recursive + val roots = + note.replyTo + ?.map { + if (it !in testedNotes) { + searchRoot(it, testedNotes) + } else { + null + } + } + ?.filterNotNull() - fun findThreadFor(noteId: String): Set { - checkNotInMainThread() - - val (result, elapsed) = - measureTimedValue { - val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet() - - if (note.event != null) { - val thread = mutableSetOf() - - val threadRoot = searchRoot(note, thread) ?: note - - loadDown(threadRoot, thread) - // adds the replies of the note in case the search for Root - // did not added them. - note.replies.forEach { loadDown(it, thread) } - - thread.toSet() - } else { - setOf(note) + if (roots != null && roots.isNotEmpty()) { + return roots[0] } - } - println("Model Refresh: Thread loaded in $elapsed") - - return result - } - - fun loadDown( - note: Note, - thread: MutableSet, - ) { - if (note !in thread) { - thread.add(note) - - note.replies.forEach { loadDown(it, thread) } + return null + } + + fun findThreadFor(noteId: String): Set { + checkNotInMainThread() + + val (result, elapsed) = + measureTimedValue { + val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet() + + if (note.event != null) { + val thread = mutableSetOf() + + val threadRoot = searchRoot(note, thread) ?: note + + loadDown(threadRoot, thread) + // adds the replies of the note in case the search for Root + // did not added them. + note.replies.forEach { loadDown(it, thread) } + + thread.toSet() + } else { + setOf(note) + } + } + + println("Model Refresh: Thread loaded in $elapsed") + + return result + } + + fun loadDown( + note: Note, + thread: MutableSet, + ) { + if (note !in thread) { + thread.add(note) + + note.replies.forEach { loadDown(it, thread) } + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt index 9a77ef5a2..f55e0ec12 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt @@ -31,55 +31,54 @@ import kotlinx.coroutines.withContext @Stable object UrlCachedPreviewer { - var cache = LruCache(100) - private set + var cache = LruCache(100) + private set - suspend fun previewInfo( - url: String, - onReady: suspend (UrlPreviewState) -> Unit, - ) = - withContext(Dispatchers.IO) { - cache[url]?.let { - onReady(it) - return@withContext - } + suspend fun previewInfo( + url: String, + onReady: suspend (UrlPreviewState) -> Unit, + ) = withContext(Dispatchers.IO) { + cache[url]?.let { + onReady(it) + return@withContext + } - BahaUrlPreview( - url, - object : IUrlPreviewCallback { - override suspend fun onComplete(urlInfo: UrlInfoItem) = - withContext(Dispatchers.IO) { - cache[url]?.let { - if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) { - onReady(it) - return@withContext - } - } + BahaUrlPreview( + url, + object : IUrlPreviewCallback { + override suspend fun onComplete(urlInfo: UrlInfoItem) = + withContext(Dispatchers.IO) { + cache[url]?.let { + if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) { + onReady(it) + return@withContext + } + } - val state = - if (urlInfo.fetchComplete() && urlInfo.url == url) { - UrlPreviewState.Loaded(urlInfo) - } else { - UrlPreviewState.Empty - } + val state = + if (urlInfo.fetchComplete() && urlInfo.url == url) { + UrlPreviewState.Loaded(urlInfo) + } else { + UrlPreviewState.Empty + } - cache.put(url, state) - onReady(state) - } + cache.put(url, state) + onReady(state) + } - override suspend fun onFailed(throwable: Throwable) = - withContext(Dispatchers.IO) { - cache[url]?.let { - onReady(it) - return@withContext - } + override suspend fun onFailed(throwable: Throwable) = + withContext(Dispatchers.IO) { + cache[url]?.let { + onReady(it) + return@withContext + } - val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview") - cache.put(url, state) - onReady(state) - } - }, + val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview") + cache.put(url, state) + onReady(state) + } + }, ) - .fetchUrlPreview() + .fetchUrlPreview() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 951cbe02a..23f68e951 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -43,572 +43,572 @@ import com.vitorpamplona.quartz.events.MetadataEvent import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.UserMetadata import com.vitorpamplona.quartz.events.toImmutableListOfLists -import java.math.BigDecimal import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import java.math.BigDecimal @Stable class User(val pubkeyHex: String) { - var info: UserMetadata? = null + var info: UserMetadata? = null - var latestContactList: ContactListEvent? = null - var latestBookmarkList: BookmarkListEvent? = null + var latestContactList: ContactListEvent? = null + var latestBookmarkList: BookmarkListEvent? = null - var reports = mapOf>() - private set + var reports = mapOf>() + private set - var latestEOSEs: Map = emptyMap() + var latestEOSEs: Map = emptyMap() - var zaps = mapOf() - private set + var zaps = mapOf() + private set - var relaysBeingUsed = mapOf() - private set + var relaysBeingUsed = mapOf() + private set - var privateChatrooms = mapOf() - private set + var privateChatrooms = mapOf() + private set - fun pubkey() = Hex.decode(pubkeyHex) + fun pubkey() = Hex.decode(pubkeyHex) - fun pubkeyNpub() = pubkey().toNpub() + fun pubkeyNpub() = pubkey().toNpub() - fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex() + fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex() - fun toNostrUri() = "nostr:${pubkeyNpub()}" + fun toNostrUri() = "nostr:${pubkeyNpub()}" - override fun toString(): String = pubkeyHex + override fun toString(): String = pubkeyHex - fun toBestShortFirstName(): String { - val fullName = bestDisplayName() ?: bestUsername() ?: return pubkeyDisplayHex() + fun toBestShortFirstName(): String { + val fullName = bestDisplayName() ?: bestUsername() ?: return pubkeyDisplayHex() - val names = fullName.split(' ') + val names = fullName.split(' ') - val firstName = - if (names[0].length <= 3) { - // too short. Remove Dr. - "${names[0]} ${names.getOrNull(1) ?: ""}" - } else { - names[0] - } + val firstName = + if (names[0].length <= 3) { + // too short. Remove Dr. + "${names[0]} ${names.getOrNull(1) ?: ""}" + } else { + names[0] + } - return firstName - } - - fun toBestDisplayName(): String { - return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex() - } - - fun bestUsername(): String? { - return info?.name?.ifBlank { null } ?: info?.username?.ifBlank { null } - } - - fun bestDisplayName(): String? { - return info?.displayName?.ifBlank { null } - } - - fun nip05(): String? { - return info?.nip05?.ifBlank { null } - } - - fun profilePicture(): String? { - if (info?.picture.isNullOrBlank()) info?.picture = null - return info?.picture - } - - fun updateBookmark(event: BookmarkListEvent) { - if (event.id == latestBookmarkList?.id) return - - latestBookmarkList = event - liveSet?.innerBookmarks?.invalidateData() - } - - fun clearEOSE() { - latestEOSEs = emptyMap() - } - - fun updateContactList(event: ContactListEvent) { - if (event.id == latestContactList?.id) return - - val oldContactListEvent = latestContactList - latestContactList = event - - // Update following of the current user - liveSet?.innerFollows?.invalidateData() - - // Update Followers of the past user list - // Update Followers of the new contact list - (oldContactListEvent)?.unverifiedFollowKeySet()?.forEach { - LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData() - } - (latestContactList)?.unverifiedFollowKeySet()?.forEach { - LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData() + return firstName } - liveSet?.innerRelays?.invalidateData() - flowSet?.relays?.invalidateData() - } - - fun addReport(note: Note) { - val author = note.author ?: return - - val reportsBy = reports[author] - if (reportsBy == null) { - reports = reports + Pair(author, setOf(note)) - liveSet?.innerReports?.invalidateData() - } else if (!reportsBy.contains(note)) { - reports = reports + Pair(author, reportsBy + note) - liveSet?.innerReports?.invalidateData() - } - } - - fun removeReport(deleteNote: Note) { - val author = deleteNote.author ?: return - - if (reports[author]?.contains(deleteNote) == true) { - reports[author]?.let { - reports = reports + Pair(author, it.minus(deleteNote)) - liveSet?.innerReports?.invalidateData() - } - } - } - - fun addZap( - zapRequest: Note, - zap: Note?, - ) { - if (zaps[zapRequest] == null) { - zaps = zaps + Pair(zapRequest, zap) - liveSet?.innerZaps?.invalidateData() - } - } - - fun removeZap(zapRequestOrZapEvent: Note) { - if (zaps.containsKey(zapRequestOrZapEvent)) { - zaps = zaps.minus(zapRequestOrZapEvent) - liveSet?.innerZaps?.invalidateData() - } else if (zaps.containsValue(zapRequestOrZapEvent)) { - zaps = zaps.filter { it.value != zapRequestOrZapEvent } - liveSet?.innerZaps?.invalidateData() - } - } - - fun zappedAmount(): BigDecimal { - var amount = BigDecimal.ZERO - zaps.forEach { - val itemValue = (it.value?.event as? LnZapEvent)?.amount - if (itemValue != null) { - amount += itemValue - } + fun toBestDisplayName(): String { + return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex() } - return amount - } + fun bestUsername(): String? { + return info?.name?.ifBlank { null } ?: info?.username?.ifBlank { null } + } - fun reportsBy(user: User): Set { - return reports[user] ?: emptySet() - } + fun bestDisplayName(): String? { + return info?.displayName?.ifBlank { null } + } - fun countReportAuthorsBy(users: Set): Int { - return reports.count { it.key.pubkeyHex in users } - } + fun nip05(): String? { + return info?.nip05?.ifBlank { null } + } - fun reportsBy(users: Set): List { - return reports - .mapNotNull { - if (it.key.pubkeyHex in users) { - it.value + fun profilePicture(): String? { + if (info?.picture.isNullOrBlank()) info?.picture = null + return info?.picture + } + + fun updateBookmark(event: BookmarkListEvent) { + if (event.id == latestBookmarkList?.id) return + + latestBookmarkList = event + liveSet?.innerBookmarks?.invalidateData() + } + + fun clearEOSE() { + latestEOSEs = emptyMap() + } + + fun updateContactList(event: ContactListEvent) { + if (event.id == latestContactList?.id) return + + val oldContactListEvent = latestContactList + latestContactList = event + + // Update following of the current user + liveSet?.innerFollows?.invalidateData() + + // Update Followers of the past user list + // Update Followers of the new contact list + (oldContactListEvent)?.unverifiedFollowKeySet()?.forEach { + LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData() + } + (latestContactList)?.unverifiedFollowKeySet()?.forEach { + LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData() + } + + liveSet?.innerRelays?.invalidateData() + flowSet?.relays?.invalidateData() + } + + fun addReport(note: Note) { + val author = note.author ?: return + + val reportsBy = reports[author] + if (reportsBy == null) { + reports = reports + Pair(author, setOf(note)) + liveSet?.innerReports?.invalidateData() + } else if (!reportsBy.contains(note)) { + reports = reports + Pair(author, reportsBy + note) + liveSet?.innerReports?.invalidateData() + } + } + + fun removeReport(deleteNote: Note) { + val author = deleteNote.author ?: return + + if (reports[author]?.contains(deleteNote) == true) { + reports[author]?.let { + reports = reports + Pair(author, it.minus(deleteNote)) + liveSet?.innerReports?.invalidateData() + } + } + } + + fun addZap( + zapRequest: Note, + zap: Note?, + ) { + if (zaps[zapRequest] == null) { + zaps = zaps + Pair(zapRequest, zap) + liveSet?.innerZaps?.invalidateData() + } + } + + fun removeZap(zapRequestOrZapEvent: Note) { + if (zaps.containsKey(zapRequestOrZapEvent)) { + zaps = zaps.minus(zapRequestOrZapEvent) + liveSet?.innerZaps?.invalidateData() + } else if (zaps.containsValue(zapRequestOrZapEvent)) { + zaps = zaps.filter { it.value != zapRequestOrZapEvent } + liveSet?.innerZaps?.invalidateData() + } + } + + fun zappedAmount(): BigDecimal { + var amount = BigDecimal.ZERO + zaps.forEach { + val itemValue = (it.value?.event as? LnZapEvent)?.amount + if (itemValue != null) { + amount += itemValue + } + } + + return amount + } + + fun reportsBy(user: User): Set { + return reports[user] ?: emptySet() + } + + fun countReportAuthorsBy(users: Set): Int { + return reports.count { it.key.pubkeyHex in users } + } + + fun reportsBy(users: Set): List { + return reports + .mapNotNull { + if (it.key.pubkeyHex in users) { + it.value + } else { + null + } + } + .flatten() + } + + @Synchronized + private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom { + checkNotInMainThread() + + return privateChatrooms[key] + ?: run { + val privateChatroom = Chatroom() + privateChatrooms = privateChatrooms + Pair(key, privateChatroom) + privateChatroom + } + } + + private fun getOrCreatePrivateChatroom(user: User): Chatroom { + val key = ChatroomKey(persistentSetOf(user.pubkeyHex)) + return getOrCreatePrivateChatroom(key) + } + + private fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom { + return privateChatrooms[key] ?: getOrCreatePrivateChatroomSync(key) + } + + fun addMessage( + room: ChatroomKey, + msg: Note, + ) { + val privateChatroom = getOrCreatePrivateChatroom(room) + if (msg !in privateChatroom.roomMessages) { + privateChatroom.addMessageSync(msg) + liveSet?.innerMessages?.invalidateData() + } + } + + fun addMessage( + user: User, + msg: Note, + ) { + val privateChatroom = getOrCreatePrivateChatroom(user) + if (msg !in privateChatroom.roomMessages) { + privateChatroom.addMessageSync(msg) + liveSet?.innerMessages?.invalidateData() + } + } + + fun createChatroom(withKey: ChatroomKey) { + getOrCreatePrivateChatroom(withKey) + } + + fun removeMessage( + user: User, + msg: Note, + ) { + checkNotInMainThread() + + val privateChatroom = getOrCreatePrivateChatroom(user) + if (msg in privateChatroom.roomMessages) { + privateChatroom.removeMessageSync(msg) + liveSet?.innerMessages?.invalidateData() + } + } + + fun addRelayBeingUsed( + relay: Relay, + eventTime: Long, + ) { + val here = relaysBeingUsed[relay.url] + if (here == null) { + relaysBeingUsed = relaysBeingUsed + Pair(relay.url, RelayInfo(relay.url, eventTime, 1)) } else { - null + if (eventTime > here.lastEvent) { + here.lastEvent = eventTime + } + here.counter++ } - } - .flatten() - } - @Synchronized - private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom { - checkNotInMainThread() - - return privateChatrooms[key] - ?: run { - val privateChatroom = Chatroom() - privateChatrooms = privateChatrooms + Pair(key, privateChatroom) - privateChatroom - } - } - - private fun getOrCreatePrivateChatroom(user: User): Chatroom { - val key = ChatroomKey(persistentSetOf(user.pubkeyHex)) - return getOrCreatePrivateChatroom(key) - } - - private fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom { - return privateChatrooms[key] ?: getOrCreatePrivateChatroomSync(key) - } - - fun addMessage( - room: ChatroomKey, - msg: Note, - ) { - val privateChatroom = getOrCreatePrivateChatroom(room) - if (msg !in privateChatroom.roomMessages) { - privateChatroom.addMessageSync(msg) - liveSet?.innerMessages?.invalidateData() - } - } - - fun addMessage( - user: User, - msg: Note, - ) { - val privateChatroom = getOrCreatePrivateChatroom(user) - if (msg !in privateChatroom.roomMessages) { - privateChatroom.addMessageSync(msg) - liveSet?.innerMessages?.invalidateData() - } - } - - fun createChatroom(withKey: ChatroomKey) { - getOrCreatePrivateChatroom(withKey) - } - - fun removeMessage( - user: User, - msg: Note, - ) { - checkNotInMainThread() - - val privateChatroom = getOrCreatePrivateChatroom(user) - if (msg in privateChatroom.roomMessages) { - privateChatroom.removeMessageSync(msg) - liveSet?.innerMessages?.invalidateData() - } - } - - fun addRelayBeingUsed( - relay: Relay, - eventTime: Long, - ) { - val here = relaysBeingUsed[relay.url] - if (here == null) { - relaysBeingUsed = relaysBeingUsed + Pair(relay.url, RelayInfo(relay.url, eventTime, 1)) - } else { - if (eventTime > here.lastEvent) { - here.lastEvent = eventTime - } - here.counter++ + liveSet?.innerRelayInfo?.invalidateData() } - liveSet?.innerRelayInfo?.invalidateData() - } + fun updateUserInfo( + newUserInfo: UserMetadata, + latestMetadata: MetadataEvent, + ) { + info = newUserInfo + info?.latestMetadata = latestMetadata + info?.updatedMetadataAt = latestMetadata.createdAt + info?.tags = latestMetadata.tags.toImmutableListOfLists() - fun updateUserInfo( - newUserInfo: UserMetadata, - latestMetadata: MetadataEvent, - ) { - info = newUserInfo - info?.latestMetadata = latestMetadata - info?.updatedMetadataAt = latestMetadata.createdAt - info?.tags = latestMetadata.tags.toImmutableListOfLists() - - if (newUserInfo.lud16.isNullOrBlank()) { - info?.lud06?.let { - if (it.lowercase().startsWith("lnurl")) { - info?.lud16 = Lud06().toLud16(it) + if (newUserInfo.lud16.isNullOrBlank()) { + info?.lud06?.let { + if (it.lowercase().startsWith("lnurl")) { + info?.lud16 = Lud06().toLud16(it) + } + } } - } + + liveSet?.innerMetadata?.invalidateData() } - liveSet?.innerMetadata?.invalidateData() - } - - fun isFollowing(user: User): Boolean { - return latestContactList?.isTaggedUser(user.pubkeyHex) ?: false - } - - fun isFollowingHashtag(tag: String): Boolean { - return latestContactList?.isTaggedHash(tag) ?: false - } - - fun isFollowingHashtagCached(tag: String): Boolean { - return latestContactList?.verifiedFollowTagSet?.let { - return tag.lowercase() in it + fun isFollowing(user: User): Boolean { + return latestContactList?.isTaggedUser(user.pubkeyHex) ?: false } - ?: false - } - fun isFollowingGeohashCached(geoTag: String): Boolean { - return latestContactList?.verifiedFollowGeohashSet?.let { - return geoTag.lowercase() in it + fun isFollowingHashtag(tag: String): Boolean { + return latestContactList?.isTaggedHash(tag) ?: false } - ?: false - } - fun isFollowingCached(user: User): Boolean { - return latestContactList?.verifiedFollowKeySet?.let { - return user.pubkeyHex in it + fun isFollowingHashtagCached(tag: String): Boolean { + return latestContactList?.verifiedFollowTagSet?.let { + return tag.lowercase() in it + } + ?: false } - ?: false - } - fun isFollowingCached(userHex: String): Boolean { - return latestContactList?.verifiedFollowKeySet?.let { - return userHex in it + fun isFollowingGeohashCached(geoTag: String): Boolean { + return latestContactList?.verifiedFollowGeohashSet?.let { + return geoTag.lowercase() in it + } + ?: false } - ?: false - } - fun transientFollowCount(): Int? { - return latestContactList?.unverifiedFollowKeySet()?.size - } - - suspend fun transientFollowerCount(): Int { - return LocalCache.users.count { it.value.latestContactList?.isTaggedUser(pubkeyHex) ?: false } - } - - fun cachedFollowingKeySet(): Set { - return latestContactList?.verifiedFollowKeySet ?: emptySet() - } - - fun cachedFollowingTagSet(): Set { - return latestContactList?.verifiedFollowTagSet ?: emptySet() - } - - fun cachedFollowingGeohashSet(): Set { - return latestContactList?.verifiedFollowGeohashSet ?: emptySet() - } - - fun cachedFollowingCommunitiesSet(): Set { - return latestContactList?.verifiedFollowCommunitySet ?: emptySet() - } - - fun cachedFollowCount(): Int? { - return latestContactList?.verifiedFollowKeySet?.size - } - - suspend fun cachedFollowerCount(): Int { - return LocalCache.users.count { it.value.latestContactList?.isTaggedUser(pubkeyHex) ?: false } - } - - fun hasSentMessagesTo(key: ChatroomKey?): Boolean { - val messagesToUser = privateChatrooms[key] ?: return false - - return messagesToUser.roomMessages.any { this.pubkeyHex == it.author?.pubkeyHex } - } - - fun hasReport( - loggedIn: User, - type: ReportEvent.ReportType, - ): Boolean { - return reports[loggedIn]?.firstOrNull { - it.event is ReportEvent && - (it.event as ReportEvent).reportedAuthor().any { it.reportType == type } - } != null - } - - fun anyNameStartsWith(username: String): Boolean { - return info?.anyNameStartsWith(username) ?: false - } - - var liveSet: UserLiveSet? = null - var flowSet: UserFlowSet? = null - - fun live(): UserLiveSet { - if (liveSet == null) { - createOrDestroyLiveSync(true) + fun isFollowingCached(user: User): Boolean { + return latestContactList?.verifiedFollowKeySet?.let { + return user.pubkeyHex in it + } + ?: false } - return liveSet!! - } - fun clearLive() { - if (liveSet != null && liveSet?.isInUse() == false) { - createOrDestroyLiveSync(false) + fun isFollowingCached(userHex: String): Boolean { + return latestContactList?.verifiedFollowKeySet?.let { + return userHex in it + } + ?: false } - } - @Synchronized - fun createOrDestroyLiveSync(create: Boolean) { - if (create) { - if (liveSet == null) { - liveSet = UserLiveSet(this) - } - } else { - if (liveSet != null && liveSet?.isInUse() == false) { - liveSet?.destroy() - liveSet = null - } + fun transientFollowCount(): Int? { + return latestContactList?.unverifiedFollowKeySet()?.size } - } - @Synchronized - fun createOrDestroyFlowSync(create: Boolean) { - if (create) { - if (flowSet == null) { - flowSet = UserFlowSet(this) - } - } else { - if (flowSet != null && flowSet?.isInUse() == false) { - flowSet?.destroy() - flowSet = null - } + suspend fun transientFollowerCount(): Int { + return LocalCache.users.count { it.value.latestContactList?.isTaggedUser(pubkeyHex) ?: false } } - } - fun flow(): UserFlowSet { - if (flowSet == null) { - createOrDestroyFlowSync(true) + fun cachedFollowingKeySet(): Set { + return latestContactList?.verifiedFollowKeySet ?: emptySet() } - return flowSet!! - } - fun clearFlow() { - if (flowSet != null && flowSet?.isInUse() == false) { - createOrDestroyFlowSync(false) + fun cachedFollowingTagSet(): Set { + return latestContactList?.verifiedFollowTagSet ?: emptySet() + } + + fun cachedFollowingGeohashSet(): Set { + return latestContactList?.verifiedFollowGeohashSet ?: emptySet() + } + + fun cachedFollowingCommunitiesSet(): Set { + return latestContactList?.verifiedFollowCommunitySet ?: emptySet() + } + + fun cachedFollowCount(): Int? { + return latestContactList?.verifiedFollowKeySet?.size + } + + suspend fun cachedFollowerCount(): Int { + return LocalCache.users.count { it.value.latestContactList?.isTaggedUser(pubkeyHex) ?: false } + } + + fun hasSentMessagesTo(key: ChatroomKey?): Boolean { + val messagesToUser = privateChatrooms[key] ?: return false + + return messagesToUser.roomMessages.any { this.pubkeyHex == it.author?.pubkeyHex } + } + + fun hasReport( + loggedIn: User, + type: ReportEvent.ReportType, + ): Boolean { + return reports[loggedIn]?.firstOrNull { + it.event is ReportEvent && + (it.event as ReportEvent).reportedAuthor().any { it.reportType == type } + } != null + } + + fun anyNameStartsWith(username: String): Boolean { + return info?.anyNameStartsWith(username) ?: false + } + + var liveSet: UserLiveSet? = null + var flowSet: UserFlowSet? = null + + fun live(): UserLiveSet { + if (liveSet == null) { + createOrDestroyLiveSync(true) + } + return liveSet!! + } + + fun clearLive() { + if (liveSet != null && liveSet?.isInUse() == false) { + createOrDestroyLiveSync(false) + } + } + + @Synchronized + fun createOrDestroyLiveSync(create: Boolean) { + if (create) { + if (liveSet == null) { + liveSet = UserLiveSet(this) + } + } else { + if (liveSet != null && liveSet?.isInUse() == false) { + liveSet?.destroy() + liveSet = null + } + } + } + + @Synchronized + fun createOrDestroyFlowSync(create: Boolean) { + if (create) { + if (flowSet == null) { + flowSet = UserFlowSet(this) + } + } else { + if (flowSet != null && flowSet?.isInUse() == false) { + flowSet?.destroy() + flowSet = null + } + } + } + + fun flow(): UserFlowSet { + if (flowSet == null) { + createOrDestroyFlowSync(true) + } + return flowSet!! + } + + fun clearFlow() { + if (flowSet != null && flowSet?.isInUse() == false) { + createOrDestroyFlowSync(false) + } } - } } @Stable class UserFlowSet(u: User) { - // Observers line up here. - val relays = UserBundledRefresherFlow(u) + // Observers line up here. + val relays = UserBundledRefresherFlow(u) - fun isInUse(): Boolean { - return relays.stateFlow.subscriptionCount.value > 0 - } + fun isInUse(): Boolean { + return relays.stateFlow.subscriptionCount.value > 0 + } - fun destroy() { - relays.destroy() - } + fun destroy() { + relays.destroy() + } } @Stable class UserLiveSet(u: User) { - val innerMetadata = UserBundledRefresherLiveData(u) + val innerMetadata = UserBundledRefresherLiveData(u) - // UI Observers line up here. - val innerFollows = UserBundledRefresherLiveData(u) - val innerFollowers = UserBundledRefresherLiveData(u) - val innerReports = UserBundledRefresherLiveData(u) - val innerMessages = UserBundledRefresherLiveData(u) - val innerRelays = UserBundledRefresherLiveData(u) - val innerRelayInfo = UserBundledRefresherLiveData(u) - val innerZaps = UserBundledRefresherLiveData(u) - val innerBookmarks = UserBundledRefresherLiveData(u) - val innerStatuses = UserBundledRefresherLiveData(u) + // UI Observers line up here. + val innerFollows = UserBundledRefresherLiveData(u) + val innerFollowers = UserBundledRefresherLiveData(u) + val innerReports = UserBundledRefresherLiveData(u) + val innerMessages = UserBundledRefresherLiveData(u) + val innerRelays = UserBundledRefresherLiveData(u) + val innerRelayInfo = UserBundledRefresherLiveData(u) + val innerZaps = UserBundledRefresherLiveData(u) + val innerBookmarks = UserBundledRefresherLiveData(u) + val innerStatuses = UserBundledRefresherLiveData(u) - // UI Observers line up here. - val metadata = innerMetadata.map { it } - val follows = innerFollows.map { it } - val followers = innerFollowers.map { it } - val reports = innerReports.map { it } - val messages = innerMessages.map { it } - val relays = innerRelays.map { it } - val relayInfo = innerRelayInfo.map { it } - val zaps = innerZaps.map { it } - val bookmarks = innerBookmarks.map { it } - val statuses = innerStatuses.map { it } + // UI Observers line up here. + val metadata = innerMetadata.map { it } + val follows = innerFollows.map { it } + val followers = innerFollowers.map { it } + val reports = innerReports.map { it } + val messages = innerMessages.map { it } + val relays = innerRelays.map { it } + val relayInfo = innerRelayInfo.map { it } + val zaps = innerZaps.map { it } + val bookmarks = innerBookmarks.map { it } + val statuses = innerStatuses.map { it } - val profilePictureChanges = innerMetadata.map { it.user.profilePicture() }.distinctUntilChanged() + val profilePictureChanges = innerMetadata.map { it.user.profilePicture() }.distinctUntilChanged() - val nip05Changes = innerMetadata.map { it.user.nip05() }.distinctUntilChanged() + val nip05Changes = innerMetadata.map { it.user.nip05() }.distinctUntilChanged() - val userMetadataInfo = innerMetadata.map { it.user.info }.distinctUntilChanged() + val userMetadataInfo = innerMetadata.map { it.user.info }.distinctUntilChanged() - fun isInUse(): Boolean { - return metadata.hasObservers() || - follows.hasObservers() || - followers.hasObservers() || - reports.hasObservers() || - messages.hasObservers() || - relays.hasObservers() || - relayInfo.hasObservers() || - zaps.hasObservers() || - bookmarks.hasObservers() || - statuses.hasObservers() || - profilePictureChanges.hasObservers() || - nip05Changes.hasObservers() || - userMetadataInfo.hasObservers() - } + fun isInUse(): Boolean { + return metadata.hasObservers() || + follows.hasObservers() || + followers.hasObservers() || + reports.hasObservers() || + messages.hasObservers() || + relays.hasObservers() || + relayInfo.hasObservers() || + zaps.hasObservers() || + bookmarks.hasObservers() || + statuses.hasObservers() || + profilePictureChanges.hasObservers() || + nip05Changes.hasObservers() || + userMetadataInfo.hasObservers() + } - fun destroy() { - innerMetadata.destroy() - innerFollows.destroy() - innerFollowers.destroy() - innerReports.destroy() - innerMessages.destroy() - innerRelays.destroy() - innerRelayInfo.destroy() - innerZaps.destroy() - innerBookmarks.destroy() - innerStatuses.destroy() - } + fun destroy() { + innerMetadata.destroy() + innerFollows.destroy() + innerFollowers.destroy() + innerReports.destroy() + innerMessages.destroy() + innerRelays.destroy() + innerRelayInfo.destroy() + innerZaps.destroy() + innerBookmarks.destroy() + innerStatuses.destroy() + } } @Immutable data class RelayInfo( - val url: String, - var lastEvent: Long, - var counter: Long, + val url: String, + var lastEvent: Long, + var counter: Long, ) class UserBundledRefresherLiveData(val user: User) : LiveData(UserState(user)) { - // Refreshes observers in batches. - private val bundler = BundledUpdate(500, Dispatchers.IO) + // Refreshes observers in batches. + private val bundler = BundledUpdate(500, Dispatchers.IO) - fun destroy() { - bundler.cancel() - } - - fun invalidateData() { - checkNotInMainThread() - - bundler.invalidate { - checkNotInMainThread() - - postValue(UserState(user)) + fun destroy() { + bundler.cancel() } - } - fun map(transform: (UserState) -> Y): UserLoadingLiveData { - val initialValue = this.value?.let { transform(it) } - val result = UserLoadingLiveData(user, initialValue) - result.addSource(this) { x -> result.value = transform(x) } - return result - } + fun invalidateData() { + checkNotInMainThread() + + bundler.invalidate { + checkNotInMainThread() + + postValue(UserState(user)) + } + } + + fun map(transform: (UserState) -> Y): UserLoadingLiveData { + val initialValue = this.value?.let { transform(it) } + val result = UserLoadingLiveData(user, initialValue) + result.addSource(this) { x -> result.value = transform(x) } + return result + } } @Stable class UserBundledRefresherFlow(val user: User) { - // Refreshes observers in batches. - private val bundler = BundledUpdate(500, Dispatchers.IO) - val stateFlow = MutableStateFlow(UserState(user)) + // Refreshes observers in batches. + private val bundler = BundledUpdate(500, Dispatchers.IO) + val stateFlow = MutableStateFlow(UserState(user)) - fun destroy() { - bundler.cancel() - } - - fun invalidateData() { - checkNotInMainThread() - - bundler.invalidate { - checkNotInMainThread() - - stateFlow.emit(UserState(user)) + fun destroy() { + bundler.cancel() + } + + fun invalidateData() { + checkNotInMainThread() + + bundler.invalidate { + checkNotInMainThread() + + stateFlow.emit(UserState(user)) + } } - } } class UserLoadingLiveData(val user: User, initialValue: Y?) : MediatorLiveData(initialValue) { - override fun onActive() { - super.onActive() - NostrSingleUserDataSource.add(user) - } + override fun onActive() { + super.onActive() + NostrSingleUserDataSource.add(user) + } - override fun onInactive() { - super.onInactive() - NostrSingleUserDataSource.remove(user) - } + override fun onInactive() { + super.onInactive() + NostrSingleUserDataSource.remove(user) + } } @Immutable class UserState(val user: User) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt index 943600c68..7ab7f8735 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt @@ -27,296 +27,294 @@ import kotlin.math.pow import kotlin.math.withSign object BlurHashDecoder { - // cache Math.cos() calculations to improve performance. - // The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * - // 2 * nBitmaps - // the cache is enabled by default, it is recommended to disable it only when just a few images - // are displayed - private val cacheCosinesX = HashMap() - private val cacheCosinesY = HashMap() + // cache Math.cos() calculations to improve performance. + // The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * + // 2 * nBitmaps + // the cache is enabled by default, it is recommended to disable it only when just a few images + // are displayed + private val cacheCosinesX = HashMap() + private val cacheCosinesY = HashMap() - /** - * Clear calculations stored in memory cache. The cache is not big, but will increase when many - * image sizes are used, if the app needs memory it is recommended to clear it. - */ - fun clearCache() { - cacheCosinesX.clear() - cacheCosinesY.clear() - } - - /** Returns width/height */ - fun aspectRatio(blurHash: String?): Float? { - if (blurHash == null || blurHash.length < 6) { - return null - } - val numCompEnc = decode83(blurHash, 0, 1) - val numCompX = (numCompEnc % 9) + 1 - val numCompY = (numCompEnc / 9) + 1 - if (blurHash.length != 4 + 2 * numCompX * numCompY) { - return null + /** + * Clear calculations stored in memory cache. The cache is not big, but will increase when many + * image sizes are used, if the app needs memory it is recommended to clear it. + */ + fun clearCache() { + cacheCosinesX.clear() + cacheCosinesY.clear() } - return numCompX.toFloat() / numCompY.toFloat() - } + /** Returns width/height */ + fun aspectRatio(blurHash: String?): Float? { + if (blurHash == null || blurHash.length < 6) { + return null + } + val numCompEnc = decode83(blurHash, 0, 1) + val numCompX = (numCompEnc % 9) + 1 + val numCompY = (numCompEnc / 9) + 1 + if (blurHash.length != 4 + 2 * numCompX * numCompY) { + return null + } - /** - * Decode a blur hash into a new bitmap. - * - * @param useCache use in memory cache for the calculated math, reused by images with same size. - * if the cache does not exist yet it will be created and populated with new calculations. By - * default it is true. - */ - fun decode( - blurHash: String?, - width: Int, - height: Int, - punch: Float = 1f, - useCache: Boolean = true, - ): Bitmap? { - checkNotInMainThread() + return numCompX.toFloat() / numCompY.toFloat() + } - if (blurHash == null || blurHash.length < 6) { - return null + /** + * Decode a blur hash into a new bitmap. + * + * @param useCache use in memory cache for the calculated math, reused by images with same size. + * if the cache does not exist yet it will be created and populated with new calculations. By + * default it is true. + */ + fun decode( + blurHash: String?, + width: Int, + height: Int, + punch: Float = 1f, + useCache: Boolean = true, + ): Bitmap? { + checkNotInMainThread() + + if (blurHash == null || blurHash.length < 6) { + return null + } + val numCompEnc = decode83(blurHash, 0, 1) + val numCompX = (numCompEnc % 9) + 1 + val numCompY = (numCompEnc / 9) + 1 + if (blurHash.length != 4 + 2 * numCompX * numCompY) { + return null + } + val maxAcEnc = decode83(blurHash, 1, 2) + val maxAc = (maxAcEnc + 1) / 166f + val colors = + Array(numCompX * numCompY) { i -> + if (i == 0) { + val colorEnc = decode83(blurHash, 2, 6) + decodeDc(colorEnc) + } else { + val from = 4 + i * 2 + val colorEnc = decode83(blurHash, from, from + 2) + decodeAc(colorEnc, maxAc * punch) + } + } + return composeBitmap(width, height, numCompX, numCompY, colors, useCache) } - val numCompEnc = decode83(blurHash, 0, 1) - val numCompX = (numCompEnc % 9) + 1 - val numCompY = (numCompEnc / 9) + 1 - if (blurHash.length != 4 + 2 * numCompX * numCompY) { - return null + + private fun decode83( + str: String, + from: Int = 0, + to: Int = str.length, + ): Int { + var result = 0 + for (i in from until to) { + val index = charMap[str[i]] ?: -1 + if (index != -1) { + result = result * 83 + index + } + } + return result } - val maxAcEnc = decode83(blurHash, 1, 2) - val maxAc = (maxAcEnc + 1) / 166f - val colors = - Array(numCompX * numCompY) { i -> - if (i == 0) { - val colorEnc = decode83(blurHash, 2, 6) - decodeDc(colorEnc) + + private fun decodeDc(colorEnc: Int): FloatArray { + val r = colorEnc shr 16 + val g = (colorEnc shr 8) and 255 + val b = colorEnc and 255 + return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)) + } + + private fun srgbToLinear(colorEnc: Int): Float { + val v = colorEnc / 255f + return if (v <= 0.04045f) { + (v / 12.92f) } else { - val from = 4 + i * 2 - val colorEnc = decode83(blurHash, from, from + 2) - decodeAc(colorEnc, maxAc * punch) + ((v + 0.055f) / 1.055f).pow(2.4f) } - } - return composeBitmap(width, height, numCompX, numCompY, colors, useCache) - } - - private fun decode83( - str: String, - from: Int = 0, - to: Int = str.length, - ): Int { - var result = 0 - for (i in from until to) { - val index = charMap[str[i]] ?: -1 - if (index != -1) { - result = result * 83 + index - } } - return result - } - private fun decodeDc(colorEnc: Int): FloatArray { - val r = colorEnc shr 16 - val g = (colorEnc shr 8) and 255 - val b = colorEnc and 255 - return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)) - } - - private fun srgbToLinear(colorEnc: Int): Float { - val v = colorEnc / 255f - return if (v <= 0.04045f) { - (v / 12.92f) - } else { - ((v + 0.055f) / 1.055f).pow(2.4f) + private fun decodeAc( + value: Int, + maxAc: Float, + ): FloatArray { + val r = value / (19 * 19) + val g = (value / 19) % 19 + val b = value % 19 + return floatArrayOf( + signedPow2((r - 9) / 9.0f) * maxAc, + signedPow2((g - 9) / 9.0f) * maxAc, + signedPow2((b - 9) / 9.0f) * maxAc, + ) } - } - private fun decodeAc( - value: Int, - maxAc: Float, - ): FloatArray { - val r = value / (19 * 19) - val g = (value / 19) % 19 - val b = value % 19 - return floatArrayOf( - signedPow2((r - 9) / 9.0f) * maxAc, - signedPow2((g - 9) / 9.0f) * maxAc, - signedPow2((b - 9) / 9.0f) * maxAc, - ) - } + private fun signedPow2(value: Float) = value.pow(2f).withSign(value) - private fun signedPow2(value: Float) = value.pow(2f).withSign(value) - - private fun composeBitmap( - width: Int, - height: Int, - numCompX: Int, - numCompY: Int, - colors: Array, - useCache: Boolean, - ): Bitmap { - // use an array for better performance when writing pixel colors - val imageArray = IntArray(width * height) - val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX) - val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX) - val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY) - val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY) - for (y in 0 until height) { - for (x in 0 until width) { - var r = 0f - var g = 0f - var b = 0f - for (j in 0 until numCompY) { - for (i in 0 until numCompX) { - val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width) - val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height) - val basis = (cosX * cosY).toFloat() - val color = colors[j * numCompX + i] - r += color[0] * basis - g += color[1] * basis - b += color[2] * basis - } + private fun composeBitmap( + width: Int, + height: Int, + numCompX: Int, + numCompY: Int, + colors: Array, + useCache: Boolean, + ): Bitmap { + // use an array for better performance when writing pixel colors + val imageArray = IntArray(width * height) + val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX) + val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX) + val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY) + val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY) + for (y in 0 until height) { + for (x in 0 until width) { + var r = 0f + var g = 0f + var b = 0f + for (j in 0 until numCompY) { + for (i in 0 until numCompX) { + val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width) + val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height) + val basis = (cosX * cosY).toFloat() + val color = colors[j * numCompX + i] + r += color[0] * basis + g += color[1] * basis + b += color[2] * basis + } + } + imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)) + } } - imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)) - } - } - return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888) - } - - private fun getArrayForCosinesY( - calculate: Boolean, - height: Int, - numCompY: Int, - ) = - when { - calculate -> { - DoubleArray(height * numCompY).also { cacheCosinesY[height * numCompY] = it } - } - else -> { - cacheCosinesY[height * numCompY]!! - } + return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888) } - private fun getArrayForCosinesX( - calculate: Boolean, - width: Int, - numCompX: Int, - ) = - when { - calculate -> { - DoubleArray(width * numCompX).also { cacheCosinesX[width * numCompX] = it } - } - else -> cacheCosinesX[width * numCompX]!! + private fun getArrayForCosinesY( + calculate: Boolean, + height: Int, + numCompY: Int, + ) = when { + calculate -> { + DoubleArray(height * numCompY).also { cacheCosinesY[height * numCompY] = it } + } + else -> { + cacheCosinesY[height * numCompY]!! + } } - private fun DoubleArray.getCos( - calculate: Boolean, - x: Int, - numComp: Int, - y: Int, - size: Int, - ): Double { - if (calculate) { - this[x + numComp * y] = cos(Math.PI * y * x / size) + private fun getArrayForCosinesX( + calculate: Boolean, + width: Int, + numCompX: Int, + ) = when { + calculate -> { + DoubleArray(width * numCompX).also { cacheCosinesX[width * numCompX] = it } + } + else -> cacheCosinesX[width * numCompX]!! } - return this[x + numComp * y] - } - private fun linearToSrgb(value: Float): Int { - val v = value.coerceIn(0f, 1f) - return if (v <= 0.0031308f) { - (v * 12.92f * 255f + 0.5f).toInt() - } else { - ((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt() + private fun DoubleArray.getCos( + calculate: Boolean, + x: Int, + numComp: Int, + y: Int, + size: Int, + ): Double { + if (calculate) { + this[x + numComp * y] = cos(Math.PI * y * x / size) + } + return this[x + numComp * y] } - } - private val charMap = - listOf( - '0', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - 'A', - 'B', - 'C', - 'D', - 'E', - 'F', - 'G', - 'H', - 'I', - 'J', - 'K', - 'L', - 'M', - 'N', - 'O', - 'P', - 'Q', - 'R', - 'S', - 'T', - 'U', - 'V', - 'W', - 'X', - 'Y', - 'Z', - 'a', - 'b', - 'c', - 'd', - 'e', - 'f', - 'g', - 'h', - 'i', - 'j', - 'k', - 'l', - 'm', - 'n', - 'o', - 'p', - 'q', - 'r', - 's', - 't', - 'u', - 'v', - 'w', - 'x', - 'y', - 'z', - '#', - '$', - '%', - '*', - '+', - ',', - '-', - '.', - ':', - ';', - '=', - '?', - '@', - '[', - ']', - '^', - '_', - '{', - '|', - '}', - '~', - ) - .mapIndexed { i, c -> c to i } - .toMap() + private fun linearToSrgb(value: Float): Int { + val v = value.coerceIn(0f, 1f) + return if (v <= 0.0031308f) { + (v * 12.92f * 255f + 0.5f).toInt() + } else { + ((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt() + } + } + + private val charMap = + listOf( + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'J', + 'K', + 'L', + 'M', + 'N', + 'O', + 'P', + 'Q', + 'R', + 'S', + 'T', + 'U', + 'V', + 'W', + 'X', + 'Y', + 'Z', + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'g', + 'h', + 'i', + 'j', + 'k', + 'l', + 'm', + 'n', + 'o', + 'p', + 'q', + 'r', + 's', + 't', + 'u', + 'v', + 'w', + 'x', + 'y', + 'z', + '#', + '$', + '%', + '*', + '+', + ',', + '-', + '.', + ':', + ';', + '=', + '?', + '@', + '[', + ']', + '^', + '_', + '{', + '|', + '}', + '~', + ) + .mapIndexed { i, c -> c to i } + .toMap() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt index dcf177cb0..94025bee9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt @@ -37,58 +37,58 @@ import kotlin.math.roundToInt @Stable class BlurHashFetcher( - private val options: Options, - private val data: Uri, + private val options: Options, + private val data: Uri, ) : Fetcher { - override suspend fun fetch(): FetchResult { - checkNotInMainThread() + override suspend fun fetch(): FetchResult { + checkNotInMainThread() - val encodedHash = data.toString().removePrefix("bluehash:") - val hash = URLDecoder.decode(encodedHash, "utf-8") + val encodedHash = data.toString().removePrefix("bluehash:") + val hash = URLDecoder.decode(encodedHash, "utf-8") - val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f + val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f - val preferredWidth = 100 + val preferredWidth = 100 - val bitmap = - BlurHashDecoder.decode( - hash, - preferredWidth, - (preferredWidth * (1 / aspectRatio)).roundToInt(), - ) + val bitmap = + BlurHashDecoder.decode( + hash, + preferredWidth, + (preferredWidth * (1 / aspectRatio)).roundToInt(), + ) - if (bitmap == null) { - throw Exception("Unable to convert Bluehash $hash") + if (bitmap == null) { + throw Exception("Unable to convert Bluehash $hash") + } + + return DrawableResult( + drawable = bitmap.toDrawable(options.context.resources), + isSampled = false, + dataSource = DataSource.MEMORY, + ) } - return DrawableResult( - drawable = bitmap.toDrawable(options.context.resources), - isSampled = false, - dataSource = DataSource.MEMORY, - ) - } - - object Factory : Fetcher.Factory { - override fun create( - data: Uri, - options: Options, - imageLoader: ImageLoader, - ): Fetcher { - return BlurHashFetcher(options, data) + object Factory : Fetcher.Factory { + override fun create( + data: Uri, + options: Options, + imageLoader: ImageLoader, + ): Fetcher { + return BlurHashFetcher(options, data) + } } - } } object BlurHashRequester { - fun imageRequest( - context: Context, - message: String, - ): ImageRequest { - val encodedMessage = URLEncoder.encode(message, "utf-8") + fun imageRequest( + context: Context, + message: String, + ): ImageRequest { + val encodedMessage = URLEncoder.encode(message, "utf-8") - return ImageRequest.Builder(context) - .data("bluehash:$encodedMessage") - .fetcherFactory(BlurHashFetcher.Factory) - .build() - } + return ImageRequest.Builder(context) + .data("bluehash:$encodedMessage") + .fetcherFactory(BlurHashFetcher.Factory) + .build() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt index c34368283..c6097c5a4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt @@ -35,7 +35,6 @@ import com.vitorpamplona.amethyst.ui.components.removeQueryParamsForExtensionCom import com.vitorpamplona.amethyst.ui.components.tagIndex import com.vitorpamplona.amethyst.ui.components.videoExtensions import com.vitorpamplona.quartz.events.ImmutableListOfLists -import java.util.regex.Pattern import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableSet @@ -43,33 +42,34 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableSet +import java.util.regex.Pattern @Immutable data class RichTextViewerState( - val urlSet: ImmutableSet, - val imagesForPager: ImmutableMap, - val imageList: ImmutableList, - val customEmoji: ImmutableMap, - val paragraphs: ImmutableList, + val urlSet: ImmutableSet, + val imagesForPager: ImmutableMap, + val imageList: ImmutableList, + val customEmoji: ImmutableMap, + val paragraphs: ImmutableList, ) data class ParagraphState(val words: ImmutableList, val isRTL: Boolean) object CachedRichTextParser { - val richTextCache = LruCache(200) + val richTextCache = LruCache(200) - fun parseText( - content: String, - tags: ImmutableListOfLists, - ): RichTextViewerState { - return if (richTextCache[content] != null) { - richTextCache[content] - } else { - val newUrls = RichTextParser().parseText(content, tags) - richTextCache.put(content, newUrls) - newUrls + fun parseText( + content: String, + tags: ImmutableListOfLists, + ): RichTextViewerState { + return if (richTextCache[content] != null) { + richTextCache[content] + } else { + val newUrls = RichTextParser().parseText(content, tags) + richTextCache.put(content, newUrls) + newUrls + } } - } } // Group 1 = url, group 4 additional chars @@ -78,236 +78,236 @@ object CachedRichTextParser { // Android9 seems to have an issue starting this regex. val noProtocolUrlValidator = - try { - Pattern.compile( - "(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)", - ) - } catch (e: Exception) { - Pattern.compile( - "(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)", - ) - } + try { + Pattern.compile( + "(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)", + ) + } catch (e: Exception) { + Pattern.compile( + "(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)", + ) + } val HTTPRegex = - "^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?" - .toRegex(RegexOption.IGNORE_CASE) + "^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?" + .toRegex(RegexOption.IGNORE_CASE) class RichTextParser() { - fun parseMediaUrl(fullUrl: String): ZoomableUrlContent? { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) - return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - val frags = Nip44UrlParser().parse(fullUrl) - ZoomableUrlImage( - url = fullUrl, - description = frags["alt"], - hash = frags["x"], - blurhash = frags["blurhash"], - dim = frags["dim"], - ) - } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { - val frags = Nip44UrlParser().parse(fullUrl) - ZoomableUrlVideo( - url = fullUrl, - description = frags["alt"], - hash = frags["x"], - blurhash = frags["blurhash"], - dim = frags["dim"], - ) - } else { - null - } - } - - fun parseText( - content: String, - tags: ImmutableListOfLists, - ): RichTextViewerState { - val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() - - val urlSet = - urls.mapNotNullTo(LinkedHashSet(urls.size)) { - // removes e-mails - if (Patterns.EMAIL_ADDRESS.matcher(it.originalUrl).matches()) { - null - } else if (isNumber(it.originalUrl)) { - null - } else if (it.originalUrl.contains("ใ€‚")) { - null + fun parseMediaUrl(fullUrl: String): ZoomableUrlContent? { + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) + return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { + val frags = Nip44UrlParser().parse(fullUrl) + ZoomableUrlImage( + url = fullUrl, + description = frags["alt"], + hash = frags["x"], + blurhash = frags["blurhash"], + dim = frags["dim"], + ) + } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { + val frags = Nip44UrlParser().parse(fullUrl) + ZoomableUrlVideo( + url = fullUrl, + description = frags["alt"], + hash = frags["x"], + blurhash = frags["blurhash"], + dim = frags["dim"], + ) } else { - if (HTTPRegex.matches(it.originalUrl)) { - it.originalUrl - } else { null - } } - } - - val imagesForPager = - urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl) }.associateBy { it.url } - val imageList = imagesForPager.values.toList() - - val emojiMap = - tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] } - - val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags) - - return RichTextViewerState( - urlSet.toImmutableSet(), - imagesForPager.toImmutableMap(), - imageList.toImmutableList(), - emojiMap.toImmutableMap(), - segments, - ) - } - - private fun findTextSegments( - content: String, - images: Set, - urls: Set, - emojis: Map, - tags: ImmutableListOfLists, - ): ImmutableList { - var paragraphSegments = persistentListOf() - - content.split('\n').forEach { paragraph -> - var segments = persistentListOf() - var isDirty = false - - val isRTL = isArabic(paragraph) - - val wordList = paragraph.trimEnd().split(' ') - wordList.forEach { word -> - val wordSegment = wordIdentifier(word, images, urls, emojis, tags) - if (wordSegment !is RegularTextSegment) { - isDirty = true - } - segments = segments.add(wordSegment) - } - - val newSegments = - if (isDirty) { - ParagraphState(segments, isRTL) - } else { - ParagraphState(persistentListOf(RegularTextSegment(paragraph)), isRTL) - } - - paragraphSegments = paragraphSegments.add(newSegments) } - return paragraphSegments - } + fun parseText( + content: String, + tags: ImmutableListOfLists, + ): RichTextViewerState { + val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() - fun isNumber(word: String): Boolean { - return numberPattern.matcher(word).matches() - } - - fun isDate(word: String): Boolean { - return shortDatePattern.matcher(word).matches() || longDatePattern.matcher(word).matches() - } - - private fun isArabic(text: String): Boolean { - return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' } - } - - private fun wordIdentifier( - word: String, - images: Set, - urls: Set, - emojis: Map, - tags: ImmutableListOfLists, - ): Segment { - val emailMatcher = Patterns.EMAIL_ADDRESS.matcher(word) - val phoneMatcher = Patterns.PHONE.matcher(word) - val schemelessMatcher = noProtocolUrlValidator.matcher(word) - - return if (word.isEmpty()) { - RegularTextSegment(word) - } else if (images.contains(word)) { - ImageSegment(word) - } else if (urls.contains(word)) { - LinkSegment(word) - } else if (emojis.any { word.contains(it.key) }) { - EmojiSegment(word) - } else if (word.startsWith("lnbc", true)) { - InvoiceSegment(word) - } else if (word.startsWith("lnurl", true)) { - WithdrawSegment(word) - } else if (word.startsWith("cashuA", true)) { - CashuSegment(word) - } else if (emailMatcher.matches()) { - EmailSegment(word) - } else if (word.length in 7..14 && !isDate(word) && phoneMatcher.matches()) { - PhoneSegment(word) - } else if (startsWithNIP19Scheme(word)) { - BechSegment(word) - } else if (word.startsWith("#")) { - parseHash(word, tags) - } else if (word.contains(".") && schemelessMatcher.find()) { - val url = schemelessMatcher.group(1) // url - val additionalChars = schemelessMatcher.group(4) // additional chars - val pattern = - """^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?""" - .toRegex(RegexOption.IGNORE_CASE) - if (pattern.find(word) != null) { - SchemelessUrlSegment(word, url, additionalChars) - } else { - RegularTextSegment(word) - } - } else { - RegularTextSegment(word) - } - } - - private fun parseHash( - word: String, - tags: ImmutableListOfLists, - ): Segment { - // First #[n] - - val matcher = tagIndex.matcher(word) - try { - if (matcher.find()) { - val index = matcher.group(1)?.toInt() - val suffix = matcher.group(2) - - if (index != null && index >= 0 && index < tags.lists.size) { - val tag = tags.lists[index] - - if (tag.size > 1) { - if (tag[0] == "p") { - return HashIndexUserSegment(word, tag[1], suffix) - } else if (tag[0] == "e" || tag[0] == "a") { - return HashIndexEventSegment(word, tag[1], suffix) + val urlSet = + urls.mapNotNullTo(LinkedHashSet(urls.size)) { + // removes e-mails + if (Patterns.EMAIL_ADDRESS.matcher(it.originalUrl).matches()) { + null + } else if (isNumber(it.originalUrl)) { + null + } else if (it.originalUrl.contains("ใ€‚")) { + null + } else { + if (HTTPRegex.matches(it.originalUrl)) { + it.originalUrl + } else { + null + } + } } - } - } - } - } catch (e: Exception) { - Log.w("Tag Parser", "Couldn't link tag $word", e) + + val imagesForPager = + urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl) }.associateBy { it.url } + val imageList = imagesForPager.values.toList() + + val emojiMap = + tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] } + + val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags) + + return RichTextViewerState( + urlSet.toImmutableSet(), + imagesForPager.toImmutableMap(), + imageList.toImmutableList(), + emojiMap.toImmutableMap(), + segments, + ) } - // Second #Amethyst - val hashtagMatcher = hashTagsPattern.matcher(word) + private fun findTextSegments( + content: String, + images: Set, + urls: Set, + emojis: Map, + tags: ImmutableListOfLists, + ): ImmutableList { + var paragraphSegments = persistentListOf() - try { - if (hashtagMatcher.find()) { - val hashtag = hashtagMatcher.group(1) - if (hashtag != null) { - return HashTagSegment(word, hashtag, hashtagMatcher.group(2)) + content.split('\n').forEach { paragraph -> + var segments = persistentListOf() + var isDirty = false + + val isRTL = isArabic(paragraph) + + val wordList = paragraph.trimEnd().split(' ') + wordList.forEach { word -> + val wordSegment = wordIdentifier(word, images, urls, emojis, tags) + if (wordSegment !is RegularTextSegment) { + isDirty = true + } + segments = segments.add(wordSegment) + } + + val newSegments = + if (isDirty) { + ParagraphState(segments, isRTL) + } else { + ParagraphState(persistentListOf(RegularTextSegment(paragraph)), isRTL) + } + + paragraphSegments = paragraphSegments.add(newSegments) } - } - } catch (e: Exception) { - Log.e("Hashtag Parser", "Couldn't link hashtag $word", e) + + return paragraphSegments } - return RegularTextSegment(word) - } + fun isNumber(word: String): Boolean { + return numberPattern.matcher(word).matches() + } - companion object { - val longDatePattern: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$") - val shortDatePattern: Pattern = Pattern.compile("^\\d{2}-\\d{2}-\\d{2}$") - val numberPattern: Pattern = Pattern.compile("^(-?[\\d.]+)([a-zA-Z%]*)$") - } + fun isDate(word: String): Boolean { + return shortDatePattern.matcher(word).matches() || longDatePattern.matcher(word).matches() + } + + private fun isArabic(text: String): Boolean { + return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' } + } + + private fun wordIdentifier( + word: String, + images: Set, + urls: Set, + emojis: Map, + tags: ImmutableListOfLists, + ): Segment { + val emailMatcher = Patterns.EMAIL_ADDRESS.matcher(word) + val phoneMatcher = Patterns.PHONE.matcher(word) + val schemelessMatcher = noProtocolUrlValidator.matcher(word) + + return if (word.isEmpty()) { + RegularTextSegment(word) + } else if (images.contains(word)) { + ImageSegment(word) + } else if (urls.contains(word)) { + LinkSegment(word) + } else if (emojis.any { word.contains(it.key) }) { + EmojiSegment(word) + } else if (word.startsWith("lnbc", true)) { + InvoiceSegment(word) + } else if (word.startsWith("lnurl", true)) { + WithdrawSegment(word) + } else if (word.startsWith("cashuA", true)) { + CashuSegment(word) + } else if (emailMatcher.matches()) { + EmailSegment(word) + } else if (word.length in 7..14 && !isDate(word) && phoneMatcher.matches()) { + PhoneSegment(word) + } else if (startsWithNIP19Scheme(word)) { + BechSegment(word) + } else if (word.startsWith("#")) { + parseHash(word, tags) + } else if (word.contains(".") && schemelessMatcher.find()) { + val url = schemelessMatcher.group(1) // url + val additionalChars = schemelessMatcher.group(4) // additional chars + val pattern = + """^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?""" + .toRegex(RegexOption.IGNORE_CASE) + if (pattern.find(word) != null) { + SchemelessUrlSegment(word, url, additionalChars) + } else { + RegularTextSegment(word) + } + } else { + RegularTextSegment(word) + } + } + + private fun parseHash( + word: String, + tags: ImmutableListOfLists, + ): Segment { + // First #[n] + + val matcher = tagIndex.matcher(word) + try { + if (matcher.find()) { + val index = matcher.group(1)?.toInt() + val suffix = matcher.group(2) + + if (index != null && index >= 0 && index < tags.lists.size) { + val tag = tags.lists[index] + + if (tag.size > 1) { + if (tag[0] == "p") { + return HashIndexUserSegment(word, tag[1], suffix) + } else if (tag[0] == "e" || tag[0] == "a") { + return HashIndexEventSegment(word, tag[1], suffix) + } + } + } + } + } catch (e: Exception) { + Log.w("Tag Parser", "Couldn't link tag $word", e) + } + + // Second #Amethyst + val hashtagMatcher = hashTagsPattern.matcher(word) + + try { + if (hashtagMatcher.find()) { + val hashtag = hashtagMatcher.group(1) + if (hashtag != null) { + return HashTagSegment(word, hashtag, hashtagMatcher.group(2)) + } + } + } catch (e: Exception) { + Log.e("Hashtag Parser", "Couldn't link hashtag $word", e) + } + + return RegularTextSegment(word) + } + + companion object { + val longDatePattern: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$") + val shortDatePattern: Pattern = Pattern.compile("^\\d{2}-\\d{2}-\\d{2}$") + val numberPattern: Pattern = Pattern.compile("^(-?[\\d.]+)([a-zA-Z%]*)$") + } } @Immutable open class Segment(val segmentText: String) @@ -332,27 +332,27 @@ class RichTextParser() { @Immutable open class HashIndexSegment(segment: String, val hex: String, val extras: String?) : - Segment(segment) + Segment(segment) @Immutable class HashIndexUserSegment(segment: String, hex: String, extras: String?) : - HashIndexSegment(segment, hex, extras) + HashIndexSegment(segment, hex, extras) @Immutable class HashIndexEventSegment(segment: String, hex: String, extras: String?) : - HashIndexSegment(segment, hex, extras) + HashIndexSegment(segment, hex, extras) @Immutable class HashTagSegment(segment: String, val hashtag: String, val extras: String?) : Segment(segment) @Immutable class SchemelessUrlSegment(segment: String, val url: String, val extras: String?) : - Segment(segment) + Segment(segment) @Immutable class RegularTextSegment(segment: String) : Segment(segment) fun startsWithNIP19Scheme(word: String): Boolean { - val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@") + val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@") - return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) } + return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt index 8a97bf34b..f19a4cf6a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt @@ -28,193 +28,193 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.quartz.events.Event -import java.util.Base64 import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import java.util.Base64 @Immutable data class CashuToken( - val token: String, - val mint: String, - val totalAmount: Long, - val proofs: JsonNode, + val token: String, + val mint: String, + val totalAmount: Long, + val proofs: JsonNode, ) class CashuProcessor { - fun parse(cashuToken: String): GenericLoadable { - checkNotInMainThread() + fun parse(cashuToken: String): GenericLoadable { + checkNotInMainThread() - try { - val base64token = cashuToken.replace("cashuA", "") - val cashu = jacksonObjectMapper().readTree(String(Base64.getDecoder().decode(base64token))) - val token = cashu.get("token").get(0) - val proofs = token.get("proofs") - val mint = token.get("mint").asText() + try { + val base64token = cashuToken.replace("cashuA", "") + val cashu = jacksonObjectMapper().readTree(String(Base64.getDecoder().decode(base64token))) + val token = cashu.get("token").get(0) + val proofs = token.get("proofs") + val mint = token.get("mint").asText() - var totalAmount = 0L - for (proof in proofs) { - totalAmount += proof.get("amount").asLong() - } + var totalAmount = 0L + for (proof in proofs) { + totalAmount += proof.get("amount").asLong() + } - return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs)) - } catch (e: Exception) { - return GenericLoadable.Error("Could not parse this cashu token") + return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs)) + } catch (e: Exception) { + return GenericLoadable.Error("Could not parse this cashu token") + } } - } - suspend fun melt( - token: CashuToken, - lud16: String, - onSuccess: (String, String) -> Unit, - onError: (String, String) -> Unit, - context: Context, - ) { - checkNotInMainThread() + suspend fun melt( + token: CashuToken, + lud16: String, + onSuccess: (String, String) -> Unit, + onError: (String, String) -> Unit, + context: Context, + ) { + checkNotInMainThread() - runCatching { - LightningAddressResolver() - .lnAddressInvoice( - lnaddress = lud16, - // Make invoice and leave room for fees - milliSats = token.totalAmount * 1000, - message = "Calculate Fees for Cashu", - onSuccess = { baseInvoice -> - feeCalculator( - token.mint, - baseInvoice, - onSuccess = { fees -> - LightningAddressResolver() - .lnAddressInvoice( + runCatching { + LightningAddressResolver() + .lnAddressInvoice( lnaddress = lud16, // Make invoice and leave room for fees - milliSats = (token.totalAmount - fees) * 1000, - message = "Redeem Cashu", - onSuccess = { invoice -> - meltInvoice(token, invoice, fees, onSuccess, onError, context) + milliSats = token.totalAmount * 1000, + message = "Calculate Fees for Cashu", + onSuccess = { baseInvoice -> + feeCalculator( + token.mint, + baseInvoice, + onSuccess = { fees -> + LightningAddressResolver() + .lnAddressInvoice( + lnaddress = lud16, + // Make invoice and leave room for fees + milliSats = (token.totalAmount - fees) * 1000, + message = "Redeem Cashu", + onSuccess = { invoice -> + meltInvoice(token, invoice, fees, onSuccess, onError, context) + }, + onProgress = {}, + onError = onError, + context = context, + ) + }, + onError = onError, + context, + ) }, onProgress = {}, onError = onError, context = context, - ) - }, - onError = onError, - context, + ) + } + } + + fun feeCalculator( + mintAddress: String, + invoice: String, + onSuccess: (Int) -> Unit, + onError: (String, String) -> Unit, + context: Context, + ) { + checkNotInMainThread() + + try { + val client = HttpClient.getHttpClient() + val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint + + val factory = Event.mapper.nodeFactory + + val jsonObject = factory.objectNode() + jsonObject.put("pr", invoice) + + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = jsonObject.toString().toRequestBody(mediaType) + val request = Request.Builder().url(url).post(requestBody).build() + + client.newCall(request).execute().use { + val body = it.body.string() + val tree = jacksonObjectMapper().readTree(body) + + val feeCost = tree?.get("fee")?.asInt() + + if (feeCost != null) { + onSuccess( + feeCost, + ) + } else { + val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null } + onError( + context.getString(R.string.cashu_failed_redemption), + if (msg != null) { + context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg) + } else { + context.getString(R.string.cashu_failed_redemption_explainer_error_msg) + }, + ) + } + } + } catch (e: Exception) { + onError( + context.getString(R.string.cashu_successful_redemption), + context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message), ) - }, - onProgress = {}, - onError = onError, - context = context, - ) - } - } - - fun feeCalculator( - mintAddress: String, - invoice: String, - onSuccess: (Int) -> Unit, - onError: (String, String) -> Unit, - context: Context, - ) { - checkNotInMainThread() - - try { - val client = HttpClient.getHttpClient() - val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint - - val factory = Event.mapper.nodeFactory - - val jsonObject = factory.objectNode() - jsonObject.put("pr", invoice) - - val mediaType = "application/json; charset=utf-8".toMediaType() - val requestBody = jsonObject.toString().toRequestBody(mediaType) - val request = Request.Builder().url(url).post(requestBody).build() - - client.newCall(request).execute().use { - val body = it.body.string() - val tree = jacksonObjectMapper().readTree(body) - - val feeCost = tree?.get("fee")?.asInt() - - if (feeCost != null) { - onSuccess( - feeCost, - ) - } else { - val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null } - onError( - context.getString(R.string.cashu_failed_redemption), - if (msg != null) { - context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg) - } else { - context.getString(R.string.cashu_failed_redemption_explainer_error_msg) - }, - ) } - } - } catch (e: Exception) { - onError( - context.getString(R.string.cashu_successful_redemption), - context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message), - ) } - } - private fun meltInvoice( - token: CashuToken, - invoice: String, - fees: Int, - onSuccess: (String, String) -> Unit, - onError: (String, String) -> Unit, - context: Context, - ) { - try { - val client = HttpClient.getHttpClient() - val url = token.mint + "/melt" // Melt cashu tokens at Mint + private fun meltInvoice( + token: CashuToken, + invoice: String, + fees: Int, + onSuccess: (String, String) -> Unit, + onError: (String, String) -> Unit, + context: Context, + ) { + try { + val client = HttpClient.getHttpClient() + val url = token.mint + "/melt" // Melt cashu tokens at Mint - val factory = Event.mapper.nodeFactory + val factory = Event.mapper.nodeFactory - val jsonObject = factory.objectNode() - jsonObject.put("proofs", token.proofs) - jsonObject.put("pr", invoice) + val jsonObject = factory.objectNode() + jsonObject.put("proofs", token.proofs) + jsonObject.put("pr", invoice) - val mediaType = "application/json; charset=utf-8".toMediaType() - val requestBody = jsonObject.toString().toRequestBody(mediaType) - val request = Request.Builder().url(url).post(requestBody).build() + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = jsonObject.toString().toRequestBody(mediaType) + val request = Request.Builder().url(url).post(requestBody).build() - client.newCall(request).execute().use { - val body = it.body.string() - val tree = jacksonObjectMapper().readTree(body) + client.newCall(request).execute().use { + val body = it.body.string() + val tree = jacksonObjectMapper().readTree(body) - val successful = tree?.get("paid")?.asText() == "true" + val successful = tree?.get("paid")?.asText() == "true" - if (successful) { - onSuccess( - context.getString(R.string.cashu_successful_redemption), - context.getString( - R.string.cashu_successful_redemption_explainer, - token.totalAmount.toString(), - fees.toString(), - ), - ) - } else { - val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null } - onError( - context.getString(R.string.cashu_failed_redemption), - if (msg != null) { - context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg) - } else { - context.getString(R.string.cashu_failed_redemption_explainer_error_msg) - }, - ) + if (successful) { + onSuccess( + context.getString(R.string.cashu_successful_redemption), + context.getString( + R.string.cashu_successful_redemption_explainer, + token.totalAmount.toString(), + fees.toString(), + ), + ) + } else { + val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null } + onError( + context.getString(R.string.cashu_failed_redemption), + if (msg != null) { + context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg) + } else { + context.getString(R.string.cashu_failed_redemption_explainer_error_msg) + }, + ) + } + } + } catch (e: Exception) { + onError( + context.getString(R.string.cashu_successful_redemption), + context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message), + ) } - } - } catch (e: Exception) { - onError( - context.getString(R.string.cashu_successful_redemption), - context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message), - ) } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt index 8a83c6bcf..5ee9de38b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt @@ -23,106 +23,109 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.quartz.events.ImmutableListOfLists fun String.isUTF16Char(pos: Int): Boolean { - return Character.charCount(this.codePointAt(pos)) == 2 + return Character.charCount(this.codePointAt(pos)) == 2 } fun String.firstFullCharOld(): String { - return when (this.length) { - 0, - 1, -> return this - 2, - 3, -> return if (isUTF16Char(0)) this.take(2) else this.take(1) - else -> { - val first = isUTF16Char(0) - val second = isUTF16Char(2) - if (first && second) { - this.take(4) - } else if (first) { - this.take(2) - } else { - this.take(1) - } + return when (this.length) { + 0, + 1, + -> return this + 2, + 3, + -> return if (isUTF16Char(0)) this.take(2) else this.take(1) + else -> { + val first = isUTF16Char(0) + val second = isUTF16Char(2) + if (first && second) { + this.take(4) + } else if (first) { + this.take(2) + } else { + this.take(1) + } + } } - } } fun String.firstFullChar(): String { - var isInJoin = false - var hasHadSecondChance = false - var start = 0 - var previousCharLength = 0 - var next: Int - var codePoint: Int + var isInJoin = false + var hasHadSecondChance = false + var start = 0 + var previousCharLength = 0 + var next: Int + var codePoint: Int - var i = 0 + var i = 0 - while (i < this.length) { - codePoint = codePointAt(i) + while (i < this.length) { + codePoint = codePointAt(i) - // Skips if it starts with the join char 0x200D - if (codePoint == 0x200D && previousCharLength == 0) { - next = offsetByCodePoints(i, 1) - start = next - } else { - // If join, searches for the next char - if (codePoint == 0xFE0F) {} else if (codePoint == 0x200D) { - isInJoin = true - } else { - // stops when two chars are not joined together - if (previousCharLength > 0 && !isInJoin) { - if (Character.charCount(codePoint) == 1 || hasHadSecondChance) { - break - } else { - hasHadSecondChance = true - } + // Skips if it starts with the join char 0x200D + if (codePoint == 0x200D && previousCharLength == 0) { + next = offsetByCodePoints(i, 1) + start = next } else { - hasHadSecondChance = false + // If join, searches for the next char + if (codePoint == 0xFE0F) { + } else if (codePoint == 0x200D) { + isInJoin = true + } else { + // stops when two chars are not joined together + if (previousCharLength > 0 && !isInJoin) { + if (Character.charCount(codePoint) == 1 || hasHadSecondChance) { + break + } else { + hasHadSecondChance = true + } + } else { + hasHadSecondChance = false + } + + isInJoin = false + } + + // next char to evaluate + next = offsetByCodePoints(i, 1) + previousCharLength += (next - i) } - isInJoin = false - } - - // next char to evaluate - next = offsetByCodePoints(i, 1) - previousCharLength += (next - i) + i = next } - i = next - } + // if ends in join, then seachers backwards until a char is found. + if (isInJoin) { + i = previousCharLength - 1 + while (i > 0) { + if (this[i].code == 0x200D) { + previousCharLength -= 1 + } else { + break + } - // if ends in join, then seachers backwards until a char is found. - if (isInJoin) { - i = previousCharLength - 1 - while (i > 0) { - if (this[i].code == 0x200D) { - previousCharLength -= 1 - } else { - break - } - - i -= 1 + i -= 1 + } } - } - return substring(start, start + previousCharLength) + return substring(start, start + previousCharLength) } fun String.firstFullCharOrEmoji(tags: ImmutableListOfLists): String { - if (length <= 2) { - return firstFullChar() - } - - if (this[0] == ':') { - // makes sure an emoji exists - val emojiParts = this.split(":", limit = 3) - if (emojiParts.size >= 2) { - val emojiName = emojiParts[1] - val emojiUrl = tags.lists.firstOrNull { it.size > 1 && it[1] == emojiName }?.getOrNull(2) - if (emojiUrl != null) { - return ":$emojiName:$emojiUrl" - } + if (length <= 2) { + return firstFullChar() } - } - return firstFullChar() + if (this[0] == ':') { + // makes sure an emoji exists + val emojiParts = this.split(":", limit = 3) + if (emojiParts.size >= 2) { + val emojiName = emojiParts[1] + val emojiUrl = tags.lists.firstOrNull { it.size > 1 && it[1] == emojiName }?.getOrNull(2) + if (emojiUrl != null) { + return ":$emojiName:$emojiUrl" + } + } + } + + return firstFullChar() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt index 2e126c033..f763482d0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt @@ -36,229 +36,230 @@ import java.io.IOException import kotlin.math.roundToInt class FileHeader( - val mimeType: String?, - val hash: String, - val size: Int, - val dim: String?, - val blurHash: String?, + val mimeType: String?, + val hash: String, + val size: Int, + val dim: String?, + val blurHash: String?, ) { - companion object { - suspend fun prepare( - fileUrl: String, - mimeType: String?, - dimPrecomputed: String?, - onReady: (FileHeader) -> Unit, - onError: (String?) -> Unit, - ) { - try { - val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl) + companion object { + suspend fun prepare( + fileUrl: String, + mimeType: String?, + dimPrecomputed: String?, + onReady: (FileHeader) -> Unit, + onError: (String?) -> Unit, + ) { + try { + val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl) - if (imageData != null) { - prepare(imageData, mimeType, dimPrecomputed, onReady, onError) - } else { - onError(null) - } - } catch (e: Exception) { - Log.e("ImageDownload", "Couldn't download image from server: ${e.message}") - onError(e.message) - } - } - - fun prepare( - data: ByteArray, - mimeType: String?, - dimPrecomputed: String?, - onReady: (FileHeader) -> Unit, - onError: (String?) -> Unit, - ) { - try { - val hash = CryptoUtils.sha256(data).toHexKey() - val size = data.size - - val (blurHash, dim) = - if (mimeType?.startsWith("image/") == true) { - val opt = BitmapFactory.Options() - opt.inPreferredConfig = Bitmap.Config.ARGB_8888 - val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt) - - val intArray = IntArray(mBitmap.width * mBitmap.height) - mBitmap.getPixels( - intArray, - 0, - mBitmap.width, - 0, - 0, - mBitmap.width, - mBitmap.height, - ) - - val dim = "${mBitmap.width}x${mBitmap.height}" - - val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat() - - if (aspectRatio > 1) { - Pair( - BlurHash.encode( - intArray, - mBitmap.width, - mBitmap.height, - 9, - (9 * (1 / aspectRatio)).roundToInt(), - ), - dim, - ) - } else if (aspectRatio < 1) { - Pair( - BlurHash.encode( - intArray, - mBitmap.width, - mBitmap.height, - (9 * aspectRatio).roundToInt(), - 9, - ), - dim, - ) - } else { - Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim) - } - } else if (mimeType?.startsWith("video/") == true) { - val mediaMetadataRetriever = MediaMetadataRetriever() - mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data)) - - val newDim = mediaMetadataRetriever.prepareDimFromVideo() ?: dimPrecomputed - - val blurhash = - mediaMetadataRetriever.getThumbnail()?.let { thumbnail -> - val aspectRatio = (thumbnail.width).toFloat() / (thumbnail.height).toFloat() - - val intArray = IntArray(thumbnail.width * thumbnail.height) - thumbnail.getPixels( - intArray, - 0, - thumbnail.width, - 0, - 0, - thumbnail.width, - thumbnail.height, - ) - - if (aspectRatio > 1) { - BlurHash.encode( - intArray, - thumbnail.width, - thumbnail.height, - 9, - (9 * (1 / aspectRatio)).roundToInt(), - ) - } else if (aspectRatio < 1) { - BlurHash.encode( - intArray, - thumbnail.width, - thumbnail.height, - (9 * aspectRatio).roundToInt(), - 9, - ) + if (imageData != null) { + prepare(imageData, mimeType, dimPrecomputed, onReady, onError) } else { - BlurHash.encode(intArray, thumbnail.width, thumbnail.height, 4, 4) + onError(null) } - } - - if (newDim != "0x0") { - Pair(blurhash, newDim) - } else { - Pair(blurhash, null) + } catch (e: Exception) { + Log.e("ImageDownload", "Couldn't download image from server: ${e.message}") + onError(e.message) } - } else { - Pair(null, null) - } + } - onReady(FileHeader(mimeType, hash, size, dim, blurHash)) - } catch (e: Exception) { - Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}") - onError(e.message) - } + fun prepare( + data: ByteArray, + mimeType: String?, + dimPrecomputed: String?, + onReady: (FileHeader) -> Unit, + onError: (String?) -> Unit, + ) { + try { + val hash = CryptoUtils.sha256(data).toHexKey() + val size = data.size + + val (blurHash, dim) = + if (mimeType?.startsWith("image/") == true) { + val opt = BitmapFactory.Options() + opt.inPreferredConfig = Bitmap.Config.ARGB_8888 + val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt) + + val intArray = IntArray(mBitmap.width * mBitmap.height) + mBitmap.getPixels( + intArray, + 0, + mBitmap.width, + 0, + 0, + mBitmap.width, + mBitmap.height, + ) + + val dim = "${mBitmap.width}x${mBitmap.height}" + + val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat() + + if (aspectRatio > 1) { + Pair( + BlurHash.encode( + intArray, + mBitmap.width, + mBitmap.height, + 9, + (9 * (1 / aspectRatio)).roundToInt(), + ), + dim, + ) + } else if (aspectRatio < 1) { + Pair( + BlurHash.encode( + intArray, + mBitmap.width, + mBitmap.height, + (9 * aspectRatio).roundToInt(), + 9, + ), + dim, + ) + } else { + Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim) + } + } else if (mimeType?.startsWith("video/") == true) { + val mediaMetadataRetriever = MediaMetadataRetriever() + mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data)) + + val newDim = mediaMetadataRetriever.prepareDimFromVideo() ?: dimPrecomputed + + val blurhash = + mediaMetadataRetriever.getThumbnail()?.let { thumbnail -> + val aspectRatio = (thumbnail.width).toFloat() / (thumbnail.height).toFloat() + + val intArray = IntArray(thumbnail.width * thumbnail.height) + thumbnail.getPixels( + intArray, + 0, + thumbnail.width, + 0, + 0, + thumbnail.width, + thumbnail.height, + ) + + if (aspectRatio > 1) { + BlurHash.encode( + intArray, + thumbnail.width, + thumbnail.height, + 9, + (9 * (1 / aspectRatio)).roundToInt(), + ) + } else if (aspectRatio < 1) { + BlurHash.encode( + intArray, + thumbnail.width, + thumbnail.height, + (9 * aspectRatio).roundToInt(), + 9, + ) + } else { + BlurHash.encode(intArray, thumbnail.width, thumbnail.height, 4, 4) + } + } + + if (newDim != "0x0") { + Pair(blurhash, newDim) + } else { + Pair(blurhash, null) + } + } else { + Pair(null, null) + } + + onReady(FileHeader(mimeType, hash, size, dim, blurHash)) + } catch (e: Exception) { + Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}") + onError(e.message) + } + } } - } } fun MediaMetadataRetriever.getThumbnail(): Bitmap? { - val raw: ByteArray? = getEmbeddedPicture() - if (raw != null) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - return ImageDecoder.decodeBitmap(ImageDecoder.createSource(raw)) + val raw: ByteArray? = getEmbeddedPicture() + if (raw != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + return ImageDecoder.decodeBitmap(ImageDecoder.createSource(raw)) + } } - } - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - val params = BitmapParams() - params.preferredConfig = Bitmap.Config.ARGB_8888 + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val params = BitmapParams() + params.preferredConfig = Bitmap.Config.ARGB_8888 - // Fall back to middle of video - // Note: METADATA_KEY_DURATION unit is in ms, not us. - val thumbnailTimeUs: Long = - (extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0) * 1000 / 2 + // Fall back to middle of video + // Note: METADATA_KEY_DURATION unit is in ms, not us. + val thumbnailTimeUs: Long = + (extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0) * 1000 / 2 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - getFrameAtTime(thumbnailTimeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC, params) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + getFrameAtTime(thumbnailTimeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC, params) + } else { + null + } } else { - null + null } - } else { - null - } } fun MediaMetadataRetriever.prepareDimFromVideo(): String? { - val width = prepareVideoWidth() ?: return null - val height = prepareVideoHeight() ?: return null + val width = prepareVideoWidth() ?: return null + val height = prepareVideoHeight() ?: return null - return "${width}x$height" + return "${width}x$height" } fun MediaMetadataRetriever.prepareVideoWidth(): Int? { - val widthData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) - return if (widthData.isNullOrEmpty()) { - null - } else { - widthData.toInt() - } + val widthData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) + return if (widthData.isNullOrEmpty()) { + null + } else { + widthData.toInt() + } } fun MediaMetadataRetriever.prepareVideoHeight(): Int? { - val heightData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) - return if (heightData.isNullOrEmpty()) { - null - } else { - heightData.toInt() - } + val heightData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) + return if (heightData.isNullOrEmpty()) { + null + } else { + heightData.toInt() + } } class ByteArrayMediaDataSource(var imageData: ByteArray) : MediaDataSource() { - override fun getSize(): Long { - return imageData.size.toLong() - } - - @Throws(IOException::class) - override fun readAt( - position: Long, - buffer: ByteArray, - offset: Int, - size: Int, - ): Int { - if (position >= imageData.size) { - return -1 + override fun getSize(): Long { + return imageData.size.toLong() } - val newSize = - if (position + size > imageData.size) { - size - ((position.toInt() + size) - imageData.size) - } else { - size - } - imageData.copyInto(buffer, offset, position.toInt(), position.toInt() + newSize) + @Throws(IOException::class) + override fun readAt( + position: Long, + buffer: ByteArray, + offset: Int, + size: Int, + ): Int { + if (position >= imageData.size) { + return -1 + } + val newSize = + if (position + size > imageData.size) { + size - ((position.toInt() + size) - imageData.size) + } else { + size + } - return newSize - } + imageData.copyInto(buffer, offset, position.toInt(), position.toInt() + newSize) - @Throws(IOException::class) override fun close() {} + return newSize + } + + @Throws(IOException::class) + override fun close() {} } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/HttpClient.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/HttpClient.kt index 40b731b5e..7645f8e1e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/HttpClient.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/HttpClient.kt @@ -22,98 +22,98 @@ package com.vitorpamplona.amethyst.service import android.util.Log import com.vitorpamplona.amethyst.BuildConfig +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response import java.io.IOException import java.net.InetSocketAddress import java.net.Proxy import java.time.Duration import kotlin.properties.Delegates -import okhttp3.Interceptor -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response object HttpClient { - val DEFAULT_TIMEOUT_ON_WIFI = Duration.ofSeconds(10L) - val DEFAULT_TIMEOUT_ON_MOBILE = Duration.ofSeconds(30L) + val DEFAULT_TIMEOUT_ON_WIFI = Duration.ofSeconds(10L) + val DEFAULT_TIMEOUT_ON_MOBILE = Duration.ofSeconds(30L) - var proxyChangeListeners = ArrayList<() -> Unit>() - var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI + var proxyChangeListeners = ArrayList<() -> Unit>() + var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI - var defaultHttpClient: OkHttpClient? = null + var defaultHttpClient: OkHttpClient? = null - // fires off every time value of the property changes - private var internalProxy: Proxy? by - Delegates.observable(null) { _, oldValue, newValue -> - if (oldValue != newValue) { - proxyChangeListeners.forEach { it() } - } + // fires off every time value of the property changes + private var internalProxy: Proxy? by + Delegates.observable(null) { _, oldValue, newValue -> + if (oldValue != newValue) { + proxyChangeListeners.forEach { it() } + } + } + + fun start(proxy: Proxy?) { + if (internalProxy != proxy) { + this.internalProxy = proxy + this.defaultHttpClient = getHttpClient() + } } - fun start(proxy: Proxy?) { - if (internalProxy != proxy) { - this.internalProxy = proxy - this.defaultHttpClient = getHttpClient() + fun changeTimeouts(timeout: Duration) { + Log.d("HttpClient", "Changing timeout to: $timeout") + if (this.defaultTimeout.seconds != timeout.seconds) { + this.defaultTimeout = timeout + this.defaultHttpClient = getHttpClient() + } } - } - fun changeTimeouts(timeout: Duration) { - Log.d("HttpClient", "Changing timeout to: $timeout") - if (this.defaultTimeout.seconds != timeout.seconds) { - this.defaultTimeout = timeout - this.defaultHttpClient = getHttpClient() + fun getHttpClient(timeout: Duration): OkHttpClient { + val seconds = if (internalProxy != null) timeout.seconds * 2 else timeout.seconds + val duration = Duration.ofSeconds(seconds) + return OkHttpClient.Builder() + .proxy(internalProxy) + .readTimeout(duration) + .connectTimeout(duration) + .writeTimeout(duration) + .addInterceptor(DefaultContentTypeInterceptor()) + .followRedirects(true) + .followSslRedirects(true) + .build() } - } - fun getHttpClient(timeout: Duration): OkHttpClient { - val seconds = if (internalProxy != null) timeout.seconds * 2 else timeout.seconds - val duration = Duration.ofSeconds(seconds) - return OkHttpClient.Builder() - .proxy(internalProxy) - .readTimeout(duration) - .connectTimeout(duration) - .writeTimeout(duration) - .addInterceptor(DefaultContentTypeInterceptor()) - .followRedirects(true) - .followSslRedirects(true) - .build() - } - - class DefaultContentTypeInterceptor : Interceptor { - @Throws(IOException::class) - override fun intercept(chain: Interceptor.Chain): Response { - val originalRequest: Request = chain.request() - val requestWithUserAgent: Request = - originalRequest - .newBuilder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .build() - return chain.proceed(requestWithUserAgent) + class DefaultContentTypeInterceptor : Interceptor { + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest: Request = chain.request() + val requestWithUserAgent: Request = + originalRequest + .newBuilder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .build() + return chain.proceed(requestWithUserAgent) + } } - } - fun getHttpClientForRelays(): OkHttpClient { - if (this.defaultHttpClient == null) { - this.defaultHttpClient = getHttpClient(defaultTimeout) + fun getHttpClientForRelays(): OkHttpClient { + if (this.defaultHttpClient == null) { + this.defaultHttpClient = getHttpClient(defaultTimeout) + } + return defaultHttpClient!! } - return defaultHttpClient!! - } - fun getHttpClient(): OkHttpClient { - if (this.defaultHttpClient == null) { - this.defaultHttpClient = getHttpClient(defaultTimeout) + fun getHttpClient(): OkHttpClient { + if (this.defaultHttpClient == null) { + this.defaultHttpClient = getHttpClient(defaultTimeout) + } + return defaultHttpClient!! } - return defaultHttpClient!! - } - fun getProxy(): Proxy? { - return internalProxy - } + fun getProxy(): Proxy? { + return internalProxy + } - fun initProxy( - useProxy: Boolean, - hostname: String, - port: Int, - ): Proxy? { - return if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress(hostname, port)) else null - } + fun initProxy( + useProxy: Boolean, + hostname: String, + port: Int, + ): Proxy? { + return if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress(hostname, port)) else null + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt index 739af3b5b..f634b08a6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt @@ -32,81 +32,81 @@ import androidx.compose.runtime.mutableStateOf import kotlinx.coroutines.flow.MutableStateFlow class LocationUtil(context: Context) { - companion object { - const val MIN_TIME: Long = 1000L - const val MIN_DISTANCE: Float = 0.0f - } - - private val locationManager = - context.getSystemService(Context.LOCATION_SERVICE) as LocationManager - private var locationListener: LocationListener? = null - - val locationStateFlow = MutableStateFlow(Location(LocationManager.NETWORK_PROVIDER)) - val providerState = mutableStateOf(false) - val isStart: MutableState = mutableStateOf(false) - - private val locHandlerThread = HandlerThread("LocationUtil Thread") - - init { - locHandlerThread.start() - } - - @SuppressLint("MissingPermission") - fun start( - minTimeMs: Long = MIN_TIME, - minDistanceM: Float = MIN_DISTANCE, - ) { - locationListener().let { - locationListener = it - locationManager.requestLocationUpdates( - LocationManager.NETWORK_PROVIDER, - minTimeMs, - minDistanceM, - it, - locHandlerThread.looper, - ) + companion object { + const val MIN_TIME: Long = 1000L + const val MIN_DISTANCE: Float = 0.0f } - providerState.value = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) - isStart.value = true - } - fun stop() { - locationListener?.let { locationManager.removeUpdates(it) } - isStart.value = false - } + private val locationManager = + context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + private var locationListener: LocationListener? = null - private fun locationListener() = - object : LocationListener { - override fun onLocationChanged(location: Location) { - locationStateFlow.value = location - } + val locationStateFlow = MutableStateFlow(Location(LocationManager.NETWORK_PROVIDER)) + val providerState = mutableStateOf(false) + val isStart: MutableState = mutableStateOf(false) - override fun onProviderEnabled(provider: String) { - providerState.value = true - } + private val locHandlerThread = HandlerThread("LocationUtil Thread") - override fun onProviderDisabled(provider: String) { - providerState.value = false - } + init { + locHandlerThread.start() } + + @SuppressLint("MissingPermission") + fun start( + minTimeMs: Long = MIN_TIME, + minDistanceM: Float = MIN_DISTANCE, + ) { + locationListener().let { + locationListener = it + locationManager.requestLocationUpdates( + LocationManager.NETWORK_PROVIDER, + minTimeMs, + minDistanceM, + it, + locHandlerThread.looper, + ) + } + providerState.value = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + isStart.value = true + } + + fun stop() { + locationListener?.let { locationManager.removeUpdates(it) } + isStart.value = false + } + + private fun locationListener() = + object : LocationListener { + override fun onLocationChanged(location: Location) { + locationStateFlow.value = location + } + + override fun onProviderEnabled(provider: String) { + providerState.value = true + } + + override fun onProviderDisabled(provider: String) { + providerState.value = false + } + } } class ReverseGeoLocationUtil { - suspend fun execute( - location: Location, - context: Context, - ): String? { - return try { - Geocoder(context) - .getFromLocation(location.latitude, location.longitude, 1) - ?.firstOrNull() - ?.let { address -> - listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode) - .joinToString(", ") + suspend fun execute( + location: Location, + context: Context, + ): String? { + return try { + Geocoder(context) + .getFromLocation(location.latitude, location.longitude, 1) + ?.firstOrNull() + ?.let { address -> + listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode) + .joinToString(", ") + } + } catch (e: Exception) { + e.printStackTrace() + return null } - } catch (e: Exception) { - e.printStackTrace() - return null } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/MainThreadChecker.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/MainThreadChecker.kt index 24b175a24..67c78f792 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/MainThreadChecker.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/MainThreadChecker.kt @@ -24,9 +24,9 @@ import android.os.Looper import com.vitorpamplona.amethyst.BuildConfig fun checkNotInMainThread() { - if (BuildConfig.DEBUG && isMainThread()) { - throw OnMainThreadException("It should not be in the MainThread") - } + if (BuildConfig.DEBUG && isMainThread()) { + throw OnMainThreadException("It should not be in the MainThread") + } } fun isMainThread() = Looper.myLooper() == Looper.getMainLooper() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt index 9a7b176df..fa97b0a93 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt @@ -30,121 +30,120 @@ import okhttp3.Request import okhttp3.Response class Nip05NostrAddressVerifier() { - fun assembleUrl(nip05address: String): String? { - val parts = nip05address.trim().split("@") + fun assembleUrl(nip05address: String): String? { + val parts = nip05address.trim().split("@") - if (parts.size == 2) { - return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}" - } - if (parts.size == 1) { - return "https://${parts[0]}/.well-known/nostr.json?name=_" + if (parts.size == 2) { + return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}" + } + if (parts.size == 1) { + return "https://${parts[0]}/.well-known/nostr.json?name=_" + } + + return null } - return null - } - - suspend fun fetchNip05Json( - nip05: String, - onSuccess: (String) -> Unit, - onError: (String) -> Unit, - ) = - withContext(Dispatchers.IO) { - checkNotInMainThread() - - val url = assembleUrl(nip05) - - if (url == null) { - onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup") - return@withContext - } - - try { - val request = - Request.Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url(url) - .build() - - HttpClient.getHttpClient() - .newCall(request) - .enqueue( - object : Callback { - override fun onResponse( - call: Call, - response: Response, - ) { - checkNotInMainThread() - - response.use { - if (it.isSuccessful) { - onSuccess(it.body.string()) - } else { - onError( - "Could not resolve $nip05. Error: ${it.code}. Check if the server is up and if the address $nip05 is correct", - ) - } - } - } - - override fun onFailure( - call: Call, - e: java.io.IOException, - ) { - onError( - "Could not resolve $url. Check if the server is up and if the address $nip05 is correct", - ) - e.printStackTrace() - } - }, - ) - } catch (e: java.lang.Exception) { - onError("Could not resolve '$url': ${e.message}") - } - } - - suspend fun verifyNip05( - nip05: String, - onSuccess: (String) -> Unit, - onError: (String) -> Unit, - ) { - // check fails on tests - checkNotInMainThread() - - val mapper = jacksonObjectMapper() - - fetchNip05Json( - nip05, - onSuccess = { + suspend fun fetchNip05Json( + nip05: String, + onSuccess: (String) -> Unit, + onError: (String) -> Unit, + ) = withContext(Dispatchers.IO) { checkNotInMainThread() - // NIP05 usernames are case insensitive, but JSON properties are not - // converts the json to lowercase and then tries to access the username via a - // lowercase version of the username. - val nip05url = - try { - mapper.readTree(it.lowercase()) - } catch (t: Throwable) { - onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup") - null - } + val url = assembleUrl(nip05) - val parts = nip05.split("@") - val user = - if (parts.size == 2) { - parts[0].lowercase() - } else { - "_" - } - - val hexKey = nip05url?.get("names")?.get(user)?.asText() - - if (hexKey == null) { - onError("Username not found in the NIP05 JSON") - } else { - onSuccess(hexKey) + if (url == null) { + onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup") + return@withContext } - }, - onError = onError, - ) - } + + try { + val request = + Request.Builder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url(url) + .build() + + HttpClient.getHttpClient() + .newCall(request) + .enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + checkNotInMainThread() + + response.use { + if (it.isSuccessful) { + onSuccess(it.body.string()) + } else { + onError( + "Could not resolve $nip05. Error: ${it.code}. Check if the server is up and if the address $nip05 is correct", + ) + } + } + } + + override fun onFailure( + call: Call, + e: java.io.IOException, + ) { + onError( + "Could not resolve $url. Check if the server is up and if the address $nip05 is correct", + ) + e.printStackTrace() + } + }, + ) + } catch (e: java.lang.Exception) { + onError("Could not resolve '$url': ${e.message}") + } + } + + suspend fun verifyNip05( + nip05: String, + onSuccess: (String) -> Unit, + onError: (String) -> Unit, + ) { + // check fails on tests + checkNotInMainThread() + + val mapper = jacksonObjectMapper() + + fetchNip05Json( + nip05, + onSuccess = { + checkNotInMainThread() + + // NIP05 usernames are case insensitive, but JSON properties are not + // converts the json to lowercase and then tries to access the username via a + // lowercase version of the username. + val nip05url = + try { + mapper.readTree(it.lowercase()) + } catch (t: Throwable) { + onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup") + null + } + + val parts = nip05.split("@") + val user = + if (parts.size == 2) { + parts[0].lowercase() + } else { + "_" + } + + val hexKey = nip05url?.get("names")?.get(user)?.asText() + + if (hexKey == null) { + onError("Username not found in the NIP05 JSON") + } else { + onSuccess(hexKey) + } + }, + onError = onError, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt index d3984b4e4..93815bf0e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt @@ -23,107 +23,107 @@ package com.vitorpamplona.amethyst.service import android.util.Log import android.util.LruCache import com.vitorpamplona.amethyst.model.RelayInformation -import java.io.IOException import okhttp3.Call import okhttp3.Callback import okhttp3.Request import okhttp3.Response +import java.io.IOException object Nip11CachedRetriever { - val relayInformationDocumentCache = LruCache(100) - val retriever = Nip11Retriever() + val relayInformationDocumentCache = LruCache(100) + val retriever = Nip11Retriever() - suspend fun loadRelayInfo( - dirtyUrl: String, - onInfo: (RelayInformation) -> Unit, - onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, - ) { - val url = retriever.cleanUrl(dirtyUrl) - val doc = relayInformationDocumentCache.get(url) + suspend fun loadRelayInfo( + dirtyUrl: String, + onInfo: (RelayInformation) -> Unit, + onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, + ) { + val url = retriever.cleanUrl(dirtyUrl) + val doc = relayInformationDocumentCache.get(url) - if (doc != null) { - onInfo(doc) - } else { - Nip11Retriever() - .loadRelayInfo( - url, - dirtyUrl, - onInfo = { - relayInformationDocumentCache.put(url, it) - onInfo(it) - }, - onError, - ) + if (doc != null) { + onInfo(doc) + } else { + Nip11Retriever() + .loadRelayInfo( + url, + dirtyUrl, + onInfo = { + relayInformationDocumentCache.put(url, it) + onInfo(it) + }, + onError, + ) + } } - } } class Nip11Retriever { - enum class ErrorCode { - FAIL_TO_ASSEMBLE_URL, - FAIL_TO_REACH_SERVER, - FAIL_TO_PARSE_RESULT, - FAIL_WITH_HTTP_STATUS, - } - - fun cleanUrl(dirtyUrl: String): String { - return if (dirtyUrl.contains("://")) { - dirtyUrl.replace("wss://", "https://").replace("ws://", "http://") - } else { - "https://$dirtyUrl" + enum class ErrorCode { + FAIL_TO_ASSEMBLE_URL, + FAIL_TO_REACH_SERVER, + FAIL_TO_PARSE_RESULT, + FAIL_WITH_HTTP_STATUS, } - } - suspend fun loadRelayInfo( - url: String, - dirtyUrl: String, - onInfo: (RelayInformation) -> Unit, - onError: (String, ErrorCode, String?) -> Unit, - ) { - try { - val request: Request = - Request.Builder().header("Accept", "application/nostr+json").url(url).build() - - HttpClient.getHttpClient() - .newCall(request) - .enqueue( - object : Callback { - override fun onResponse( - call: Call, - response: Response, - ) { - checkNotInMainThread() - response.use { - val body = it.body.string() - try { - if (it.isSuccessful) { - onInfo(RelayInformation.fromJson(body)) - } else { - onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString()) - } - } catch (e: Exception) { - Log.e( - "RelayInfoFail", - "Resulting Message from Relay $dirtyUrl in not parseable: $body", - e, - ) - onError(dirtyUrl, ErrorCode.FAIL_TO_PARSE_RESULT, e.message) - } - } - } - - override fun onFailure( - call: Call, - e: IOException, - ) { - Log.e("RelayInfoFail", "$dirtyUrl unavailable", e) - onError(dirtyUrl, ErrorCode.FAIL_TO_REACH_SERVER, e.message) - } - }, - ) - } catch (e: Exception) { - Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e) - onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message) + fun cleanUrl(dirtyUrl: String): String { + return if (dirtyUrl.contains("://")) { + dirtyUrl.replace("wss://", "https://").replace("ws://", "http://") + } else { + "https://$dirtyUrl" + } + } + + suspend fun loadRelayInfo( + url: String, + dirtyUrl: String, + onInfo: (RelayInformation) -> Unit, + onError: (String, ErrorCode, String?) -> Unit, + ) { + try { + val request: Request = + Request.Builder().header("Accept", "application/nostr+json").url(url).build() + + HttpClient.getHttpClient() + .newCall(request) + .enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + checkNotInMainThread() + response.use { + val body = it.body.string() + try { + if (it.isSuccessful) { + onInfo(RelayInformation.fromJson(body)) + } else { + onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString()) + } + } catch (e: Exception) { + Log.e( + "RelayInfoFail", + "Resulting Message from Relay $dirtyUrl in not parseable: $body", + e, + ) + onError(dirtyUrl, ErrorCode.FAIL_TO_PARSE_RESULT, e.message) + } + } + } + + override fun onFailure( + call: Call, + e: IOException, + ) { + Log.e("RelayInfoFail", "$dirtyUrl unavailable", e) + onError(dirtyUrl, ErrorCode.FAIL_TO_REACH_SERVER, e.message) + } + }, + ) + } catch (e: Exception) { + Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e) + onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message) + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip30CustomEmoji.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip30CustomEmoji.kt index 6fb340578..38d75338d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip30CustomEmoji.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip30CustomEmoji.kt @@ -25,31 +25,31 @@ import java.util.regex.Pattern @Immutable class Nip30CustomEmoji { - val customEmojiPattern: Pattern = - Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE) + val customEmojiPattern: Pattern = + Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE) - fun buildArray(input: String): List { - val matcher = customEmojiPattern.matcher(input) - val list = mutableListOf() - while (matcher.find()) { - list.add(matcher.group()) + fun buildArray(input: String): List { + val matcher = customEmojiPattern.matcher(input) + val list = mutableListOf() + while (matcher.find()) { + list.add(matcher.group()) + } + + if (list.isEmpty()) { + return listOf(input) + } + + val regularChars = input.split(customEmojiPattern.toRegex()) + + val finalList = mutableListOf() + var index = 0 + for (e in regularChars) { + finalList.add(e) + if (index < list.size) { + finalList.add(list[index]) + } + index++ + } + return finalList } - - if (list.isEmpty()) { - return listOf(input) - } - - val regularChars = input.split(customEmojiPattern.toRegex()) - - val finalList = mutableListOf() - var index = 0 - for (e in regularChars) { - finalList.add(e) - if (index < list.size) { - finalList.add(list[index]) - } - index++ - } - return finalList - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip44UrlParser.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip44UrlParser.kt index 57dd508f3..a3297527f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip44UrlParser.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip44UrlParser.kt @@ -24,21 +24,21 @@ import java.net.URI import java.net.URLDecoder class Nip44UrlParser { - fun parse(url: String): Map { - return try { - fragments(URI(url)) - } catch (e: Exception) { - emptyMap() + fun parse(url: String): Map { + return try { + fragments(URI(url)) + } catch (e: Exception) { + emptyMap() + } } - } - private fun fragments(uri: URI): Map { - if (uri.rawFragment == null) return emptyMap() - return uri.rawFragment.split('&').associate { keyValuePair -> - val parts = keyValuePair.split('=') - val name = parts.firstOrNull() ?: "" - val value = parts.getOrNull(1)?.let { URLDecoder.decode(it, "UTF-8") } ?: "" - Pair(name, value) + private fun fragments(uri: URI): Map { + if (uri.rawFragment == null) return emptyMap() + return uri.rawFragment.split('&').associate { keyValuePair -> + val parts = keyValuePair.split('=') + val name = parts.firstOrNull() ?: "" + val value = parts.getOrNull(1)?.let { URLDecoder.decode(it, "UTF-8") } ?: "" + Pair(name, value) + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47WalletConnectParser.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47WalletConnectParser.kt index 0fd5ea8fe..075b537cc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47WalletConnectParser.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47WalletConnectParser.kt @@ -27,27 +27,27 @@ import com.vitorpamplona.quartz.encoders.toHexKey // Rename to the corect nip number when ready. object Nip47WalletConnectParser { - fun parse(uri: String): Nip47URI { - // nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D + fun parse(uri: String): Nip47URI { + // nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D - val url = Uri.parse(uri) + val url = Uri.parse(uri) - if (url.scheme != "nostrwalletconnect" && url.scheme != "nostr+walletconnect") { - throw IllegalArgumentException("Not a Wallet Connect QR Code") + if (url.scheme != "nostrwalletconnect" && url.scheme != "nostr+walletconnect") { + throw IllegalArgumentException("Not a Wallet Connect QR Code") + } + + val pubkey = url.host ?: throw IllegalArgumentException("Hostname cannot be null") + + val pubkeyHex = + try { + decodePublicKey(pubkey).toHexKey() + } catch (e: Exception) { + throw IllegalArgumentException("Hostname is not a valid Nostr Pubkey") + } + + val relay = url.getQueryParameter("relay") + val secret = url.getQueryParameter("secret") + + return Nip47URI(pubkeyHex, relay, secret) } - - val pubkey = url.host ?: throw IllegalArgumentException("Hostname cannot be null") - - val pubkeyHex = - try { - decodePublicKey(pubkey).toHexKey() - } catch (e: Exception) { - throw IllegalArgumentException("Hostname is not a valid Nostr Pubkey") - } - - val relay = url.getQueryParameter("relay") - val secret = url.getQueryParameter("secret") - - return Nip47URI(pubkeyHex, relay, secret) - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt index f10aaa2db..be1fac14f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt @@ -27,85 +27,85 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import okhttp3.Request object Nip96MediaServers { - val DEFAULT = - listOf( - ServerName("Nostr.Build", "https://nostr.build"), - ServerName("NostrCheck.me", "https://nostrcheck.me"), - ServerName("Nostrage", "https://nostrage.com"), - ServerName("Sove", "https://sove.rent"), - ServerName("Sovbit", "https://files.sovbit.host"), - ServerName("Void.cat", "https://void.cat"), - ) + val DEFAULT = + listOf( + ServerName("Nostr.Build", "https://nostr.build"), + ServerName("NostrCheck.me", "https://nostrcheck.me"), + ServerName("Nostrage", "https://nostrage.com"), + ServerName("Sove", "https://sove.rent"), + ServerName("Sovbit", "https://files.sovbit.host"), + ServerName("Void.cat", "https://void.cat"), + ) - data class ServerName(val name: String, val baseUrl: String) + data class ServerName(val name: String, val baseUrl: String) - val cache: MutableMap = mutableMapOf() + val cache: MutableMap = mutableMapOf() - suspend fun load(url: String): Nip96Retriever.ServerInfo { - val cached = cache[url] - if (cached != null) return cached + suspend fun load(url: String): Nip96Retriever.ServerInfo { + val cached = cache[url] + if (cached != null) return cached - val fetched = Nip96Retriever().loadInfo(url) - cache[url] = fetched - return fetched - } + val fetched = Nip96Retriever().loadInfo(url) + cache[url] = fetched + return fetched + } } class Nip96Retriever { - data class ServerInfo( - @JsonProperty("api_url") val apiUrl: String, - @JsonProperty("download_url") val downloadUrl: String? = null, - @JsonProperty("delegated_to_url") val delegatedToUrl: String? = null, - @JsonProperty("supported_nips") val supportedNips: ArrayList = arrayListOf(), - @JsonProperty("tos_url") val tosUrl: String? = null, - @JsonProperty("content_types") val contentTypes: ArrayList = arrayListOf(), - @JsonProperty("plans") val plans: Map = mapOf(), - ) + data class ServerInfo( + @JsonProperty("api_url") val apiUrl: String, + @JsonProperty("download_url") val downloadUrl: String? = null, + @JsonProperty("delegated_to_url") val delegatedToUrl: String? = null, + @JsonProperty("supported_nips") val supportedNips: ArrayList = arrayListOf(), + @JsonProperty("tos_url") val tosUrl: String? = null, + @JsonProperty("content_types") val contentTypes: ArrayList = arrayListOf(), + @JsonProperty("plans") val plans: Map = mapOf(), + ) - data class Plan( - @JsonProperty("name") val name: String? = null, - @JsonProperty("is_nip98_required") val isNip98Required: Boolean? = null, - @JsonProperty("url") val url: String? = null, - @JsonProperty("max_byte_size") val maxByteSize: Long? = null, - @JsonProperty("file_expiration") val fileExpiration: ArrayList = arrayListOf(), - @JsonProperty("media_transformations") - val mediaTransformations: Map> = emptyMap(), - ) + data class Plan( + @JsonProperty("name") val name: String? = null, + @JsonProperty("is_nip98_required") val isNip98Required: Boolean? = null, + @JsonProperty("url") val url: String? = null, + @JsonProperty("max_byte_size") val maxByteSize: Long? = null, + @JsonProperty("file_expiration") val fileExpiration: ArrayList = arrayListOf(), + @JsonProperty("media_transformations") + val mediaTransformations: Map> = emptyMap(), + ) - fun parse(body: String): ServerInfo { - val mapper = - jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - return mapper.readValue(body, ServerInfo::class.java) - } - - suspend fun loadInfo(baseUrl: String): ServerInfo { - checkNotInMainThread() - - val request: Request = - Request.Builder() - .header("Accept", "application/nostr+json") - .url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json") - .build() - - HttpClient.getHttpClient().newCall(request).execute().use { response -> - checkNotInMainThread() - response.use { - val body = it.body.string() - try { - if (it.isSuccessful) { - return parse(body) - } else { - throw RuntimeException( - "Resulting Message from $baseUrl is an error: ${response.code} ${response.message}", - ) - } - } catch (e: Exception) { - Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e) - throw e - } - } + fun parse(body: String): ServerInfo { + val mapper = + jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + return mapper.readValue(body, ServerInfo::class.java) + } + + suspend fun loadInfo(baseUrl: String): ServerInfo { + checkNotInMainThread() + + val request: Request = + Request.Builder() + .header("Accept", "application/nostr+json") + .url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json") + .build() + + HttpClient.getHttpClient().newCall(request).execute().use { response -> + checkNotInMainThread() + response.use { + val body = it.body.string() + try { + if (it.isSuccessful) { + return parse(body) + } else { + throw RuntimeException( + "Resulting Message from $baseUrl is an error: ${response.code} ${response.message}", + ) + } + } catch (e: Exception) { + Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e) + throw e + } + } + } } - } } typealias PlanName = String diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt index 8a5d6fa4b..c4552a3a0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt @@ -30,9 +30,6 @@ import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.model.Account -import java.io.InputStream -import java.util.Base64 -import kotlin.coroutines.resume import kotlinx.coroutines.delay import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull @@ -42,276 +39,279 @@ import okhttp3.Request import okhttp3.RequestBody import okio.BufferedSink import okio.source +import java.io.InputStream +import java.util.Base64 +import kotlin.coroutines.resume val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') fun randomChars() = List(16) { charPool.random() }.joinToString("") class Nip96Uploader(val account: Account?) { - suspend fun uploadImage( - uri: Uri, - contentType: String?, - size: Long?, - alt: String?, - sensitiveContent: String?, - server: Nip96MediaServers.ServerName, - contentResolver: ContentResolver, - onProgress: (percentage: Float) -> Unit, - ): PartialEvent { - val serverInfo = - Nip96Retriever() - .loadInfo( - server.baseUrl, + suspend fun uploadImage( + uri: Uri, + contentType: String?, + size: Long?, + alt: String?, + sensitiveContent: String?, + server: Nip96MediaServers.ServerName, + contentResolver: ContentResolver, + onProgress: (percentage: Float) -> Unit, + ): PartialEvent { + val serverInfo = + Nip96Retriever() + .loadInfo( + server.baseUrl, + ) + + return uploadImage( + uri, + contentType, + size, + alt, + sensitiveContent, + serverInfo, + contentResolver, + onProgress, ) + } - return uploadImage( - uri, - contentType, - size, - alt, - sensitiveContent, - serverInfo, - contentResolver, - onProgress, - ) - } + suspend fun uploadImage( + uri: Uri, + contentType: String?, + size: Long?, + alt: String?, + sensitiveContent: String?, + server: Nip96Retriever.ServerInfo, + contentResolver: ContentResolver, + onProgress: (percentage: Float) -> Unit, + ): PartialEvent { + checkNotInMainThread() - suspend fun uploadImage( - uri: Uri, - contentType: String?, - size: Long?, - alt: String?, - sensitiveContent: String?, - server: Nip96Retriever.ServerInfo, - contentResolver: ContentResolver, - onProgress: (percentage: Float) -> Unit, - ): PartialEvent { - checkNotInMainThread() + val myContentType = contentType ?: contentResolver.getType(uri) + val imageInputStream = contentResolver.openInputStream(uri) - val myContentType = contentType ?: contentResolver.getType(uri) - val imageInputStream = contentResolver.openInputStream(uri) + val length = + size + ?: contentResolver.query(uri, null, null, null, null)?.use { + it.moveToFirst() + val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) + it.getLong(sizeIndex) + } + ?: kotlin.runCatching { uri.toFile().length() }.getOrNull() ?: 0 - val length = - size - ?: contentResolver.query(uri, null, null, null, null)?.use { - it.moveToFirst() - val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) - it.getLong(sizeIndex) - } - ?: kotlin.runCatching { uri.toFile().length() }.getOrNull() ?: 0 + checkNotNull(imageInputStream) { "Can't open the image input stream" } - checkNotNull(imageInputStream) { "Can't open the image input stream" } + return uploadImage( + imageInputStream, + length, + myContentType, + alt, + sensitiveContent, + server, + onProgress, + ) + } - return uploadImage( - imageInputStream, - length, - myContentType, - alt, - sensitiveContent, - server, - onProgress, - ) - } + suspend fun uploadImage( + inputStream: InputStream, + length: Long, + contentType: String?, + alt: String?, + sensitiveContent: String?, + server: Nip96Retriever.ServerInfo, + onProgress: (percentage: Float) -> Unit, + ): PartialEvent { + checkNotInMainThread() - suspend fun uploadImage( - inputStream: InputStream, - length: Long, - contentType: String?, - alt: String?, - sensitiveContent: String?, - server: Nip96Retriever.ServerInfo, - onProgress: (percentage: Float) -> Unit, - ): PartialEvent { - checkNotInMainThread() + val fileName = randomChars() + val extension = + contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" - val fileName = randomChars() - val extension = - contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" + val client = HttpClient.getHttpClient() + val requestBody: RequestBody + val requestBuilder = Request.Builder() - val client = HttpClient.getHttpClient() - val requestBody: RequestBody - val requestBuilder = Request.Builder() + requestBody = + MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("expiration", "") + .addFormDataPart("size", length.toString()) + .also { body -> + alt?.let { body.addFormDataPart("alt", it) } + sensitiveContent?.let { body.addFormDataPart("content-warning", it) } + contentType?.let { body.addFormDataPart("content_type", it) } + } + .addFormDataPart( + "file", + "$fileName.$extension", + object : RequestBody() { + override fun contentType() = contentType?.toMediaType() - requestBody = - MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart("expiration", "") - .addFormDataPart("size", length.toString()) - .also { body -> - alt?.let { body.addFormDataPart("alt", it) } - sensitiveContent?.let { body.addFormDataPart("content-warning", it) } - contentType?.let { body.addFormDataPart("content_type", it) } - } - .addFormDataPart( - "file", - "$fileName.$extension", - object : RequestBody() { - override fun contentType() = contentType?.toMediaType() + override fun contentLength() = length - override fun contentLength() = length + override fun writeTo(sink: BufferedSink) { + inputStream.source().use(sink::writeAll) + } + }, + ) + .build() - override fun writeTo(sink: BufferedSink) { - inputStream.source().use(sink::writeAll) + nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) } + + requestBuilder + .addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url(server.apiUrl) + .post(requestBody) + + val request = requestBuilder.build() + + client.newCall(request).execute().use { response -> + if (response.isSuccessful) { + response.body.use { body -> + val str = body.string() + val result = parseResults(str) + + if (!result.processingUrl.isNullOrBlank()) { + return waitProcessing(result, server, onProgress) + } else if (result.status == "success" && result.nip94Event != null) { + return result.nip94Event + } else { + throw RuntimeException("Failed to upload with message: ${result.message}") + } + } + } else { + throw RuntimeException("Error Uploading image: ${response.code}") } - }, - ) - .build() - - nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) } - - requestBuilder - .addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url(server.apiUrl) - .post(requestBody) - - val request = requestBuilder.build() - - client.newCall(request).execute().use { response -> - if (response.isSuccessful) { - response.body.use { body -> - val str = body.string() - val result = parseResults(str) - - if (!result.processingUrl.isNullOrBlank()) { - return waitProcessing(result, server, onProgress) - } else if (result.status == "success" && result.nip94Event != null) { - return result.nip94Event - } else { - throw RuntimeException("Failed to upload with message: ${result.message}") - } } - } else { - throw RuntimeException("Error Uploading image: ${response.code}") - } } - } - suspend fun delete( - hash: String, - contentType: String?, - server: Nip96Retriever.ServerInfo, - ): Boolean { - val extension = - contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" + suspend fun delete( + hash: String, + contentType: String?, + server: Nip96Retriever.ServerInfo, + ): Boolean { + val extension = + contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" - val client = HttpClient.getHttpClient() + val client = HttpClient.getHttpClient() - val requestBuilder = Request.Builder() + val requestBuilder = Request.Builder() - nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) } + nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) } - println(server.apiUrl.removeSuffix("/") + "/$hash.$extension") + println(server.apiUrl.removeSuffix("/") + "/$hash.$extension") - val request = - requestBuilder - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url(server.apiUrl.removeSuffix("/") + "/$hash.$extension") - .delete() - .build() + val request = + requestBuilder + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url(server.apiUrl.removeSuffix("/") + "/$hash.$extension") + .delete() + .build() - client.newCall(request).execute().use { response -> - if (response.isSuccessful) { - response.body.use { body -> - val str = body.string() - val result = parseDeleteResults(str) - return result.status == "success" + client.newCall(request).execute().use { response -> + if (response.isSuccessful) { + response.body.use { body -> + val str = body.string() + val result = parseDeleteResults(str) + return result.status == "success" + } + } else { + throw RuntimeException("Error Uploading image: ${response.code}") + } } - } else { - throw RuntimeException("Error Uploading image: ${response.code}") - } } - } - private suspend fun waitProcessing( - result: Nip96Result, - server: Nip96Retriever.ServerInfo, - onProgress: (percentage: Float) -> Unit, - ): PartialEvent { - val client = HttpClient.getHttpClient() - var currentResult = result + private suspend fun waitProcessing( + result: Nip96Result, + server: Nip96Retriever.ServerInfo, + onProgress: (percentage: Float) -> Unit, + ): PartialEvent { + val client = HttpClient.getHttpClient() + var currentResult = result - while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) { - onProgress((currentResult.percentage ?: 100) / 100f) + while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) { + onProgress((currentResult.percentage ?: 100) / 100f) - val request: Request = - Request.Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url(result.processingUrl) - .build() + val request: Request = + Request.Builder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url(result.processingUrl) + .build() - client.newCall(request).execute().use { - if (it.isSuccessful) { - it.body.use { currentResult = parseResults(it.string()) } + client.newCall(request).execute().use { + if (it.isSuccessful) { + it.body.use { currentResult = parseResults(it.string()) } + } + } + + delay(500) } - } + onProgress((currentResult.percentage ?: 100) / 100f) - delay(500) - } - onProgress((currentResult.percentage ?: 100) / 100f) + val nip94 = currentResult.nip94Event - val nip94 = currentResult.nip94Event - - if (nip94 != null) { - return nip94 - } else { - throw RuntimeException("Error waiting for processing. Final result is unavailable") - } - } - - suspend fun nip98Header(url: String): String? { - return withTimeoutOrNull(5000) { - suspendCancellableCoroutine { continuation -> - nip98Header(url, "POST") { authorizationToken -> continuation.resume(authorizationToken) } - } - } - } - - fun nip98Header( - url: String, - method: String, - file: ByteArray? = null, - onReady: (String?) -> Unit, - ) { - val myAccount = account - - if (myAccount == null) { - onReady(null) - return + if (nip94 != null) { + return nip94 + } else { + throw RuntimeException("Error waiting for processing. Final result is unavailable") + } } - myAccount.createHTTPAuthorization(url, method, file) { - val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray()) - onReady("Nostr $encodedNIP98Event") + suspend fun nip98Header(url: String): String? { + return withTimeoutOrNull(5000) { + suspendCancellableCoroutine { continuation -> + nip98Header(url, "POST") { authorizationToken -> continuation.resume(authorizationToken) } + } + } } - } - data class DeleteResult( - val status: String?, - val message: String?, - ) + fun nip98Header( + url: String, + method: String, + file: ByteArray? = null, + onReady: (String?) -> Unit, + ) { + val myAccount = account - private fun parseDeleteResults(body: String): DeleteResult { - val mapper = - jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - return mapper.readValue(body, DeleteResult::class.java) - } + if (myAccount == null) { + onReady(null) + return + } - data class Nip96Result( - val status: String? = null, - val message: String? = null, - @JsonProperty("processing_url") val processingUrl: String? = null, - val percentage: Int? = null, - @JsonProperty("nip94_event") val nip94Event: PartialEvent? = null, - ) + myAccount.createHTTPAuthorization(url, method, file) { + val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray()) + onReady("Nostr $encodedNIP98Event") + } + } - class PartialEvent( - val tags: Array>? = null, - val content: String? = null, - ) + data class DeleteResult( + val status: String?, + val message: String?, + ) - private fun parseResults(body: String): Nip96Result { - val mapper = - jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - return mapper.readValue(body, Nip96Result::class.java) - } + private fun parseDeleteResults(body: String): DeleteResult { + val mapper = + jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + return mapper.readValue(body, DeleteResult::class.java) + } + + data class Nip96Result( + val status: String? = null, + val message: String? = null, + @JsonProperty("processing_url") val processingUrl: String? = null, + val percentage: Int? = null, + @JsonProperty("nip94_event") val nip94Event: PartialEvent? = null, + ) + + class PartialEvent( + val tags: Array>? = null, + val content: String? = null, + ) + + private fun parseResults(body: String): Nip96Result { + val mapper = + jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + return mapper.readValue(body, Nip96Result::class.java) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index be0e7e8f2..f9fd7daba 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -58,289 +58,290 @@ import com.vitorpamplona.quartz.utils.TimeUtils // TODO: Migrate this to a property of AccountVi object NostrAccountDataSource : NostrDataSource("AccountData") { - lateinit var account: Account - var otherAccounts = listOf() + lateinit var account: Account + var otherAccounts = listOf() - val latestEOSEs = EOSEAccount() - val hasLoadedTheBasics = mutableMapOf() + val latestEOSEs = EOSEAccount() + val hasLoadedTheBasics = mutableMapOf() - fun createAccountContactListFilter(): TypedFilter { - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(ContactListEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - limit = 1, - ), - ) - } - - fun createAccountMetadataFilter(): TypedFilter { - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(MetadataEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - limit = 1, - ), - ) - } - - fun createAccountRelayListFilter(): TypedFilter { - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(AdvertisedRelayListEvent.KIND, StatusEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - limit = 5, - ), - ) - } - - fun createOtherAccountsBaseFilter(): TypedFilter? { - if (otherAccounts.isEmpty()) return null - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = - listOf( - MetadataEvent.KIND, - ContactListEvent.KIND, - AdvertisedRelayListEvent.KIND, - MuteListEvent.KIND, - PeopleListEvent.KIND, - ), - authors = otherAccounts.filter { it != account.userProfile().pubkeyHex }, - limit = 100, - ), - ) - } - - fun createAccountAcceptedAwardsFilter(): TypedFilter { - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(BadgeProfilesEvent.KIND, EmojiPackSelectionEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - limit = 10, - ), - ) - } - - fun createAccountBookmarkListFilter(): TypedFilter { - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, MuteListEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - limit = 100, - ), - ) - } - - fun createAccountReportsFilter(): TypedFilter { - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(ReportEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultNotificationFollowList.value) - ?.relayList, - ), - ) - } - - fun createAccountLastPostsListFilter(): TypedFilter { - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - authors = listOf(account.userProfile().pubkeyHex), - limit = 400, - ), - ) - } - - fun createNotificationFilter(): TypedFilter { - val since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultNotificationFollowList.value) - ?.relayList - ?: account.activeRelays()?.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) } - ?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) } - - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = - listOf( - TextNoteEvent.KIND, - PollNoteEvent.KIND, - ReactionEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - ReportEvent.KIND, - LnZapEvent.KIND, - LnZapPaymentResponseEvent.KIND, - ChannelMessageEvent.KIND, - BadgeAwardEvent.KIND, - ), - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - limit = 4000, - since = since, - ), - ) - } - - fun createGiftWrapsToMeFilter() = - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(GiftWrapEvent.KIND), - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - ), - ) - - val accountChannel = requestNewChannel { time, relayUrl -> - if (hasLoadedTheBasics[account.userProfile()] != null) { - latestEOSEs.addOrUpdate( - account.userProfile(), - account.defaultNotificationFollowList.value, - relayUrl, - time, - ) - } else { - hasLoadedTheBasics[account.userProfile()] = true - - invalidateFilters() - } - } - - override fun consume( - event: Event, - relay: Relay, - ) { - checkNotInMainThread() - - if (LocalCache.justVerify(event)) { - if (event is GiftWrapEvent) { - // Avoid decrypting over and over again if the event already exist. - val note = LocalCache.getNoteIfExists(event.id) - if (note != null && relay.brief in note.relays) return - - event.cachedGift(account.signer) { this.consume(it, relay) } - } - - if (event is SealedGossipEvent) { - // Avoid decrypting over and over again if the event already exist. - val note = LocalCache.getNoteIfExists(event.id) - if (note != null && relay.brief in note.relays) return - - event.cachedGossip(account.signer) { LocalCache.justConsume(it, relay) } - } else { - LocalCache.justConsume(event, relay) - } - } - } - - override fun markAsSeenOnRelay( - eventId: String, - relay: Relay, - ) { - checkNotInMainThread() - - super.markAsSeenOnRelay(eventId, relay) - - val note = LocalCache.getNoteIfExists(eventId) ?: return - val privKey = account.keyPair.privKey ?: return - - val noteEvent = note.event ?: return - markInnerAsSeenOnRelay(noteEvent, privKey, relay) - } - - private fun markInnerAsSeenOnRelay( - noteEvent: EventInterface, - privKey: ByteArray, - relay: Relay, - ) { - LocalCache.getNoteIfExists(noteEvent.id())?.addRelay(relay) - - if (noteEvent is GiftWrapEvent) { - noteEvent.cachedGift(account.signer) { gift -> markInnerAsSeenOnRelay(gift, privKey, relay) } - } else if (noteEvent is SealedGossipEvent) { - noteEvent.cachedGossip(account.signer) { rumor -> - markInnerAsSeenOnRelay(rumor, privKey, relay) - } - } - } - - override fun updateChannelFilters() { - return if (hasLoadedTheBasics[account.userProfile()] != null) { - // gets everything about the user logged in - accountChannel.typedFilters = - listOfNotNull( - createAccountMetadataFilter(), - createAccountContactListFilter(), - createAccountRelayListFilter(), - createNotificationFilter(), - createGiftWrapsToMeFilter(), - createAccountReportsFilter(), - createAccountAcceptedAwardsFilter(), - createAccountBookmarkListFilter(), - createAccountLastPostsListFilter(), - createOtherAccountsBaseFilter(), - ) - .ifEmpty { null } - } else { - // just the basics. - accountChannel.typedFilters = - listOf( - createAccountMetadataFilter(), - createAccountContactListFilter(), - createAccountRelayListFilter(), - createAccountBookmarkListFilter(), - ) - .ifEmpty { null } - } - } - - override fun auth( - relay: Relay, - challenge: String, - ) { - super.auth(relay, challenge) - - if (this::account.isInitialized) { - account.createAuthEvent(relay, challenge) { - Client.send( - it, - relay.url, + fun createAccountContactListFilter(): TypedFilter { + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(ContactListEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + limit = 1, + ), ) - } } - } - override fun notify( - relay: Relay, - description: String, - ) { - super.notify(relay, description) - - if (this::account.isInitialized) { - account.addPaymentRequestIfNew(Account.PaymentRequest(relay.url, description)) + fun createAccountMetadataFilter(): TypedFilter { + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(MetadataEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + limit = 1, + ), + ) + } + + fun createAccountRelayListFilter(): TypedFilter { + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(AdvertisedRelayListEvent.KIND, StatusEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + limit = 5, + ), + ) + } + + fun createOtherAccountsBaseFilter(): TypedFilter? { + if (otherAccounts.isEmpty()) return null + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = + listOf( + MetadataEvent.KIND, + ContactListEvent.KIND, + AdvertisedRelayListEvent.KIND, + MuteListEvent.KIND, + PeopleListEvent.KIND, + ), + authors = otherAccounts.filter { it != account.userProfile().pubkeyHex }, + limit = 100, + ), + ) + } + + fun createAccountAcceptedAwardsFilter(): TypedFilter { + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(BadgeProfilesEvent.KIND, EmojiPackSelectionEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + limit = 10, + ), + ) + } + + fun createAccountBookmarkListFilter(): TypedFilter { + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, MuteListEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + limit = 100, + ), + ) + } + + fun createAccountReportsFilter(): TypedFilter { + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(ReportEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultNotificationFollowList.value) + ?.relayList, + ), + ) + } + + fun createAccountLastPostsListFilter(): TypedFilter { + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + authors = listOf(account.userProfile().pubkeyHex), + limit = 400, + ), + ) + } + + fun createNotificationFilter(): TypedFilter { + val since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultNotificationFollowList.value) + ?.relayList + ?: account.activeRelays()?.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) } + ?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) } + + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = + listOf( + TextNoteEvent.KIND, + PollNoteEvent.KIND, + ReactionEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ReportEvent.KIND, + LnZapEvent.KIND, + LnZapPaymentResponseEvent.KIND, + ChannelMessageEvent.KIND, + BadgeAwardEvent.KIND, + ), + tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), + limit = 4000, + since = since, + ), + ) + } + + fun createGiftWrapsToMeFilter() = + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(GiftWrapEvent.KIND), + tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), + ), + ) + + val accountChannel = + requestNewChannel { time, relayUrl -> + if (hasLoadedTheBasics[account.userProfile()] != null) { + latestEOSEs.addOrUpdate( + account.userProfile(), + account.defaultNotificationFollowList.value, + relayUrl, + time, + ) + } else { + hasLoadedTheBasics[account.userProfile()] = true + + invalidateFilters() + } + } + + override fun consume( + event: Event, + relay: Relay, + ) { + checkNotInMainThread() + + if (LocalCache.justVerify(event)) { + if (event is GiftWrapEvent) { + // Avoid decrypting over and over again if the event already exist. + val note = LocalCache.getNoteIfExists(event.id) + if (note != null && relay.brief in note.relays) return + + event.cachedGift(account.signer) { this.consume(it, relay) } + } + + if (event is SealedGossipEvent) { + // Avoid decrypting over and over again if the event already exist. + val note = LocalCache.getNoteIfExists(event.id) + if (note != null && relay.brief in note.relays) return + + event.cachedGossip(account.signer) { LocalCache.justConsume(it, relay) } + } else { + LocalCache.justConsume(event, relay) + } + } + } + + override fun markAsSeenOnRelay( + eventId: String, + relay: Relay, + ) { + checkNotInMainThread() + + super.markAsSeenOnRelay(eventId, relay) + + val note = LocalCache.getNoteIfExists(eventId) ?: return + val privKey = account.keyPair.privKey ?: return + + val noteEvent = note.event ?: return + markInnerAsSeenOnRelay(noteEvent, privKey, relay) + } + + private fun markInnerAsSeenOnRelay( + noteEvent: EventInterface, + privKey: ByteArray, + relay: Relay, + ) { + LocalCache.getNoteIfExists(noteEvent.id())?.addRelay(relay) + + if (noteEvent is GiftWrapEvent) { + noteEvent.cachedGift(account.signer) { gift -> markInnerAsSeenOnRelay(gift, privKey, relay) } + } else if (noteEvent is SealedGossipEvent) { + noteEvent.cachedGossip(account.signer) { rumor -> + markInnerAsSeenOnRelay(rumor, privKey, relay) + } + } + } + + override fun updateChannelFilters() { + return if (hasLoadedTheBasics[account.userProfile()] != null) { + // gets everything about the user logged in + accountChannel.typedFilters = + listOfNotNull( + createAccountMetadataFilter(), + createAccountContactListFilter(), + createAccountRelayListFilter(), + createNotificationFilter(), + createGiftWrapsToMeFilter(), + createAccountReportsFilter(), + createAccountAcceptedAwardsFilter(), + createAccountBookmarkListFilter(), + createAccountLastPostsListFilter(), + createOtherAccountsBaseFilter(), + ) + .ifEmpty { null } + } else { + // just the basics. + accountChannel.typedFilters = + listOf( + createAccountMetadataFilter(), + createAccountContactListFilter(), + createAccountRelayListFilter(), + createAccountBookmarkListFilter(), + ) + .ifEmpty { null } + } + } + + override fun auth( + relay: Relay, + challenge: String, + ) { + super.auth(relay, challenge) + + if (this::account.isInitialized) { + account.createAuthEvent(relay, challenge) { + Client.send( + it, + relay.url, + ) + } + } + } + + override fun notify( + relay: Relay, + description: String, + ) { + super.notify(relay, description) + + if (this::account.isInitialized) { + account.addPaymentRequestIfNew(Account.PaymentRequest(relay.url, description)) + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt index 21bd9482e..b72f8ecd6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt @@ -31,89 +31,89 @@ import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent object NostrChannelDataSource : NostrDataSource("ChatroomFeed") { - var account: Account? = null - var channel: Channel? = null + var account: Account? = null + var channel: Channel? = null - fun loadMessagesBetween( - account: Account, - channel: Channel, - ) { - this.account = account - this.channel = channel - resetFilters() - } - - fun clear() { - account = null - channel = null - } - - fun createMessagesByMeToChannelFilter(): TypedFilter? { - val myAccount = account ?: return null - - if (channel is PublicChatChannel) { - // Brings on messages by the user from all other relays. - // Since we ship with write to public, read from private only - // this guarantees that messages from the author do not disappear. - return TypedFilter( - types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH), - filter = - JsonFilter( - kinds = listOf(ChannelMessageEvent.KIND), - authors = listOf(myAccount.userProfile().pubkeyHex), - limit = 50, - ), - ) - } else if (channel is LiveActivitiesChannel) { - // Brings on messages by the user from all other relays. - // Since we ship with write to public, read from private only - // this guarantees that messages from the author do not disappear. - return TypedFilter( - types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH), - filter = - JsonFilter( - kinds = listOf(LiveActivitiesChatMessageEvent.KIND), - authors = listOf(myAccount.userProfile().pubkeyHex), - limit = 50, - ), - ) + fun loadMessagesBetween( + account: Account, + channel: Channel, + ) { + this.account = account + this.channel = channel + resetFilters() } - return null - } - fun createMessagesToChannelFilter(): TypedFilter? { - if (channel is PublicChatChannel) { - return TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - JsonFilter( - kinds = listOf(ChannelMessageEvent.KIND), - tags = mapOf("e" to listOfNotNull(channel?.idHex)), - limit = 200, - ), - ) - } else if (channel is LiveActivitiesChannel) { - return TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - JsonFilter( - kinds = listOf(LiveActivitiesChatMessageEvent.KIND), - tags = mapOf("a" to listOfNotNull(channel?.idHex)), - limit = 200, - ), - ) + fun clear() { + account = null + channel = null } - return null - } - val messagesChannel = requestNewChannel() + fun createMessagesByMeToChannelFilter(): TypedFilter? { + val myAccount = account ?: return null - override fun updateChannelFilters() { - messagesChannel.typedFilters = - listOfNotNull( - createMessagesToChannelFilter(), - createMessagesByMeToChannelFilter(), - ) - .ifEmpty { null } - } + if (channel is PublicChatChannel) { + // Brings on messages by the user from all other relays. + // Since we ship with write to public, read from private only + // this guarantees that messages from the author do not disappear. + return TypedFilter( + types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH), + filter = + JsonFilter( + kinds = listOf(ChannelMessageEvent.KIND), + authors = listOf(myAccount.userProfile().pubkeyHex), + limit = 50, + ), + ) + } else if (channel is LiveActivitiesChannel) { + // Brings on messages by the user from all other relays. + // Since we ship with write to public, read from private only + // this guarantees that messages from the author do not disappear. + return TypedFilter( + types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH), + filter = + JsonFilter( + kinds = listOf(LiveActivitiesChatMessageEvent.KIND), + authors = listOf(myAccount.userProfile().pubkeyHex), + limit = 50, + ), + ) + } + return null + } + + fun createMessagesToChannelFilter(): TypedFilter? { + if (channel is PublicChatChannel) { + return TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + JsonFilter( + kinds = listOf(ChannelMessageEvent.KIND), + tags = mapOf("e" to listOfNotNull(channel?.idHex)), + limit = 200, + ), + ) + } else if (channel is LiveActivitiesChannel) { + return TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + JsonFilter( + kinds = listOf(LiveActivitiesChatMessageEvent.KIND), + tags = mapOf("a" to listOfNotNull(channel?.idHex)), + limit = 200, + ), + ) + } + return null + } + + val messagesChannel = requestNewChannel() + + override fun updateChannelFilters() { + messagesChannel.typedFilters = + listOfNotNull( + createMessagesToChannelFilter(), + createMessagesByMeToChannelFilter(), + ) + .ifEmpty { null } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt index a7cbac4c7..0adc07472 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt @@ -29,80 +29,81 @@ import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.PrivateDmEvent object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") { - lateinit var account: Account - private var withRoom: ChatroomKey? = null + lateinit var account: Account + private var withRoom: ChatroomKey? = null - private val latestEOSEs = EOSEAccount() + private val latestEOSEs = EOSEAccount() - fun loadMessagesBetween( - accountIn: Account, - withRoom: ChatroomKey, - ) { - this.account = accountIn - this.withRoom = withRoom - resetFilters() - } - - fun createMessagesToMeFilter(): TypedFilter? { - val myPeer = withRoom - - return if (myPeer != null) { - TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = - JsonFilter( - kinds = listOf(PrivateDmEvent.KIND), - authors = myPeer.users.map { it }, - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(withRoom.hashCode().toString()) - ?.relayList, - ), - ) - } else { - null + fun loadMessagesBetween( + accountIn: Account, + withRoom: ChatroomKey, + ) { + this.account = accountIn + this.withRoom = withRoom + resetFilters() } - } - fun createMessagesFromMeFilter(): TypedFilter? { - val myPeer = withRoom + fun createMessagesToMeFilter(): TypedFilter? { + val myPeer = withRoom - return if (myPeer != null) { - TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = - JsonFilter( - kinds = listOf(PrivateDmEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - tags = mapOf("p" to myPeer.users.map { it }), - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(withRoom.hashCode().toString()) - ?.relayList, - ), - ) - } else { - null + return if (myPeer != null) { + TypedFilter( + types = setOf(FeedType.PRIVATE_DMS), + filter = + JsonFilter( + kinds = listOf(PrivateDmEvent.KIND), + authors = myPeer.users.map { it }, + tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(withRoom.hashCode().toString()) + ?.relayList, + ), + ) + } else { + null + } } - } - fun clearEOSEs(account: Account) { - latestEOSEs.removeDataFor(account.userProfile()) - } + fun createMessagesFromMeFilter(): TypedFilter? { + val myPeer = withRoom - val inandoutChannel = requestNewChannel { time, relayUrl -> - latestEOSEs.addOrUpdate(account.userProfile(), withRoom.hashCode().toString(), relayUrl, time) - } + return if (myPeer != null) { + TypedFilter( + types = setOf(FeedType.PRIVATE_DMS), + filter = + JsonFilter( + kinds = listOf(PrivateDmEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + tags = mapOf("p" to myPeer.users.map { it }), + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(withRoom.hashCode().toString()) + ?.relayList, + ), + ) + } else { + null + } + } - override fun updateChannelFilters() { - inandoutChannel.typedFilters = - listOfNotNull( - createMessagesToMeFilter(), - createMessagesFromMeFilter(), - ) - .ifEmpty { null } - } + fun clearEOSEs(account: Account) { + latestEOSEs.removeDataFor(account.userProfile()) + } + + val inandoutChannel = + requestNewChannel { time, relayUrl -> + latestEOSEs.addOrUpdate(account.userProfile(), withRoom.hashCode().toString(), relayUrl, time) + } + + override fun updateChannelFilters() { + inandoutChannel.typedFilters = + listOfNotNull( + createMessagesToMeFilter(), + createMessagesFromMeFilter(), + ) + .ifEmpty { null } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt index 0f0833d8f..f567be888 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt @@ -32,124 +32,125 @@ import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.PrivateDmEvent object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") { - lateinit var account: Account + lateinit var account: Account - val latestEOSEs = EOSEAccount() - val chatRoomList = "ChatroomList" + val latestEOSEs = EOSEAccount() + val chatRoomList = "ChatroomList" - fun createMessagesToMeFilter() = - TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = - JsonFilter( - kinds = listOf(PrivateDmEvent.KIND), - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - since = - latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList, - ), - ) - - fun createMessagesFromMeFilter() = - TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = - JsonFilter( - kinds = listOf(PrivateDmEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - since = - latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList, - ), - ) - - fun createChannelsCreatedbyMeFilter() = - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - JsonFilter( - kinds = listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - since = - latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList, - ), - ) - - fun createMyChannelsFilter(): TypedFilter? { - val followingEvents = account.selectedChatsFollowList() - - if (followingEvents.isEmpty()) return null - - return TypedFilter( - // Metadata comes from any relay - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(ChannelCreateEvent.KIND), - ids = followingEvents.toList(), - since = - latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList, - ), - ) - } - - fun createLastChannelInfoFilter(): List? { - val followingEvents = account.selectedChatsFollowList() - - if (followingEvents.isEmpty()) return null - - return followingEvents.map { - TypedFilter( - // Metadata comes from any relay - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(ChannelMetadataEvent.KIND), - tags = mapOf("e" to listOf(it)), - limit = 1, - ), - ) - } - } - - fun createLastMessageOfEachChannelFilter(): List? { - val followingEvents = account.selectedChatsFollowList() - - if (followingEvents.isEmpty()) return null - - return followingEvents.map { - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - JsonFilter( - kinds = listOf(ChannelMessageEvent.KIND), - tags = mapOf("e" to listOf(it)), - since = - latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList, - // Remember to consider spam that is being removed from the UI - limit = 50, - ), - ) - } - } - - val chatroomListChannel = requestNewChannel { time, relayUrl -> - latestEOSEs.addOrUpdate(account.userProfile(), chatRoomList, relayUrl, time) - } - - override fun updateChannelFilters() { - val list = - listOfNotNull( - createMessagesToMeFilter(), - createMessagesFromMeFilter(), - createMyChannelsFilter(), - ) - - chatroomListChannel.typedFilters = - listOfNotNull( - list, - createLastChannelInfoFilter(), - createLastMessageOfEachChannelFilter(), + fun createMessagesToMeFilter() = + TypedFilter( + types = setOf(FeedType.PRIVATE_DMS), + filter = + JsonFilter( + kinds = listOf(PrivateDmEvent.KIND), + tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), + since = + latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList, + ), ) - .flatten() - .ifEmpty { null } - } + + fun createMessagesFromMeFilter() = + TypedFilter( + types = setOf(FeedType.PRIVATE_DMS), + filter = + JsonFilter( + kinds = listOf(PrivateDmEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + since = + latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList, + ), + ) + + fun createChannelsCreatedbyMeFilter() = + TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + JsonFilter( + kinds = listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + since = + latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList, + ), + ) + + fun createMyChannelsFilter(): TypedFilter? { + val followingEvents = account.selectedChatsFollowList() + + if (followingEvents.isEmpty()) return null + + return TypedFilter( + // Metadata comes from any relay + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(ChannelCreateEvent.KIND), + ids = followingEvents.toList(), + since = + latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList, + ), + ) + } + + fun createLastChannelInfoFilter(): List? { + val followingEvents = account.selectedChatsFollowList() + + if (followingEvents.isEmpty()) return null + + return followingEvents.map { + TypedFilter( + // Metadata comes from any relay + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(ChannelMetadataEvent.KIND), + tags = mapOf("e" to listOf(it)), + limit = 1, + ), + ) + } + } + + fun createLastMessageOfEachChannelFilter(): List? { + val followingEvents = account.selectedChatsFollowList() + + if (followingEvents.isEmpty()) return null + + return followingEvents.map { + TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + JsonFilter( + kinds = listOf(ChannelMessageEvent.KIND), + tags = mapOf("e" to listOf(it)), + since = + latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList, + // Remember to consider spam that is being removed from the UI + limit = 50, + ), + ) + } + } + + val chatroomListChannel = + requestNewChannel { time, relayUrl -> + latestEOSEs.addOrUpdate(account.userProfile(), chatRoomList, relayUrl, time) + } + + override fun updateChannelFilters() { + val list = + listOfNotNull( + createMessagesToMeFilter(), + createMessagesFromMeFilter(), + createMyChannelsFilter(), + ) + + chatroomListChannel.typedFilters = + listOfNotNull( + list, + createLastChannelInfoFilter(), + createLastMessageOfEachChannelFilter(), + ) + .flatten() + .ifEmpty { null } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt index cd1bc4c74..955ee622f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt @@ -28,40 +28,40 @@ import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent object NostrCommunityDataSource : NostrDataSource("SingleCommunityFeed") { - private var communityToWatch: AddressableNote? = null + private var communityToWatch: AddressableNote? = null - private fun createLoadCommunityFilter(): TypedFilter? { - val myCommunityToWatch = communityToWatch ?: return null + private fun createLoadCommunityFilter(): TypedFilter? { + val myCommunityToWatch = communityToWatch ?: return null - val community = myCommunityToWatch.event as? CommunityDefinitionEvent ?: return null + val community = myCommunityToWatch.event as? CommunityDefinitionEvent ?: return null - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - authors = - community - .moderators() - .map { it.key } - .plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex)), - tags = - mapOf( - "a" to listOf(myCommunityToWatch.address.toTag()), - ), - kinds = listOf(CommunityPostApprovalEvent.KIND), - limit = 500, - ), - ) - } + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + authors = + community + .moderators() + .map { it.key } + .plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex)), + tags = + mapOf( + "a" to listOf(myCommunityToWatch.address.toTag()), + ), + kinds = listOf(CommunityPostApprovalEvent.KIND), + limit = 500, + ), + ) + } - val loadCommunityChannel = requestNewChannel() + val loadCommunityChannel = requestNewChannel() - override fun updateChannelFilters() { - loadCommunityChannel.typedFilters = listOfNotNull(createLoadCommunityFilter()).ifEmpty { null } - } + override fun updateChannelFilters() { + loadCommunityChannel.typedFilters = listOfNotNull(createLoadCommunityFilter()).ifEmpty { null } + } - fun loadCommunity(note: AddressableNote?) { - communityToWatch = note - invalidateFilters() - } + fun loadCommunity(note: AddressableNote?) { + communityToWatch = note + invalidateFilters() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index 6e2bbb477..c672d6cca 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -28,302 +28,302 @@ import com.vitorpamplona.amethyst.service.relays.Subscription import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.utils.TimeUtils -import java.util.UUID -import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean abstract class NostrDataSource(val debugName: String) { - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private var subscriptions = mapOf() + private var subscriptions = mapOf() - data class Counter(var counter: Int) + data class Counter(var counter: Int) - private var eventCounter = mapOf() - var changingFilters = AtomicBoolean() + private var eventCounter = mapOf() + var changingFilters = AtomicBoolean() - private var active: Boolean = false + private var active: Boolean = false - fun printCounter() { - eventCounter.forEach { - Log.d( - "STATE DUMP ${this.javaClass.simpleName}", - "Received Events ${it.key}: ${it.value.counter}", - ) + fun printCounter() { + eventCounter.forEach { + Log.d( + "STATE DUMP ${this.javaClass.simpleName}", + "Received Events ${it.key}: ${it.value.counter}", + ) + } } - } - private val clientListener = - object : Client.Listener() { - override fun onEvent( + private val clientListener = + object : Client.Listener() { + override fun onEvent( + event: Event, + subscriptionId: String, + relay: Relay, + afterEOSE: Boolean, + ) { + if (subscriptions.containsKey(subscriptionId)) { + val key = "$debugName $subscriptionId ${event.kind}" + val keyValue = eventCounter.get(key) + if (keyValue != null) { + keyValue.counter++ + } else { + eventCounter = eventCounter + Pair(key, Counter(1)) + } + + // Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url}: ${event.kind}") + + consume(event, relay) + if (afterEOSE) { + markAsEOSE(subscriptionId, relay) + } + } + } + + override fun onError( + error: Error, + subscriptionId: String, + relay: Relay, + ) { + // if (subscriptions.containsKey(subscriptionId)) { + // Log.e( + // this@NostrDataSource.javaClass.simpleName, + // "Relay OnError ${relay.url}: ${error.message}" + // ) + // } + } + + override fun onRelayStateChange( + type: Relay.StateType, + relay: Relay, + subscriptionId: String?, + ) { + // if (subscriptions.containsKey(subscriptionId)) { + // Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url} ${subscriptionId} + // ${type.name}") + // } + + if ( + type == Relay.StateType.EOSE && + subscriptionId != null && + subscriptions.containsKey(subscriptionId) + ) { + markAsEOSE(subscriptionId, relay) + } + } + + override fun onSendResponse( + eventId: String, + success: Boolean, + message: String, + relay: Relay, + ) { + if (success) { + markAsSeenOnRelay(eventId, relay) + } + } + + override fun onAuth( + relay: Relay, + challenge: String, + ) { + auth(relay, challenge) + } + + override fun onNotify( + relay: Relay, + description: String, + ) { + notify(relay, description) + } + } + + init { + Log.d(this.javaClass.simpleName, "${this.javaClass.simpleName} Subscribe") + Client.subscribe(clientListener) + } + + fun destroy() { + // makes sure to run + Log.d(this.javaClass.simpleName, "${this.javaClass.simpleName} Unsubscribe") + stop() + Client.unsubscribe(clientListener) + scope.cancel() + bundler.cancel() + } + + open fun start() { + println("DataSource: ${this.javaClass.simpleName} Start") + active = true + resetFilters() + } + + open fun stop() { + active = false + println("DataSource: ${this.javaClass.simpleName} Stop") + + GlobalScope.launch(Dispatchers.IO) { + subscriptions.values.forEach { subscription -> + Client.close(subscription.id) + subscription.typedFilters = null + } + } + } + + open fun stopSync() { + active = false + println("DataSource: ${this.javaClass.simpleName} Stop") + + subscriptions.values.forEach { subscription -> + Client.close(subscription.id) + subscription.typedFilters = null + } + } + + fun requestNewChannel(onEOSE: ((Long, String) -> Unit)? = null): Subscription { + val newSubscription = Subscription(UUID.randomUUID().toString().substring(0, 4), onEOSE) + subscriptions = subscriptions + Pair(newSubscription.id, newSubscription) + return newSubscription + } + + fun dismissChannel(subscription: Subscription) { + Client.close(subscription.id) + subscriptions = subscriptions.minus(subscription.id) + } + + // Refreshes observers in batches. + private val bundler = BundledUpdate(300, Dispatchers.IO) + + fun invalidateFilters() { + scope.launch(Dispatchers.IO) { + bundler.invalidate { + // println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") + + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + resetFiltersSuspend() + } + } + } + + fun resetFilters() { + scope.launch(Dispatchers.IO) { resetFiltersSuspend() } + } + + fun resetFiltersSuspend() { + println("DataSource: ${this.javaClass.simpleName} resetFiltersSuspend $active") + checkNotInMainThread() + + // saves the channels that are currently active + val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null } + // saves the current content to only update if it changes + val currentFilters = activeSubscriptions.associate { it.id to it.toJson() } + + changingFilters.getAndSet(true) + + updateChannelFilters() + + // Makes sure to only send an updated filter when it actually changes. + subscriptions.values.forEach { updatedSubscription -> + val updatedSubscriptionNewFilters = updatedSubscription.typedFilters + + val isActive = Client.isActive(updatedSubscription.id) + + if (!isActive && updatedSubscriptionNewFilters != null) { + // Filter was removed from the active list + if (active) { + Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) + } + } else { + if (currentFilters.containsKey(updatedSubscription.id)) { + if (updatedSubscriptionNewFilters == null) { + // was active and is not active anymore, just close. + Client.close(updatedSubscription.id) + } else { + // was active and is still active, check if it has changed. + if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) { + Client.close(updatedSubscription.id) + if (active) { + Log.d( + this@NostrDataSource.javaClass.simpleName, + "Update Filter 1 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}", + ) + Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) + } + } else { + // hasn't changed, does nothing. + if (active) { + Log.d( + this@NostrDataSource.javaClass.simpleName, + "Update Filter 2 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}", + ) + Client.sendFilterOnlyIfDisconnected( + updatedSubscription.id, + updatedSubscriptionNewFilters, + ) + } + } + } + } else { + if (updatedSubscriptionNewFilters == null) { + // was not active and is still not active, does nothing + } else { + // was not active and becomes active, sends the filter. + if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) { + if (active) { + Log.d( + this@NostrDataSource.javaClass.simpleName, + "Update Filter 3 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}", + ) + Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) + } + } + } + } + } + } + + changingFilters.getAndSet(false) + } + + open fun consume( event: Event, - subscriptionId: String, relay: Relay, - afterEOSE: Boolean, - ) { - if (subscriptions.containsKey(subscriptionId)) { - val key = "$debugName $subscriptionId ${event.kind}" - val keyValue = eventCounter.get(key) - if (keyValue != null) { - keyValue.counter++ - } else { - eventCounter = eventCounter + Pair(key, Counter(1)) - } + ) { + LocalCache.verifyAndConsume(event, relay) + } - // Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url}: ${event.kind}") - - consume(event, relay) - if (afterEOSE) { - markAsEOSE(subscriptionId, relay) - } - } - } - - override fun onError( - error: Error, - subscriptionId: String, - relay: Relay, - ) { - // if (subscriptions.containsKey(subscriptionId)) { - // Log.e( - // this@NostrDataSource.javaClass.simpleName, - // "Relay OnError ${relay.url}: ${error.message}" - // ) - // } - } - - override fun onRelayStateChange( - type: Relay.StateType, - relay: Relay, - subscriptionId: String?, - ) { - // if (subscriptions.containsKey(subscriptionId)) { - // Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url} ${subscriptionId} - // ${type.name}") - // } - - if ( - type == Relay.StateType.EOSE && - subscriptionId != null && - subscriptions.containsKey(subscriptionId) - ) { - markAsEOSE(subscriptionId, relay) - } - } - - override fun onSendResponse( + open fun markAsSeenOnRelay( eventId: String, - success: Boolean, - message: String, relay: Relay, - ) { - if (success) { - markAsSeenOnRelay(eventId, relay) - } - } + ) { + LocalCache.getNoteIfExists(eventId)?.addRelay(relay) + } - override fun onAuth( + open fun markAsEOSE( + subscriptionId: String, + relay: Relay, + ) { + subscriptions[subscriptionId]?.updateEOSE( + // in case people's clock is slighly off. + TimeUtils.oneMinuteAgo(), + relay.url, + ) + } + + abstract fun updateChannelFilters() + + open fun auth( relay: Relay, challenge: String, - ) { - auth(relay, challenge) - } + ) = Unit - override fun onNotify( + open fun notify( relay: Relay, description: String, - ) { - notify(relay, description) - } - } - - init { - Log.d(this.javaClass.simpleName, "${this.javaClass.simpleName} Subscribe") - Client.subscribe(clientListener) - } - - fun destroy() { - // makes sure to run - Log.d(this.javaClass.simpleName, "${this.javaClass.simpleName} Unsubscribe") - stop() - Client.unsubscribe(clientListener) - scope.cancel() - bundler.cancel() - } - - open fun start() { - println("DataSource: ${this.javaClass.simpleName} Start") - active = true - resetFilters() - } - - open fun stop() { - active = false - println("DataSource: ${this.javaClass.simpleName} Stop") - - GlobalScope.launch(Dispatchers.IO) { - subscriptions.values.forEach { subscription -> - Client.close(subscription.id) - subscription.typedFilters = null - } - } - } - - open fun stopSync() { - active = false - println("DataSource: ${this.javaClass.simpleName} Stop") - - subscriptions.values.forEach { subscription -> - Client.close(subscription.id) - subscription.typedFilters = null - } - } - - fun requestNewChannel(onEOSE: ((Long, String) -> Unit)? = null): Subscription { - val newSubscription = Subscription(UUID.randomUUID().toString().substring(0, 4), onEOSE) - subscriptions = subscriptions + Pair(newSubscription.id, newSubscription) - return newSubscription - } - - fun dismissChannel(subscription: Subscription) { - Client.close(subscription.id) - subscriptions = subscriptions.minus(subscription.id) - } - - // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.IO) - - fun invalidateFilters() { - scope.launch(Dispatchers.IO) { - bundler.invalidate { - // println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") - - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - resetFiltersSuspend() - } - } - } - - fun resetFilters() { - scope.launch(Dispatchers.IO) { resetFiltersSuspend() } - } - - fun resetFiltersSuspend() { - println("DataSource: ${this.javaClass.simpleName} resetFiltersSuspend $active") - checkNotInMainThread() - - // saves the channels that are currently active - val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null } - // saves the current content to only update if it changes - val currentFilters = activeSubscriptions.associate { it.id to it.toJson() } - - changingFilters.getAndSet(true) - - updateChannelFilters() - - // Makes sure to only send an updated filter when it actually changes. - subscriptions.values.forEach { updatedSubscription -> - val updatedSubscriptionNewFilters = updatedSubscription.typedFilters - - val isActive = Client.isActive(updatedSubscription.id) - - if (!isActive && updatedSubscriptionNewFilters != null) { - // Filter was removed from the active list - if (active) { - Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) - } - } else { - if (currentFilters.containsKey(updatedSubscription.id)) { - if (updatedSubscriptionNewFilters == null) { - // was active and is not active anymore, just close. - Client.close(updatedSubscription.id) - } else { - // was active and is still active, check if it has changed. - if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) { - Client.close(updatedSubscription.id) - if (active) { - Log.d( - this@NostrDataSource.javaClass.simpleName, - "Update Filter 1 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}", - ) - Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) - } - } else { - // hasn't changed, does nothing. - if (active) { - Log.d( - this@NostrDataSource.javaClass.simpleName, - "Update Filter 2 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}", - ) - Client.sendFilterOnlyIfDisconnected( - updatedSubscription.id, - updatedSubscriptionNewFilters, - ) - } - } - } - } else { - if (updatedSubscriptionNewFilters == null) { - // was not active and is still not active, does nothing - } else { - // was not active and becomes active, sends the filter. - if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) { - if (active) { - Log.d( - this@NostrDataSource.javaClass.simpleName, - "Update Filter 3 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}", - ) - Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) - } - } - } - } - } - } - - changingFilters.getAndSet(false) - } - - open fun consume( - event: Event, - relay: Relay, - ) { - LocalCache.verifyAndConsume(event, relay) - } - - open fun markAsSeenOnRelay( - eventId: String, - relay: Relay, - ) { - LocalCache.getNoteIfExists(eventId)?.addRelay(relay) - } - - open fun markAsEOSE( - subscriptionId: String, - relay: Relay, - ) { - subscriptions[subscriptionId]?.updateEOSE( - // in case people's clock is slighly off. - TimeUtils.oneMinuteAgo(), - relay.url, - ) - } - - abstract fun updateChannelFilters() - - open fun auth( - relay: Relay, - challenge: String, - ) = Unit - - open fun notify( - relay: Relay, - description: String, - ) = Unit + ) = Unit } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt index 14f3f8d53..07a795216 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt @@ -39,384 +39,385 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { - lateinit var account: Account + lateinit var account: Account - val scope = Amethyst.instance.applicationIOScope - val latestEOSEs = EOSEAccount() + val scope = Amethyst.instance.applicationIOScope + val latestEOSEs = EOSEAccount() - var job: Job? = null + var job: Job? = null - override fun start() { - job?.cancel() - job = - scope.launch(Dispatchers.IO) { - account.liveDiscoveryFollowLists.collect { - if (this@NostrDiscoveryDataSource::account.isInitialized) { - invalidateFilters() - } + override fun start() { + job?.cancel() + job = + scope.launch(Dispatchers.IO) { + account.liveDiscoveryFollowLists.collect { + if (this@NostrDiscoveryDataSource::account.isInitialized) { + invalidateFilters() + } + } + } + super.start() + } + + override fun stop() { + super.stop() + job?.cancel() + } + + fun createMarketplaceFilter(): List { + val follows = account.liveDiscoveryFollowLists.value?.users?.toList() + val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList() + val geohashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList() + + return listOfNotNull( + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + authors = follows, + kinds = listOf(ClassifiedsEvent.KIND), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ), + hashToLoad?.let { + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + kinds = listOf(ClassifiedsEvent.KIND), + tags = + mapOf( + "t" to + it + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ) + }, + geohashToLoad?.let { + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + kinds = listOf(ClassifiedsEvent.KIND), + tags = + mapOf( + "g" to + it + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ) + }, + ) + } + + fun createLiveStreamFilter(): List { + val follows = account.liveDiscoveryFollowLists.value?.users?.toList() + + return listOfNotNull( + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + authors = follows, + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ), + follows?.let { + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + tags = mapOf("p" to it), + kinds = listOf(LiveActivitiesEvent.KIND), + limit = 100, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ) + }, + ) + } + + fun createPublicChatFilter(): List { + val follows = account.liveDiscoveryFollowLists.value?.users?.toList() + val followChats = account.selectedChatsFollowList().toList() + + return listOfNotNull( + TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + JsonFilter( + authors = follows, + kinds = + listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ), + if (followChats.isNotEmpty()) { + TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + JsonFilter( + ids = followChats, + kinds = listOf(ChannelCreateEvent.KIND), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ) + } else { + null + }, + ) + } + + fun createCommunitiesFilter(): TypedFilter { + val follows = account.liveDiscoveryFollowLists.value?.users?.toList() + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + authors = follows, + kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ) + } + + fun createLiveStreamTagsFilter(): TypedFilter? { + val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList() + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), + tags = + mapOf( + "t" to + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ) + } + + fun createLiveStreamGeohashesFilter(): TypedFilter? { + val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList() + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), + tags = + mapOf( + "g" to + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ) + } + + fun createPublicChatsTagsFilter(): TypedFilter? { + val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList() + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + JsonFilter( + kinds = + listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND), + tags = + mapOf( + "t" to + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ) + } + + fun createPublicChatsGeohashesFilter(): TypedFilter? { + val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList() + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + JsonFilter( + kinds = + listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND), + tags = + mapOf( + "g" to + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ) + } + + fun createCommunitiesTagsFilter(): TypedFilter? { + val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList() + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), + tags = + mapOf( + "t" to + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ) + } + + fun createCommunitiesGeohashesFilter(): TypedFilter? { + val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList() + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), + tags = + mapOf( + "g" to + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ) + } + + val discoveryFeedChannel = + requestNewChannel { time, relayUrl -> + latestEOSEs.addOrUpdate( + account.userProfile(), + account.defaultDiscoveryFollowList.value, + relayUrl, + time, + ) } - } - super.start() - } - override fun stop() { - super.stop() - job?.cancel() - } - - fun createMarketplaceFilter(): List { - val follows = account.liveDiscoveryFollowLists.value?.users?.toList() - val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList() - val geohashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList() - - return listOfNotNull( - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - JsonFilter( - authors = follows, - kinds = listOf(ClassifiedsEvent.KIND), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ), - hashToLoad?.let { - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - JsonFilter( - kinds = listOf(ClassifiedsEvent.KIND), - tags = - mapOf( - "t" to - it - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - }, - geohashToLoad?.let { - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - JsonFilter( - kinds = listOf(ClassifiedsEvent.KIND), - tags = - mapOf( - "g" to - it - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - }, - ) - } - - fun createLiveStreamFilter(): List { - val follows = account.liveDiscoveryFollowLists.value?.users?.toList() - - return listOfNotNull( - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - JsonFilter( - authors = follows, - kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ), - follows?.let { - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - JsonFilter( - tags = mapOf("p" to it), - kinds = listOf(LiveActivitiesEvent.KIND), - limit = 100, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - }, - ) - } - - fun createPublicChatFilter(): List { - val follows = account.liveDiscoveryFollowLists.value?.users?.toList() - val followChats = account.selectedChatsFollowList().toList() - - return listOfNotNull( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - JsonFilter( - authors = follows, - kinds = - listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ), - if (followChats.isNotEmpty()) { - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - JsonFilter( - ids = followChats, - kinds = listOf(ChannelCreateEvent.KIND), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } else { - null - }, - ) - } - - fun createCommunitiesFilter(): TypedFilter { - val follows = account.liveDiscoveryFollowLists.value?.users?.toList() - - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - JsonFilter( - authors = follows, - kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } - - fun createLiveStreamTagsFilter(): TypedFilter? { - val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - JsonFilter( - kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), - tags = - mapOf( - "t" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } - - fun createLiveStreamGeohashesFilter(): TypedFilter? { - val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - JsonFilter( - kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), - tags = - mapOf( - "g" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } - - fun createPublicChatsTagsFilter(): TypedFilter? { - val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - JsonFilter( - kinds = - listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND), - tags = - mapOf( - "t" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } - - fun createPublicChatsGeohashesFilter(): TypedFilter? { - val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - JsonFilter( - kinds = - listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND), - tags = - mapOf( - "g" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } - - fun createCommunitiesTagsFilter(): TypedFilter? { - val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - JsonFilter( - kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), - tags = - mapOf( - "t" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } - - fun createCommunitiesGeohashesFilter(): TypedFilter? { - val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - JsonFilter( - kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), - tags = - mapOf( - "g" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } - - val discoveryFeedChannel = requestNewChannel { time, relayUrl -> - latestEOSEs.addOrUpdate( - account.userProfile(), - account.defaultDiscoveryFollowList.value, - relayUrl, - time, - ) - } - - override fun updateChannelFilters() { - discoveryFeedChannel.typedFilters = - createLiveStreamFilter() - .plus(createPublicChatFilter()) - .plus(createMarketplaceFilter()) - .plus( - listOfNotNull( - createLiveStreamTagsFilter(), - createLiveStreamGeohashesFilter(), - createCommunitiesFilter(), - createCommunitiesTagsFilter(), - createCommunitiesGeohashesFilter(), - createPublicChatsTagsFilter(), - createPublicChatsGeohashesFilter(), - ), - ) - .ifEmpty { null } - } + override fun updateChannelFilters() { + discoveryFeedChannel.typedFilters = + createLiveStreamFilter() + .plus(createPublicChatFilter()) + .plus(createMarketplaceFilter()) + .plus( + listOfNotNull( + createLiveStreamTagsFilter(), + createLiveStreamGeohashesFilter(), + createCommunitiesFilter(), + createCommunitiesTagsFilter(), + createCommunitiesGeohashesFilter(), + createPublicChatsTagsFilter(), + createPublicChatsGeohashesFilter(), + ), + ) + .ifEmpty { null } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt index 0c0da05c3..2daaef580 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt @@ -34,48 +34,48 @@ import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent object NostrGeohashDataSource : NostrDataSource("SingleGeoHashFeed") { - private var geohashToWatch: String? = null + private var geohashToWatch: String? = null - fun createLoadHashtagFilter(): TypedFilter? { - val hashToLoad = geohashToWatch ?: return null + fun createLoadHashtagFilter(): TypedFilter? { + val hashToLoad = geohashToWatch ?: return null - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - tags = - mapOf( - "g" to - listOf( - hashToLoad, + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + tags = + mapOf( + "g" to + listOf( + hashToLoad, + ), + ), + kinds = + listOf( + TextNoteEvent.KIND, + ChannelMessageEvent.KIND, + LongTextNoteEvent.KIND, + PollNoteEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + ), + limit = 200, ), - ), - kinds = - listOf( - TextNoteEvent.KIND, - ChannelMessageEvent.KIND, - LongTextNoteEvent.KIND, - PollNoteEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - AudioTrackEvent.KIND, - AudioHeaderEvent.KIND, - ), - limit = 200, - ), - ) - } + ) + } - val loadGeohashChannel = requestNewChannel() + val loadGeohashChannel = requestNewChannel() - override fun updateChannelFilters() { - loadGeohashChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null } - } + override fun updateChannelFilters() { + loadGeohashChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null } + } - fun loadHashtag(tag: String?) { - geohashToWatch = tag + fun loadHashtag(tag: String?) { + geohashToWatch = tag - invalidateFilters() - } + invalidateFilters() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt index 62ea28487..fa3f87f36 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt @@ -34,51 +34,51 @@ import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") { - private var hashtagToWatch: String? = null + private var hashtagToWatch: String? = null - fun createLoadHashtagFilter(): TypedFilter? { - val hashToLoad = hashtagToWatch ?: return null + fun createLoadHashtagFilter(): TypedFilter? { + val hashToLoad = hashtagToWatch ?: return null - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - tags = - mapOf( - "t" to - listOf( - hashToLoad, - hashToLoad.lowercase(), - hashToLoad.uppercase(), - hashToLoad.capitalize(), + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + tags = + mapOf( + "t" to + listOf( + hashToLoad, + hashToLoad.lowercase(), + hashToLoad.uppercase(), + hashToLoad.capitalize(), + ), + ), + kinds = + listOf( + TextNoteEvent.KIND, + ChannelMessageEvent.KIND, + LongTextNoteEvent.KIND, + PollNoteEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + ), + limit = 200, ), - ), - kinds = - listOf( - TextNoteEvent.KIND, - ChannelMessageEvent.KIND, - LongTextNoteEvent.KIND, - PollNoteEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - AudioTrackEvent.KIND, - AudioHeaderEvent.KIND, - ), - limit = 200, - ), - ) - } + ) + } - val loadHashtagChannel = requestNewChannel() + val loadHashtagChannel = requestNewChannel() - override fun updateChannelFilters() { - loadHashtagChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null } - } + override fun updateChannelFilters() { + loadHashtagChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null } + } - fun loadHashtag(tag: String?) { - hashtagToWatch = tag + fun loadHashtag(tag: String?) { + hashtagToWatch = tag - invalidateFilters() - } + invalidateFilters() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index 577c0c072..c3a0254be 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -45,190 +45,191 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext object NostrHomeDataSource : NostrDataSource("HomeFeed") { - lateinit var account: Account + lateinit var account: Account - val scope = Amethyst.instance.applicationIOScope - val latestEOSEs = EOSEAccount() + val scope = Amethyst.instance.applicationIOScope + val latestEOSEs = EOSEAccount() - var job: Job? = null + var job: Job? = null - override fun start() { - job?.cancel() - job = - scope.launch(Dispatchers.IO) { - // creates cache on main - withContext(Dispatchers.Main) { account.userProfile().live() } - account.liveHomeFollowLists.collect { - if (this@NostrHomeDataSource::account.isInitialized) { - invalidateFilters() - } - } - } - super.start() - } + override fun start() { + job?.cancel() + job = + scope.launch(Dispatchers.IO) { + // creates cache on main + withContext(Dispatchers.Main) { account.userProfile().live() } + account.liveHomeFollowLists.collect { + if (this@NostrHomeDataSource::account.isInitialized) { + invalidateFilters() + } + } + } + super.start() + } - override fun stop() { - super.stop() - job?.cancel() - } + override fun stop() { + super.stop() + job?.cancel() + } - fun createFollowAccountsFilter(): TypedFilter { - val follows = account.liveHomeFollowLists.value?.users - val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null } + fun createFollowAccountsFilter(): TypedFilter { + val follows = account.liveHomeFollowLists.value?.users + val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null } - return TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = - JsonFilter( - kinds = - listOf( - TextNoteEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - ClassifiedsEvent.KIND, - LongTextNoteEvent.KIND, - PollNoteEvent.KIND, - HighlightEvent.KIND, - AudioTrackEvent.KIND, - AudioHeaderEvent.KIND, - PinListEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - LiveActivitiesEvent.KIND, - ), - authors = followSet, - limit = 400, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultHomeFollowList.value) - ?.relayList, - ), - ) - } - - fun createFollowTagsFilter(): TypedFilter? { - val hashToLoad = account.liveHomeFollowLists.value?.hashtags ?: return null - - if (hashToLoad.isEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = - JsonFilter( - kinds = - listOf( - TextNoteEvent.KIND, - LongTextNoteEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - AudioHeaderEvent.KIND, - AudioTrackEvent.KIND, - PinListEvent.KIND, - ), - tags = - mapOf( - "t" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), - limit = 100, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultHomeFollowList.value) - ?.relayList, - ), - ) - } - - fun createFollowGeohashesFilter(): TypedFilter? { - val hashToLoad = account.liveHomeFollowLists.value?.geotags ?: return null - - if (hashToLoad.isEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = - JsonFilter( - kinds = - listOf( - TextNoteEvent.KIND, - LongTextNoteEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - AudioHeaderEvent.KIND, - AudioTrackEvent.KIND, - PinListEvent.KIND, - ), - tags = - mapOf( - "g" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), - limit = 100, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultHomeFollowList.value) - ?.relayList, - ), - ) - } - - fun createFollowCommunitiesFilter(): TypedFilter? { - val communitiesToLoad = account.liveHomeFollowLists.value?.communities ?: return null - - if (communitiesToLoad.isEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = - JsonFilter( - kinds = - listOf( - TextNoteEvent.KIND, - LongTextNoteEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - AudioHeaderEvent.KIND, - AudioTrackEvent.KIND, - PinListEvent.KIND, - CommunityPostApprovalEvent.KIND, - ), - tags = - mapOf( - "a" to communitiesToLoad.toList(), - ), - limit = 100, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultHomeFollowList.value) - ?.relayList, - ), - ) - } - - val followAccountChannel = requestNewChannel { time, relayUrl -> - latestEOSEs.addOrUpdate( - account.userProfile(), - account.defaultHomeFollowList.value, - relayUrl, - time, - ) - } - - override fun updateChannelFilters() { - followAccountChannel.typedFilters = - listOfNotNull( - createFollowAccountsFilter(), - createFollowCommunitiesFilter(), - createFollowTagsFilter(), - createFollowGeohashesFilter(), + return TypedFilter( + types = setOf(FeedType.FOLLOWS), + filter = + JsonFilter( + kinds = + listOf( + TextNoteEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ClassifiedsEvent.KIND, + LongTextNoteEvent.KIND, + PollNoteEvent.KIND, + HighlightEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + PinListEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + LiveActivitiesEvent.KIND, + ), + authors = followSet, + limit = 400, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultHomeFollowList.value) + ?.relayList, + ), ) - .ifEmpty { null } - } + } + + fun createFollowTagsFilter(): TypedFilter? { + val hashToLoad = account.liveHomeFollowLists.value?.hashtags ?: return null + + if (hashToLoad.isEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.FOLLOWS), + filter = + JsonFilter( + kinds = + listOf( + TextNoteEvent.KIND, + LongTextNoteEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + AudioHeaderEvent.KIND, + AudioTrackEvent.KIND, + PinListEvent.KIND, + ), + tags = + mapOf( + "t" to + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 100, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultHomeFollowList.value) + ?.relayList, + ), + ) + } + + fun createFollowGeohashesFilter(): TypedFilter? { + val hashToLoad = account.liveHomeFollowLists.value?.geotags ?: return null + + if (hashToLoad.isEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.FOLLOWS), + filter = + JsonFilter( + kinds = + listOf( + TextNoteEvent.KIND, + LongTextNoteEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + AudioHeaderEvent.KIND, + AudioTrackEvent.KIND, + PinListEvent.KIND, + ), + tags = + mapOf( + "g" to + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 100, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultHomeFollowList.value) + ?.relayList, + ), + ) + } + + fun createFollowCommunitiesFilter(): TypedFilter? { + val communitiesToLoad = account.liveHomeFollowLists.value?.communities ?: return null + + if (communitiesToLoad.isEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.FOLLOWS), + filter = + JsonFilter( + kinds = + listOf( + TextNoteEvent.KIND, + LongTextNoteEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + AudioHeaderEvent.KIND, + AudioTrackEvent.KIND, + PinListEvent.KIND, + CommunityPostApprovalEvent.KIND, + ), + tags = + mapOf( + "a" to communitiesToLoad.toList(), + ), + limit = 100, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultHomeFollowList.value) + ?.relayList, + ), + ) + } + + val followAccountChannel = + requestNewChannel { time, relayUrl -> + latestEOSEs.addOrUpdate( + account.userProfile(), + account.defaultHomeFollowList.value, + relayUrl, + time, + ) + } + + override fun updateChannelFilters() { + followAccountChannel.typedFilters = + listOfNotNull( + createFollowAccountsFilter(), + createFollowCommunitiesFilter(), + createFollowTagsFilter(), + createFollowGeohashesFilter(), + ) + .ifEmpty { null } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt index f325165e6..adb340c95 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt @@ -30,50 +30,50 @@ import com.vitorpamplona.quartz.events.RelayAuthEvent import com.vitorpamplona.quartz.signers.NostrSigner class NostrLnZapPaymentResponseDataSource( - private val fromServiceHex: String, - private val toUserHex: String, - private val replyingToHex: String, - private val authSigner: NostrSigner, + private val fromServiceHex: String, + private val toUserHex: String, + private val replyingToHex: String, + private val authSigner: NostrSigner, ) : NostrDataSource("LnZapPaymentResponseFeed") { - val feedTypes = setOf(FeedType.WALLET_CONNECT) + val feedTypes = setOf(FeedType.WALLET_CONNECT) - private fun createWalletConnectServiceWatcher(): TypedFilter { - // downloads all the reactions to a given event. - return TypedFilter( - types = feedTypes, - filter = - JsonFilter( - kinds = listOf(LnZapPaymentResponseEvent.KIND), - authors = listOf(fromServiceHex), - tags = - mapOf( - "e" to listOf(replyingToHex), - "p" to listOf(toUserHex), - ), - limit = 1, - ), - ) - } - - val channel = requestNewChannel() - - override fun updateChannelFilters() { - val wc = createWalletConnectServiceWatcher() - - channel.typedFilters = listOfNotNull(wc).ifEmpty { null } - } - - override fun auth( - relay: Relay, - challenge: String, - ) { - super.auth(relay, challenge) - - RelayAuthEvent.create(relay.url, challenge, authSigner) { - Client.send( - it, - relay.url, - ) + private fun createWalletConnectServiceWatcher(): TypedFilter { + // downloads all the reactions to a given event. + return TypedFilter( + types = feedTypes, + filter = + JsonFilter( + kinds = listOf(LnZapPaymentResponseEvent.KIND), + authors = listOf(fromServiceHex), + tags = + mapOf( + "e" to listOf(replyingToHex), + "p" to listOf(toUserHex), + ), + limit = 1, + ), + ) + } + + val channel = requestNewChannel() + + override fun updateChannelFilters() { + val wc = createWalletConnectServiceWatcher() + + channel.typedFilters = listOfNotNull(wc).ifEmpty { null } + } + + override fun auth( + relay: Relay, + challenge: String, + ) { + super.auth(relay, challenge) + + RelayAuthEvent.create(relay.url, challenge, authSigner) { + Client.send( + it, + relay.url, + ) + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt index 7c8a799ab..a4a3a39ec 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt @@ -48,120 +48,120 @@ import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") { - private var searchString: String? = null + private var searchString: String? = null - private fun createAnythingWithIDFilter(): List? { - val mySearchString = searchString - if (mySearchString.isNullOrBlank()) { - return null - } + private fun createAnythingWithIDFilter(): List? { + val mySearchString = searchString + if (mySearchString.isNullOrBlank()) { + return null + } - val hexToWatch = - try { - val isAStraightHex = - if (HexValidator.isHex(mySearchString)) { - Hex.decode(mySearchString).toHexKey() - } else { - null - } + val hexToWatch = + try { + val isAStraightHex = + if (HexValidator.isHex(mySearchString)) { + Hex.decode(mySearchString).toHexKey() + } else { + null + } - Nip19.uriToRoute(mySearchString)?.hex ?: isAStraightHex - } catch (e: Exception) { - null - } + Nip19.uriToRoute(mySearchString)?.hex ?: isAStraightHex + } catch (e: Exception) { + null + } - // downloads all the reactions to a given event. - return listOfNotNull( - hexToWatch?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - ids = listOfNotNull(hexToWatch), + // downloads all the reactions to a given event. + return listOfNotNull( + hexToWatch?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + ids = listOfNotNull(hexToWatch), + ), + ) + }, + hexToWatch?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(MetadataEvent.KIND), + authors = listOfNotNull(hexToWatch), + ), + ) + }, + TypedFilter( + types = setOf(FeedType.SEARCH), + filter = + JsonFilter( + kinds = listOf(MetadataEvent.KIND), + search = mySearchString, + limit = 100, + ), + ), + TypedFilter( + types = setOf(FeedType.SEARCH), + filter = + JsonFilter( + kinds = + listOf( + TextNoteEvent.KIND, + LongTextNoteEvent.KIND, + BadgeDefinitionEvent.KIND, + PeopleListEvent.KIND, + BookmarkListEvent.KIND, + AudioHeaderEvent.KIND, + AudioTrackEvent.KIND, + PinListEvent.KIND, + PollNoteEvent.KIND, + ChannelCreateEvent.KIND, + ), + search = mySearchString, + limit = 100, + ), + ), + TypedFilter( + types = setOf(FeedType.SEARCH), + filter = + JsonFilter( + kinds = + listOf( + ChannelMetadataEvent.KIND, + ClassifiedsEvent.KIND, + CommunityDefinitionEvent.KIND, + EmojiPackEvent.KIND, + HighlightEvent.KIND, + LiveActivitiesEvent.KIND, + PollNoteEvent.KIND, + NNSEvent.KIND, + ), + search = mySearchString, + limit = 100, + ), ), ) - }, - hexToWatch?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(MetadataEvent.KIND), - authors = listOfNotNull(hexToWatch), - ), - ) - }, - TypedFilter( - types = setOf(FeedType.SEARCH), - filter = - JsonFilter( - kinds = listOf(MetadataEvent.KIND), - search = mySearchString, - limit = 100, - ), - ), - TypedFilter( - types = setOf(FeedType.SEARCH), - filter = - JsonFilter( - kinds = - listOf( - TextNoteEvent.KIND, - LongTextNoteEvent.KIND, - BadgeDefinitionEvent.KIND, - PeopleListEvent.KIND, - BookmarkListEvent.KIND, - AudioHeaderEvent.KIND, - AudioTrackEvent.KIND, - PinListEvent.KIND, - PollNoteEvent.KIND, - ChannelCreateEvent.KIND, - ), - search = mySearchString, - limit = 100, - ), - ), - TypedFilter( - types = setOf(FeedType.SEARCH), - filter = - JsonFilter( - kinds = - listOf( - ChannelMetadataEvent.KIND, - ClassifiedsEvent.KIND, - CommunityDefinitionEvent.KIND, - EmojiPackEvent.KIND, - HighlightEvent.KIND, - LiveActivitiesEvent.KIND, - PollNoteEvent.KIND, - NNSEvent.KIND, - ), - search = mySearchString, - limit = 100, - ), - ), - ) - } - - val searchChannel = requestNewChannel() - - override fun updateChannelFilters() { - searchChannel.typedFilters = createAnythingWithIDFilter() - } - - fun search(searchString: String) { - if (this.searchString != searchString) { - println("DataSource: ${this.javaClass.simpleName} Search for $searchString") - this.searchString = searchString - invalidateFilters() } - } - fun clear() { - if (searchString != null) { - println("DataSource: ${this.javaClass.simpleName} Clear") - searchString = null - invalidateFilters() + val searchChannel = requestNewChannel() + + override fun updateChannelFilters() { + searchChannel.typedFilters = createAnythingWithIDFilter() + } + + fun search(searchString: String) { + if (this.searchString != searchString) { + println("DataSource: ${this.javaClass.simpleName} Search for $searchString") + this.searchString = searchString + invalidateFilters() + } + } + + fun clear() { + if (searchString != null) { + println("DataSource: ${this.javaClass.simpleName} Clear") + searchString = null + invalidateFilters() + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt index cb09c9cb3..364460ab9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt @@ -31,95 +31,95 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") { - private var channelsToWatch = setOf() + private var channelsToWatch = setOf() - private fun createMetadataChangeFilter(): TypedFilter? { - val reactionsToWatch = channelsToWatch.filter { it is PublicChatChannel }.map { it.idHex } + private fun createMetadataChangeFilter(): TypedFilter? { + val reactionsToWatch = channelsToWatch.filter { it is PublicChatChannel }.map { it.idHex } - if (reactionsToWatch.isEmpty()) { - return null - } + if (reactionsToWatch.isEmpty()) { + return null + } - // downloads all the reactions to a given event. - return TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - JsonFilter( - kinds = listOf(ChannelMetadataEvent.KIND), - tags = mapOf("e" to reactionsToWatch), - ), - ) - } - - fun createLoadEventsIfNotLoadedFilter(): TypedFilter? { - val directEventsToLoad = - channelsToWatch.filter { it.notes.isEmpty() && it is PublicChatChannel } - - val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet() - - if (interestedEvents.isEmpty()) { - return null - } - - // downloads linked events to this event. - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(ChannelCreateEvent.KIND), - ids = interestedEvents.toList(), - ), - ) - } - - fun createLoadStreamingIfNotLoadedFilter(): List? { - val directEventsToLoad = - channelsToWatch.filterIsInstance().filter { it.info == null } - - val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet() - - if (interestedEvents.isEmpty()) { - return null - } - - // downloads linked events to this event. - return directEventsToLoad.map { - it.address().let { aTag -> - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(aTag.kind), - tags = mapOf("d" to listOf(aTag.dTag)), - authors = listOf(aTag.pubKeyHex), - ), + // downloads all the reactions to a given event. + return TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + JsonFilter( + kinds = listOf(ChannelMetadataEvent.KIND), + tags = mapOf("e" to reactionsToWatch), + ), ) - } } - } - val singleChannelChannel = requestNewChannel() + fun createLoadEventsIfNotLoadedFilter(): TypedFilter? { + val directEventsToLoad = + channelsToWatch.filter { it.notes.isEmpty() && it is PublicChatChannel } - override fun updateChannelFilters() { - val reactions = createMetadataChangeFilter() - val missing = createLoadEventsIfNotLoadedFilter() - val missingStreaming = createLoadStreamingIfNotLoadedFilter() + val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet() - singleChannelChannel.typedFilters = - ((listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList())).ifEmpty { null } - } + if (interestedEvents.isEmpty()) { + return null + } - fun add(eventId: Channel) { - if (eventId !in channelsToWatch) { - channelsToWatch = channelsToWatch.plus(eventId) - invalidateFilters() + // downloads linked events to this event. + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(ChannelCreateEvent.KIND), + ids = interestedEvents.toList(), + ), + ) } - } - fun remove(eventId: Channel) { - if (eventId in channelsToWatch) { - channelsToWatch = channelsToWatch.minus(eventId) - invalidateFilters() + fun createLoadStreamingIfNotLoadedFilter(): List? { + val directEventsToLoad = + channelsToWatch.filterIsInstance().filter { it.info == null } + + val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet() + + if (interestedEvents.isEmpty()) { + return null + } + + // downloads linked events to this event. + return directEventsToLoad.map { + it.address().let { aTag -> + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(aTag.kind), + tags = mapOf("d" to listOf(aTag.dTag)), + authors = listOf(aTag.pubKeyHex), + ), + ) + } + } + } + + val singleChannelChannel = requestNewChannel() + + override fun updateChannelFilters() { + val reactions = createMetadataChangeFilter() + val missing = createLoadEventsIfNotLoadedFilter() + val missingStreaming = createLoadStreamingIfNotLoadedFilter() + + singleChannelChannel.typedFilters = + ((listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList())).ifEmpty { null } + } + + fun add(eventId: Channel) { + if (eventId !in channelsToWatch) { + channelsToWatch = channelsToWatch.plus(eventId) + invalidateFilters() + } + } + + fun remove(eventId: Channel) { + if (eventId in channelsToWatch) { + channelsToWatch = channelsToWatch.minus(eventId) + invalidateFilters() + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index 461b5b603..3b4239a6d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -38,242 +38,245 @@ import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.TextNoteEvent object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { - private var eventsToWatch = setOf() - private var addressesToWatch = setOf() + private var eventsToWatch = setOf() + private var addressesToWatch = setOf() - private fun createReactionsToWatchInAddressFilter(): List? { - val addressesToWatch = - (eventsToWatch.filter { it.address() != null } + - addressesToWatch.filter { it.address() != null }) - .toSet() + private fun createReactionsToWatchInAddressFilter(): List? { + val addressesToWatch = + ( + eventsToWatch.filter { it.address() != null } + + addressesToWatch.filter { it.address() != null } + ) + .toSet() - if (addressesToWatch.isEmpty()) { - return null - } - - return groupByEOSEPresence(addressesToWatch).map { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = - listOf( - TextNoteEvent.KIND, - ReactionEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - ReportEvent.KIND, - LnZapEvent.KIND, - PollNoteEvent.KIND, - CommunityPostApprovalEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - ), - tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }), - since = findMinimumEOSEs(it), - // Max amount of "replies" to download on a specific event. - limit = 1000, - ), - ) - } - } - - private fun createAddressFilter(): List? { - val addressesToWatch = addressesToWatch.filter { it.event == null } - - if (addressesToWatch.isEmpty()) { - return null - } - - return addressesToWatch.mapNotNull { - it.address()?.let { aTag -> - if (aTag.kind < 25000 && aTag.dTag.isBlank()) { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(aTag.kind), - authors = listOf(aTag.pubKeyHex), - limit = 5, - ), - ) - } else { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(aTag.kind), - tags = mapOf("d" to listOf(aTag.dTag)), - authors = listOf(aTag.pubKeyHex), - limit = 5, - ), - ) + if (addressesToWatch.isEmpty()) { + return null } - } - } - } - private fun createRepliesAndReactionsFilter(): List? { - if (eventsToWatch.isEmpty()) { - return null + return groupByEOSEPresence(addressesToWatch).map { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = + listOf( + TextNoteEvent.KIND, + ReactionEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ReportEvent.KIND, + LnZapEvent.KIND, + PollNoteEvent.KIND, + CommunityPostApprovalEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + ), + tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }), + since = findMinimumEOSEs(it), + // Max amount of "replies" to download on a specific event. + limit = 1000, + ), + ) + } } - return groupByEOSEPresence(eventsToWatch).map { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = - listOf( - TextNoteEvent.KIND, - ReactionEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - ReportEvent.KIND, - LnZapEvent.KIND, - PollNoteEvent.KIND, - ), - tags = mapOf("e" to it.map { it.idHex }), - since = findMinimumEOSEs(it), - // Max amount of "replies" to download on a specific event. - limit = 1000, - ), - ) - } - } + private fun createAddressFilter(): List? { + val addressesToWatch = addressesToWatch.filter { it.event == null } - fun createLoadEventsIfNotLoadedFilter(): List? { - val directEventsToLoad = eventsToWatch.filter { it.event == null } + if (addressesToWatch.isEmpty()) { + return null + } - val threadingEventsToLoad = - eventsToWatch - .mapNotNull { it.replyTo } - .flatten() - .filter { it !is AddressableNote && it.event == null } - - val interestedEvents = (directEventsToLoad + threadingEventsToLoad).map { it.idHex }.toSet() - - if (interestedEvents.isEmpty()) { - return null + return addressesToWatch.mapNotNull { + it.address()?.let { aTag -> + if (aTag.kind < 25000 && aTag.dTag.isBlank()) { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(aTag.kind), + authors = listOf(aTag.pubKeyHex), + limit = 5, + ), + ) + } else { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(aTag.kind), + tags = mapOf("d" to listOf(aTag.dTag)), + authors = listOf(aTag.pubKeyHex), + limit = 5, + ), + ) + } + } + } } - // downloads linked events to this event. - return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - ids = interestedEvents.toList(), - ), - ), - ) - } + private fun createRepliesAndReactionsFilter(): List? { + if (eventsToWatch.isEmpty()) { + return null + } - val singleEventChannel = requestNewChannel { time, relayUrl -> - // Ignores EOSE if it is in the middle of a filter change. - if (changingFilters.get()) return@requestNewChannel - - checkNotInMainThread() - - eventsToWatch.forEach { - val eose = it.lastReactionsDownloadTime[relayUrl] - if (eose == null) { - it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time)) - } else { - eose.time = time - } + return groupByEOSEPresence(eventsToWatch).map { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = + listOf( + TextNoteEvent.KIND, + ReactionEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ReportEvent.KIND, + LnZapEvent.KIND, + PollNoteEvent.KIND, + ), + tags = mapOf("e" to it.map { it.idHex }), + since = findMinimumEOSEs(it), + // Max amount of "replies" to download on a specific event. + limit = 1000, + ), + ) + } } - addressesToWatch.forEach { - val eose = it.lastReactionsDownloadTime[relayUrl] - if (eose == null) { - it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time)) - } else { - eose.time = time - } + fun createLoadEventsIfNotLoadedFilter(): List? { + val directEventsToLoad = eventsToWatch.filter { it.event == null } + + val threadingEventsToLoad = + eventsToWatch + .mapNotNull { it.replyTo } + .flatten() + .filter { it !is AddressableNote && it.event == null } + + val interestedEvents = (directEventsToLoad + threadingEventsToLoad).map { it.idHex }.toSet() + + if (interestedEvents.isEmpty()) { + return null + } + + // downloads linked events to this event. + return listOf( + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + ids = interestedEvents.toList(), + ), + ), + ) } - // Many relays operate with limits in the amount of filters. - // As information comes, the filters will be rotated to get more data. - invalidateFilters() - } + val singleEventChannel = + requestNewChannel { time, relayUrl -> + // Ignores EOSE if it is in the middle of a filter change. + if (changingFilters.get()) return@requestNewChannel - override fun updateChannelFilters() { - val reactions = createRepliesAndReactionsFilter() - val missing = createLoadEventsIfNotLoadedFilter() - val addresses = createAddressFilter() - val addressReactions = createReactionsToWatchInAddressFilter() + checkNotInMainThread() - singleEventChannel.typedFilters = - listOfNotNull(missing, addresses, reactions, addressReactions).flatten().ifEmpty { null } - } + eventsToWatch.forEach { + val eose = it.lastReactionsDownloadTime[relayUrl] + if (eose == null) { + it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time)) + } else { + eose.time = time + } + } - fun add(eventId: Note) { - if (!eventsToWatch.contains(eventId)) { - eventsToWatch = eventsToWatch.plus(eventId) - invalidateFilters() + addressesToWatch.forEach { + val eose = it.lastReactionsDownloadTime[relayUrl] + if (eose == null) { + it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time)) + } else { + eose.time = time + } + } + + // Many relays operate with limits in the amount of filters. + // As information comes, the filters will be rotated to get more data. + invalidateFilters() + } + + override fun updateChannelFilters() { + val reactions = createRepliesAndReactionsFilter() + val missing = createLoadEventsIfNotLoadedFilter() + val addresses = createAddressFilter() + val addressReactions = createReactionsToWatchInAddressFilter() + + singleEventChannel.typedFilters = + listOfNotNull(missing, addresses, reactions, addressReactions).flatten().ifEmpty { null } } - } - fun remove(eventId: Note) { - if (eventsToWatch.contains(eventId)) { - eventsToWatch = eventsToWatch.minus(eventId) - invalidateFilters() + fun add(eventId: Note) { + if (!eventsToWatch.contains(eventId)) { + eventsToWatch = eventsToWatch.plus(eventId) + invalidateFilters() + } } - } - fun addAddress(addressableNote: Note) { - if (!addressesToWatch.contains(addressableNote)) { - addressesToWatch = addressesToWatch.plus(addressableNote) - invalidateFilters() + fun remove(eventId: Note) { + if (eventsToWatch.contains(eventId)) { + eventsToWatch = eventsToWatch.minus(eventId) + invalidateFilters() + } } - } - fun removeAddress(addressableNote: Note) { - if (addressesToWatch.contains(addressableNote)) { - addressesToWatch = addressesToWatch.minus(addressableNote) - invalidateFilters() + fun addAddress(addressableNote: Note) { + if (!addressesToWatch.contains(addressableNote)) { + addressesToWatch = addressesToWatch.plus(addressableNote) + invalidateFilters() + } + } + + fun removeAddress(addressableNote: Note) { + if (addressesToWatch.contains(addressableNote)) { + addressesToWatch = addressesToWatch.minus(addressableNote) + invalidateFilters() + } } - } } fun groupByEOSEPresence(notes: Set): Collection> { - return notes.groupBy { it.lastReactionsDownloadTime.keys.sorted().joinToString(",") }.values + return notes.groupBy { it.lastReactionsDownloadTime.keys.sorted().joinToString(",") }.values } fun groupByEOSEPresence(users: Iterable): Collection> { - return users.groupBy { it.latestEOSEs.keys.sorted().joinToString(",") }.values + return users.groupBy { it.latestEOSEs.keys.sorted().joinToString(",") }.values } fun findMinimumEOSEs(notes: List): Map { - val minLatestEOSEs = mutableMapOf() + val minLatestEOSEs = mutableMapOf() - notes.forEach { - it.lastReactionsDownloadTime.forEach { - val minEose = minLatestEOSEs[it.key] - if (minEose == null) { - minLatestEOSEs.put(it.key, EOSETime(it.value.time)) - } else if (it.value.time < minEose.time) { - minEose.time = it.value.time - } + notes.forEach { + it.lastReactionsDownloadTime.forEach { + val minEose = minLatestEOSEs[it.key] + if (minEose == null) { + minLatestEOSEs.put(it.key, EOSETime(it.value.time)) + } else if (it.value.time < minEose.time) { + minEose.time = it.value.time + } + } } - } - return minLatestEOSEs + return minLatestEOSEs } fun findMinimumEOSEsForUsers(users: List): Map { - val minLatestEOSEs = mutableMapOf() + val minLatestEOSEs = mutableMapOf() - users.forEach { - it.latestEOSEs.forEach { - val minEose = minLatestEOSEs[it.key] - if (minEose == null) { - minLatestEOSEs.put(it.key, EOSETime(it.value.time)) - } else if (it.value.time < minEose.time) { - minEose.time = it.value.time - } + users.forEach { + it.latestEOSEs.forEach { + val minEose = minLatestEOSEs[it.key] + if (minEose == null) { + minLatestEOSEs.put(it.key, EOSETime(it.value.time)) + } else if (it.value.time < minEose.time) { + minEose.time = it.value.time + } + } } - } - return minLatestEOSEs + return minLatestEOSEs } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt index bec73e2e1..cf7adbb73 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt @@ -30,100 +30,101 @@ import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.StatusEvent object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") { - var usersToWatch = setOf() + var usersToWatch = setOf() - fun createUserMetadataFilter(): List? { - if (usersToWatch.isEmpty()) return null + fun createUserMetadataFilter(): List? { + if (usersToWatch.isEmpty()) return null - val firstTimers = usersToWatch.filter { it.info?.latestMetadata == null }.map { it.pubkeyHex } + val firstTimers = usersToWatch.filter { it.info?.latestMetadata == null }.map { it.pubkeyHex } - if (firstTimers.isEmpty()) return null + if (firstTimers.isEmpty()) return null - return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(MetadataEvent.KIND), - authors = firstTimers, - ), - ), - ) - } - - fun createUserMetadataStatusReportFilter(): List? { - if (usersToWatch.isEmpty()) return null - - val secondTimers = usersToWatch.filter { it.info?.latestMetadata != null } - - if (secondTimers.isEmpty()) return null - - return groupByEOSEPresence(secondTimers) - .map { group -> - val groupIds = group.map { it.pubkeyHex } - val minEOSEs = findMinimumEOSEsForUsers(group) - listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND), - authors = groupIds, - since = minEOSEs, - ), - ), - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(ReportEvent.KIND), - tags = mapOf("p" to groupIds), - since = minEOSEs, - ), - ), + return listOf( + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(MetadataEvent.KIND), + authors = firstTimers, + ), + ), ) - } - .flatten() - } + } - val userChannel = requestNewChannel { time, relayUrl -> - checkNotInMainThread() + fun createUserMetadataStatusReportFilter(): List? { + if (usersToWatch.isEmpty()) return null - usersToWatch.forEach { - if (it.info?.latestMetadata != null) { - val eose = it.latestEOSEs[relayUrl] - if (eose == null) { - it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time)) - } else { - eose.time = time + val secondTimers = usersToWatch.filter { it.info?.latestMetadata != null } + + if (secondTimers.isEmpty()) return null + + return groupByEOSEPresence(secondTimers) + .map { group -> + val groupIds = group.map { it.pubkeyHex } + val minEOSEs = findMinimumEOSEsForUsers(group) + listOf( + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND), + authors = groupIds, + since = minEOSEs, + ), + ), + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(ReportEvent.KIND), + tags = mapOf("p" to groupIds), + since = minEOSEs, + ), + ), + ) + } + .flatten() + } + + val userChannel = + requestNewChannel { time, relayUrl -> + checkNotInMainThread() + + usersToWatch.forEach { + if (it.info?.latestMetadata != null) { + val eose = it.latestEOSEs[relayUrl] + if (eose == null) { + it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time)) + } else { + eose.time = time + } + } + } } - } + + override fun updateChannelFilters() { + checkNotInMainThread() + + userChannel.typedFilters = + listOfNotNull( + createUserMetadataFilter(), + createUserMetadataStatusReportFilter(), + ) + .flatten() + .ifEmpty { null } } - } - override fun updateChannelFilters() { - checkNotInMainThread() - - userChannel.typedFilters = - listOfNotNull( - createUserMetadataFilter(), - createUserMetadataStatusReportFilter(), - ) - .flatten() - .ifEmpty { null } - } - - fun add(user: User) { - if (!usersToWatch.contains(user)) { - usersToWatch = usersToWatch.plus(user) - invalidateFilters() + fun add(user: User) { + if (!usersToWatch.contains(user)) { + usersToWatch = usersToWatch.plus(user) + invalidateFilters() + } } - } - fun remove(user: User) { - if (usersToWatch.contains(user)) { - usersToWatch = usersToWatch.minus(user) - invalidateFilters() + fun remove(user: User) { + if (usersToWatch.contains(user)) { + usersToWatch = usersToWatch.minus(user) + invalidateFilters() + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt index 81a8031ca..b7d71fa81 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt @@ -26,47 +26,48 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") { - private var eventToWatch: String? = null + private var eventToWatch: String? = null - fun createLoadEventsIfNotLoadedFilter(): TypedFilter? { - val threadToLoad = eventToWatch ?: return null + fun createLoadEventsIfNotLoadedFilter(): TypedFilter? { + val threadToLoad = eventToWatch ?: return null - val eventsToLoad = - ThreadAssembler() - .findThreadFor(threadToLoad) - .filter { it.event == null } - .map { it.idHex } - .toSet() - .ifEmpty { null } - ?: return null + val eventsToLoad = + ThreadAssembler() + .findThreadFor(threadToLoad) + .filter { it.event == null } + .map { it.idHex } + .toSet() + .ifEmpty { null } + ?: return null - if (eventsToLoad.isEmpty()) return null + if (eventsToLoad.isEmpty()) return null - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - ids = eventsToLoad.toList(), - ), - ) - } - - val loadEventsChannel = requestNewChannel { _, _ -> - // Many relays operate with limits in the amount of filters. - // As information comes, the filters will be rotated to get more data. - invalidateFilters() - } - - override fun updateChannelFilters() { - loadEventsChannel.typedFilters = - listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null } - } - - fun loadThread(noteId: String?) { - if (eventToWatch != noteId) { - eventToWatch = noteId - - invalidateFilters() + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + ids = eventsToLoad.toList(), + ), + ) + } + + val loadEventsChannel = + requestNewChannel { _, _ -> + // Many relays operate with limits in the amount of filters. + // As information comes, the filters will be rotated to get more data. + invalidateFilters() + } + + override fun updateChannelFilters() { + loadEventsChannel.typedFilters = + listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null } + } + + fun loadThread(noteId: String?) { + if (eventToWatch != noteId) { + eventToWatch = noteId + + invalidateFilters() + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt index d1107a3fe..5f0631cde 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -43,140 +43,140 @@ import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.TextNoteEvent object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { - var user: User? = null + var user: User? = null - fun loadUserProfile(user: User?) { - this.user = user - } - - fun createUserInfoFilter() = - user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(MetadataEvent.KIND), - authors = listOf(it.pubkeyHex), - limit = 1, - ), - ) + fun loadUserProfile(user: User?) { + this.user = user } - fun createUserPostsFilter() = - user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = - listOf( - TextNoteEvent.KIND, - GenericRepostEvent.KIND, - RepostEvent.KIND, - LongTextNoteEvent.KIND, - AudioTrackEvent.KIND, - AudioHeaderEvent.KIND, - PinListEvent.KIND, - PollNoteEvent.KIND, - HighlightEvent.KIND, - ), - authors = listOf(it.pubkeyHex), - limit = 200, - ), - ) + fun createUserInfoFilter() = + user?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(MetadataEvent.KIND), + authors = listOf(it.pubkeyHex), + limit = 1, + ), + ) + } + + fun createUserPostsFilter() = + user?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = + listOf( + TextNoteEvent.KIND, + GenericRepostEvent.KIND, + RepostEvent.KIND, + LongTextNoteEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + PinListEvent.KIND, + PollNoteEvent.KIND, + HighlightEvent.KIND, + ), + authors = listOf(it.pubkeyHex), + limit = 200, + ), + ) + } + + fun createUserReceivedZapsFilter() = + user?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(LnZapEvent.KIND), + tags = mapOf("p" to listOf(it.pubkeyHex)), + ), + ) + } + + fun createFollowFilter() = + user?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(ContactListEvent.KIND), + authors = listOf(it.pubkeyHex), + limit = 1, + ), + ) + } + + fun createFollowersFilter() = + user?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(ContactListEvent.KIND), + tags = mapOf("p" to listOf(it.pubkeyHex)), + ), + ) + } + + fun createAcceptedAwardsFilter() = + user?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(BadgeProfilesEvent.KIND), + authors = listOf(it.pubkeyHex), + limit = 1, + ), + ) + } + + fun createBookmarksFilter() = + user?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = + listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, AppRecommendationEvent.KIND), + authors = listOf(it.pubkeyHex), + limit = 100, + ), + ) + } + + fun createReceivedAwardsFilter() = + user?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(BadgeAwardEvent.KIND), + tags = mapOf("p" to listOf(it.pubkeyHex)), + limit = 20, + ), + ) + } + + val userInfoChannel = requestNewChannel() + + override fun updateChannelFilters() { + userInfoChannel.typedFilters = + listOfNotNull( + createUserInfoFilter(), + createUserPostsFilter(), + createFollowFilter(), + createFollowersFilter(), + createUserReceivedZapsFilter(), + createAcceptedAwardsFilter(), + createReceivedAwardsFilter(), + createBookmarksFilter(), + ) + .ifEmpty { null } } - - fun createUserReceivedZapsFilter() = - user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(LnZapEvent.KIND), - tags = mapOf("p" to listOf(it.pubkeyHex)), - ), - ) - } - - fun createFollowFilter() = - user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(ContactListEvent.KIND), - authors = listOf(it.pubkeyHex), - limit = 1, - ), - ) - } - - fun createFollowersFilter() = - user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(ContactListEvent.KIND), - tags = mapOf("p" to listOf(it.pubkeyHex)), - ), - ) - } - - fun createAcceptedAwardsFilter() = - user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(BadgeProfilesEvent.KIND), - authors = listOf(it.pubkeyHex), - limit = 1, - ), - ) - } - - fun createBookmarksFilter() = - user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = - listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, AppRecommendationEvent.KIND), - authors = listOf(it.pubkeyHex), - limit = 100, - ), - ) - } - - fun createReceivedAwardsFilter() = - user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = listOf(BadgeAwardEvent.KIND), - tags = mapOf("p" to listOf(it.pubkeyHex)), - limit = 20, - ), - ) - } - - val userInfoChannel = requestNewChannel() - - override fun updateChannelFilters() { - userInfoChannel.typedFilters = - listOfNotNull( - createUserInfoFilter(), - createUserPostsFilter(), - createFollowFilter(), - createFollowersFilter(), - createUserReceivedZapsFilter(), - createAcceptedAwardsFilter(), - createReceivedAwardsFilter(), - createBookmarksFilter(), - ) - .ifEmpty { null } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt index e256667eb..393c81fd7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt @@ -33,120 +33,121 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch object NostrVideoDataSource : NostrDataSource("VideoFeed") { - lateinit var account: Account + lateinit var account: Account - val scope = Amethyst.instance.applicationIOScope - val latestEOSEs = EOSEAccount() + val scope = Amethyst.instance.applicationIOScope + val latestEOSEs = EOSEAccount() - var job: Job? = null + var job: Job? = null - override fun start() { - job?.cancel() - job = - scope.launch(Dispatchers.IO) { - account.liveStoriesFollowLists.collect { - if (this@NostrVideoDataSource::account.isInitialized) { - invalidateFilters() - } - } - } - super.start() - } + override fun start() { + job?.cancel() + job = + scope.launch(Dispatchers.IO) { + account.liveStoriesFollowLists.collect { + if (this@NostrVideoDataSource::account.isInitialized) { + invalidateFilters() + } + } + } + super.start() + } - override fun stop() { - super.stop() - job?.cancel() - } + override fun stop() { + super.stop() + job?.cancel() + } - fun createContextualFilter(): TypedFilter { - val follows = account.liveStoriesFollowLists.value?.users?.toList() + fun createContextualFilter(): TypedFilter { + val follows = account.liveStoriesFollowLists.value?.users?.toList() - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - JsonFilter( - authors = follows, - kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), - limit = 200, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultStoriesFollowList.value) - ?.relayList, - ), - ) - } - - fun createFollowTagsFilter(): TypedFilter? { - val hashToLoad = account.liveStoriesFollowLists.value?.hashtags?.toList() ?: return null - - if (hashToLoad.isEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - JsonFilter( - kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), - tags = - mapOf( - "t" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), - limit = 100, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultStoriesFollowList.value) - ?.relayList, - ), - ) - } - - fun createFollowGeohashesFilter(): TypedFilter? { - val hashToLoad = account.liveStoriesFollowLists.value?.geotags?.toList() ?: return null - - if (hashToLoad.isEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - JsonFilter( - kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), - tags = - mapOf( - "g" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), - limit = 100, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultStoriesFollowList.value) - ?.relayList, - ), - ) - } - - val videoFeedChannel = requestNewChannel { time, relayUrl -> - latestEOSEs.addOrUpdate( - account.userProfile(), - account.defaultStoriesFollowList.value, - relayUrl, - time, - ) - } - - override fun updateChannelFilters() { - videoFeedChannel.typedFilters = - listOfNotNull( - createContextualFilter(), - createFollowTagsFilter(), - createFollowGeohashesFilter(), + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + authors = follows, + kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), + limit = 200, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultStoriesFollowList.value) + ?.relayList, + ), ) - .ifEmpty { null } - } + } + + fun createFollowTagsFilter(): TypedFilter? { + val hashToLoad = account.liveStoriesFollowLists.value?.hashtags?.toList() ?: return null + + if (hashToLoad.isEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), + tags = + mapOf( + "t" to + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 100, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultStoriesFollowList.value) + ?.relayList, + ), + ) + } + + fun createFollowGeohashesFilter(): TypedFilter? { + val hashToLoad = account.liveStoriesFollowLists.value?.geotags?.toList() ?: return null + + if (hashToLoad.isEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), + tags = + mapOf( + "g" to + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 100, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultStoriesFollowList.value) + ?.relayList, + ), + ) + } + + val videoFeedChannel = + requestNewChannel { time, relayUrl -> + latestEOSEs.addOrUpdate( + account.userProfile(), + account.defaultStoriesFollowList.value, + relayUrl, + time, + ) + } + + override fun updateChannelFilters() { + videoFeedChannel.typedFilters = + listOfNotNull( + createContextualFilter(), + createFollowTagsFilter(), + createFollowGeohashesFilter(), + ) + .ifEmpty { null } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt index 906274093..937efa8c4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt @@ -29,46 +29,46 @@ import okhttp3.Request @Immutable data class OnlineCheckResult(val timeInMs: Long, val online: Boolean) object OnlineChecker { - val checkOnlineCache = LruCache(100) - val fiveMinutes = 1000 * 60 * 5 + val checkOnlineCache = LruCache(100) + val fiveMinutes = 1000 * 60 * 5 - fun isOnlineCached(url: String?): Boolean { - if (url.isNullOrBlank()) return false - if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) { - return checkOnlineCache.get(url).online - } - return false - } - - fun isOnline(url: String?): Boolean { - checkNotInMainThread() - - if (url.isNullOrBlank()) return false - if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) { - return checkOnlineCache.get(url).online - } - - Log.d("OnlineChecker", "isOnline $url") - - return try { - val request = - Request.Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url(url) - .get() - .build() - - val result = - HttpClient.getHttpClient().newCall(request).execute().use { - checkNotInMainThread() - it.isSuccessful + fun isOnlineCached(url: String?): Boolean { + if (url.isNullOrBlank()) return false + if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) { + return checkOnlineCache.get(url).online + } + return false + } + + fun isOnline(url: String?): Boolean { + checkNotInMainThread() + + if (url.isNullOrBlank()) return false + if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) { + return checkOnlineCache.get(url).online + } + + Log.d("OnlineChecker", "isOnline $url") + + return try { + val request = + Request.Builder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url(url) + .get() + .build() + + val result = + HttpClient.getHttpClient().newCall(request).execute().use { + checkNotInMainThread() + it.isSuccessful + } + checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result)) + result + } catch (e: Exception) { + checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), false)) + Log.e("LiveActivities", "Failed to check streaming url $url", e) + false } - checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result)) - result - } catch (e: Exception) { - checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), false)) - Log.e("LiveActivities", "Failed to check streaming url $url", e) - false } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/PackageUtils.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/PackageUtils.kt index a870604dd..f7767cd8f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/PackageUtils.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/PackageUtils.kt @@ -23,20 +23,20 @@ package com.vitorpamplona.amethyst.service import android.content.Context object PackageUtils { - private fun isPackageInstalled( - context: Context, - target: String, - ): Boolean { - return context.packageManager.getInstalledApplications(0).find { info -> - info.packageName == target - } != null - } + private fun isPackageInstalled( + context: Context, + target: String, + ): Boolean { + return context.packageManager.getInstalledApplications(0).find { info -> + info.packageName == target + } != null + } - fun isOrbotInstalled(context: Context): Boolean { - return isPackageInstalled(context, "org.torproject.android") - } + fun isOrbotInstalled(context: Context): Boolean { + return isPackageInstalled(context, "org.torproject.android") + } - fun isAmberInstalled(context: Context): Boolean { - return isPackageInstalled(context, "com.greenart7c3.nostrsigner") - } + fun isAmberInstalled(context: Context): Boolean { + return isPackageInstalled(context, "com.greenart7c3.nostrsigner") + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index 484ecca18..ea1d1f096 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -32,230 +32,229 @@ import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse import com.vitorpamplona.quartz.events.ZapSplitSetup -import kotlin.math.round import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlin.math.round class ZapPaymentHandler(val account: Account) { - @Immutable - data class Payable( - val info: ZapSplitSetup, - val user: User?, - val amountMilliSats: Long, - val invoice: String, - ) + @Immutable + data class Payable( + val info: ZapSplitSetup, + val user: User?, + val amountMilliSats: Long, + val invoice: String, + ) - suspend fun zap( - note: Note, - amountMilliSats: Long, - pollOption: Int?, - message: String, - context: Context, - onError: (String, String) -> Unit, - onProgress: (percent: Float) -> Unit, - onPayViaIntent: (ImmutableList) -> Unit, - zapType: LnZapEvent.ZapType, - ) = - withContext(Dispatchers.IO) { - val zapSplitSetup = note.event?.zapSplitSetup() + suspend fun zap( + note: Note, + amountMilliSats: Long, + pollOption: Int?, + message: String, + context: Context, + onError: (String, String) -> Unit, + onProgress: (percent: Float) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit, + zapType: LnZapEvent.ZapType, + ) = withContext(Dispatchers.IO) { + val zapSplitSetup = note.event?.zapSplitSetup() - val noteEvent = note.event + val noteEvent = note.event - val zapsToSend = - if (!zapSplitSetup.isNullOrEmpty()) { - zapSplitSetup - } else if (noteEvent is LiveActivitiesEvent && noteEvent.hasHost()) { - noteEvent.hosts().map { ZapSplitSetup(it, null, weight = 1.0, false) } - } else { - val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim() + val zapsToSend = + if (!zapSplitSetup.isNullOrEmpty()) { + zapSplitSetup + } else if (noteEvent is LiveActivitiesEvent && noteEvent.hasHost()) { + noteEvent.hosts().map { ZapSplitSetup(it, null, weight = 1.0, false) } + } else { + val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim() - if (lud16.isNullOrBlank()) { - onError( - context.getString(R.string.missing_lud16), - context.getString( - R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats, - ), - ) - return@withContext - } + if (lud16.isNullOrBlank()) { + onError( + context.getString(R.string.missing_lud16), + context.getString( + R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats, + ), + ) + return@withContext + } - listOf(ZapSplitSetup(lud16, null, weight = 1.0, true)) - } + listOf(ZapSplitSetup(lud16, null, weight = 1.0, true)) + } - val totalWeight = zapsToSend.sumOf { it.weight } + val totalWeight = zapsToSend.sumOf { it.weight } - val invoicesToPayOnIntent = mutableListOf() + val invoicesToPayOnIntent = mutableListOf() - zapsToSend.forEachIndexed { index, value -> - val outerProgressMin = index / zapsToSend.size.toFloat() - val outerProgressMax = (index + 1) / zapsToSend.size.toFloat() + zapsToSend.forEachIndexed { index, value -> + val outerProgressMin = index / zapsToSend.size.toFloat() + val outerProgressMax = (index + 1) / zapsToSend.size.toFloat() - val zapValue = round((amountMilliSats * value.weight / totalWeight) / 1000f).toLong() * 1000 + val zapValue = round((amountMilliSats * value.weight / totalWeight) / 1000f).toLong() * 1000 - if (value.isLnAddress) { - innerZap( - lud16 = value.lnAddressOrPubKeyHex, - note = note, - amount = zapValue, - pollOption = pollOption, - message = message, - context = context, - onError = onError, - onProgress = { - onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin) - }, - zapType = zapType, - onPayInvoiceThroughIntent = { - invoicesToPayOnIntent.add( - Payable( - info = value, - user = null, - amountMilliSats = zapValue, - invoice = it, - ), - ) - }, - ) - } else { - val user = LocalCache.getUserIfExists(value.lnAddressOrPubKeyHex) - val lud16 = user?.info?.lnAddress() - - if (lud16 != null) { - innerZap( - lud16 = lud16, - note = note, - amount = zapValue, - pollOption = pollOption, - message = message, - context = context, - onError = onError, - onProgress = { - onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin) - }, - zapType = zapType, - overrideUser = user, - onPayInvoiceThroughIntent = { - invoicesToPayOnIntent.add( - Payable( - info = value, - user = user, - amountMilliSats = zapValue, - invoice = it, - ), + if (value.isLnAddress) { + innerZap( + lud16 = value.lnAddressOrPubKeyHex, + note = note, + amount = zapValue, + pollOption = pollOption, + message = message, + context = context, + onError = onError, + onProgress = { + onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin) + }, + zapType = zapType, + onPayInvoiceThroughIntent = { + invoicesToPayOnIntent.add( + Payable( + info = value, + user = null, + amountMilliSats = zapValue, + invoice = it, + ), + ) + }, ) - }, - ) - } else { - onError( - context.getString( - R.string.missing_lud16, - ), - context.getString( - R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, - user?.toBestDisplayName() ?: value.lnAddressOrPubKeyHex, - ), - ) - } - } - } + } else { + val user = LocalCache.getUserIfExists(value.lnAddressOrPubKeyHex) + val lud16 = user?.info?.lnAddress() - if (invoicesToPayOnIntent.isNotEmpty()) { - onPayViaIntent(invoicesToPayOnIntent.toImmutableList()) - onProgress(1f) - } else { - launch(Dispatchers.IO) { - // Awaits for the event to come back to LocalCache. - var count = 0 - while (invoicesToPayOnIntent.size < zapsToSend.size || count < 4) { - count++ - Thread.sleep(5000) - } - if (invoicesToPayOnIntent.isNotEmpty()) { + if (lud16 != null) { + innerZap( + lud16 = lud16, + note = note, + amount = zapValue, + pollOption = pollOption, + message = message, + context = context, + onError = onError, + onProgress = { + onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin) + }, + zapType = zapType, + overrideUser = user, + onPayInvoiceThroughIntent = { + invoicesToPayOnIntent.add( + Payable( + info = value, + user = user, + amountMilliSats = zapValue, + invoice = it, + ), + ) + }, + ) + } else { + onError( + context.getString( + R.string.missing_lud16, + ), + context.getString( + R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, + user?.toBestDisplayName() ?: value.lnAddressOrPubKeyHex, + ), + ) + } + } + } + + if (invoicesToPayOnIntent.isNotEmpty()) { onPayViaIntent(invoicesToPayOnIntent.toImmutableList()) onProgress(1f) - } else { - onProgress(1f) - } - } - } - } - - private fun prepareZapRequestIfNeeded( - note: Note, - pollOption: Int?, - message: String, - zapType: LnZapEvent.ZapType, - overrideUser: User? = null, - onReady: (String?) -> Unit, - ) { - if (zapType != LnZapEvent.ZapType.NONZAP) { - account.createZapRequestFor(note, pollOption, message, zapType, overrideUser) { zapRequest -> - onReady(zapRequest.toJson()) - } - } else { - onReady(null) - } - } - - private suspend fun innerZap( - lud16: String, - note: Note, - amount: Long, - pollOption: Int?, - message: String, - context: Context, - onError: (String, String) -> Unit, - onProgress: (percent: Float) -> Unit, - onPayInvoiceThroughIntent: (String) -> Unit, - zapType: LnZapEvent.ZapType, - overrideUser: User? = null, - ) { - onProgress(0.05f) - - prepareZapRequestIfNeeded(note, pollOption, message, zapType, overrideUser) { zapRequestJson -> - onProgress(0.10f) - - LightningAddressResolver() - .lnAddressInvoice( - lud16, - amount, - message, - zapRequestJson, - onSuccess = { - onProgress(0.7f) - if (account.hasWalletConnectSetup()) { - account.sendZapPaymentRequestFor( - bolt11 = it, - note, - onResponse = { response -> - if (response is PayInvoiceErrorResponse) { - onProgress(0.0f) - onError( - context.getString(R.string.error_dialog_pay_invoice_error), - context.getString( - R.string.wallet_connect_pay_invoice_error_error, - response.error?.message - ?: response.error?.code?.toString() ?: "Error parsing error message", - ), - ) - } else { + } else { + launch(Dispatchers.IO) { + // Awaits for the event to come back to LocalCache. + var count = 0 + while (invoicesToPayOnIntent.size < zapsToSend.size || count < 4) { + count++ + Thread.sleep(5000) + } + if (invoicesToPayOnIntent.isNotEmpty()) { + onPayViaIntent(invoicesToPayOnIntent.toImmutableList()) onProgress(1f) - } - }, - ) - onProgress(0.8f) - } else { - onPayInvoiceThroughIntent(it) - onProgress(0f) + } else { + onProgress(1f) + } } - }, - onError = onError, - onProgress = onProgress, - context = context, - ) + } + } + + private fun prepareZapRequestIfNeeded( + note: Note, + pollOption: Int?, + message: String, + zapType: LnZapEvent.ZapType, + overrideUser: User? = null, + onReady: (String?) -> Unit, + ) { + if (zapType != LnZapEvent.ZapType.NONZAP) { + account.createZapRequestFor(note, pollOption, message, zapType, overrideUser) { zapRequest -> + onReady(zapRequest.toJson()) + } + } else { + onReady(null) + } + } + + private suspend fun innerZap( + lud16: String, + note: Note, + amount: Long, + pollOption: Int?, + message: String, + context: Context, + onError: (String, String) -> Unit, + onProgress: (percent: Float) -> Unit, + onPayInvoiceThroughIntent: (String) -> Unit, + zapType: LnZapEvent.ZapType, + overrideUser: User? = null, + ) { + onProgress(0.05f) + + prepareZapRequestIfNeeded(note, pollOption, message, zapType, overrideUser) { zapRequestJson -> + onProgress(0.10f) + + LightningAddressResolver() + .lnAddressInvoice( + lud16, + amount, + message, + zapRequestJson, + onSuccess = { + onProgress(0.7f) + if (account.hasWalletConnectSetup()) { + account.sendZapPaymentRequestFor( + bolt11 = it, + note, + onResponse = { response -> + if (response is PayInvoiceErrorResponse) { + onProgress(0.0f) + onError( + context.getString(R.string.error_dialog_pay_invoice_error), + context.getString( + R.string.wallet_connect_pay_invoice_error_error, + response.error?.message + ?: response.error?.code?.toString() ?: "Error parsing error message", + ), + ) + } else { + onProgress(1f) + } + }, + ) + onProgress(0.8f) + } else { + onPayInvoiceThroughIntent(it) + onProgress(0f) + } + }, + onError = onError, + onProgress = onProgress, + context = context, + ) + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt index 6582e193a..1615a0dab 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt @@ -29,263 +29,263 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.encoders.LnInvoiceUtil import com.vitorpamplona.quartz.encoders.Lud06 import com.vitorpamplona.quartz.encoders.toLnUrl +import okhttp3.Request import java.math.BigDecimal import java.math.RoundingMode import java.net.URLEncoder -import okhttp3.Request class LightningAddressResolver() { - val client = HttpClient.getHttpClient() + val client = HttpClient.getHttpClient() - fun assembleUrl(lnaddress: String): String? { - val parts = lnaddress.split("@") + fun assembleUrl(lnaddress: String): String? { + val parts = lnaddress.split("@") - if (parts.size == 2) { - return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}" - } - - if (lnaddress.lowercase().startsWith("lnurl")) { - return Lud06().toLnUrlp(lnaddress) - } - - return null - } - - private fun fetchLightningAddressJson( - lnaddress: String, - onSuccess: (String) -> Unit, - onError: (String, String) -> Unit, - context: Context, - ) { - checkNotInMainThread() - - val url = assembleUrl(lnaddress) - - if (url == null) { - onError( - context.getString(R.string.error_unable_to_fetch_invoice), - context.getString( - R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup, - lnaddress, - ), - ) - return - } - - try { - val request: Request = - Request.Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url(url) - .build() - - client.newCall(request).execute().use { - if (it.isSuccessful) { - onSuccess(it.body.string()) - } else { - onError( - context.getString(R.string.error_unable_to_fetch_invoice), - context.getString( - R.string - .the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct, - url, - lnaddress, - it.code.toString(), - ), - ) + if (parts.size == 2) { + return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}" } - } - } catch (e: Exception) { - e.printStackTrace() - onError( - context.getString(R.string.error_unable_to_fetch_invoice), - context.getString( - R.string - .could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct, - url, - lnaddress, - ), - ) - } - } - fun fetchLightningInvoice( - lnCallback: String, - milliSats: Long, - message: String, - nostrRequest: String? = null, - onSuccess: (String) -> Unit, - onError: (String, String) -> Unit, - context: Context, - ) { - checkNotInMainThread() + if (lnaddress.lowercase().startsWith("lnurl")) { + return Lud06().toLnUrlp(lnaddress) + } - val encodedMessage = URLEncoder.encode(message, "utf-8") - - val urlBinder = if (lnCallback.contains("?")) "&" else "?" - var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage" - - if (nostrRequest != null) { - val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8") - url += "&nostr=$encodedNostrRequest" + return null } - val request: Request = - Request.Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url(url) - .build() + private fun fetchLightningAddressJson( + lnaddress: String, + onSuccess: (String) -> Unit, + onError: (String, String) -> Unit, + context: Context, + ) { + checkNotInMainThread() - client.newCall(request).execute().use { - if (it.isSuccessful) { - onSuccess(it.body.string()) - } else { - onError( - context.getString(R.string.error_unable_to_fetch_invoice), - context.getString(R.string.could_not_fetch_invoice_from, lnCallback), - ) - } - } - } + val url = assembleUrl(lnaddress) - fun lnAddressToLnUrl( - lnaddress: String, - onSuccess: (String) -> Unit, - onError: (String, String) -> Unit, - context: Context, - ) { - fetchLightningAddressJson( - lnaddress, - onSuccess = { onSuccess(it.toByteArray().toLnUrl()) }, - onError = onError, - context = context, - ) - } - - fun lnAddressInvoice( - lnaddress: String, - milliSats: Long, - message: String, - nostrRequest: String? = null, - onSuccess: (String) -> Unit, - onError: (String, String) -> Unit, - onProgress: (percent: Float) -> Unit, - context: Context, - ) { - val mapper = jacksonObjectMapper() - - fetchLightningAddressJson( - lnaddress, - onSuccess = { lnAddressJson -> - onProgress(0.4f) - - val lnurlp = - try { - mapper.readTree(lnAddressJson) - } catch (t: Throwable) { + if (url == null) { onError( - context.getString(R.string.error_unable_to_fetch_invoice), - context.getString( - R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup, - ), + context.getString(R.string.error_unable_to_fetch_invoice), + context.getString( + R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup, + lnaddress, + ), ) - null - } - - val callback = lnurlp?.get("callback")?.asText() - - if (callback == null) { - onError( - context.getString(R.string.error_unable_to_fetch_invoice), - context.getString( - R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration, - ), - ) + return } - val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false + try { + val request: Request = + Request.Builder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url(url) + .build() - callback?.let { cb -> - fetchLightningInvoice( - cb, - milliSats, - message, - if (allowsNostr) nostrRequest else null, - onSuccess = { - onProgress(0.6f) + client.newCall(request).execute().use { + if (it.isSuccessful) { + onSuccess(it.body.string()) + } else { + onError( + context.getString(R.string.error_unable_to_fetch_invoice), + context.getString( + R.string + .the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct, + url, + lnaddress, + it.code.toString(), + ), + ) + } + } + } catch (e: Exception) { + e.printStackTrace() + onError( + context.getString(R.string.error_unable_to_fetch_invoice), + context.getString( + R.string + .could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct, + url, + lnaddress, + ), + ) + } + } - val lnInvoice = - try { - mapper.readTree(it) - } catch (t: Throwable) { - onError( + fun fetchLightningInvoice( + lnCallback: String, + milliSats: Long, + message: String, + nostrRequest: String? = null, + onSuccess: (String) -> Unit, + onError: (String, String) -> Unit, + context: Context, + ) { + checkNotInMainThread() + + val encodedMessage = URLEncoder.encode(message, "utf-8") + + val urlBinder = if (lnCallback.contains("?")) "&" else "?" + var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage" + + if (nostrRequest != null) { + val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8") + url += "&nostr=$encodedNostrRequest" + } + + val request: Request = + Request.Builder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url(url) + .build() + + client.newCall(request).execute().use { + if (it.isSuccessful) { + onSuccess(it.body.string()) + } else { + onError( context.getString(R.string.error_unable_to_fetch_invoice), - context.getString( - R.string - .error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup, - ), - ) - null + context.getString(R.string.could_not_fetch_invoice_from, lnCallback), + ) + } + } + } + + fun lnAddressToLnUrl( + lnaddress: String, + onSuccess: (String) -> Unit, + onError: (String, String) -> Unit, + context: Context, + ) { + fetchLightningAddressJson( + lnaddress, + onSuccess = { onSuccess(it.toByteArray().toLnUrl()) }, + onError = onError, + context = context, + ) + } + + fun lnAddressInvoice( + lnaddress: String, + milliSats: Long, + message: String, + nostrRequest: String? = null, + onSuccess: (String) -> Unit, + onError: (String, String) -> Unit, + onProgress: (percent: Float) -> Unit, + context: Context, + ) { + val mapper = jacksonObjectMapper() + + fetchLightningAddressJson( + lnaddress, + onSuccess = { lnAddressJson -> + onProgress(0.4f) + + val lnurlp = + try { + mapper.readTree(lnAddressJson) + } catch (t: Throwable) { + onError( + context.getString(R.string.error_unable_to_fetch_invoice), + context.getString( + R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup, + ), + ) + null + } + + val callback = lnurlp?.get("callback")?.asText() + + if (callback == null) { + onError( + context.getString(R.string.error_unable_to_fetch_invoice), + context.getString( + R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration, + ), + ) } - lnInvoice - ?.get("pr") - ?.asText() - ?.ifBlank { null } - ?.let { pr -> - // Forces LN Invoice amount to be the requested amount. - val expectedAmountInSats = - BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong() - val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr) - if (invoiceAmount.toLong() == expectedAmountInSats) { - onProgress(0.7f) - onSuccess(pr) - } else { - onProgress(0.0f) - onError( - context.getString(R.string.error_unable_to_fetch_invoice), - context.getString( - R.string.incorrect_invoice_amount_sats_from_it_should_have_been, - invoiceAmount.toLong().toString(), - lnaddress, - expectedAmountInSats.toString(), - ), + val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false + + callback?.let { cb -> + fetchLightningInvoice( + cb, + milliSats, + message, + if (allowsNostr) nostrRequest else null, + onSuccess = { + onProgress(0.6f) + + val lnInvoice = + try { + mapper.readTree(it) + } catch (t: Throwable) { + onError( + context.getString(R.string.error_unable_to_fetch_invoice), + context.getString( + R.string + .error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup, + ), + ) + null + } + + lnInvoice + ?.get("pr") + ?.asText() + ?.ifBlank { null } + ?.let { pr -> + // Forces LN Invoice amount to be the requested amount. + val expectedAmountInSats = + BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong() + val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr) + if (invoiceAmount.toLong() == expectedAmountInSats) { + onProgress(0.7f) + onSuccess(pr) + } else { + onProgress(0.0f) + onError( + context.getString(R.string.error_unable_to_fetch_invoice), + context.getString( + R.string.incorrect_invoice_amount_sats_from_it_should_have_been, + invoiceAmount.toLong().toString(), + lnaddress, + expectedAmountInSats.toString(), + ), + ) + } + } + ?: lnInvoice + ?.get("reason") + ?.asText() + ?.ifBlank { null } + ?.let { reason -> + onProgress(0.0f) + onError( + context.getString(R.string.error_unable_to_fetch_invoice), + context.getString( + R.string + .unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error, + reason, + ), + ) + } + ?: run { + onProgress(0.0f) + onError( + context.getString(R.string.error_unable_to_fetch_invoice), + context.getString( + R.string + .unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json, + ), + ) + } + }, + onError = onError, + context, ) - } - } - ?: lnInvoice - ?.get("reason") - ?.asText() - ?.ifBlank { null } - ?.let { reason -> - onProgress(0.0f) - onError( - context.getString(R.string.error_unable_to_fetch_invoice), - context.getString( - R.string - .unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error, - reason, - ), - ) - } - ?: run { - onProgress(0.0f) - onError( - context.getString(R.string.error_unable_to_fetch_invoice), - context.getString( - R.string - .unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json, - ), - ) } }, onError = onError, context, - ) - } - }, - onError = onError, - context, - ) - } + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/zaps/UserZaps.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/zaps/UserZaps.kt index a2e0d5d1d..8ba7f66cc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/zaps/UserZaps.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/zaps/UserZaps.kt @@ -25,12 +25,14 @@ import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse import com.vitorpamplona.quartz.events.LnZapEventInterface object UserZaps { - fun forProfileFeed(zaps: Map?): List { - if (zaps == null) return emptyList() + fun forProfileFeed(zaps: Map?): List { + if (zaps == null) return emptyList() - return (zaps - .mapNotNull { entry -> entry.value?.let { ZapReqResponse(entry.key, it) } } - .sortedBy { (it.zapEvent.event as? LnZapEventInterface)?.amount() } - .reversed()) - } + return ( + zaps + .mapNotNull { entry -> entry.value?.let { ZapReqResponse(entry.key, it) } } + .sortedBy { (it.zapEvent.event as? LnZapEventInterface)?.amount() } + .reversed() + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index a5dbb29d0..edaa7ff4c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -40,203 +40,207 @@ import com.vitorpamplona.quartz.events.LnZapRequestEvent import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.utils.TimeUtils -import java.math.BigDecimal import kotlinx.collections.immutable.persistentSetOf +import java.math.BigDecimal class EventNotificationConsumer(private val applicationContext: Context) { - suspend fun consume(event: GiftWrapEvent) { - if (!LocalCache.justVerify(event)) return - if (!notificationManager().areNotificationsEnabled()) return + suspend fun consume(event: GiftWrapEvent) { + if (!LocalCache.justVerify(event)) return + if (!notificationManager().areNotificationsEnabled()) return - // PushNotification Wraps don't include a receiver. - // Test with all logged in accounts - LocalPreferences.allSavedAccounts().forEach { - if (it.hasPrivKey || it.loggedInWithExternalSigner) { - LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)?.let { acc -> - consumeIfMatchesAccount(event, acc) - } - } - } - } - - private suspend fun consumeIfMatchesAccount( - pushWrappedEvent: GiftWrapEvent, - account: Account, - ) { - pushWrappedEvent.cachedGift(account.signer) { notificationEvent -> - LocalCache.justConsume(notificationEvent, null) - - unwrapAndConsume(notificationEvent, account) { innerEvent -> - if (innerEvent is PrivateDmEvent) { - notify(innerEvent, account) - } else if (innerEvent is LnZapEvent) { - notify(innerEvent, account) - } else if (innerEvent is ChatMessageEvent) { - notify(innerEvent, account) - } - } - } - } - - private fun unwrapAndConsume( - event: Event, - account: Account, - onReady: (Event) -> Unit, - ) { - if (!LocalCache.justVerify(event)) return - - when (event) { - is GiftWrapEvent -> { - event.cachedGift(account.signer) { unwrapAndConsume(it, account, onReady) } - } - is SealedGossipEvent -> { - event.cachedGossip(account.signer) { - // this is not verifiable - LocalCache.justConsume(it, null) - onReady(it) - } - } - else -> { - LocalCache.justConsume(event, null) - onReady(event) - } - } - } - - private fun notify( - event: ChatMessageEvent, - acc: Account, - ) { - if ( - event.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted - event.pubKey != acc.userProfile().pubkeyHex - ) { // from the user - - val chatNote = LocalCache.notes[event.id] ?: return - val chatRoom = event.chatroomKey(acc.keyPair.pubKey.toHexKey()) - - val followingKeySet = acc.followingKeySet() - - val isKnownRoom = - (acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true || - acc.userProfile().hasSentMessagesTo(chatRoom)) && !acc.isAllHidden(chatRoom.users) - - if (isKnownRoom) { - val content = chatNote.event?.content() ?: "" - val user = chatNote.author?.toBestDisplayName() ?: "" - val userPicture = chatNote.author?.profilePicture() - val noteUri = chatNote.toNEvent() - notificationManager() - .sendDMNotification( - event.id, - content, - user, - userPicture, - noteUri, - applicationContext, - ) - } - } - } - - private fun notify( - event: PrivateDmEvent, - acc: Account, - ) { - val note = LocalCache.notes[event.id] ?: return - - // old event being re-broadcast - if (event.createdAt < TimeUtils.fiveMinutesAgo()) return - - if (acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) { - val followingKeySet = acc.followingKeySet() - - val knownChatrooms = - acc - .userProfile() - .privateChatrooms - .keys - .filter { - (acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true || - acc.userProfile().hasSentMessagesTo(it)) && !acc.isAllHidden(it.users) - } - .toSet() - - note.author?.let { - if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) { - acc.decryptContent(note) { content -> - val user = note.author?.toBestDisplayName() ?: "" - val userPicture = note.author?.profilePicture() - val noteUri = note.toNEvent() - notificationManager() - .sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext) - } - } - } - } - } - - private fun notify( - event: LnZapEvent, - acc: Account, - ) { - val noteZapEvent = LocalCache.notes[event.id] ?: return - - // old event being re-broadcast - if (event.createdAt < TimeUtils.fiveMinutesAgo()) return - - val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return - val noteZapped = - event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return - - if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return - - if (acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) { - val amount = showAmount(event.amount) - (noteZapRequest.event as? LnZapRequestEvent)?.let { event -> - acc.decryptZapContentAuthor(noteZapRequest) { - val author = LocalCache.getOrCreateUser(it.pubKey) - val senderInfo = Pair(author, it.content.ifBlank { null }) - - acc.decryptContent(noteZapped) { - val zappedContent = it.split("\n").get(0) - - val user = senderInfo.first.toBestDisplayName() - var title = - applicationContext.getString(R.string.app_notification_zaps_channel_message, amount) - senderInfo.second?.ifBlank { null }?.let { title += " ($it)" } - var content = - applicationContext.getString( - R.string.app_notification_zaps_channel_message_from, - user, - ) - zappedContent?.let { - content += - " " + - applicationContext.getString( - R.string.app_notification_zaps_channel_message_for, - zappedContent, - ) + // PushNotification Wraps don't include a receiver. + // Test with all logged in accounts + LocalPreferences.allSavedAccounts().forEach { + if (it.hasPrivKey || it.loggedInWithExternalSigner) { + LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)?.let { acc -> + consumeIfMatchesAccount(event, acc) + } } - val userPicture = senderInfo?.first?.profilePicture() - val noteUri = "nostr:Notifications" - notificationManager() - .sendZapNotification( - event.id, - content, - title, - userPicture, - noteUri, - applicationContext, - ) - } } - } } - } - fun notificationManager(): NotificationManager { - return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) - as NotificationManager - } + private suspend fun consumeIfMatchesAccount( + pushWrappedEvent: GiftWrapEvent, + account: Account, + ) { + pushWrappedEvent.cachedGift(account.signer) { notificationEvent -> + LocalCache.justConsume(notificationEvent, null) + + unwrapAndConsume(notificationEvent, account) { innerEvent -> + if (innerEvent is PrivateDmEvent) { + notify(innerEvent, account) + } else if (innerEvent is LnZapEvent) { + notify(innerEvent, account) + } else if (innerEvent is ChatMessageEvent) { + notify(innerEvent, account) + } + } + } + } + + private fun unwrapAndConsume( + event: Event, + account: Account, + onReady: (Event) -> Unit, + ) { + if (!LocalCache.justVerify(event)) return + + when (event) { + is GiftWrapEvent -> { + event.cachedGift(account.signer) { unwrapAndConsume(it, account, onReady) } + } + is SealedGossipEvent -> { + event.cachedGossip(account.signer) { + // this is not verifiable + LocalCache.justConsume(it, null) + onReady(it) + } + } + else -> { + LocalCache.justConsume(event, null) + onReady(event) + } + } + } + + private fun notify( + event: ChatMessageEvent, + acc: Account, + ) { + if ( + event.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted + event.pubKey != acc.userProfile().pubkeyHex + ) { // from the user + + val chatNote = LocalCache.notes[event.id] ?: return + val chatRoom = event.chatroomKey(acc.keyPair.pubKey.toHexKey()) + + val followingKeySet = acc.followingKeySet() + + val isKnownRoom = + ( + acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true || + acc.userProfile().hasSentMessagesTo(chatRoom) + ) && !acc.isAllHidden(chatRoom.users) + + if (isKnownRoom) { + val content = chatNote.event?.content() ?: "" + val user = chatNote.author?.toBestDisplayName() ?: "" + val userPicture = chatNote.author?.profilePicture() + val noteUri = chatNote.toNEvent() + notificationManager() + .sendDMNotification( + event.id, + content, + user, + userPicture, + noteUri, + applicationContext, + ) + } + } + } + + private fun notify( + event: PrivateDmEvent, + acc: Account, + ) { + val note = LocalCache.notes[event.id] ?: return + + // old event being re-broadcast + if (event.createdAt < TimeUtils.fiveMinutesAgo()) return + + if (acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) { + val followingKeySet = acc.followingKeySet() + + val knownChatrooms = + acc + .userProfile() + .privateChatrooms + .keys + .filter { + ( + acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true || + acc.userProfile().hasSentMessagesTo(it) + ) && !acc.isAllHidden(it.users) + } + .toSet() + + note.author?.let { + if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) { + acc.decryptContent(note) { content -> + val user = note.author?.toBestDisplayName() ?: "" + val userPicture = note.author?.profilePicture() + val noteUri = note.toNEvent() + notificationManager() + .sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext) + } + } + } + } + } + + private fun notify( + event: LnZapEvent, + acc: Account, + ) { + val noteZapEvent = LocalCache.notes[event.id] ?: return + + // old event being re-broadcast + if (event.createdAt < TimeUtils.fiveMinutesAgo()) return + + val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return + val noteZapped = + event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return + + if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return + + if (acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) { + val amount = showAmount(event.amount) + (noteZapRequest.event as? LnZapRequestEvent)?.let { event -> + acc.decryptZapContentAuthor(noteZapRequest) { + val author = LocalCache.getOrCreateUser(it.pubKey) + val senderInfo = Pair(author, it.content.ifBlank { null }) + + acc.decryptContent(noteZapped) { + val zappedContent = it.split("\n").get(0) + + val user = senderInfo.first.toBestDisplayName() + var title = + applicationContext.getString(R.string.app_notification_zaps_channel_message, amount) + senderInfo.second?.ifBlank { null }?.let { title += " ($it)" } + var content = + applicationContext.getString( + R.string.app_notification_zaps_channel_message_from, + user, + ) + zappedContent?.let { + content += + " " + + applicationContext.getString( + R.string.app_notification_zaps_channel_message_for, + zappedContent, + ) + } + val userPicture = senderInfo?.first?.profilePicture() + val noteUri = "nostr:Notifications" + notificationManager() + .sendZapNotification( + event.id, + content, + title, + userPicture, + noteUri, + applicationContext, + ) + } + } + } + } + } + + fun notificationManager(): NotificationManager { + return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) + as NotificationManager + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index ec8262f06..a0f8c4b36 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -36,213 +36,213 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.MainActivity object NotificationUtils { - private var dmChannel: NotificationChannel? = null - private var zapChannel: NotificationChannel? = null - private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION" - private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION" + private var dmChannel: NotificationChannel? = null + private var zapChannel: NotificationChannel? = null + private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION" + private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION" - fun NotificationManager.getOrCreateDMChannel(applicationContext: Context): NotificationChannel { - if (dmChannel != null) return dmChannel!! + fun NotificationManager.getOrCreateDMChannel(applicationContext: Context): NotificationChannel { + if (dmChannel != null) return dmChannel!! - dmChannel = - NotificationChannel( - applicationContext.getString(R.string.app_notification_dms_channel_id), - applicationContext.getString(R.string.app_notification_dms_channel_name), - NotificationManager.IMPORTANCE_DEFAULT, - ) - .apply { - description = - applicationContext.getString(R.string.app_notification_dms_channel_description) - } + dmChannel = + NotificationChannel( + applicationContext.getString(R.string.app_notification_dms_channel_id), + applicationContext.getString(R.string.app_notification_dms_channel_name), + NotificationManager.IMPORTANCE_DEFAULT, + ) + .apply { + description = + applicationContext.getString(R.string.app_notification_dms_channel_description) + } - // Register the channel with the system - val notificationManager: NotificationManager = - applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + // Register the channel with the system + val notificationManager: NotificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(dmChannel!!) + notificationManager.createNotificationChannel(dmChannel!!) - return dmChannel!! - } - - fun NotificationManager.getOrCreateZapChannel(applicationContext: Context): NotificationChannel { - if (zapChannel != null) return zapChannel!! - - zapChannel = - NotificationChannel( - applicationContext.getString(R.string.app_notification_zaps_channel_id), - applicationContext.getString(R.string.app_notification_zaps_channel_name), - NotificationManager.IMPORTANCE_DEFAULT, - ) - .apply { - description = - applicationContext.getString(R.string.app_notification_zaps_channel_description) - } - - // Register the channel with the system - val notificationManager: NotificationManager = - applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - notificationManager.createNotificationChannel(zapChannel!!) - - return zapChannel!! - } - - fun NotificationManager.sendZapNotification( - id: String, - messageBody: String, - messageTitle: String, - pictureUrl: String?, - uri: String, - applicationContext: Context, - ) { - val zapChannel = getOrCreateZapChannel(applicationContext) - val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id) - - sendNotification( - id, - messageBody, - messageTitle, - pictureUrl, - uri, - channelId, - ZAP_GROUP_KEY, - applicationContext, - ) - } - - fun NotificationManager.sendDMNotification( - id: String, - messageBody: String, - messageTitle: String, - pictureUrl: String?, - uri: String, - applicationContext: Context, - ) { - val dmChannel = getOrCreateDMChannel(applicationContext) - val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id) - - sendNotification( - id, - messageBody, - messageTitle, - pictureUrl, - uri, - channelId, - DM_GROUP_KEY, - applicationContext, - ) - } - - fun NotificationManager.sendNotification( - id: String, - messageBody: String, - messageTitle: String, - pictureUrl: String?, - uri: String, - channelId: String, - notificationGroupKey: String, - applicationContext: Context, - ) { - if (pictureUrl != null) { - val request = ImageRequest.Builder(applicationContext).data(pictureUrl).build() - - val imageLoader = ImageLoader(applicationContext) - val imageResult = imageLoader.executeBlocking(request) - sendNotification( - id = id, - messageBody = messageBody, - messageTitle = messageTitle, - picture = imageResult.drawable as? BitmapDrawable, - uri = uri, - channelId, - notificationGroupKey, - applicationContext = applicationContext, - ) - } else { - sendNotification( - id = id, - messageBody = messageBody, - messageTitle = messageTitle, - picture = null, - uri = uri, - channelId, - notificationGroupKey, - applicationContext = applicationContext, - ) - } - } - - private fun NotificationManager.sendNotification( - id: String, - messageBody: String, - messageTitle: String, - picture: BitmapDrawable?, - uri: String, - channelId: String, - notificationGroupKey: String, - applicationContext: Context, - ) { - val notId = id.hashCode() - - // dont notify twice - val notifications: Array = getActiveNotifications() - for (notification in notifications) { - if (notification.id == notId) { - return - } + return dmChannel!! } - val contentIntent = - Intent(applicationContext, MainActivity::class.java).apply { data = Uri.parse(uri) } + fun NotificationManager.getOrCreateZapChannel(applicationContext: Context): NotificationChannel { + if (zapChannel != null) return zapChannel!! - val contentPendingIntent = - PendingIntent.getActivity( - applicationContext, - notId, - contentIntent, - PendingIntent.FLAG_MUTABLE, - ) + zapChannel = + NotificationChannel( + applicationContext.getString(R.string.app_notification_zaps_channel_id), + applicationContext.getString(R.string.app_notification_zaps_channel_name), + NotificationManager.IMPORTANCE_DEFAULT, + ) + .apply { + description = + applicationContext.getString(R.string.app_notification_zaps_channel_description) + } - // Build the notification - val builderPublic = - NotificationCompat.Builder( - applicationContext, - channelId, + // Register the channel with the system + val notificationManager: NotificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannel(zapChannel!!) + + return zapChannel!! + } + + fun NotificationManager.sendZapNotification( + id: String, + messageBody: String, + messageTitle: String, + pictureUrl: String?, + uri: String, + applicationContext: Context, + ) { + val zapChannel = getOrCreateZapChannel(applicationContext) + val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id) + + sendNotification( + id, + messageBody, + messageTitle, + pictureUrl, + uri, + channelId, + ZAP_GROUP_KEY, + applicationContext, ) - .setSmallIcon(R.drawable.amethyst) - .setContentTitle(messageTitle) - .setContentText(applicationContext.getString(R.string.app_notification_private_message)) - .setLargeIcon(picture?.bitmap) - // .setGroup(messageTitle) - // .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we - // activate this - .setContentIntent(contentPendingIntent) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setAutoCancel(true) + } - // Build the notification - val builder = - NotificationCompat.Builder( - applicationContext, - channelId, + fun NotificationManager.sendDMNotification( + id: String, + messageBody: String, + messageTitle: String, + pictureUrl: String?, + uri: String, + applicationContext: Context, + ) { + val dmChannel = getOrCreateDMChannel(applicationContext) + val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id) + + sendNotification( + id, + messageBody, + messageTitle, + pictureUrl, + uri, + channelId, + DM_GROUP_KEY, + applicationContext, ) - .setSmallIcon(R.drawable.amethyst) - .setContentTitle(messageTitle) - .setContentText(messageBody) - .setLargeIcon(picture?.bitmap) - // .setGroup(messageTitle) - // .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we - // activate this - .setContentIntent(contentPendingIntent) - .setPublicVersion(builderPublic.build()) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setAutoCancel(true) + } - notify(notId, builder.build()) - } + fun NotificationManager.sendNotification( + id: String, + messageBody: String, + messageTitle: String, + pictureUrl: String?, + uri: String, + channelId: String, + notificationGroupKey: String, + applicationContext: Context, + ) { + if (pictureUrl != null) { + val request = ImageRequest.Builder(applicationContext).data(pictureUrl).build() - /** Cancels all notifications. */ - fun NotificationManager.cancelNotifications() { - cancelAll() - } + val imageLoader = ImageLoader(applicationContext) + val imageResult = imageLoader.executeBlocking(request) + sendNotification( + id = id, + messageBody = messageBody, + messageTitle = messageTitle, + picture = imageResult.drawable as? BitmapDrawable, + uri = uri, + channelId, + notificationGroupKey, + applicationContext = applicationContext, + ) + } else { + sendNotification( + id = id, + messageBody = messageBody, + messageTitle = messageTitle, + picture = null, + uri = uri, + channelId, + notificationGroupKey, + applicationContext = applicationContext, + ) + } + } + + private fun NotificationManager.sendNotification( + id: String, + messageBody: String, + messageTitle: String, + picture: BitmapDrawable?, + uri: String, + channelId: String, + notificationGroupKey: String, + applicationContext: Context, + ) { + val notId = id.hashCode() + + // dont notify twice + val notifications: Array = getActiveNotifications() + for (notification in notifications) { + if (notification.id == notId) { + return + } + } + + val contentIntent = + Intent(applicationContext, MainActivity::class.java).apply { data = Uri.parse(uri) } + + val contentPendingIntent = + PendingIntent.getActivity( + applicationContext, + notId, + contentIntent, + PendingIntent.FLAG_MUTABLE, + ) + + // Build the notification + val builderPublic = + NotificationCompat.Builder( + applicationContext, + channelId, + ) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle(messageTitle) + .setContentText(applicationContext.getString(R.string.app_notification_private_message)) + .setLargeIcon(picture?.bitmap) + // .setGroup(messageTitle) + // .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we + // activate this + .setContentIntent(contentPendingIntent) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + + // Build the notification + val builder = + NotificationCompat.Builder( + applicationContext, + channelId, + ) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle(messageTitle) + .setContentText(messageBody) + .setLargeIcon(picture?.bitmap) + // .setGroup(messageTitle) + // .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we + // activate this + .setContentIntent(contentPendingIntent) + .setPublicVersion(builderPublic.build()) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + + notify(notId, builder.build()) + } + + /** Cancels all notifications. */ + fun NotificationManager.cancelNotifications() { + cancelAll() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt index 420d3a54d..53df87b4d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt @@ -34,96 +34,96 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody class RegisterAccounts( - private val accounts: List, + private val accounts: List, ) { - private fun recursiveAuthCreation( - notificationToken: String, - remainingTos: List>, - output: MutableList, - onReady: (List) -> Unit, - ) { - if (remainingTos.isEmpty()) { - onReady(output) - return - } - - val next = remainingTos.first() - - next.first.createAuthEvent(next.second, notificationToken) { - output.add(it) - recursiveAuthCreation(notificationToken, remainingTos.filter { next != it }, output, onReady) - } - } - - // creates proof that it controls all accounts - private suspend fun signEventsToProveControlOfAccounts( - accounts: List, - notificationToken: String, - onReady: (List) -> Unit, - ) { - val readyToSend = - accounts.mapNotNull { - val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub) - if (acc != null && acc.isWriteable()) { - val readRelays = - acc.userProfile().latestContactList?.relays() ?: acc.backupContactList?.relays() - - val relayToUse = readRelays?.firstNotNullOfOrNull { if (it.value.read) it.key else null } - if (relayToUse != null) { - Pair(acc, relayToUse) - } else { - null - } - } else { - null + private fun recursiveAuthCreation( + notificationToken: String, + remainingTos: List>, + output: MutableList, + onReady: (List) -> Unit, + ) { + if (remainingTos.isEmpty()) { + onReady(output) + return } - } - val listOfAuthEvents = mutableListOf() - recursiveAuthCreation( - notificationToken, - readyToSend, - listOfAuthEvents, - onReady, - ) - } + val next = remainingTos.first() - fun postRegistrationEvent(events: List) { - try { - val jsonObject = - """{ + next.first.createAuthEvent(next.second, notificationToken) { + output.add(it) + recursiveAuthCreation(notificationToken, remainingTos.filter { next != it }, output, onReady) + } + } + + // creates proof that it controls all accounts + private suspend fun signEventsToProveControlOfAccounts( + accounts: List, + notificationToken: String, + onReady: (List) -> Unit, + ) { + val readyToSend = + accounts.mapNotNull { + val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub) + if (acc != null && acc.isWriteable()) { + val readRelays = + acc.userProfile().latestContactList?.relays() ?: acc.backupContactList?.relays() + + val relayToUse = readRelays?.firstNotNullOfOrNull { if (it.value.read) it.key else null } + if (relayToUse != null) { + Pair(acc, relayToUse) + } else { + null + } + } else { + null + } + } + + val listOfAuthEvents = mutableListOf() + recursiveAuthCreation( + notificationToken, + readyToSend, + listOfAuthEvents, + onReady, + ) + } + + fun postRegistrationEvent(events: List) { + try { + val jsonObject = + """{ "events": [ ${events.joinToString(", ") { it.toJson() }} ] } """ - val mediaType = "application/json; charset=utf-8".toMediaType() - val body = jsonObject.toRequestBody(mediaType) + val mediaType = "application/json; charset=utf-8".toMediaType() + val body = jsonObject.toRequestBody(mediaType) - val request = - Request.Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url("https://push.amethyst.social/register") - .post(body) - .build() + val request = + Request.Builder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url("https://push.amethyst.social/register") + .post(body) + .build() - val client = HttpClient.getHttpClient() + val client = HttpClient.getHttpClient() - val isSucess = client.newCall(request).execute().use { it.isSuccessful } - } catch (e: java.lang.Exception) { - val tag = - if (BuildConfig.FLAVOR == "play") { - "FirebaseMsgService" - } else { - "UnifiedPushService" + val isSucess = client.newCall(request).execute().use { it.isSuccessful } + } catch (e: java.lang.Exception) { + val tag = + if (BuildConfig.FLAVOR == "play") { + "FirebaseMsgService" + } else { + "UnifiedPushService" + } + Log.e(tag, "Unable to register with push server", e) } - Log.e(tag, "Unable to register with push server", e) } - } - suspend fun go(notificationToken: String) = - withContext(Dispatchers.IO) { - signEventsToProveControlOfAccounts(accounts, notificationToken) { postRegistrationEvent(it) } + suspend fun go(notificationToken: String) = + withContext(Dispatchers.IO) { + signEventsToProveControlOfAccounts(accounts, notificationToken) { postRegistrationEvent(it) } - PushNotificationUtils.hasInit = true - } + PushNotificationUtils.hasInit = true + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt index e40e4421c..83daaecd2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt @@ -32,138 +32,138 @@ import androidx.media3.common.Player.STATE_READY import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession import com.vitorpamplona.amethyst.ui.MainActivity -import kotlin.math.abs import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import kotlin.math.abs class MultiPlayerPlaybackManager( - private val dataSourceFactory: androidx.media3.exoplayer.source.MediaSource.Factory? = null, - private val cachedPositions: VideoViewedPositionCache, + private val dataSourceFactory: androidx.media3.exoplayer.source.MediaSource.Factory? = null, + private val cachedPositions: VideoViewedPositionCache, ) { - // protects from LruCache killing playing sessions - private val playingMap = mutableMapOf() + // protects from LruCache killing playing sessions + private val playingMap = mutableMapOf() - private val cache = - object : LruCache(10) { // up to 10 videos in the screen at the same time - override fun entryRemoved( - evicted: Boolean, - key: String?, - oldValue: MediaSession?, - newValue: MediaSession?, - ) { - super.entryRemoved(evicted, key, oldValue, newValue) + private val cache = + object : LruCache(10) { // up to 10 videos in the screen at the same time + override fun entryRemoved( + evicted: Boolean, + key: String?, + oldValue: MediaSession?, + newValue: MediaSession?, + ) { + super.entryRemoved(evicted, key, oldValue, newValue) - if (!playingMap.contains(key)) { - oldValue?.let { - it.player.release() - it.release() - } - } - } - } - - private fun getCallbackIntent( - callbackUri: String, - applicationContext: Context, - ): PendingIntent { - return PendingIntent.getActivity( - applicationContext, - 0, - Intent(Intent.ACTION_VIEW, callbackUri.toUri(), applicationContext, MainActivity::class.java), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - } - - @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) - fun getMediaSession( - id: String, - uri: String, - callbackUri: String?, - context: Context, - applicationContext: Context, - ): MediaSession { - val existingSession = playingMap.get(id) ?: cache.get(id) - if (existingSession != null) return existingSession - - val player = - ExoPlayer.Builder(context).run { - dataSourceFactory?.let { setMediaSourceFactory(it) } - build() - } - - player.apply { - repeatMode = Player.REPEAT_MODE_ALL - videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT - volume = 0f - } - - val mediaSession = - MediaSession.Builder(context, player).run { - callbackUri?.let { setSessionActivity(getCallbackIntent(it, applicationContext)) } - setId(id) - build() - } - - player.addListener( - object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - if (isPlaying) { - player.setWakeMode(C.WAKE_MODE_NETWORK) - playingMap.put(id, mediaSession) - } else { - player.setWakeMode(C.WAKE_MODE_NONE) - cachedPositions.add(uri, player.currentPosition) - cache.put(id, mediaSession) - playingMap.remove(id, mediaSession) - } - } - - override fun onPlaybackStateChanged(playbackState: Int) { - when (playbackState) { - STATE_IDLE -> { - // only saves if it wqs playing - if (abs(player.currentPosition) > 1) { - cachedPositions.add(uri, player.currentPosition) - } - } - STATE_READY -> { - cachedPositions.get(uri)?.let { lastPosition -> - if (abs(player.currentPosition - lastPosition) > 5 * 60) { - player.seekTo(lastPosition) + if (!playingMap.contains(key)) { + oldValue?.let { + it.player.release() + it.release() + } } - } } - else -> { - // only saves if it wqs playing - if (abs(player.currentPosition) > 1) { - cachedPositions.add(uri, player.currentPosition) - } - } - } } - }, - ) - cache.put(id, mediaSession) - - return mediaSession - } - - @OptIn(DelicateCoroutinesApi::class) - fun releaseAppPlayers() { - GlobalScope.launch(Dispatchers.Main) { - cache.evictAll() - playingMap.forEach { - it.value.player.release() - it.value.release() - } - playingMap.clear() + private fun getCallbackIntent( + callbackUri: String, + applicationContext: Context, + ): PendingIntent { + return PendingIntent.getActivity( + applicationContext, + 0, + Intent(Intent.ACTION_VIEW, callbackUri.toUri(), applicationContext, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) } - } - fun playingContent(): Collection { - return playingMap.values - } + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + fun getMediaSession( + id: String, + uri: String, + callbackUri: String?, + context: Context, + applicationContext: Context, + ): MediaSession { + val existingSession = playingMap.get(id) ?: cache.get(id) + if (existingSession != null) return existingSession + + val player = + ExoPlayer.Builder(context).run { + dataSourceFactory?.let { setMediaSourceFactory(it) } + build() + } + + player.apply { + repeatMode = Player.REPEAT_MODE_ALL + videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT + volume = 0f + } + + val mediaSession = + MediaSession.Builder(context, player).run { + callbackUri?.let { setSessionActivity(getCallbackIntent(it, applicationContext)) } + setId(id) + build() + } + + player.addListener( + object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (isPlaying) { + player.setWakeMode(C.WAKE_MODE_NETWORK) + playingMap.put(id, mediaSession) + } else { + player.setWakeMode(C.WAKE_MODE_NONE) + cachedPositions.add(uri, player.currentPosition) + cache.put(id, mediaSession) + playingMap.remove(id, mediaSession) + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + STATE_IDLE -> { + // only saves if it wqs playing + if (abs(player.currentPosition) > 1) { + cachedPositions.add(uri, player.currentPosition) + } + } + STATE_READY -> { + cachedPositions.get(uri)?.let { lastPosition -> + if (abs(player.currentPosition - lastPosition) > 5 * 60) { + player.seekTo(lastPosition) + } + } + } + else -> { + // only saves if it wqs playing + if (abs(player.currentPosition) > 1) { + cachedPositions.add(uri, player.currentPosition) + } + } + } + } + }, + ) + + cache.put(id, mediaSession) + + return mediaSession + } + + @OptIn(DelicateCoroutinesApi::class) + fun releaseAppPlayers() { + GlobalScope.launch(Dispatchers.Main) { + cache.evictAll() + playingMap.forEach { + it.value.player.release() + it.value.release() + } + playingMap.clear() + } + } + + fun playingContent(): Collection { + return playingMap.values + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackClientController.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackClientController.kt index bd66234a1..94499d3c9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackClientController.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackClientController.kt @@ -30,45 +30,45 @@ import androidx.media3.session.SessionToken import com.google.common.util.concurrent.MoreExecutors object PlaybackClientController { - val cache = LruCache(1) + val cache = LruCache(1) - @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) - fun prepareController( - controllerID: String, - videoUri: String, - callbackUri: String?, - context: Context, - onReady: (MediaController) -> Unit, - ) { - try { - // creating a bundle object - // creating a bundle object - val bundle = Bundle() - bundle.putString("id", controllerID) - bundle.putString("uri", videoUri) - bundle.putString("callbackUri", callbackUri) + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + fun prepareController( + controllerID: String, + videoUri: String, + callbackUri: String?, + context: Context, + onReady: (MediaController) -> Unit, + ) { + try { + // creating a bundle object + // creating a bundle object + val bundle = Bundle() + bundle.putString("id", controllerID) + bundle.putString("uri", videoUri) + bundle.putString("callbackUri", callbackUri) - var session = cache.get(context.hashCode()) - if (session == null) { - session = SessionToken(context, ComponentName(context, PlaybackService::class.java)) - cache.put(context.hashCode(), session) - } + var session = cache.get(context.hashCode()) + if (session == null) { + session = SessionToken(context, ComponentName(context, PlaybackService::class.java)) + cache.put(context.hashCode(), session) + } - val controllerFuture = - MediaController.Builder(context, session).setConnectionHints(bundle).buildAsync() + val controllerFuture = + MediaController.Builder(context, session).setConnectionHints(bundle).buildAsync() - controllerFuture.addListener( - { - try { - onReady(controllerFuture.get()) - } catch (e: Exception) { + controllerFuture.addListener( + { + try { + onReady(controllerFuture.get()) + } catch (e: Exception) { + Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e) + } + }, + MoreExecutors.directExecutor(), + ) + } catch (e: Exception) { Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e) - } - }, - MoreExecutors.directExecutor(), - ) - } catch (e: Exception) { - Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e) + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt index d2416e279..82dfc93b2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt @@ -35,161 +35,161 @@ import com.vitorpamplona.amethyst.service.HttpClient @UnstableApi // Extend MediaSessionService class PlaybackService : MediaSessionService() { - private var videoViewedPositionCache = VideoViewedPositionCache() + private var videoViewedPositionCache = VideoViewedPositionCache() - private var managerHls: MultiPlayerPlaybackManager? = null - private var managerProgressive: MultiPlayerPlaybackManager? = null - private var managerLocal: MultiPlayerPlaybackManager? = null + private var managerHls: MultiPlayerPlaybackManager? = null + private var managerProgressive: MultiPlayerPlaybackManager? = null + private var managerLocal: MultiPlayerPlaybackManager? = null - fun newHslDataSource(): MediaSource.Factory { - return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClient.getHttpClient())) - } - - fun newProgressiveDataSource(): MediaSource.Factory { - return ProgressiveMediaSource.Factory( - (applicationContext as Amethyst).videoCache.get(HttpClient.getHttpClient()), - ) - } - - fun lazyHlsDS(): MultiPlayerPlaybackManager { - managerHls?.let { - return it + fun newHslDataSource(): MediaSource.Factory { + return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClient.getHttpClient())) } - val newInstance = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache) - managerHls = newInstance - return newInstance - } - - fun lazyProgressiveDS(): MultiPlayerPlaybackManager { - managerProgressive?.let { - return it + fun newProgressiveDataSource(): MediaSource.Factory { + return ProgressiveMediaSource.Factory( + (applicationContext as Amethyst).videoCache.get(HttpClient.getHttpClient()), + ) } - val newInstance = - MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache) - managerProgressive = newInstance - return newInstance - } + fun lazyHlsDS(): MultiPlayerPlaybackManager { + managerHls?.let { + return it + } - fun lazyLocalDS(): MultiPlayerPlaybackManager { - managerLocal?.let { - return it + val newInstance = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache) + managerHls = newInstance + return newInstance } - val newInstance = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache) - managerLocal = newInstance - return newInstance - } + fun lazyProgressiveDS(): MultiPlayerPlaybackManager { + managerProgressive?.let { + return it + } - // Create your Player and MediaSession in the onCreate lifecycle event - @OptIn(UnstableApi::class) - override fun onCreate() { - super.onCreate() - - Log.d("Lifetime Event", "PlaybackService.onCreate") - - // Stop all videos and recreates all managers when the proxy changes. - HttpClient.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated) - } - - private fun onProxyUpdated() { - val toDestroyHls = managerHls - val toDestroyProgressive = managerProgressive - - managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache) - managerProgressive = - MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache) - - toDestroyHls?.releaseAppPlayers() - toDestroyProgressive?.releaseAppPlayers() - } - - override fun onTaskRemoved(rootIntent: Intent?) { - super.onTaskRemoved(rootIntent) - - Log.d("Lifetime Event", "onTaskRemoved") - } - - override fun onDestroy() { - Log.d("Lifetime Event", "PlaybackService.onDestroy") - - HttpClient.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated) - - managerHls?.releaseAppPlayers() - managerLocal?.releaseAppPlayers() - managerProgressive?.releaseAppPlayers() - - super.onDestroy() - } - - fun getAppropriateMediaSessionManager(fileName: String): MultiPlayerPlaybackManager? { - return if (fileName.startsWith("file")) { - lazyLocalDS() - } else if (fileName.endsWith("m3u8")) { - lazyHlsDS() - } else { - lazyProgressiveDS() + val newInstance = + MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache) + managerProgressive = newInstance + return newInstance } - } - override fun onUpdateNotification( - session: MediaSession, - startInForegroundRequired: Boolean, - ) { - // Updates any new player ready - super.onUpdateNotification(session, startInForegroundRequired) + fun lazyLocalDS(): MultiPlayerPlaybackManager { + managerLocal?.let { + return it + } - // Overrides the notification with any player actually playing - managerHls?.playingContent()?.forEach { - if (it.player.isPlaying) { - super.onUpdateNotification(it, startInForegroundRequired) - } + val newInstance = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache) + managerLocal = newInstance + return newInstance } - managerLocal?.playingContent()?.forEach { - if (it.player.isPlaying) { + + // Create your Player and MediaSession in the onCreate lifecycle event + @OptIn(UnstableApi::class) + override fun onCreate() { + super.onCreate() + + Log.d("Lifetime Event", "PlaybackService.onCreate") + + // Stop all videos and recreates all managers when the proxy changes. + HttpClient.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated) + } + + private fun onProxyUpdated() { + val toDestroyHls = managerHls + val toDestroyProgressive = managerProgressive + + managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache) + managerProgressive = + MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache) + + toDestroyHls?.releaseAppPlayers() + toDestroyProgressive?.releaseAppPlayers() + } + + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + + Log.d("Lifetime Event", "onTaskRemoved") + } + + override fun onDestroy() { + Log.d("Lifetime Event", "PlaybackService.onDestroy") + + HttpClient.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated) + + managerHls?.releaseAppPlayers() + managerLocal?.releaseAppPlayers() + managerProgressive?.releaseAppPlayers() + + super.onDestroy() + } + + fun getAppropriateMediaSessionManager(fileName: String): MultiPlayerPlaybackManager? { + return if (fileName.startsWith("file")) { + lazyLocalDS() + } else if (fileName.endsWith("m3u8")) { + lazyHlsDS() + } else { + lazyProgressiveDS() + } + } + + override fun onUpdateNotification( + session: MediaSession, + startInForegroundRequired: Boolean, + ) { + // Updates any new player ready super.onUpdateNotification(session, startInForegroundRequired) - } - } - managerProgressive?.playingContent()?.forEach { - if (it.player.isPlaying) { - super.onUpdateNotification(session, startInForegroundRequired) - } + + // Overrides the notification with any player actually playing + managerHls?.playingContent()?.forEach { + if (it.player.isPlaying) { + super.onUpdateNotification(it, startInForegroundRequired) + } + } + managerLocal?.playingContent()?.forEach { + if (it.player.isPlaying) { + super.onUpdateNotification(session, startInForegroundRequired) + } + } + managerProgressive?.playingContent()?.forEach { + if (it.player.isPlaying) { + super.onUpdateNotification(session, startInForegroundRequired) + } + } + + // Overrides again with playing with audio + managerHls?.playingContent()?.forEach { + if (it.player.isPlaying && it.player.volume > 0) { + super.onUpdateNotification(it, startInForegroundRequired) + } + } + managerLocal?.playingContent()?.forEach { + if (it.player.isPlaying && it.player.volume > 0) { + super.onUpdateNotification(session, startInForegroundRequired) + } + } + managerProgressive?.playingContent()?.forEach { + if (it.player.isPlaying && it.player.volume > 0) { + super.onUpdateNotification(session, startInForegroundRequired) + } + } } - // Overrides again with playing with audio - managerHls?.playingContent()?.forEach { - if (it.player.isPlaying && it.player.volume > 0) { - super.onUpdateNotification(it, startInForegroundRequired) - } - } - managerLocal?.playingContent()?.forEach { - if (it.player.isPlaying && it.player.volume > 0) { - super.onUpdateNotification(session, startInForegroundRequired) - } - } - managerProgressive?.playingContent()?.forEach { - if (it.player.isPlaying && it.player.volume > 0) { - super.onUpdateNotification(session, startInForegroundRequired) - } - } - } + // Return a MediaSession to link with the MediaController that is making + // this request. + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { + val id = controllerInfo.connectionHints.getString("id") ?: return null + val uri = controllerInfo.connectionHints.getString("uri") ?: return null + val callbackUri = controllerInfo.connectionHints.getString("callbackUri") - // Return a MediaSession to link with the MediaController that is making - // this request. - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { - val id = controllerInfo.connectionHints.getString("id") ?: return null - val uri = controllerInfo.connectionHints.getString("uri") ?: return null - val callbackUri = controllerInfo.connectionHints.getString("callbackUri") + val manager = getAppropriateMediaSessionManager(uri) - val manager = getAppropriateMediaSessionManager(uri) - - return manager?.getMediaSession( - id, - uri, - callbackUri, - context = this, - applicationContext = applicationContext, - ) - } + return manager?.getMediaSession( + id, + uri, + callbackUri, + context = this, + applicationContext = applicationContext, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoCache.kt index 14bff8581..5f2bf1083 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoCache.kt @@ -27,47 +27,47 @@ import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.SimpleCache import androidx.media3.datasource.okhttp.OkHttpDataSource -import java.io.File import okhttp3.OkHttpClient +import java.io.File @SuppressLint("UnsafeOptInUsageError") class VideoCache { - var exoPlayerCacheSize: Long = 150 * 1024 * 1024 // 90MB + var exoPlayerCacheSize: Long = 150 * 1024 * 1024 // 90MB - var leastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(exoPlayerCacheSize) + var leastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(exoPlayerCacheSize) - lateinit var exoDatabaseProvider: StandaloneDatabaseProvider - lateinit var simpleCache: SimpleCache + lateinit var exoDatabaseProvider: StandaloneDatabaseProvider + lateinit var simpleCache: SimpleCache - lateinit var cacheDataSourceFactory: CacheDataSource.Factory + lateinit var cacheDataSourceFactory: CacheDataSource.Factory - @Synchronized - fun initFileCache(context: Context) { - exoDatabaseProvider = StandaloneDatabaseProvider(context) + @Synchronized + fun initFileCache(context: Context) { + exoDatabaseProvider = StandaloneDatabaseProvider(context) - simpleCache = - SimpleCache( - File(context.cacheDir, "exoplayer"), - leastRecentlyUsedCacheEvictor, - exoDatabaseProvider, - ) - } + simpleCache = + SimpleCache( + File(context.cacheDir, "exoplayer"), + leastRecentlyUsedCacheEvictor, + exoDatabaseProvider, + ) + } - // This method should be called when proxy setting changes. - fun renewCacheFactory(client: OkHttpClient) { - cacheDataSourceFactory = - CacheDataSource.Factory() - .setCache(simpleCache) - .setUpstreamDataSourceFactory( - OkHttpDataSource.Factory(client), - ) - .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) - } + // This method should be called when proxy setting changes. + fun renewCacheFactory(client: OkHttpClient) { + cacheDataSourceFactory = + CacheDataSource.Factory() + .setCache(simpleCache) + .setUpstreamDataSourceFactory( + OkHttpDataSource.Factory(client), + ) + .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) + } - fun get(client: OkHttpClient): CacheDataSource.Factory { - // Renews the factory because OkHttpMight have changed. - renewCacheFactory(client) + fun get(client: OkHttpClient): CacheDataSource.Factory { + // Renews the factory because OkHttpMight have changed. + renewCacheFactory(client) - return cacheDataSourceFactory - } + return cacheDataSourceFactory + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoViewedPositionCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoViewedPositionCache.kt index e0984c0fb..df600ec98 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoViewedPositionCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoViewedPositionCache.kt @@ -23,16 +23,16 @@ package com.vitorpamplona.amethyst.service.playback import android.util.LruCache class VideoViewedPositionCache { - val cachedPosition = LruCache(100) + val cachedPosition = LruCache(100) - fun add( - uri: String, - position: Long, - ) { - cachedPosition.put(uri, position) - } + fun add( + uri: String, + position: Long, + ) { + cachedPosition.put(uri, position) + } - fun get(uri: String): Long? { - return cachedPosition.get(uri) - } + fun get(uri: String): Long? { + return cachedPosition.get(uri) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/BahaUrlPreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/BahaUrlPreview.kt index 01b448577..aedb40df6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/BahaUrlPreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/BahaUrlPreview.kt @@ -24,20 +24,20 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) { - suspend fun fetchUrlPreview(timeOut: Int = 30000) = - withContext(Dispatchers.IO) { - try { - fetch(timeOut) - } catch (t: Throwable) { - callback?.onFailed(t) - } + suspend fun fetchUrlPreview(timeOut: Int = 30000) = + withContext(Dispatchers.IO) { + try { + fetch(timeOut) + } catch (t: Throwable) { + callback?.onFailed(t) + } + } + + private suspend fun fetch(timeOut: Int = 30000) { + callback?.onComplete(getDocument(url, timeOut)) } - private suspend fun fetch(timeOut: Int = 30000) { - callback?.onComplete(getDocument(url, timeOut)) - } - - fun cleanUp() { - callback = null - } + fun cleanUp() { + callback = null + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/IUrlPreviewCallback.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/IUrlPreviewCallback.kt index 5f333a34c..db5e6f2ba 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/IUrlPreviewCallback.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/IUrlPreviewCallback.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.service.previews interface IUrlPreviewCallback { - suspend fun onComplete(urlInfo: UrlInfoItem) + suspend fun onComplete(urlInfo: UrlInfoItem) - suspend fun onFailed(throwable: Throwable) + suspend fun onFailed(throwable: Throwable) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlInfoItem.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlInfoItem.kt index d89f5734b..3135f2285 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlInfoItem.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlInfoItem.kt @@ -21,30 +21,30 @@ package com.vitorpamplona.amethyst.service.previews import androidx.compose.runtime.Immutable -import java.net.URL import okhttp3.MediaType +import java.net.URL @Immutable class UrlInfoItem( - val url: String = "", - val title: String = "", - val description: String = "", - val image: String = "", - val mimeType: MediaType, + val url: String = "", + val title: String = "", + val description: String = "", + val image: String = "", + val mimeType: MediaType, ) { - val verifiedUrl = kotlin.runCatching { URL(url) }.getOrNull() - val imageUrlFullPath = - if (image.startsWith("/")) { - URL(verifiedUrl, image).toString() - } else { - image + val verifiedUrl = kotlin.runCatching { URL(url) }.getOrNull() + val imageUrlFullPath = + if (image.startsWith("/")) { + URL(verifiedUrl, image).toString() + } else { + image + } + + fun fetchComplete(): Boolean { + return url.isNotEmpty() && image.isNotEmpty() } - fun fetchComplete(): Boolean { - return url.isNotEmpty() && image.isNotEmpty() - } - - fun allFetchComplete(): Boolean { - return title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty() - } + fun allFetchComplete(): Boolean { + return title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreviewUtils.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreviewUtils.kt index 9594bba51..ae521cfee 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreviewUtils.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreviewUtils.kt @@ -36,147 +36,147 @@ private const val ATTRIBUTE_VALUE_ITEMPROP = "itemprop" // for - if (title.isEmpty()) { - title = it.attr(CONTENT) - } - in META_X_DESCRIPTION -> - if (description.isEmpty()) { - description = it.attr(CONTENT) - } - in META_X_IMAGE -> - if (image.isEmpty()) { - image = it.attr(CONTENT) - } - } + metaTags.forEach { + when (it.attr(ATTRIBUTE_VALUE_PROPERTY)) { + in META_X_TITLE -> + if (title.isEmpty()) { + title = it.attr(CONTENT) + } + in META_X_DESCRIPTION -> + if (description.isEmpty()) { + description = it.attr(CONTENT) + } + in META_X_IMAGE -> + if (image.isEmpty()) { + image = it.attr(CONTENT) + } + } - when (it.attr(ATTRIBUTE_VALUE_NAME)) { - in META_X_TITLE -> - if (title.isEmpty()) { - title = it.attr(CONTENT) - } - in META_X_DESCRIPTION -> - if (description.isEmpty()) { - description = it.attr(CONTENT) - } - in META_X_IMAGE -> - if (image.isEmpty()) { - image = it.attr(CONTENT) - } - } + when (it.attr(ATTRIBUTE_VALUE_NAME)) { + in META_X_TITLE -> + if (title.isEmpty()) { + title = it.attr(CONTENT) + } + in META_X_DESCRIPTION -> + if (description.isEmpty()) { + description = it.attr(CONTENT) + } + in META_X_IMAGE -> + if (image.isEmpty()) { + image = it.attr(CONTENT) + } + } - when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) { - in META_X_TITLE -> - if (title.isEmpty()) { - title = it.attr(CONTENT) - } - in META_X_DESCRIPTION -> - if (description.isEmpty()) { - description = it.attr(CONTENT) - } - in META_X_IMAGE -> - if (image.isEmpty()) { - image = it.attr(CONTENT) - } - } + when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) { + in META_X_TITLE -> + if (title.isEmpty()) { + title = it.attr(CONTENT) + } + in META_X_DESCRIPTION -> + if (description.isEmpty()) { + description = it.attr(CONTENT) + } + in META_X_IMAGE -> + if (image.isEmpty()) { + image = it.attr(CONTENT) + } + } - if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) { + if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) { + return@withContext UrlInfoItem(url, title, description, image, type) + } + } return@withContext UrlInfoItem(url, title, description, image, type) - } } - return@withContext UrlInfoItem(url, title, description, image, type) - } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt index d89311996..e16a07c30 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt @@ -24,293 +24,293 @@ import android.util.Log import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.EventInterface -import java.util.UUID import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.util.UUID /** * The Nostr Client manages multiple personae the user may switch between. Events are received and * published through multiple relays. Events are stored with their respective persona. */ object Client : RelayPool.Listener { - private var listeners = setOf() - private var relays = emptyArray() - private var subscriptions = mapOf>() + private var listeners = setOf() + private var relays = emptyArray() + private var subscriptions = mapOf>() - @Synchronized - fun reconnect( - relays: Array?, - onlyIfChanged: Boolean = false, - ) { - Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays") - checkNotInMainThread() + @Synchronized + fun reconnect( + relays: Array?, + onlyIfChanged: Boolean = false, + ) { + Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays") + checkNotInMainThread() - if (onlyIfChanged) { - if (!isSameRelaySetConfig(relays)) { - if (this.relays.isNotEmpty()) { - RelayPool.disconnect() - RelayPool.unregister(this) - RelayPool.unloadRelays() + if (onlyIfChanged) { + if (!isSameRelaySetConfig(relays)) { + if (this.relays.isNotEmpty()) { + RelayPool.disconnect() + RelayPool.unregister(this) + RelayPool.unloadRelays() + } + + if (relays != null) { + RelayPool.register(this) + RelayPool.loadRelays(relays.toList()) + RelayPool.requestAndWatch() + this.relays = relays + } + } + } else { + if (this.relays.isNotEmpty()) { + RelayPool.disconnect() + RelayPool.unregister(this) + RelayPool.unloadRelays() + } + + if (relays != null) { + RelayPool.register(this) + RelayPool.loadRelays(relays.toList()) + RelayPool.requestAndWatch() + this.relays = relays + } + } + } + + fun isSameRelaySetConfig(newRelayConfig: Array?): Boolean { + if (relays.size != newRelayConfig?.size) return false + + relays.forEach { oldRelayInfo -> + val newRelayInfo = newRelayConfig.find { it.url == oldRelayInfo.url } ?: return false + + if (!oldRelayInfo.isSameRelayConfig(newRelayInfo)) return false } - if (relays != null) { - RelayPool.register(this) - RelayPool.loadRelays(relays.toList()) - RelayPool.requestAndWatch() - this.relays = relays + return true + } + + fun sendFilter( + subscriptionId: String = UUID.randomUUID().toString().substring(0..10), + filters: List = listOf(), + ) { + checkNotInMainThread() + + subscriptions = subscriptions + Pair(subscriptionId, filters) + RelayPool.sendFilter(subscriptionId) + } + + fun sendFilterOnlyIfDisconnected( + subscriptionId: String = UUID.randomUUID().toString().substring(0..10), + filters: List = listOf(), + ) { + checkNotInMainThread() + + subscriptions = subscriptions + Pair(subscriptionId, filters) + RelayPool.sendFilterOnlyIfDisconnected(subscriptionId) + } + + fun send( + signedEvent: EventInterface, + relay: String? = null, + feedTypes: Set? = null, + relayList: List? = null, + onDone: (() -> Unit)? = null, + ) { + checkNotInMainThread() + + if (relayList != null) { + RelayPool.sendToSelectedRelays(relayList, signedEvent) + } else if (relay == null) { + RelayPool.send(signedEvent) + } else { + val useConnectedRelayIfPresent = RelayPool.getRelays(relay) + + if (useConnectedRelayIfPresent.isNotEmpty()) { + useConnectedRelayIfPresent.forEach { it.send(signedEvent) } + } else { + /** temporary connection */ + newSporadicRelay( + relay, + feedTypes, + onConnected = { relay -> relay.send(signedEvent) }, + onDone = onDone, + ) + } } - } - } else { - if (this.relays.isNotEmpty()) { - RelayPool.disconnect() - RelayPool.unregister(this) - RelayPool.unloadRelays() - } - - if (relays != null) { - RelayPool.register(this) - RelayPool.loadRelays(relays.toList()) - RelayPool.requestAndWatch() - this.relays = relays - } - } - } - - fun isSameRelaySetConfig(newRelayConfig: Array?): Boolean { - if (relays.size != newRelayConfig?.size) return false - - relays.forEach { oldRelayInfo -> - val newRelayInfo = newRelayConfig.find { it.url == oldRelayInfo.url } ?: return false - - if (!oldRelayInfo.isSameRelayConfig(newRelayInfo)) return false } - return true - } + @OptIn(DelicateCoroutinesApi::class) + private fun newSporadicRelay( + url: String, + feedTypes: Set?, + onConnected: (Relay) -> Unit, + onDone: (() -> Unit)?, + ) { + val relay = Relay(url, true, true, feedTypes ?: emptySet()) + RelayPool.addRelay(relay) - fun sendFilter( - subscriptionId: String = UUID.randomUUID().toString().substring(0..10), - filters: List = listOf(), - ) { - checkNotInMainThread() + relay.connectAndRun { + allSubscriptions().forEach { relay.sendFilter(requestId = it) } - subscriptions = subscriptions + Pair(subscriptionId, filters) - RelayPool.sendFilter(subscriptionId) - } + onConnected(relay) - fun sendFilterOnlyIfDisconnected( - subscriptionId: String = UUID.randomUUID().toString().substring(0..10), - filters: List = listOf(), - ) { - checkNotInMainThread() + GlobalScope.launch(Dispatchers.IO) { + delay(60000) // waits for a reply + relay.disconnect() + RelayPool.removeRelay(relay) - subscriptions = subscriptions + Pair(subscriptionId, filters) - RelayPool.sendFilterOnlyIfDisconnected(subscriptionId) - } - - fun send( - signedEvent: EventInterface, - relay: String? = null, - feedTypes: Set? = null, - relayList: List? = null, - onDone: (() -> Unit)? = null, - ) { - checkNotInMainThread() - - if (relayList != null) { - RelayPool.sendToSelectedRelays(relayList, signedEvent) - } else if (relay == null) { - RelayPool.send(signedEvent) - } else { - val useConnectedRelayIfPresent = RelayPool.getRelays(relay) - - if (useConnectedRelayIfPresent.isNotEmpty()) { - useConnectedRelayIfPresent.forEach { it.send(signedEvent) } - } else { - /** temporary connection */ - newSporadicRelay( - relay, - feedTypes, - onConnected = { relay -> relay.send(signedEvent) }, - onDone = onDone, - ) - } - } - } - - @OptIn(DelicateCoroutinesApi::class) - private fun newSporadicRelay( - url: String, - feedTypes: Set?, - onConnected: (Relay) -> Unit, - onDone: (() -> Unit)?, - ) { - val relay = Relay(url, true, true, feedTypes ?: emptySet()) - RelayPool.addRelay(relay) - - relay.connectAndRun { - allSubscriptions().forEach { relay.sendFilter(requestId = it) } - - onConnected(relay) - - GlobalScope.launch(Dispatchers.IO) { - delay(60000) // waits for a reply - relay.disconnect() - RelayPool.removeRelay(relay) - - if (onDone != null) { - onDone() + if (onDone != null) { + onDone() + } + } } - } } - } - fun close(subscriptionId: String) { - RelayPool.close(subscriptionId) - subscriptions = subscriptions.minus(subscriptionId) - } - - fun isActive(subscriptionId: String): Boolean { - return subscriptions.contains(subscriptionId) - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onEvent( - event: Event, - subscriptionId: String, - relay: Relay, - afterEOSE: Boolean, - ) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - GlobalScope.launch(Dispatchers.Default) { - listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) } + fun close(subscriptionId: String) { + RelayPool.close(subscriptionId) + subscriptions = subscriptions.minus(subscriptionId) } - } - @OptIn(DelicateCoroutinesApi::class) - override fun onError( - error: Error, - subscriptionId: String, - relay: Relay, - ) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - GlobalScope.launch(Dispatchers.Default) { - listeners.forEach { it.onError(error, subscriptionId, relay) } + fun isActive(subscriptionId: String): Boolean { + return subscriptions.contains(subscriptionId) } - } - @OptIn(DelicateCoroutinesApi::class) - override fun onRelayStateChange( - type: Relay.StateType, - relay: Relay, - channel: String?, - ) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - GlobalScope.launch(Dispatchers.Default) { - listeners.forEach { it.onRelayStateChange(type, relay, channel) } + @OptIn(DelicateCoroutinesApi::class) + override fun onEvent( + event: Event, + subscriptionId: String, + relay: Relay, + afterEOSE: Boolean, + ) { + // Releases the Web thread for the new payload. + // May need to add a processing queue if processing new events become too costly. + GlobalScope.launch(Dispatchers.Default) { + listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) } + } } - } - @OptIn(DelicateCoroutinesApi::class) - override fun onSendResponse( - eventId: String, - success: Boolean, - message: String, - relay: Relay, - ) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - GlobalScope.launch(Dispatchers.Default) { - listeners.forEach { it.onSendResponse(eventId, success, message, relay) } + @OptIn(DelicateCoroutinesApi::class) + override fun onError( + error: Error, + subscriptionId: String, + relay: Relay, + ) { + // Releases the Web thread for the new payload. + // May need to add a processing queue if processing new events become too costly. + GlobalScope.launch(Dispatchers.Default) { + listeners.forEach { it.onError(error, subscriptionId, relay) } + } } - } - @OptIn(DelicateCoroutinesApi::class) - override fun onAuth( - relay: Relay, - challenge: String, - ) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - GlobalScope.launch(Dispatchers.Default) { listeners.forEach { it.onAuth(relay, challenge) } } - } - - override fun onNotify( - relay: Relay, - description: String, - ) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - GlobalScope.launch(Dispatchers.Default) { - listeners.forEach { it.onNotify(relay, description) } + @OptIn(DelicateCoroutinesApi::class) + override fun onRelayStateChange( + type: Relay.StateType, + relay: Relay, + channel: String?, + ) { + // Releases the Web thread for the new payload. + // May need to add a processing queue if processing new events become too costly. + GlobalScope.launch(Dispatchers.Default) { + listeners.forEach { it.onRelayStateChange(type, relay, channel) } + } } - } - fun subscribe(listener: Listener) { - listeners = listeners.plus(listener) - } + @OptIn(DelicateCoroutinesApi::class) + override fun onSendResponse( + eventId: String, + success: Boolean, + message: String, + relay: Relay, + ) { + // Releases the Web thread for the new payload. + // May need to add a processing queue if processing new events become too costly. + GlobalScope.launch(Dispatchers.Default) { + listeners.forEach { it.onSendResponse(eventId, success, message, relay) } + } + } - fun isSubscribed(listener: Listener): Boolean { - return listeners.contains(listener) - } + @OptIn(DelicateCoroutinesApi::class) + override fun onAuth( + relay: Relay, + challenge: String, + ) { + // Releases the Web thread for the new payload. + // May need to add a processing queue if processing new events become too costly. + GlobalScope.launch(Dispatchers.Default) { listeners.forEach { it.onAuth(relay, challenge) } } + } - fun unsubscribe(listener: Listener) { - listeners = listeners.minus(listener) - } + override fun onNotify( + relay: Relay, + description: String, + ) { + // Releases the Web thread for the new payload. + // May need to add a processing queue if processing new events become too costly. + GlobalScope.launch(Dispatchers.Default) { + listeners.forEach { it.onNotify(relay, description) } + } + } - fun allSubscriptions(): Set { - return subscriptions.keys - } + fun subscribe(listener: Listener) { + listeners = listeners.plus(listener) + } - fun getSubscriptionFilters(subId: String): List { - return subscriptions[subId] ?: emptyList() - } + fun isSubscribed(listener: Listener): Boolean { + return listeners.contains(listener) + } - abstract class Listener { - /** A new message was received */ - open fun onEvent( - event: Event, - subscriptionId: String, - relay: Relay, - afterEOSE: Boolean, - ) = Unit + fun unsubscribe(listener: Listener) { + listeners = listeners.minus(listener) + } - /** A new or repeat message was received */ - open fun onError( - error: Error, - subscriptionId: String, - relay: Relay, - ) = Unit + fun allSubscriptions(): Set { + return subscriptions.keys + } - /** Connected to or disconnected from a relay */ - open fun onRelayStateChange( - type: Relay.StateType, - relay: Relay, - channel: String?, - ) = Unit + fun getSubscriptionFilters(subId: String): List { + return subscriptions[subId] ?: emptyList() + } - /** When an relay saves or rejects a new event. */ - open fun onSendResponse( - eventId: String, - success: Boolean, - message: String, - relay: Relay, - ) = Unit + abstract class Listener { + /** A new message was received */ + open fun onEvent( + event: Event, + subscriptionId: String, + relay: Relay, + afterEOSE: Boolean, + ) = Unit - open fun onAuth( - relay: Relay, - challenge: String, - ) = Unit + /** A new or repeat message was received */ + open fun onError( + error: Error, + subscriptionId: String, + relay: Relay, + ) = Unit - open fun onNotify( - relay: Relay, - description: String, - ) = Unit - } + /** Connected to or disconnected from a relay */ + open fun onRelayStateChange( + type: Relay.StateType, + relay: Relay, + channel: String?, + ) = Unit + + /** When an relay saves or rejects a new event. */ + open fun onSendResponse( + eventId: String, + success: Boolean, + message: String, + relay: Relay, + ) = Unit + + open fun onAuth( + relay: Relay, + challenge: String, + ) = Unit + + open fun onNotify( + relay: Relay, + description: String, + ) = Unit + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt index 409968b71..7e7e67b5a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt @@ -23,156 +23,156 @@ package com.vitorpamplona.amethyst.service.relays import com.vitorpamplona.amethyst.model.RelaySetupInfo object Constants { - val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS) - val activeTypesChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS) - val activeTypesGlobalChats = - setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) - val activeTypesSearch = setOf(FeedType.SEARCH) + val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS) + val activeTypesChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS) + val activeTypesGlobalChats = + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) + val activeTypesSearch = setOf(FeedType.SEARCH) - fun convertDefaultRelays(): Array { - return defaultRelays.map { Relay(it.url, it.read, it.write, it.feedTypes) }.toTypedArray() - } + fun convertDefaultRelays(): Array { + return defaultRelays.map { Relay(it.url, it.read, it.write, it.feedTypes) }.toTypedArray() + } - val defaultRelays = - arrayOf( - // Free relays for only DMs and Follows due to the amount of spam - RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes), - // Chats - RelaySetupInfo( - "wss://nostr.bitcoiner.social", - read = true, - write = true, - feedTypes = activeTypesChats, - ), - RelaySetupInfo( - "wss://relay.nostr.bg", - read = true, - write = true, - feedTypes = activeTypesChats, - ), - RelaySetupInfo( - "wss://nostr.oxtr.dev", - read = true, - write = true, - feedTypes = activeTypesChats, - ), - RelaySetupInfo( - "wss://nostr-pub.wellorder.net", - read = true, - write = true, - feedTypes = activeTypesChats, - ), - RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypesChats), - RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesChats), - // Less Reliable - // NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, - // feedTypes = activeTypes), - // NewRelayListViewModel.Relay("wss://nostr.onsats.org", read = true, write = true, feedTypes - // = activeTypes), - // NewRelayListViewModel.Relay("wss://nostr.sandwich.farm", read = true, write = true, - // feedTypes = activeTypes), - // NewRelayListViewModel.Relay("wss://relay.nostr.ch", read = true, write = true, feedTypes = - // activeTypes), - // NewRelayListViewModel.Relay("wss://nostr.zebedee.cloud", read = true, write = true, - // feedTypes = activeTypes), - // NewRelayListViewModel.Relay("wss://nostr.rocks", read = true, write = true, feedTypes = - // activeTypes), - // NewRelayListViewModel.Relay("wss://nostr.fmt.wiz.biz", read = true, write = true, feedTypes - // = activeTypes), - // NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = - // activeTypes), - // Paid relays - RelaySetupInfo( - "wss://relay.snort.social", - read = true, - write = false, - feedTypes = activeTypesGlobalChats, - ), - RelaySetupInfo( - "wss://relay.nostr.com.au", - read = true, - write = false, - feedTypes = activeTypesGlobalChats, - ), - RelaySetupInfo( - "wss://eden.nostr.land", - read = true, - write = false, - feedTypes = activeTypesGlobalChats, - ), - RelaySetupInfo( - "wss://nostr.milou.lol", - read = true, - write = false, - feedTypes = activeTypesGlobalChats, - ), - RelaySetupInfo( - "wss://puravida.nostr.land", - read = true, - write = false, - feedTypes = activeTypesGlobalChats, - ), - RelaySetupInfo( - "wss://nostr.wine", - read = true, - write = false, - feedTypes = activeTypesGlobalChats, - ), - RelaySetupInfo( - "wss://nostr.inosta.cc", - read = true, - write = false, - feedTypes = activeTypesGlobalChats, - ), - RelaySetupInfo( - "wss://atlas.nostr.land", - read = true, - write = false, - feedTypes = activeTypesGlobalChats, - ), - RelaySetupInfo( - "wss://relay.orangepill.dev", - read = true, - write = false, - feedTypes = activeTypesGlobalChats, - ), - RelaySetupInfo( - "wss://relay.nostrati.com", - read = true, - write = false, - feedTypes = activeTypesGlobalChats, - ), - // Supporting NIP-50 - RelaySetupInfo( - "wss://relay.nostr.band", - read = true, - write = false, - feedTypes = activeTypesSearch, - ), - RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch), - RelaySetupInfo( - "wss://relay.noswhere.com", - read = true, - write = false, - feedTypes = activeTypesSearch, - ), - ) + val defaultRelays = + arrayOf( + // Free relays for only DMs and Follows due to the amount of spam + RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes), + // Chats + RelaySetupInfo( + "wss://nostr.bitcoiner.social", + read = true, + write = true, + feedTypes = activeTypesChats, + ), + RelaySetupInfo( + "wss://relay.nostr.bg", + read = true, + write = true, + feedTypes = activeTypesChats, + ), + RelaySetupInfo( + "wss://nostr.oxtr.dev", + read = true, + write = true, + feedTypes = activeTypesChats, + ), + RelaySetupInfo( + "wss://nostr-pub.wellorder.net", + read = true, + write = true, + feedTypes = activeTypesChats, + ), + RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypesChats), + RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesChats), + // Less Reliable + // NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, + // feedTypes = activeTypes), + // NewRelayListViewModel.Relay("wss://nostr.onsats.org", read = true, write = true, feedTypes + // = activeTypes), + // NewRelayListViewModel.Relay("wss://nostr.sandwich.farm", read = true, write = true, + // feedTypes = activeTypes), + // NewRelayListViewModel.Relay("wss://relay.nostr.ch", read = true, write = true, feedTypes = + // activeTypes), + // NewRelayListViewModel.Relay("wss://nostr.zebedee.cloud", read = true, write = true, + // feedTypes = activeTypes), + // NewRelayListViewModel.Relay("wss://nostr.rocks", read = true, write = true, feedTypes = + // activeTypes), + // NewRelayListViewModel.Relay("wss://nostr.fmt.wiz.biz", read = true, write = true, feedTypes + // = activeTypes), + // NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = + // activeTypes), + // Paid relays + RelaySetupInfo( + "wss://relay.snort.social", + read = true, + write = false, + feedTypes = activeTypesGlobalChats, + ), + RelaySetupInfo( + "wss://relay.nostr.com.au", + read = true, + write = false, + feedTypes = activeTypesGlobalChats, + ), + RelaySetupInfo( + "wss://eden.nostr.land", + read = true, + write = false, + feedTypes = activeTypesGlobalChats, + ), + RelaySetupInfo( + "wss://nostr.milou.lol", + read = true, + write = false, + feedTypes = activeTypesGlobalChats, + ), + RelaySetupInfo( + "wss://puravida.nostr.land", + read = true, + write = false, + feedTypes = activeTypesGlobalChats, + ), + RelaySetupInfo( + "wss://nostr.wine", + read = true, + write = false, + feedTypes = activeTypesGlobalChats, + ), + RelaySetupInfo( + "wss://nostr.inosta.cc", + read = true, + write = false, + feedTypes = activeTypesGlobalChats, + ), + RelaySetupInfo( + "wss://atlas.nostr.land", + read = true, + write = false, + feedTypes = activeTypesGlobalChats, + ), + RelaySetupInfo( + "wss://relay.orangepill.dev", + read = true, + write = false, + feedTypes = activeTypesGlobalChats, + ), + RelaySetupInfo( + "wss://relay.nostrati.com", + read = true, + write = false, + feedTypes = activeTypesGlobalChats, + ), + // Supporting NIP-50 + RelaySetupInfo( + "wss://relay.nostr.band", + read = true, + write = false, + feedTypes = activeTypesSearch, + ), + RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch), + RelaySetupInfo( + "wss://relay.noswhere.com", + read = true, + write = false, + feedTypes = activeTypesSearch, + ), + ) - val forcedRelayForSearch = - arrayOf( - RelaySetupInfo( - "wss://relay.nostr.band", - read = true, - write = false, - feedTypes = activeTypesSearch, - ), - RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch), - RelaySetupInfo( - "wss://relay.noswhere.com", - read = true, - write = false, - feedTypes = activeTypesSearch, - ), - ) - val forcedRelaysForSearchSet = forcedRelayForSearch.map { it.url } + val forcedRelayForSearch = + arrayOf( + RelaySetupInfo( + "wss://relay.nostr.band", + read = true, + write = false, + feedTypes = activeTypesSearch, + ), + RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch), + RelaySetupInfo( + "wss://relay.noswhere.com", + read = true, + write = false, + feedTypes = activeTypesSearch, + ), + ) + val forcedRelaysForSearchSet = forcedRelayForSearch.map { it.url } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt index 431d4edc7..964bf777c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt @@ -23,60 +23,60 @@ package com.vitorpamplona.amethyst.service.relays import com.vitorpamplona.amethyst.model.User class EOSETime(var time: Long) { - override fun toString(): String { - return time.toString() - } + override fun toString(): String { + return time.toString() + } } class EOSERelayList(var relayList: Map = emptyMap()) { - fun addOrUpdate( - relayUrl: String, - time: Long, - ) { - val eose = relayList[relayUrl] - if (eose == null) { - relayList = relayList + Pair(relayUrl, EOSETime(time)) - } else { - eose.time = time + fun addOrUpdate( + relayUrl: String, + time: Long, + ) { + val eose = relayList[relayUrl] + if (eose == null) { + relayList = relayList + Pair(relayUrl, EOSETime(time)) + } else { + eose.time = time + } } - } } class EOSEFollowList(var followList: Map = emptyMap()) { - fun addOrUpdate( - listCode: String, - relayUrl: String, - time: Long, - ) { - val relayList = followList[listCode] - if (relayList == null) { - val newList = EOSERelayList() - newList.addOrUpdate(relayUrl, time) - followList = followList + mapOf(listCode to newList) - } else { - relayList.addOrUpdate(relayUrl, time) + fun addOrUpdate( + listCode: String, + relayUrl: String, + time: Long, + ) { + val relayList = followList[listCode] + if (relayList == null) { + val newList = EOSERelayList() + newList.addOrUpdate(relayUrl, time) + followList = followList + mapOf(listCode to newList) + } else { + relayList.addOrUpdate(relayUrl, time) + } } - } } class EOSEAccount(var users: Map = emptyMap()) { - fun addOrUpdate( - user: User, - listCode: String, - relayUrl: String, - time: Long, - ) { - val followList = users[user] - if (followList == null) { - val newList = EOSEFollowList() - newList.addOrUpdate(listCode, relayUrl, time) - users = users + mapOf(user to newList) - } else { - followList.addOrUpdate(listCode, relayUrl, time) + fun addOrUpdate( + user: User, + listCode: String, + relayUrl: String, + time: Long, + ) { + val followList = users[user] + if (followList == null) { + val newList = EOSEFollowList() + newList.addOrUpdate(listCode, relayUrl, time) + users = users + mapOf(user to newList) + } else { + followList.addOrUpdate(listCode, relayUrl, time) + } } - } - fun removeDataFor(user: User) { - users = users.minus(user) - } + fun removeDataFor(user: User) { + users = users.minus(user) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt index 91105f507..f2e6d6a43 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt @@ -23,65 +23,65 @@ package com.vitorpamplona.amethyst.service.relays import com.vitorpamplona.quartz.events.Event class JsonFilter( - val ids: List? = null, - val authors: List? = null, - val kinds: List? = null, - val tags: Map>? = null, - val since: Map? = null, - val until: Long? = null, - val limit: Int? = null, - val search: String? = null, + val ids: List? = null, + val authors: List? = null, + val kinds: List? = null, + val tags: Map>? = null, + val since: Map? = null, + val until: Long? = null, + val limit: Int? = null, + val search: String? = null, ) { - fun toJson(forRelay: String? = null): String { - val factory = Event.mapper.nodeFactory - val filter = - factory.objectNode().apply { - ids?.run { - put( - "ids", - factory.arrayNode(ids.size).apply { ids.forEach { add(it) } }, - ) - } - authors?.run { - put( - "authors", - factory.arrayNode(authors.size).apply { authors.forEach { add(it) } }, - ) - } - kinds?.run { - put( - "kinds", - factory.arrayNode(kinds.size).apply { kinds.forEach { add(it) } }, - ) - } - tags?.run { - entries.forEach { kv -> - put( - "#${kv.key}", - factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } }, - ) - } - } - since?.run { - if (!isEmpty()) { - if (forRelay != null) { - val relaySince = get(forRelay) - if (relaySince != null) { - put("since", relaySince.time) - } - } else { - val jsonObjectSince = factory.objectNode() - entries.forEach { sincePairs -> - jsonObjectSince.put(sincePairs.key, "${sincePairs.value}") - } - put("since", jsonObjectSince) + fun toJson(forRelay: String? = null): String { + val factory = Event.mapper.nodeFactory + val filter = + factory.objectNode().apply { + ids?.run { + put( + "ids", + factory.arrayNode(ids.size).apply { ids.forEach { add(it) } }, + ) + } + authors?.run { + put( + "authors", + factory.arrayNode(authors.size).apply { authors.forEach { add(it) } }, + ) + } + kinds?.run { + put( + "kinds", + factory.arrayNode(kinds.size).apply { kinds.forEach { add(it) } }, + ) + } + tags?.run { + entries.forEach { kv -> + put( + "#${kv.key}", + factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } }, + ) + } + } + since?.run { + if (!isEmpty()) { + if (forRelay != null) { + val relaySince = get(forRelay) + if (relaySince != null) { + put("since", relaySince.time) + } + } else { + val jsonObjectSince = factory.objectNode() + entries.forEach { sincePairs -> + jsonObjectSince.put(sincePairs.key, "${sincePairs.value}") + } + put("since", jsonObjectSince) + } + } + } + until?.run { put("until", until) } + limit?.run { put("limit", limit) } + search?.run { put("search", search) } } - } - } - until?.run { put("until", until) } - limit?.run { put("limit", limit) } - search?.run { put("search", search) } - } - return Event.mapper.writeValueAsString(filter) - } + return Event.mapper.writeValueAsString(filter) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt index f1585705f..080a8ba9d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt @@ -31,501 +31,501 @@ import com.vitorpamplona.quartz.events.EventInterface import com.vitorpamplona.quartz.events.RelayAuthEvent import com.vitorpamplona.quartz.events.bytesUsedInMemory import com.vitorpamplona.quartz.utils.TimeUtils -import java.lang.StringBuilder -import java.util.concurrent.atomic.AtomicBoolean import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener +import java.lang.StringBuilder +import java.util.concurrent.atomic.AtomicBoolean enum class FeedType { - FOLLOWS, - PUBLIC_CHATS, - PRIVATE_DMS, - GLOBAL, - SEARCH, - WALLET_CONNECT, + FOLLOWS, + PUBLIC_CHATS, + PRIVATE_DMS, + GLOBAL, + SEARCH, + WALLET_CONNECT, } val COMMON_FEED_TYPES = - setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) class Relay( - val url: String, - val read: Boolean = true, - val write: Boolean = true, - val activeTypes: Set = FeedType.values().toSet(), + val url: String, + val read: Boolean = true, + val write: Boolean = true, + val activeTypes: Set = FeedType.values().toSet(), ) { - val brief = RelayBriefInfoCache.get(url) + val brief = RelayBriefInfoCache.get(url) - companion object { - // waits 3 minutes to reconnect once things fail - const val RECONNECTING_IN_SECONDS = 60 * 3 - } - - private val httpClient = HttpClient.getHttpClientForRelays() - - private var listeners = setOf() - private var socket: WebSocket? = null - private var isReady: Boolean = false - private var usingCompression: Boolean = false - - var eventDownloadCounterInBytes = 0 - var eventUploadCounterInBytes = 0 - - var spamCounter = 0 - var errorCounter = 0 - var pingInMs: Long? = null - - var closingTimeInSeconds = 0L - - var afterEOSEPerSubscription = mutableMapOf() - - val authResponse = mutableMapOf() - - fun register(listener: Listener) { - listeners = listeners.plus(listener) - } - - fun unregister(listener: Listener) { - listeners = listeners.minus(listener) - } - - fun isConnected(): Boolean { - return socket != null - } - - fun connect() { - connectAndRun { - checkNotInMainThread() - - // Sends everything. - renewFilters() - } - } - - private var connectingBlock = AtomicBoolean() - - fun connectAndRun(onConnected: (Relay) -> Unit) { - Log.d("Relay", "Relay.connect $url") - // BRB is crashing OkHttp Deflater object :( - if (url.contains("brb.io")) return - - // If there is a connection, don't wait. - if (connectingBlock.getAndSet(true)) { - return + companion object { + // waits 3 minutes to reconnect once things fail + const val RECONNECTING_IN_SECONDS = 60 * 3 } - checkNotInMainThread() + private val httpClient = HttpClient.getHttpClientForRelays() - if (socket != null) return + private var listeners = setOf() + private var socket: WebSocket? = null + private var isReady: Boolean = false + private var usingCompression: Boolean = false - try { - val request = - Request.Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url(url.trim()) - .build() + var eventDownloadCounterInBytes = 0 + var eventUploadCounterInBytes = 0 - socket = httpClient.newWebSocket(request, RelayListener(onConnected)) - } catch (e: Exception) { - errorCounter++ - markConnectionAsClosed() - Log.e("Relay", "Relay Invalid $url") - e.printStackTrace() - } finally { - connectingBlock.set(false) - } - } + var spamCounter = 0 + var errorCounter = 0 + var pingInMs: Long? = null - inner class RelayListener(val onConnected: (Relay) -> Unit) : WebSocketListener() { - override fun onOpen( - webSocket: WebSocket, - response: Response, - ) { - checkNotInMainThread() - Log.d("Relay", "Connect onOpen $url $socket") + var closingTimeInSeconds = 0L - markConnectionAsReady( - pingInMs = response.receivedResponseAtMillis - response.sentRequestAtMillis, - usingCompression = - response.headers.get("Sec-WebSocket-Extensions")?.contains("permessage-deflate") ?: false, - ) + var afterEOSEPerSubscription = mutableMapOf() - // Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url") - onConnected(this@Relay) + val authResponse = mutableMapOf() - listeners.forEach { it.onRelayStateChange(this@Relay, StateType.CONNECT, null) } + fun register(listener: Listener) { + listeners = listeners.plus(listener) } - override fun onMessage( - webSocket: WebSocket, - text: String, - ) { - checkNotInMainThread() - - eventDownloadCounterInBytes += text.bytesUsedInMemory() - - try { - processNewRelayMessage(text) - } catch (t: Throwable) { - t.printStackTrace() - text.chunked(2000) { chunked -> - listeners.forEach { it.onError(this@Relay, "", Error("Problem with $chunked")) } - } - } + fun unregister(listener: Listener) { + listeners = listeners.minus(listener) } - override fun onClosing( - webSocket: WebSocket, - code: Int, - reason: String, - ) { - checkNotInMainThread() - - Log.w("Relay", "Relay onClosing $url: $reason") - - listeners.forEach { - it.onRelayStateChange( - this@Relay, - StateType.DISCONNECTING, - null, - ) - } + fun isConnected(): Boolean { + return socket != null } - override fun onClosed( - webSocket: WebSocket, - code: Int, - reason: String, - ) { - checkNotInMainThread() - - markConnectionAsClosed() - - Log.w("Relay", "Relay onClosed $url: $reason") - - listeners.forEach { it.onRelayStateChange(this@Relay, StateType.DISCONNECT, null) } - } - - override fun onFailure( - webSocket: WebSocket, - t: Throwable, - response: Response?, - ) { - checkNotInMainThread() - - errorCounter++ - - socket?.cancel() // 1000, "Normal close" - // Failures disconnect the relay. - markConnectionAsClosed() - - Log.w("Relay", "Relay onFailure $url, ${response?.message} $response") - t.printStackTrace() - listeners.forEach { - it.onError( - this@Relay, - "", - Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t), - ) - } - } - } - - fun markConnectionAsReady( - pingInMs: Long, - usingCompression: Boolean, - ) { - this.resetEOSEStatuses() - this.isReady = true - this.pingInMs = pingInMs - this.usingCompression = usingCompression - } - - fun markConnectionAsClosed() { - this.socket = null - this.isReady = false - this.usingCompression = false - this.resetEOSEStatuses() - this.closingTimeInSeconds = TimeUtils.now() - } - - fun processNewRelayMessage(newMessage: String) { - val msgArray = Event.mapper.readTree(newMessage) - - when (val type = msgArray.get(0).asText()) { - "EVENT" -> { - val subscriptionId = msgArray.get(1).asText() - val event = Event.fromJson(msgArray.get(2)) - - // Log.w("Relay", "Relay onEVENT ${event.kind} $url, $subscriptionId ${msgArray.get(2)}") - listeners.forEach { - it.onEvent( - this@Relay, - subscriptionId, - event, - afterEOSEPerSubscription[subscriptionId] == true, - ) - } - } - "EOSE" -> - listeners.forEach { - val subscriptionId = msgArray.get(1).asText() - - afterEOSEPerSubscription[subscriptionId] = true - // Log.w("Relay", "Relay onEOSE $url $subscriptionId") - it.onRelayStateChange(this@Relay, StateType.EOSE, subscriptionId) - } - "NOTICE" -> - listeners.forEach { - val message = msgArray.get(1).asText() - Log.w("Relay", "Relay onNotice $url, $message") - - it.onError(this@Relay, message, Error("Relay sent notice: $message")) - } - "OK" -> - listeners.forEach { - val eventId = msgArray[1].asText() - val success = msgArray[2].asBoolean() - val message = if (msgArray.size() > 2) msgArray[3].asText() else "" - - if (authResponse.containsKey(eventId)) { - val wasAlreadyAuthenticated = authResponse.get(eventId) - authResponse.put(eventId, success) - if (wasAlreadyAuthenticated != true && success) { - renewFilters() - } - } - - Log.w("Relay", "Relay on OK $url, $eventId, $success, $message") - it.onSendResponse(this@Relay, eventId, success, message) - } - "AUTH" -> - listeners.forEach { - // Log.w("Relay", "Relay onAuth $url, ${msg[1].asString}") - it.onAuth(this@Relay, msgArray[1].asText()) - } - "NOTIFY" -> - listeners.forEach { - // Log.w("Relay", "Relay onNotify $url, ${msg[1].asString}") - it.onNotify(this@Relay, msgArray[1].asText()) - } - "CLOSED" -> listeners.forEach { Log.w("Relay", "Relay onClosed $url, $newMessage") } - else -> - listeners.forEach { - Log.w("Relay", "Unsupported message: $newMessage") - it.onError( - this@Relay, - "", - Error("Unknown type $type on channel. Msg was $newMessage"), - ) - } - } - } - - fun disconnect() { - Log.d("Relay", "Relay.disconnect $url") - checkNotInMainThread() - - closingTimeInSeconds = TimeUtils.now() - socket?.cancel() - socket = null - isReady = false - usingCompression = false - resetEOSEStatuses() - } - - fun resetEOSEStatuses() { - afterEOSEPerSubscription = LinkedHashMap(afterEOSEPerSubscription.size) - } - - fun sendFilter(requestId: String) { - checkNotInMainThread() - - if (read) { - if (isConnected()) { - if (isReady) { - val filters = - Client.getSubscriptionFilters(requestId).filter { filter -> - activeTypes.any { it in filter.types } - } - if (filters.isNotEmpty()) { - val request = - filters.joinToStringLimited( - separator = ",", - limit = 20, - prefix = """["REQ","$requestId",""", - postfix = "]", - ) { - it.filter.toJson(url) - } - - // Log.d("Relay", "onFilterSent $url $requestId $request") - - socket?.send(request) - eventUploadCounterInBytes += request.bytesUsedInMemory() - resetEOSEStatuses() - } - } - } else { - // waits 60 seconds to reconnect after disconnected. - if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) { - // sends all filters after connection is successful. - connect() - } - } - } - } - - fun Iterable.joinToStringLimited( - separator: CharSequence = ", ", - prefix: CharSequence = "", - postfix: CharSequence = "", - limit: Int = -1, - transform: ((T) -> CharSequence)? = null, - ): String { - val buffer = StringBuilder() - buffer.append(prefix) - var count = 0 - for (element in this) { - if (limit < 0 || count <= limit) { - if (++count > 1) buffer.append(separator) - when { - transform != null -> buffer.append(transform(element)) - element is CharSequence? -> buffer.append(element) - element is Char -> buffer.append(element) - else -> buffer.append(element.toString()) - } - } else { - break - } - } - buffer.append(postfix) - return buffer.toString() - } - - fun sendFilterOnlyIfDisconnected(subscriptionId: String) { - checkNotInMainThread() - - if (socket == null) { - // waits 60 seconds to reconnect after disconnected. - if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) { - // println("sendfilter Only if Disconnected ${url} ") - connect() - } - } - } - - fun renewFilters() { - // Force update all filters after AUTH. - Client.allSubscriptions().forEach { sendFilter(requestId = it) } - } - - fun send(signedEvent: EventInterface) { - checkNotInMainThread() - - if (signedEvent is RelayAuthEvent) { - authResponse.put(signedEvent.id, false) - // specific protocol for this event. - val event = """["AUTH",${signedEvent.toJson()}]""" - socket?.send(event) - eventUploadCounterInBytes += event.bytesUsedInMemory() - } else { - if (write) { - val event = """["EVENT",${signedEvent.toJson()}]""" - if (isConnected()) { - if (isReady) { - socket?.send(event) - eventUploadCounterInBytes += event.bytesUsedInMemory() - } - } else { - // sends all filters after connection is successful. - connectAndRun { + fun connect() { + connectAndRun { checkNotInMainThread() + // Sends everything. + renewFilters() + } + } + + private var connectingBlock = AtomicBoolean() + + fun connectAndRun(onConnected: (Relay) -> Unit) { + Log.d("Relay", "Relay.connect $url") + // BRB is crashing OkHttp Deflater object :( + if (url.contains("brb.io")) return + + // If there is a connection, don't wait. + if (connectingBlock.getAndSet(true)) { + return + } + + checkNotInMainThread() + + if (socket != null) return + + try { + val request = + Request.Builder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url(url.trim()) + .build() + + socket = httpClient.newWebSocket(request, RelayListener(onConnected)) + } catch (e: Exception) { + errorCounter++ + markConnectionAsClosed() + Log.e("Relay", "Relay Invalid $url") + e.printStackTrace() + } finally { + connectingBlock.set(false) + } + } + + inner class RelayListener(val onConnected: (Relay) -> Unit) : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { + checkNotInMainThread() + Log.d("Relay", "Connect onOpen $url $socket") + + markConnectionAsReady( + pingInMs = response.receivedResponseAtMillis - response.sentRequestAtMillis, + usingCompression = + response.headers.get("Sec-WebSocket-Extensions")?.contains("permessage-deflate") ?: false, + ) + + // Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url") + onConnected(this@Relay) + + listeners.forEach { it.onRelayStateChange(this@Relay, StateType.CONNECT, null) } + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + checkNotInMainThread() + + eventDownloadCounterInBytes += text.bytesUsedInMemory() + + try { + processNewRelayMessage(text) + } catch (t: Throwable) { + t.printStackTrace() + text.chunked(2000) { chunked -> + listeners.forEach { it.onError(this@Relay, "", Error("Problem with $chunked")) } + } + } + } + + override fun onClosing( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + checkNotInMainThread() + + Log.w("Relay", "Relay onClosing $url: $reason") + + listeners.forEach { + it.onRelayStateChange( + this@Relay, + StateType.DISCONNECTING, + null, + ) + } + } + + override fun onClosed( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + checkNotInMainThread() + + markConnectionAsClosed() + + Log.w("Relay", "Relay onClosed $url: $reason") + + listeners.forEach { it.onRelayStateChange(this@Relay, StateType.DISCONNECT, null) } + } + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response?, + ) { + checkNotInMainThread() + + errorCounter++ + + socket?.cancel() // 1000, "Normal close" + // Failures disconnect the relay. + markConnectionAsClosed() + + Log.w("Relay", "Relay onFailure $url, ${response?.message} $response") + t.printStackTrace() + listeners.forEach { + it.onError( + this@Relay, + "", + Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t), + ) + } + } + } + + fun markConnectionAsReady( + pingInMs: Long, + usingCompression: Boolean, + ) { + this.resetEOSEStatuses() + this.isReady = true + this.pingInMs = pingInMs + this.usingCompression = usingCompression + } + + fun markConnectionAsClosed() { + this.socket = null + this.isReady = false + this.usingCompression = false + this.resetEOSEStatuses() + this.closingTimeInSeconds = TimeUtils.now() + } + + fun processNewRelayMessage(newMessage: String) { + val msgArray = Event.mapper.readTree(newMessage) + + when (val type = msgArray.get(0).asText()) { + "EVENT" -> { + val subscriptionId = msgArray.get(1).asText() + val event = Event.fromJson(msgArray.get(2)) + + // Log.w("Relay", "Relay onEVENT ${event.kind} $url, $subscriptionId ${msgArray.get(2)}") + listeners.forEach { + it.onEvent( + this@Relay, + subscriptionId, + event, + afterEOSEPerSubscription[subscriptionId] == true, + ) + } + } + "EOSE" -> + listeners.forEach { + val subscriptionId = msgArray.get(1).asText() + + afterEOSEPerSubscription[subscriptionId] = true + // Log.w("Relay", "Relay onEOSE $url $subscriptionId") + it.onRelayStateChange(this@Relay, StateType.EOSE, subscriptionId) + } + "NOTICE" -> + listeners.forEach { + val message = msgArray.get(1).asText() + Log.w("Relay", "Relay onNotice $url, $message") + + it.onError(this@Relay, message, Error("Relay sent notice: $message")) + } + "OK" -> + listeners.forEach { + val eventId = msgArray[1].asText() + val success = msgArray[2].asBoolean() + val message = if (msgArray.size() > 2) msgArray[3].asText() else "" + + if (authResponse.containsKey(eventId)) { + val wasAlreadyAuthenticated = authResponse.get(eventId) + authResponse.put(eventId, success) + if (wasAlreadyAuthenticated != true && success) { + renewFilters() + } + } + + Log.w("Relay", "Relay on OK $url, $eventId, $success, $message") + it.onSendResponse(this@Relay, eventId, success, message) + } + "AUTH" -> + listeners.forEach { + // Log.w("Relay", "Relay onAuth $url, ${msg[1].asString}") + it.onAuth(this@Relay, msgArray[1].asText()) + } + "NOTIFY" -> + listeners.forEach { + // Log.w("Relay", "Relay onNotify $url, ${msg[1].asString}") + it.onNotify(this@Relay, msgArray[1].asText()) + } + "CLOSED" -> listeners.forEach { Log.w("Relay", "Relay onClosed $url, $newMessage") } + else -> + listeners.forEach { + Log.w("Relay", "Unsupported message: $newMessage") + it.onError( + this@Relay, + "", + Error("Unknown type $type on channel. Msg was $newMessage"), + ) + } + } + } + + fun disconnect() { + Log.d("Relay", "Relay.disconnect $url") + checkNotInMainThread() + + closingTimeInSeconds = TimeUtils.now() + socket?.cancel() + socket = null + isReady = false + usingCompression = false + resetEOSEStatuses() + } + + fun resetEOSEStatuses() { + afterEOSEPerSubscription = LinkedHashMap(afterEOSEPerSubscription.size) + } + + fun sendFilter(requestId: String) { + checkNotInMainThread() + + if (read) { + if (isConnected()) { + if (isReady) { + val filters = + Client.getSubscriptionFilters(requestId).filter { filter -> + activeTypes.any { it in filter.types } + } + if (filters.isNotEmpty()) { + val request = + filters.joinToStringLimited( + separator = ",", + limit = 20, + prefix = """["REQ","$requestId",""", + postfix = "]", + ) { + it.filter.toJson(url) + } + + // Log.d("Relay", "onFilterSent $url $requestId $request") + + socket?.send(request) + eventUploadCounterInBytes += request.bytesUsedInMemory() + resetEOSEStatuses() + } + } + } else { + // waits 60 seconds to reconnect after disconnected. + if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) { + // sends all filters after connection is successful. + connect() + } + } + } + } + + fun Iterable.joinToStringLimited( + separator: CharSequence = ", ", + prefix: CharSequence = "", + postfix: CharSequence = "", + limit: Int = -1, + transform: ((T) -> CharSequence)? = null, + ): String { + val buffer = StringBuilder() + buffer.append(prefix) + var count = 0 + for (element in this) { + if (limit < 0 || count <= limit) { + if (++count > 1) buffer.append(separator) + when { + transform != null -> buffer.append(transform(element)) + element is CharSequence? -> buffer.append(element) + element is Char -> buffer.append(element) + else -> buffer.append(element.toString()) + } + } else { + break + } + } + buffer.append(postfix) + return buffer.toString() + } + + fun sendFilterOnlyIfDisconnected(subscriptionId: String) { + checkNotInMainThread() + + if (socket == null) { + // waits 60 seconds to reconnect after disconnected. + if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) { + // println("sendfilter Only if Disconnected ${url} ") + connect() + } + } + } + + fun renewFilters() { + // Force update all filters after AUTH. + Client.allSubscriptions().forEach { sendFilter(requestId = it) } + } + + fun send(signedEvent: EventInterface) { + checkNotInMainThread() + + if (signedEvent is RelayAuthEvent) { + authResponse.put(signedEvent.id, false) + // specific protocol for this event. + val event = """["AUTH",${signedEvent.toJson()}]""" socket?.send(event) eventUploadCounterInBytes += event.bytesUsedInMemory() + } else { + if (write) { + val event = """["EVENT",${signedEvent.toJson()}]""" + if (isConnected()) { + if (isReady) { + socket?.send(event) + eventUploadCounterInBytes += event.bytesUsedInMemory() + } + } else { + // sends all filters after connection is successful. + connectAndRun { + checkNotInMainThread() - // Sends everything. - Client.allSubscriptions().forEach { sendFilter(requestId = it) } - } + socket?.send(event) + eventUploadCounterInBytes += event.bytesUsedInMemory() + + // Sends everything. + Client.allSubscriptions().forEach { sendFilter(requestId = it) } + } + } + } } - } } - } - fun close(subscriptionId: String) { - checkNotInMainThread() + fun close(subscriptionId: String) { + checkNotInMainThread() - val msg = """["CLOSE","$subscriptionId"]""" - // Log.d("Relay", "Close Subscription $url $msg") - socket?.send(msg) - } + val msg = """["CLOSE","$subscriptionId"]""" + // Log.d("Relay", "Close Subscription $url $msg") + socket?.send(msg) + } - fun isSameRelayConfig(other: Relay): Boolean { - return url == other.url && - write == other.write && - read == other.read && - activeTypes == other.activeTypes - } + fun isSameRelayConfig(other: Relay): Boolean { + return url == other.url && + write == other.write && + read == other.read && + activeTypes == other.activeTypes + } - enum class StateType { - // Websocket connected - CONNECT, + enum class StateType { + // Websocket connected + CONNECT, - // Websocket disconnecting - DISCONNECTING, + // Websocket disconnecting + DISCONNECTING, - // Websocket disconnected - DISCONNECT, + // Websocket disconnected + DISCONNECT, - // End Of Stored Events - EOSE, - } + // End Of Stored Events + EOSE, + } - interface Listener { - /** A new message was received */ - fun onEvent( - relay: Relay, - subscriptionId: String, - event: Event, - afterEOSE: Boolean, - ) + interface Listener { + /** A new message was received */ + fun onEvent( + relay: Relay, + subscriptionId: String, + event: Event, + afterEOSE: Boolean, + ) - fun onError( - relay: Relay, - subscriptionId: String, - error: Error, - ) + fun onError( + relay: Relay, + subscriptionId: String, + error: Error, + ) - fun onSendResponse( - relay: Relay, - eventId: String, - success: Boolean, - message: String, - ) + fun onSendResponse( + relay: Relay, + eventId: String, + success: Boolean, + message: String, + ) - fun onAuth( - relay: Relay, - challenge: String, - ) + fun onAuth( + relay: Relay, + challenge: String, + ) - /** - * Connected to or disconnected from a relay - * - * @param type is 0 for disconnect and 1 for connect - */ - fun onRelayStateChange( - relay: Relay, - type: StateType, - channel: String?, - ) + /** + * Connected to or disconnected from a relay + * + * @param type is 0 for disconnect and 1 for connect + */ + fun onRelayStateChange( + relay: Relay, + type: StateType, + channel: String?, + ) - /** Relay sent an invoice */ - fun onNotify( - relay: Relay, - description: String, - ) - } + /** Relay sent an invoice */ + fun onNotify( + relay: Relay, + description: String, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt index 0d40a2898..be90cb9ba 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt @@ -33,200 +33,200 @@ import kotlinx.coroutines.flow.asSharedFlow * RelayPool manages the connection to multiple Relays and lets consumers deal with simple events. */ object RelayPool : Relay.Listener { - private var relays = listOf() - private var listeners = setOf() + private var relays = listOf() + private var listeners = setOf() - // Backing property to avoid flow emissions from other classes - private var lastStatus = RelayPoolStatus(0, 0) - private val _statusFlow = - MutableSharedFlow(1, 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) - val statusFlow: SharedFlow = _statusFlow.asSharedFlow() + // Backing property to avoid flow emissions from other classes + private var lastStatus = RelayPoolStatus(0, 0) + private val _statusFlow = + MutableSharedFlow(1, 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val statusFlow: SharedFlow = _statusFlow.asSharedFlow() - fun availableRelays(): Int { - return relays.size - } - - fun connectedRelays(): Int { - return relays.count { it.isConnected() } - } - - fun getRelay(url: String): Relay? { - return relays.firstOrNull { it.url == url } - } - - fun getRelays(url: String): List { - return relays.filter { it.url == url } - } - - fun loadRelays(relayList: List) { - if (!relayList.isNullOrEmpty()) { - relayList.forEach { addRelay(it) } - } else { - Constants.convertDefaultRelays().forEach { addRelay(it) } + fun availableRelays(): Int { + return relays.size } - } - fun unloadRelays() { - relays.forEach { it.unregister(this) } - relays = listOf() - } - - fun requestAndWatch() { - checkNotInMainThread() - - relays.forEach { it.connect() } - } - - fun sendFilter(subscriptionId: String) { - relays.forEach { it.sendFilter(subscriptionId) } - } - - fun sendFilterOnlyIfDisconnected(subscriptionId: String) { - relays.forEach { it.sendFilterOnlyIfDisconnected(subscriptionId) } - } - - fun sendToSelectedRelays( - list: List, - signedEvent: EventInterface, - ) { - list.forEach { relay -> relays.filter { it.url == relay.url }.forEach { it.send(signedEvent) } } - } - - fun send(signedEvent: EventInterface) { - relays.forEach { it.send(signedEvent) } - } - - fun close(subscriptionId: String) { - relays.forEach { it.close(subscriptionId) } - } - - fun disconnect() { - relays.forEach { it.disconnect() } - } - - fun addRelay(relay: Relay) { - relay.register(this) - relays += relay - updateStatus() - } - - fun removeRelay(relay: Relay) { - relay.unregister(this) - relays = relays.minus(relay) - updateStatus() - } - - fun register(listener: Listener) { - listeners = listeners.plus(listener) - } - - fun unregister(listener: Listener) { - listeners = listeners.minus(listener) - } - - interface Listener { - fun onEvent( - event: Event, - subscriptionId: String, - relay: Relay, - afterEOSE: Boolean, - ) - - fun onError( - error: Error, - subscriptionId: String, - relay: Relay, - ) - - fun onRelayStateChange( - type: Relay.StateType, - relay: Relay, - channel: String?, - ) - - fun onSendResponse( - eventId: String, - success: Boolean, - message: String, - relay: Relay, - ) - - fun onAuth( - relay: Relay, - challenge: String, - ) - - fun onNotify( - relay: Relay, - description: String, - ) - } - - override fun onEvent( - relay: Relay, - subscriptionId: String, - event: Event, - afterEOSE: Boolean, - ) { - listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) } - } - - override fun onError( - relay: Relay, - subscriptionId: String, - error: Error, - ) { - listeners.forEach { it.onError(error, subscriptionId, relay) } - updateStatus() - } - - override fun onRelayStateChange( - relay: Relay, - type: Relay.StateType, - channel: String?, - ) { - listeners.forEach { it.onRelayStateChange(type, relay, channel) } - if (type != Relay.StateType.EOSE) { - updateStatus() + fun connectedRelays(): Int { + return relays.count { it.isConnected() } } - } - override fun onSendResponse( - relay: Relay, - eventId: String, - success: Boolean, - message: String, - ) { - listeners.forEach { it.onSendResponse(eventId, success, message, relay) } - } - - override fun onAuth( - relay: Relay, - challenge: String, - ) { - listeners.forEach { it.onAuth(relay, challenge) } - } - - override fun onNotify( - relay: Relay, - description: String, - ) { - listeners.forEach { it.onNotify(relay, description) } - } - - private fun updateStatus() { - val connected = connectedRelays() - val available = availableRelays() - if (lastStatus.connected != connected || lastStatus.available != available) { - lastStatus = RelayPoolStatus(connected, available) - _statusFlow.tryEmit(lastStatus) + fun getRelay(url: String): Relay? { + return relays.firstOrNull { it.url == url } + } + + fun getRelays(url: String): List { + return relays.filter { it.url == url } + } + + fun loadRelays(relayList: List) { + if (!relayList.isNullOrEmpty()) { + relayList.forEach { addRelay(it) } + } else { + Constants.convertDefaultRelays().forEach { addRelay(it) } + } + } + + fun unloadRelays() { + relays.forEach { it.unregister(this) } + relays = listOf() + } + + fun requestAndWatch() { + checkNotInMainThread() + + relays.forEach { it.connect() } + } + + fun sendFilter(subscriptionId: String) { + relays.forEach { it.sendFilter(subscriptionId) } + } + + fun sendFilterOnlyIfDisconnected(subscriptionId: String) { + relays.forEach { it.sendFilterOnlyIfDisconnected(subscriptionId) } + } + + fun sendToSelectedRelays( + list: List, + signedEvent: EventInterface, + ) { + list.forEach { relay -> relays.filter { it.url == relay.url }.forEach { it.send(signedEvent) } } + } + + fun send(signedEvent: EventInterface) { + relays.forEach { it.send(signedEvent) } + } + + fun close(subscriptionId: String) { + relays.forEach { it.close(subscriptionId) } + } + + fun disconnect() { + relays.forEach { it.disconnect() } + } + + fun addRelay(relay: Relay) { + relay.register(this) + relays += relay + updateStatus() + } + + fun removeRelay(relay: Relay) { + relay.unregister(this) + relays = relays.minus(relay) + updateStatus() + } + + fun register(listener: Listener) { + listeners = listeners.plus(listener) + } + + fun unregister(listener: Listener) { + listeners = listeners.minus(listener) + } + + interface Listener { + fun onEvent( + event: Event, + subscriptionId: String, + relay: Relay, + afterEOSE: Boolean, + ) + + fun onError( + error: Error, + subscriptionId: String, + relay: Relay, + ) + + fun onRelayStateChange( + type: Relay.StateType, + relay: Relay, + channel: String?, + ) + + fun onSendResponse( + eventId: String, + success: Boolean, + message: String, + relay: Relay, + ) + + fun onAuth( + relay: Relay, + challenge: String, + ) + + fun onNotify( + relay: Relay, + description: String, + ) + } + + override fun onEvent( + relay: Relay, + subscriptionId: String, + event: Event, + afterEOSE: Boolean, + ) { + listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) } + } + + override fun onError( + relay: Relay, + subscriptionId: String, + error: Error, + ) { + listeners.forEach { it.onError(error, subscriptionId, relay) } + updateStatus() + } + + override fun onRelayStateChange( + relay: Relay, + type: Relay.StateType, + channel: String?, + ) { + listeners.forEach { it.onRelayStateChange(type, relay, channel) } + if (type != Relay.StateType.EOSE) { + updateStatus() + } + } + + override fun onSendResponse( + relay: Relay, + eventId: String, + success: Boolean, + message: String, + ) { + listeners.forEach { it.onSendResponse(eventId, success, message, relay) } + } + + override fun onAuth( + relay: Relay, + challenge: String, + ) { + listeners.forEach { it.onAuth(relay, challenge) } + } + + override fun onNotify( + relay: Relay, + description: String, + ) { + listeners.forEach { it.onNotify(relay, description) } + } + + private fun updateStatus() { + val connected = connectedRelays() + val available = availableRelays() + if (lastStatus.connected != connected || lastStatus.available != available) { + lastStatus = RelayPoolStatus(connected, available) + _statusFlow.tryEmit(lastStatus) + } } - } } @Immutable data class RelayPoolStatus( - val connected: Int, - val available: Int, - val isConnected: Boolean = connected > 0, + val connected: Int, + val available: Int, + val isConnected: Boolean = connected > 0, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt index d52bc7be9..0c7ac6535 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt @@ -25,35 +25,35 @@ import com.vitorpamplona.quartz.events.Event import java.util.UUID data class Subscription( - val id: String = UUID.randomUUID().toString().substring(0, 4), - val onEOSE: ((Long, String) -> Unit)? = null, + val id: String = UUID.randomUUID().toString().substring(0, 4), + val onEOSE: ((Long, String) -> Unit)? = null, ) { - var typedFilters: List? = null // Inactive when null + var typedFilters: List? = null // Inactive when null - fun updateEOSE( - time: Long, - relay: String, - ) { - onEOSE?.let { it(time, relay) } - } - - fun toJson(): String { - return Event.mapper.writeValueAsString(toJsonObject()) - } - - fun toJsonObject(): JsonNode { - val factory = Event.mapper.nodeFactory - - return factory.objectNode().apply { - put("id", id) - typedFilters?.also { filters -> - put( - "typedFilters", - factory.arrayNode(filters.size).apply { - filters.forEach { filter -> add(filter.toJsonObject()) } - }, - ) - } + fun updateEOSE( + time: Long, + relay: String, + ) { + onEOSE?.let { it(time, relay) } + } + + fun toJson(): String { + return Event.mapper.writeValueAsString(toJsonObject()) + } + + fun toJsonObject(): JsonNode { + val factory = Event.mapper.nodeFactory + + return factory.objectNode().apply { + put("id", id) + typedFilters?.also { filters -> + put( + "typedFilters", + factory.arrayNode(filters.size).apply { + filters.forEach { filter -> add(filter.toJsonObject()) } + }, + ) + } + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt index 277256cc1..a7b42588e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt @@ -25,56 +25,56 @@ import com.fasterxml.jackson.databind.node.ArrayNode import com.vitorpamplona.quartz.events.Event class TypedFilter( - val types: Set, - val filter: JsonFilter, + val types: Set, + val filter: JsonFilter, ) { - fun toJson(): String { - return Event.mapper.writeValueAsString(toJsonObject()) - } - - fun toJsonObject(): JsonNode { - val factory = Event.mapper.nodeFactory - - return factory.objectNode().apply { - put("types", typesToJson(types)) - put("filter", filterToJson(filter)) + fun toJson(): String { + return Event.mapper.writeValueAsString(toJsonObject()) } - } - fun typesToJson(types: Set): ArrayNode { - val factory = Event.mapper.nodeFactory - return factory.arrayNode(types.size).apply { types.forEach { add(it.name.lowercase()) } } - } + fun toJsonObject(): JsonNode { + val factory = Event.mapper.nodeFactory - fun filterToJson(filter: JsonFilter): JsonNode { - val factory = Event.mapper.nodeFactory - return factory.objectNode().apply { - filter.ids?.run { - put( - "ids", - factory.arrayNode(filter.ids.size).apply { filter.ids.forEach { add(it) } }, - ) - } - filter.authors?.run { - put( - "authors", - factory.arrayNode(filter.authors.size).apply { filter.authors.forEach { add(it) } }, - ) - } - filter.kinds?.run { - put( - "kinds", - factory.arrayNode(filter.kinds.size).apply { filter.kinds.forEach { add(it) } }, - ) - } - filter.tags?.run { - entries.forEach { kv -> - put( - "#${kv.key}", - factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } }, - ) + return factory.objectNode().apply { + put("types", typesToJson(types)) + put("filter", filterToJson(filter)) } - } + } + + fun typesToJson(types: Set): ArrayNode { + val factory = Event.mapper.nodeFactory + return factory.arrayNode(types.size).apply { types.forEach { add(it.name.lowercase()) } } + } + + fun filterToJson(filter: JsonFilter): JsonNode { + val factory = Event.mapper.nodeFactory + return factory.objectNode().apply { + filter.ids?.run { + put( + "ids", + factory.arrayNode(filter.ids.size).apply { filter.ids.forEach { add(it) } }, + ) + } + filter.authors?.run { + put( + "authors", + factory.arrayNode(filter.authors.size).apply { filter.authors.forEach { add(it) } }, + ) + } + filter.kinds?.run { + put( + "kinds", + factory.arrayNode(filter.kinds.size).apply { filter.kinds.forEach { add(it) } }, + ) + } + filter.tags?.run { + entries.forEach { kv -> + put( + "#${kv.key}", + factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } }, + ) + } + } /* Does not include since in the json comparison filter.since?.run { @@ -84,9 +84,9 @@ class TypedFilter( } jsonObject.add("since", jsonObjectSince) }*/ - filter.until?.run { put("until", filter.until) } - filter.limit?.run { put("limit", filter.limit) } - filter.search?.run { put("search", filter.search) } + filter.until?.run { put("until", filter.until) } + filter.limit?.run { put("limit", filter.limit) } + filter.search?.run { put("search", filter.search) } + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt index 52cad43ef..ffa864fa4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt @@ -28,160 +28,159 @@ import java.util.Locale const val DEF_SPEECH_AND_PITCH = 0.8f fun getErrorText(errorCode: Int): String = - when (errorCode) { - TextToSpeech.ERROR -> "ERROR" - TextToSpeech.ERROR_INVALID_REQUEST -> "ERROR_INVALID_REQUEST" - TextToSpeech.ERROR_NETWORK -> "ERROR_NETWORK" - TextToSpeech.ERROR_NETWORK_TIMEOUT -> "ERROR_NETWORK_TIMEOUT" - TextToSpeech.ERROR_SERVICE -> "ERROR_SERVICE" - TextToSpeech.ERROR_SYNTHESIS -> "ERROR_SYNTHESIS" - TextToSpeech.ERROR_NOT_INSTALLED_YET -> "ERROR_NOT_INSTALLED_YET" - else -> "UNKNOWN" - } + when (errorCode) { + TextToSpeech.ERROR -> "ERROR" + TextToSpeech.ERROR_INVALID_REQUEST -> "ERROR_INVALID_REQUEST" + TextToSpeech.ERROR_NETWORK -> "ERROR_NETWORK" + TextToSpeech.ERROR_NETWORK_TIMEOUT -> "ERROR_NETWORK_TIMEOUT" + TextToSpeech.ERROR_SERVICE -> "ERROR_SERVICE" + TextToSpeech.ERROR_SYNTHESIS -> "ERROR_SYNTHESIS" + TextToSpeech.ERROR_NOT_INSTALLED_YET -> "ERROR_NOT_INSTALLED_YET" + else -> "UNKNOWN" + } class TextToSpeechEngine private constructor() { - private var tts: TextToSpeech? = null + private var tts: TextToSpeech? = null - private var defaultPitch = 0.8f - private var defaultSpeed = 0.8f - private var defLanguage = Locale.getDefault() - private var onStartListener: (() -> Unit)? = null - private var onDoneListener: (() -> Unit)? = null - private var onErrorListener: ((String) -> Unit)? = null - private var onHighlightListener: ((Int, Int) -> Unit)? = null - private var message: String? = null + private var defaultPitch = 0.8f + private var defaultSpeed = 0.8f + private var defLanguage = Locale.getDefault() + private var onStartListener: (() -> Unit)? = null + private var onDoneListener: (() -> Unit)? = null + private var onErrorListener: ((String) -> Unit)? = null + private var onHighlightListener: ((Int, Int) -> Unit)? = null + private var message: String? = null - companion object { - private var instance: TextToSpeechEngine? = null + companion object { + private var instance: TextToSpeechEngine? = null - fun getInstance(): TextToSpeechEngine { - if (instance == null) { - instance = TextToSpeechEngine() - } - return instance!! - } - } - - fun initTTS( - context: Context, - message: String, - ) { - tts = - TextToSpeech(context) { - if (it == TextToSpeech.SUCCESS) { - tts?.let { - it.language = defLanguage - it.setPitch(defaultPitch) - it.setSpeechRate(defaultSpeed) - it.setListener( - onStart = { onStartListener?.invoke() }, - onError = { e -> e?.let { error -> onErrorListener?.invoke(error) } }, - onRange = { start, end -> - if (this@TextToSpeechEngine.message != null) { - onHighlightListener?.invoke(start, end) - } - }, - onDone = { onStartListener?.invoke() }, - ) - speak(message) - } - } else { - onErrorListener?.invoke(getErrorText(it)) + fun getInstance(): TextToSpeechEngine { + if (instance == null) { + instance = TextToSpeechEngine() + } + return instance!! } - } - } + } - private fun speak(message: String): TextToSpeechEngine { - tts?.speak( - message, - TextToSpeech.QUEUE_FLUSH, - null, - TextToSpeech.ACTION_TTS_QUEUE_PROCESSING_COMPLETED, - ) - return this - } + fun initTTS( + context: Context, + message: String, + ) { + tts = + TextToSpeech(context) { + if (it == TextToSpeech.SUCCESS) { + tts?.let { + it.language = defLanguage + it.setPitch(defaultPitch) + it.setSpeechRate(defaultSpeed) + it.setListener( + onStart = { onStartListener?.invoke() }, + onError = { e -> e?.let { error -> onErrorListener?.invoke(error) } }, + onRange = { start, end -> + if (this@TextToSpeechEngine.message != null) { + onHighlightListener?.invoke(start, end) + } + }, + onDone = { onStartListener?.invoke() }, + ) + speak(message) + } + } else { + onErrorListener?.invoke(getErrorText(it)) + } + } + } - fun setPitchAndSpeed( - pitch: Float, - speed: Float, - ) { - defaultPitch = pitch - defaultSpeed = speed - } + private fun speak(message: String): TextToSpeechEngine { + tts?.speak( + message, + TextToSpeech.QUEUE_FLUSH, + null, + TextToSpeech.ACTION_TTS_QUEUE_PROCESSING_COMPLETED, + ) + return this + } - fun resetPitchAndSpeed() { - defaultPitch = DEF_SPEECH_AND_PITCH - defaultSpeed = DEF_SPEECH_AND_PITCH - } + fun setPitchAndSpeed( + pitch: Float, + speed: Float, + ) { + defaultPitch = pitch + defaultSpeed = speed + } - fun setLanguage(local: Locale): TextToSpeechEngine { - this.defLanguage = local - return this - } + fun resetPitchAndSpeed() { + defaultPitch = DEF_SPEECH_AND_PITCH + defaultSpeed = DEF_SPEECH_AND_PITCH + } - fun setHighlightedMessage(message: String) { - this.message = message - } + fun setLanguage(local: Locale): TextToSpeechEngine { + this.defLanguage = local + return this + } - fun setOnStartListener(onStartListener: (() -> Unit)): TextToSpeechEngine { - this.onStartListener = onStartListener - return this - } + fun setHighlightedMessage(message: String) { + this.message = message + } - fun setOnCompletionListener(onDoneListener: () -> Unit): TextToSpeechEngine { - this.onDoneListener = onDoneListener - return this - } + fun setOnStartListener(onStartListener: (() -> Unit)): TextToSpeechEngine { + this.onStartListener = onStartListener + return this + } - fun setOnErrorListener(onErrorListener: (String) -> Unit): TextToSpeechEngine { - this.onErrorListener = onErrorListener - return this - } + fun setOnCompletionListener(onDoneListener: () -> Unit): TextToSpeechEngine { + this.onDoneListener = onDoneListener + return this + } - fun setOnHighlightListener(onHighlightListener: (Int, Int) -> Unit): TextToSpeechEngine { - this.onHighlightListener = onHighlightListener - return this - } + fun setOnErrorListener(onErrorListener: (String) -> Unit): TextToSpeechEngine { + this.onErrorListener = onErrorListener + return this + } - fun destroy() { - tts?.stop() - tts?.shutdown() - tts = null - instance = null - } + fun setOnHighlightListener(onHighlightListener: (Int, Int) -> Unit): TextToSpeechEngine { + this.onHighlightListener = onHighlightListener + return this + } + + fun destroy() { + tts?.stop() + tts?.shutdown() + tts = null + instance = null + } } inline fun TextToSpeech.setListener( - crossinline onStart: (String?) -> Unit = {}, - crossinline onError: (String?) -> Unit = {}, - crossinline onRange: (Int, Int) -> Unit = { _, _ -> }, - crossinline onDone: (String?) -> Unit, -) = - this.apply { + crossinline onStart: (String?) -> Unit = {}, + crossinline onError: (String?) -> Unit = {}, + crossinline onRange: (Int, Int) -> Unit = { _, _ -> }, + crossinline onDone: (String?) -> Unit, +) = this.apply { setOnUtteranceProgressListener( - object : UtteranceProgressListener() { - override fun onStart(p0: String?) { - onStart.invoke(p0) - } + object : UtteranceProgressListener() { + override fun onStart(p0: String?) { + onStart.invoke(p0) + } - override fun onDone(p0: String?) { - onDone.invoke(p0) - } + override fun onDone(p0: String?) { + onDone.invoke(p0) + } - @Deprecated("Deprecated in Java", ReplaceWith("onError.invoke(p0)")) - override fun onError(p0: String?) { - onError.invoke(p0) - } + @Deprecated("Deprecated in Java", ReplaceWith("onError.invoke(p0)")) + override fun onError(p0: String?) { + onError.invoke(p0) + } - override fun onRangeStart( - utteranceId: String?, - start: Int, - end: Int, - frame: Int, - ) { - super.onRangeStart(utteranceId, start, end, frame) - onRange.invoke(start, end) - } - }, + override fun onRangeStart( + utteranceId: String?, + start: Int, + end: Int, + frame: Int, + ) { + super.onRangeStart(utteranceId, start, end, frame) + onRange.invoke(start, end) + } + }, ) - } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechHelper.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechHelper.kt index 1a4274a55..c6922198d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechHelper.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechHelper.kt @@ -29,149 +29,149 @@ import java.lang.ref.WeakReference import java.util.Locale class TextToSpeechHelper private constructor(private val context: WeakReference) : - LifecycleEventObserver { - private val appContext - get() = context.get()!!.applicationContext + LifecycleEventObserver { + private val appContext + get() = context.get()!!.applicationContext - private var message: String? = null + private var message: String? = null - private var ttsEngine: TextToSpeechEngine? = null + private var ttsEngine: TextToSpeechEngine? = null - private var onStart: (() -> Unit)? = null + private var onStart: (() -> Unit)? = null - private var onDoneListener: (() -> Unit)? = null + private var onDoneListener: (() -> Unit)? = null - private var onErrorListener: ((String) -> Unit)? = null + private var onErrorListener: ((String) -> Unit)? = null - private var onHighlightListener: ((Pair) -> Unit)? = null + private var onHighlightListener: ((Pair) -> Unit)? = null - private var customActionForDestroy: (() -> Unit)? = null + private var customActionForDestroy: (() -> Unit)? = null - init { - Log.d("Init", "Init TTS") - initTTS() - } - - fun registerLifecycle(owner: LifecycleOwner): TextToSpeechHelper { - owner.lifecycle.addObserver(this) - return this - } - - private fun initTTS() = - context.get()?.run { - ttsEngine = - TextToSpeechEngine.getInstance() - .setOnCompletionListener { onDoneListener?.invoke() } - .setOnErrorListener { onErrorListener?.invoke(it) } - .setOnStartListener { onStart?.invoke() } - } - - fun speak(message: String): TextToSpeechHelper { - if (ttsEngine == null) { - initTTS() - } - this.message = message - - ttsEngine?.initTTS( - appContext, - message, - ) - return this - } - - /** - * This method will highlight the text in the textView - * - * @exception Exception("Message can't be null for highlighting !! Call speak() first") - */ - fun highlight(): TextToSpeechHelper { - if (message == null) { - throw Exception("Message can't be null for highlighting !! Call speak() first") - } - ttsEngine?.setHighlightedMessage(message!!) - ttsEngine?.setOnHighlightListener { i, i2 -> onHighlightListener?.invoke(Pair(i, i2)) } - return this - } - - fun removeHighlight(): TextToSpeechHelper { - message = null - onHighlightListener = null - return this - } - - fun destroy(action: (() -> Unit) = {}) { - ttsEngine?.destroy() - ttsEngine = null - action.invoke() - instance = null - } - - fun onStart(onStartListener: () -> Unit): TextToSpeechHelper { - this.onStart = onStartListener - return this - } - - fun onDone(onCompleteListener: () -> Unit): TextToSpeechHelper { - this.onDoneListener = onCompleteListener - return this - } - - fun onError(onErrorListener: (String) -> Unit): TextToSpeechHelper { - this.onErrorListener = onErrorListener - return this - } - - fun onHighlight(onHighlightListener: (Pair) -> Unit): TextToSpeechHelper { - this.onHighlightListener = onHighlightListener - return this - } - - fun setCustomActionForDestroy(action: () -> Unit): TextToSpeechHelper { - customActionForDestroy = action - return this - } - - fun setLanguage(locale: Locale): TextToSpeechHelper { - ttsEngine?.setLanguage(locale) - return this - } - - fun setPitchAndSpeed( - pitch: Float = DEF_SPEECH_AND_PITCH, - speed: Float = DEF_SPEECH_AND_PITCH, - ): TextToSpeechHelper { - ttsEngine?.setPitchAndSpeed(pitch, speed) - return this - } - - fun resetPitchAndSpeed(): TextToSpeechHelper { - ttsEngine?.resetPitchAndSpeed() - return this - } - - companion object { - private var instance: TextToSpeechHelper? = null - - fun getInstance(context: Context): TextToSpeechHelper { - synchronized(TextToSpeechHelper::class.java) { - if (instance == null) { - instance = TextToSpeechHelper(WeakReference(context)) + init { + Log.d("Init", "Init TTS") + initTTS() } - return instance!! - } - } - } - override fun onStateChanged( - source: LifecycleOwner, - event: Lifecycle.Event, - ) { - if ( - event == Lifecycle.Event.ON_DESTROY || - event == Lifecycle.Event.ON_STOP || - event == Lifecycle.Event.ON_PAUSE - ) { - destroy { customActionForDestroy?.invoke() } + fun registerLifecycle(owner: LifecycleOwner): TextToSpeechHelper { + owner.lifecycle.addObserver(this) + return this + } + + private fun initTTS() = + context.get()?.run { + ttsEngine = + TextToSpeechEngine.getInstance() + .setOnCompletionListener { onDoneListener?.invoke() } + .setOnErrorListener { onErrorListener?.invoke(it) } + .setOnStartListener { onStart?.invoke() } + } + + fun speak(message: String): TextToSpeechHelper { + if (ttsEngine == null) { + initTTS() + } + this.message = message + + ttsEngine?.initTTS( + appContext, + message, + ) + return this + } + + /** + * This method will highlight the text in the textView + * + * @exception Exception("Message can't be null for highlighting !! Call speak() first") + */ + fun highlight(): TextToSpeechHelper { + if (message == null) { + throw Exception("Message can't be null for highlighting !! Call speak() first") + } + ttsEngine?.setHighlightedMessage(message!!) + ttsEngine?.setOnHighlightListener { i, i2 -> onHighlightListener?.invoke(Pair(i, i2)) } + return this + } + + fun removeHighlight(): TextToSpeechHelper { + message = null + onHighlightListener = null + return this + } + + fun destroy(action: (() -> Unit) = {}) { + ttsEngine?.destroy() + ttsEngine = null + action.invoke() + instance = null + } + + fun onStart(onStartListener: () -> Unit): TextToSpeechHelper { + this.onStart = onStartListener + return this + } + + fun onDone(onCompleteListener: () -> Unit): TextToSpeechHelper { + this.onDoneListener = onCompleteListener + return this + } + + fun onError(onErrorListener: (String) -> Unit): TextToSpeechHelper { + this.onErrorListener = onErrorListener + return this + } + + fun onHighlight(onHighlightListener: (Pair) -> Unit): TextToSpeechHelper { + this.onHighlightListener = onHighlightListener + return this + } + + fun setCustomActionForDestroy(action: () -> Unit): TextToSpeechHelper { + customActionForDestroy = action + return this + } + + fun setLanguage(locale: Locale): TextToSpeechHelper { + ttsEngine?.setLanguage(locale) + return this + } + + fun setPitchAndSpeed( + pitch: Float = DEF_SPEECH_AND_PITCH, + speed: Float = DEF_SPEECH_AND_PITCH, + ): TextToSpeechHelper { + ttsEngine?.setPitchAndSpeed(pitch, speed) + return this + } + + fun resetPitchAndSpeed(): TextToSpeechHelper { + ttsEngine?.resetPitchAndSpeed() + return this + } + + companion object { + private var instance: TextToSpeechHelper? = null + + fun getInstance(context: Context): TextToSpeechHelper { + synchronized(TextToSpeechHelper::class.java) { + if (instance == null) { + instance = TextToSpeechHelper(WeakReference(context)) + } + return instance!! + } + } + } + + override fun onStateChanged( + source: LifecycleOwner, + event: Lifecycle.Event, + ) { + if ( + event == Lifecycle.Event.ON_DESTROY || + event == Lifecycle.Event.ON_STOP || + event == Lifecycle.Event.ON_PAUSE + ) { + destroy { customActionForDestroy?.invoke() } + } + } } - } -} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 07ea5c803..21402b7a6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -64,284 +64,284 @@ import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.PrivateDmEvent -import java.net.URLEncoder -import java.nio.charset.StandardCharsets -import java.util.Timer -import kotlin.concurrent.schedule import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.util.Timer +import kotlin.concurrent.schedule class MainActivity : AppCompatActivity() { - private val isOnMobileDataState = mutableStateOf(false) - private val isOnWifiDataState = mutableStateOf(false) + private val isOnMobileDataState = mutableStateOf(false) + private val isOnWifiDataState = mutableStateOf(false) - // Service Manager is only active when the activity is active. - val serviceManager = ServiceManager() - private var shouldPauseService = true + // Service Manager is only active when the activity is active. + val serviceManager = ServiceManager() + private var shouldPauseService = true - @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) - @RequiresApi(Build.VERSION_CODES.R) - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) + @RequiresApi(Build.VERSION_CODES.R) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) - Log.d("Lifetime Event", "MainActivity.onCreate") + Log.d("Lifetime Event", "MainActivity.onCreate") - setContent { - val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() + setContent { + val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() - val displayFeatures = calculateDisplayFeatures(this) - val windowSizeClass = calculateWindowSizeClass(this) + val displayFeatures = calculateDisplayFeatures(this) + val windowSizeClass = calculateWindowSizeClass(this) - LaunchedEffect(key1 = sharedPreferencesViewModel) { - sharedPreferencesViewModel.init() - sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures) - } + LaunchedEffect(key1 = sharedPreferencesViewModel) { + sharedPreferencesViewModel.init() + sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures) + } - LaunchedEffect(isOnMobileDataState) { - sharedPreferencesViewModel.updateConnectivityStatusState(isOnMobileDataState) - } + LaunchedEffect(isOnMobileDataState) { + sharedPreferencesViewModel.updateConnectivityStatusState(isOnMobileDataState) + } - AmethystTheme(sharedPreferencesViewModel) { - // A surface container using the 'background' color from the theme - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - val accountStateViewModel: AccountStateViewModel = viewModel() - accountStateViewModel.serviceManager = serviceManager + AmethystTheme(sharedPreferencesViewModel) { + // A surface container using the 'background' color from the theme + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + val accountStateViewModel: AccountStateViewModel = viewModel() + accountStateViewModel.serviceManager = serviceManager - LaunchedEffect(key1 = Unit) { accountStateViewModel.tryLoginExistingAccountAsync() } + LaunchedEffect(key1 = Unit) { accountStateViewModel.tryLoginExistingAccountAsync() } - AccountScreen(accountStateViewModel, sharedPreferencesViewModel) + AccountScreen(accountStateViewModel, sharedPreferencesViewModel) + } + } } - } - } - } - - fun prepareToLaunchSigner() { - shouldPauseService = false - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onResume() { - super.onResume() - - Log.d("Lifetime Event", "MainActivity.onResume") - - // starts muted every time - DEFAULT_MUTED_SETTING.value = true - - // Keep connection alive if it's calling the signer app - Log.d("shouldPauseService", "shouldPauseService onResume: $shouldPauseService") - if (shouldPauseService) { - GlobalScope.launch(Dispatchers.IO) { serviceManager.justStart() } } - GlobalScope.launch(Dispatchers.IO) { - PushNotificationUtils.init(LocalPreferences.allSavedAccounts()) + fun prepareToLaunchSigner() { + shouldPauseService = false } - val connectivityManager = - (getSystemService(ConnectivityManager::class.java) as ConnectivityManager) - connectivityManager.registerDefaultNetworkCallback(networkCallback) - connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)?.let { - updateNetworkCapabilities(it) - } + @OptIn(DelicateCoroutinesApi::class) + override fun onResume() { + super.onResume() - // resets state until next External Signer Call - Timer().schedule(350) { shouldPauseService = true } - } + Log.d("Lifetime Event", "MainActivity.onResume") - override fun onPause() { - Log.d("Lifetime Event", "MainActivity.onPause") + // starts muted every time + DEFAULT_MUTED_SETTING.value = true - LanguageTranslatorService.clear() - serviceManager.cleanObservers() - - // if (BuildConfig.DEBUG) { - GlobalScope.launch(Dispatchers.IO) { debugState(this@MainActivity) } - // } - - Log.d("shouldPauseService", "shouldPauseService onPause: $shouldPauseService") - if (shouldPauseService) { - GlobalScope.launch(Dispatchers.IO) { serviceManager.pauseForGood() } - } - - (getSystemService(ConnectivityManager::class.java) as ConnectivityManager) - .unregisterNetworkCallback(networkCallback) - - super.onPause() - } - - override fun onStart() { - super.onStart() - - Log.d("Lifetime Event", "MainActivity.onStart") - } - - override fun onStop() { - super.onStop() - - // Graph doesn't completely clear. - // GlobalScope.launch(Dispatchers.Default) { - // serviceManager.trimMemory() - // } - - Log.d("Lifetime Event", "MainActivity.onStop") - } - - override fun onDestroy() { - Log.d("Lifetime Event", "MainActivity.onDestroy") - - GlobalScope.launch(Dispatchers.Main) { - keepPlayingMutex?.stop() - keepPlayingMutex?.release() - keepPlayingMutex = null - } - - super.onDestroy() - } - - /** - * Release memory when the UI becomes hidden or when system resources become low. - * - * @param level the memory-related event that was raised. - */ - @OptIn(DelicateCoroutinesApi::class) - override fun onTrimMemory(level: Int) { - super.onTrimMemory(level) - println("Trim Memory $level") - GlobalScope.launch(Dispatchers.Default) { serviceManager.trimMemory() } - } - - fun updateNetworkCapabilities(networkCapabilities: NetworkCapabilities): Boolean { - val isOnMobileData = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) - val isOnWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) - - var changedNetwork = false - - if (isOnMobileDataState.value != isOnMobileData) { - isOnMobileDataState.value = isOnMobileData - - changedNetwork = true - } - - if (isOnWifiDataState.value != isOnWifi) { - isOnWifiDataState.value = isOnWifi - - changedNetwork = true - } - - if (changedNetwork) { - if (isOnMobileData) { - HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_MOBILE) - } else { - HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_WIFI) - } - } - - return changedNetwork - } - - @OptIn(DelicateCoroutinesApi::class) - private val networkCallback = - object : ConnectivityManager.NetworkCallback() { - var lastNetwork: Network? = null - - override fun onAvailable(network: Network) { - super.onAvailable(network) - - Log.d("ServiceManager NetworkCallback", "onAvailable: $shouldPauseService") - if (shouldPauseService && lastNetwork != null && lastNetwork != network) { - GlobalScope.launch(Dispatchers.IO) { serviceManager.forceRestart() } + // Keep connection alive if it's calling the signer app + Log.d("shouldPauseService", "shouldPauseService onResume: $shouldPauseService") + if (shouldPauseService) { + GlobalScope.launch(Dispatchers.IO) { serviceManager.justStart() } } - lastNetwork = network - } - - // Network capabilities have changed for the network - override fun onCapabilitiesChanged( - network: Network, - networkCapabilities: NetworkCapabilities, - ) { - super.onCapabilitiesChanged(network, networkCapabilities) - GlobalScope.launch(Dispatchers.IO) { - Log.d( - "ServiceManager NetworkCallback", - "onCapabilitiesChanged: ${network.networkHandle} hasMobileData ${isOnMobileDataState.value} hasWifi ${isOnWifiDataState.value}", - ) - if (updateNetworkCapabilities(networkCapabilities) && shouldPauseService) { - serviceManager.forceRestart() - } + PushNotificationUtils.init(LocalPreferences.allSavedAccounts()) } - } + + val connectivityManager = + (getSystemService(ConnectivityManager::class.java) as ConnectivityManager) + connectivityManager.registerDefaultNetworkCallback(networkCallback) + connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)?.let { + updateNetworkCapabilities(it) + } + + // resets state until next External Signer Call + Timer().schedule(350) { shouldPauseService = true } } + + override fun onPause() { + Log.d("Lifetime Event", "MainActivity.onPause") + + LanguageTranslatorService.clear() + serviceManager.cleanObservers() + + // if (BuildConfig.DEBUG) { + GlobalScope.launch(Dispatchers.IO) { debugState(this@MainActivity) } + // } + + Log.d("shouldPauseService", "shouldPauseService onPause: $shouldPauseService") + if (shouldPauseService) { + GlobalScope.launch(Dispatchers.IO) { serviceManager.pauseForGood() } + } + + (getSystemService(ConnectivityManager::class.java) as ConnectivityManager) + .unregisterNetworkCallback(networkCallback) + + super.onPause() + } + + override fun onStart() { + super.onStart() + + Log.d("Lifetime Event", "MainActivity.onStart") + } + + override fun onStop() { + super.onStop() + + // Graph doesn't completely clear. + // GlobalScope.launch(Dispatchers.Default) { + // serviceManager.trimMemory() + // } + + Log.d("Lifetime Event", "MainActivity.onStop") + } + + override fun onDestroy() { + Log.d("Lifetime Event", "MainActivity.onDestroy") + + GlobalScope.launch(Dispatchers.Main) { + keepPlayingMutex?.stop() + keepPlayingMutex?.release() + keepPlayingMutex = null + } + + super.onDestroy() + } + + /** + * Release memory when the UI becomes hidden or when system resources become low. + * + * @param level the memory-related event that was raised. + */ + @OptIn(DelicateCoroutinesApi::class) + override fun onTrimMemory(level: Int) { + super.onTrimMemory(level) + println("Trim Memory $level") + GlobalScope.launch(Dispatchers.Default) { serviceManager.trimMemory() } + } + + fun updateNetworkCapabilities(networkCapabilities: NetworkCapabilities): Boolean { + val isOnMobileData = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) + val isOnWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + + var changedNetwork = false + + if (isOnMobileDataState.value != isOnMobileData) { + isOnMobileDataState.value = isOnMobileData + + changedNetwork = true + } + + if (isOnWifiDataState.value != isOnWifi) { + isOnWifiDataState.value = isOnWifi + + changedNetwork = true + } + + if (changedNetwork) { + if (isOnMobileData) { + HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_MOBILE) + } else { + HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_WIFI) + } + } + + return changedNetwork + } + + @OptIn(DelicateCoroutinesApi::class) + private val networkCallback = + object : ConnectivityManager.NetworkCallback() { + var lastNetwork: Network? = null + + override fun onAvailable(network: Network) { + super.onAvailable(network) + + Log.d("ServiceManager NetworkCallback", "onAvailable: $shouldPauseService") + if (shouldPauseService && lastNetwork != null && lastNetwork != network) { + GlobalScope.launch(Dispatchers.IO) { serviceManager.forceRestart() } + } + + lastNetwork = network + } + + // Network capabilities have changed for the network + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities, + ) { + super.onCapabilitiesChanged(network, networkCapabilities) + + GlobalScope.launch(Dispatchers.IO) { + Log.d( + "ServiceManager NetworkCallback", + "onCapabilitiesChanged: ${network.networkHandle} hasMobileData ${isOnMobileDataState.value} hasWifi ${isOnWifiDataState.value}", + ) + if (updateNetworkCapabilities(networkCapabilities) && shouldPauseService) { + serviceManager.forceRestart() + } + } + } + } } class GetMediaActivityResultContract : ActivityResultContracts.GetContent() { - @SuppressLint("MissingSuperCall") - override fun createIntent( - context: Context, - input: String, - ): Intent { - // Force only images and videos to be selectable - // Force OPEN Document because of the resulting URI must be passed to the - // Playback service and the picker's permissions only allow the activity to read the URI - return Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - // Force only images and videos to be selectable - type = "*/*" - putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*")) + @SuppressLint("MissingSuperCall") + override fun createIntent( + context: Context, + input: String, + ): Intent { + // Force only images and videos to be selectable + // Force OPEN Document because of the resulting URI must be passed to the + // Playback service and the picker's permissions only allow the activity to read the URI + return Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + // Force only images and videos to be selectable + type = "*/*" + putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*")) + } } - } } fun uriToRoute(uri: String?): String? { - return if (uri.equals("nostr:Notifications", true)) { - Route.Notification.route.replace("{scrollToTop}", "true") - } else { - if (uri?.startsWith("nostr:Hashtag?id=") == true) { - Route.Hashtag.route.replace("{id}", uri.removePrefix("nostr:Hashtag?id=")) + return if (uri.equals("nostr:Notifications", true)) { + Route.Notification.route.replace("{scrollToTop}", "true") } else { - val nip19 = Nip19.uriToRoute(uri) - when (nip19?.type) { - Nip19.Type.USER -> "User/${nip19.hex}" - Nip19.Type.NOTE -> "Note/${nip19.hex}" - Nip19.Type.EVENT -> { - if (nip19.kind == PrivateDmEvent.KIND) { - nip19.author?.let { "RoomByAuthor/$it" } - } else if ( - nip19.kind == ChannelMessageEvent.KIND || - nip19.kind == ChannelCreateEvent.KIND || - nip19.kind == ChannelMetadataEvent.KIND - ) { - "Channel/${nip19.hex}" - } else { - "Event/${nip19.hex}" - } + if (uri?.startsWith("nostr:Hashtag?id=") == true) { + Route.Hashtag.route.replace("{id}", uri.removePrefix("nostr:Hashtag?id=")) + } else { + val nip19 = Nip19.uriToRoute(uri) + when (nip19?.type) { + Nip19.Type.USER -> "User/${nip19.hex}" + Nip19.Type.NOTE -> "Note/${nip19.hex}" + Nip19.Type.EVENT -> { + if (nip19.kind == PrivateDmEvent.KIND) { + nip19.author?.let { "RoomByAuthor/$it" } + } else if ( + nip19.kind == ChannelMessageEvent.KIND || + nip19.kind == ChannelCreateEvent.KIND || + nip19.kind == ChannelMetadataEvent.KIND + ) { + "Channel/${nip19.hex}" + } else { + "Event/${nip19.hex}" + } + } + Nip19.Type.ADDRESS -> + if (nip19.kind == CommunityDefinitionEvent.KIND) { + "Community/${nip19.hex}" + } else if (nip19.kind == LiveActivitiesEvent.KIND) { + "Channel/${nip19.hex}" + } else { + "Event/${nip19.hex}" + } + else -> null + } } - Nip19.Type.ADDRESS -> - if (nip19.kind == CommunityDefinitionEvent.KIND) { - "Community/${nip19.hex}" - } else if (nip19.kind == LiveActivitiesEvent.KIND) { - "Channel/${nip19.hex}" - } else { - "Event/${nip19.hex}" - } - else -> null - } + ?: try { + uri?.let { + Nip47WalletConnectParser.parse(it) + val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString()) + Route.Home.base + "?nip47=" + encodedUri + } + } catch (e: Exception) { + null + } } - ?: try { - uri?.let { - Nip47WalletConnectParser.parse(it) - val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString()) - Route.Home.base + "?nip47=" + encodedUri - } - } catch (e: Exception) { - null - } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt index 72dae481c..e37758f29 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt @@ -20,49 +20,49 @@ */ package com.vitorpamplona.amethyst.ui.actions +import kotlinx.coroutines.delay import java.net.HttpURLConnection import java.net.URL -import kotlinx.coroutines.delay class ImageDownloader { - suspend fun waitAndGetImage(imageUrl: String): ByteArray? { - var imageData: ByteArray? = null - var tentatives = 0 + suspend fun waitAndGetImage(imageUrl: String): ByteArray? { + var imageData: ByteArray? = null + var tentatives = 0 - // Servers are usually not ready.. so tries to download it for 15 times/seconds. - while (imageData == null && tentatives < 15) { - imageData = - try { - HttpURLConnection.setFollowRedirects(true) - var url = URL(imageUrl) - var huc = url.openConnection() as HttpURLConnection - huc.instanceFollowRedirects = true - var responseCode = huc.responseCode + // Servers are usually not ready.. so tries to download it for 15 times/seconds. + while (imageData == null && tentatives < 15) { + imageData = + try { + HttpURLConnection.setFollowRedirects(true) + var url = URL(imageUrl) + var huc = url.openConnection() as HttpURLConnection + huc.instanceFollowRedirects = true + var responseCode = huc.responseCode - if (responseCode in 300..400) { - val newUrl: String = huc.getHeaderField("Location") + if (responseCode in 300..400) { + val newUrl: String = huc.getHeaderField("Location") - // open the new connnection again - url = URL(newUrl) - huc = url.openConnection() as HttpURLConnection - responseCode = huc.responseCode - } + // open the new connnection again + url = URL(newUrl) + huc = url.openConnection() as HttpURLConnection + responseCode = huc.responseCode + } - if (responseCode in 200..300) { - huc.inputStream.use { it.readBytes() } - } else { - tentatives++ - delay(1000) + if (responseCode in 200..300) { + huc.inputStream.use { it.readBytes() } + } else { + tentatives++ + delay(1000) - null - } - } catch (e: Exception) { - tentatives++ - delay(1000) - null + null + } + } catch (e: Exception) { + tentatives++ + delay(1000) + null + } } - } - return imageData - } + return imageData + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt index 70e2037c9..d9e47197f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt @@ -31,8 +31,6 @@ import android.webkit.MimeTypeMap import androidx.annotation.RequiresApi import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.service.HttpClient -import java.io.File -import java.util.UUID import okhttp3.Call import okhttp3.Callback import okhttp3.Request @@ -42,168 +40,170 @@ import okio.IOException import okio.buffer import okio.sink import okio.source +import java.io.File +import java.util.UUID object ImageSaver { - /** - * Saves the image to the gallery. May require a storage permission. - * - * @see PICTURES_SUBDIRECTORY - */ - fun saveImage( - url: String, - context: Context, - onSuccess: () -> Any?, - onError: (Throwable) -> Any?, - ) { - val client = HttpClient.getHttpClient() + /** + * Saves the image to the gallery. May require a storage permission. + * + * @see PICTURES_SUBDIRECTORY + */ + fun saveImage( + url: String, + context: Context, + onSuccess: () -> Any?, + onError: (Throwable) -> Any?, + ) { + val client = HttpClient.getHttpClient() - val request = - Request.Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .get() - .url(url) - .build() + val request = + Request.Builder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .get() + .url(url) + .build() - client - .newCall(request) - .enqueue( - object : Callback { - override fun onFailure( - call: Call, - e: IOException, - ) { + client + .newCall(request) + .enqueue( + object : Callback { + override fun onFailure( + call: Call, + e: IOException, + ) { + e.printStackTrace() + onError(e) + } + + override fun onResponse( + call: Call, + response: Response, + ) { + try { + check(response.isSuccessful) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val contentType = response.header("Content-Type") + checkNotNull(contentType) { "Can't find out the content type" } + + saveContentQ( + displayName = File(url).nameWithoutExtension, + contentType = contentType, + contentSource = response.body.source(), + contentResolver = context.contentResolver, + ) + } else { + saveContentDefault( + fileName = File(url).name, + contentSource = response.body.source(), + context = context, + ) + } + onSuccess() + } catch (e: Exception) { + e.printStackTrace() + onError(e) + } + } + }, + ) + } + + fun saveImage( + localFile: File, + mimeType: String?, + context: Context, + onSuccess: () -> Any?, + onError: (Throwable) -> Any?, + ) { + try { + val extension = + mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" + val buffer = localFile.inputStream().source().buffer() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + saveContentQ( + displayName = UUID.randomUUID().toString(), + contentType = mimeType ?: "", + contentSource = buffer, + contentResolver = context.contentResolver, + ) + } else { + saveContentDefault( + fileName = UUID.randomUUID().toString() + ".$extension", + contentSource = buffer, + context = context, + ) + } + onSuccess() + } catch (e: Exception) { e.printStackTrace() onError(e) - } + } + } - override fun onResponse( - call: Call, - response: Response, - ) { - try { - check(response.isSuccessful) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val contentType = response.header("Content-Type") - checkNotNull(contentType) { "Can't find out the content type" } - - saveContentQ( - displayName = File(url).nameWithoutExtension, - contentType = contentType, - contentSource = response.body.source(), - contentResolver = context.contentResolver, + @RequiresApi(Build.VERSION_CODES.Q) + private fun saveContentQ( + displayName: String, + contentType: String, + contentSource: BufferedSource, + contentResolver: ContentResolver, + ) { + val contentValues = + ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) + put(MediaStore.MediaColumns.MIME_TYPE, contentType) + put( + MediaStore.MediaColumns.RELATIVE_PATH, + Environment.DIRECTORY_PICTURES + File.separatorChar + PICTURES_SUBDIRECTORY, ) - } else { - saveContentDefault( - fileName = File(url).name, - contentSource = response.body.source(), - context = context, - ) - } - onSuccess() - } catch (e: Exception) { - e.printStackTrace() - onError(e) } - } - }, - ) - } - fun saveImage( - localFile: File, - mimeType: String?, - context: Context, - onSuccess: () -> Any?, - onError: (Throwable) -> Any?, - ) { - try { - val extension = - mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" - val buffer = localFile.inputStream().source().buffer() + val masterUri = + if (contentType.startsWith("image")) { + MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } else { + MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - saveContentQ( - displayName = UUID.randomUUID().toString(), - contentType = mimeType ?: "", - contentSource = buffer, - contentResolver = context.contentResolver, - ) - } else { - saveContentDefault( - fileName = UUID.randomUUID().toString() + ".$extension", - contentSource = buffer, - context = context, - ) - } - onSuccess() - } catch (e: Exception) { - e.printStackTrace() - onError(e) - } - } + val uri = contentResolver.insert(masterUri, contentValues) + checkNotNull(uri) { "Can't insert the new content" } - @RequiresApi(Build.VERSION_CODES.Q) - private fun saveContentQ( - displayName: String, - contentType: String, - contentSource: BufferedSource, - contentResolver: ContentResolver, - ) { - val contentValues = - ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) - put(MediaStore.MediaColumns.MIME_TYPE, contentType) - put( - MediaStore.MediaColumns.RELATIVE_PATH, - Environment.DIRECTORY_PICTURES + File.separatorChar + PICTURES_SUBDIRECTORY, - ) - } + try { + val outputStream = contentResolver.openOutputStream(uri) + checkNotNull(outputStream) { "Can't open the content output stream" } - val masterUri = - if (contentType.startsWith("image")) { - MediaStore.Images.Media.EXTERNAL_CONTENT_URI - } else { - MediaStore.Video.Media.EXTERNAL_CONTENT_URI - } - - val uri = contentResolver.insert(masterUri, contentValues) - checkNotNull(uri) { "Can't insert the new content" } - - try { - val outputStream = contentResolver.openOutputStream(uri) - checkNotNull(outputStream) { "Can't open the content output stream" } - - outputStream.use { contentSource.readAll(it.sink()) } - } catch (e: Exception) { - contentResolver.delete(uri, null, null) - throw e - } - } - - private fun saveContentDefault( - fileName: String, - contentSource: BufferedSource, - context: Context, - ) { - val subdirectory = - File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), - PICTURES_SUBDIRECTORY, - ) - - if (!subdirectory.exists()) { - subdirectory.mkdirs() + outputStream.use { contentSource.readAll(it.sink()) } + } catch (e: Exception) { + contentResolver.delete(uri, null, null) + throw e + } } - val outputFile = File(subdirectory, fileName) + private fun saveContentDefault( + fileName: String, + contentSource: BufferedSource, + context: Context, + ) { + val subdirectory = + File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), + PICTURES_SUBDIRECTORY, + ) - outputFile.outputStream().use { contentSource.readAll(it.sink()) } + if (!subdirectory.exists()) { + subdirectory.mkdirs() + } - // Call the media scanner manually, so the image - // appears in the gallery faster. - MediaScannerConnection.scanFile(context, arrayOf(outputFile.toString()), null, null) - } + val outputFile = File(subdirectory, fileName) - private const val PICTURES_SUBDIRECTORY = "Amethyst" + outputFile.outputStream().use { contentSource.readAll(it.sink()) } + + // Call the media scanner manually, so the image + // appears in the gallery faster. + MediaScannerConnection.scanFile(context, arrayOf(outputFile.toString()), null, null) + } + + private const val PICTURES_SUBDIRECTORY = "Amethyst" } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt index 0c72422fb..4d6c87adb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt @@ -41,32 +41,32 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer @Composable fun InformationDialog( - title: String, - textContent: String, - buttonColors: ButtonColors = ButtonDefaults.buttonColors(), - onDismiss: () -> Unit, + title: String, + textContent: String, + buttonColors: ButtonColors = ButtonDefaults.buttonColors(), + onDismiss: () -> Unit, ) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(title) }, - text = { SelectionContainer { Text(textContent) } }, - confirmButton = { - Button( - onClick = onDismiss, - colors = buttonColors, - contentPadding = PaddingValues(horizontal = Size16dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Outlined.Done, - contentDescription = null, - ) - Spacer(StdHorzSpacer) - Text(stringResource(R.string.error_dialog_button_ok)) - } - } - }, - ) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { SelectionContainer { Text(textContent) } }, + confirmButton = { + Button( + onClick = onDismiss, + colors = buttonColors, + contentPadding = PaddingValues(horizontal = Size16dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Done, + contentDescription = null, + ) + Spacer(StdHorzSpacer) + Text(stringResource(R.string.error_dialog_button_ok)) + } + } + }, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt index c19c9ec62..a88464bf9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt @@ -101,338 +101,340 @@ import kotlinx.coroutines.withContext @Composable fun JoinUserOrChannelView( - onClose: () -> Unit, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + onClose: () -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val searchBarViewModel: SearchBarViewModel = - viewModel( - key = "SearchBarViewModel", - factory = - SearchBarViewModel.Factory( - accountViewModel.account, - ), - ) + val searchBarViewModel: SearchBarViewModel = + viewModel( + key = "SearchBarViewModel", + factory = + SearchBarViewModel.Factory( + accountViewModel.account, + ), + ) - JoinUserOrChannelView( - searchBarViewModel = searchBarViewModel, - onClose = onClose, - accountViewModel = accountViewModel, - nav = nav, - ) + JoinUserOrChannelView( + searchBarViewModel = searchBarViewModel, + onClose = onClose, + accountViewModel = accountViewModel, + nav = nav, + ) } @Composable fun JoinUserOrChannelView( - searchBarViewModel: SearchBarViewModel, - onClose: () -> Unit, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + searchBarViewModel: SearchBarViewModel, + onClose: () -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Dialog( - onDismissRequest = { - NostrSearchEventOrUserDataSource.clear() - searchBarViewModel.clear() - onClose() - }, - properties = - DialogProperties( - dismissOnClickOutside = false, - ), - ) { - Surface { - Column( - modifier = Modifier.padding(10.dp).heightIn(min = 500.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CloseButton( - onPress = { - searchBarViewModel.clear() - NostrSearchEventOrUserDataSource.clear() - onClose() - }, - ) + Dialog( + onDismissRequest = { + NostrSearchEventOrUserDataSource.clear() + searchBarViewModel.clear() + onClose() + }, + properties = + DialogProperties( + dismissOnClickOutside = false, + ), + ) { + Surface { + Column( + modifier = Modifier.padding(10.dp).heightIn(min = 500.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton( + onPress = { + searchBarViewModel.clear() + NostrSearchEventOrUserDataSource.clear() + onClose() + }, + ) - Text( - text = stringResource(R.string.channel_list_join_conversation), - fontWeight = FontWeight.Bold, - ) + Text( + text = stringResource(R.string.channel_list_join_conversation), + fontWeight = FontWeight.Bold, + ) - Text( - text = "", - color = MaterialTheme.colorScheme.placeholderText, - fontWeight = FontWeight.Bold, - ) + Text( + text = "", + color = MaterialTheme.colorScheme.placeholderText, + fontWeight = FontWeight.Bold, + ) + } + + Spacer(modifier = Modifier.height(15.dp)) + + RenderSearch(searchBarViewModel, accountViewModel, nav) + } } - - Spacer(modifier = Modifier.height(15.dp)) - - RenderSearch(searchBarViewModel, accountViewModel, nav) - } } - } } @Composable private fun RenderSearch( - searchBarViewModel: SearchBarViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + searchBarViewModel: SearchBarViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val listState = rememberLazyListState() + val listState = rememberLazyListState() - val lifeCycleOwner = LocalLifecycleOwner.current + val lifeCycleOwner = LocalLifecycleOwner.current - // Create a channel for processing search queries. - val searchTextChanges = remember { Channel(Channel.CONFLATED) } + // Create a channel for processing search queries. + val searchTextChanges = remember { Channel(Channel.CONFLATED) } - LaunchedEffect(Unit) { - launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { - checkNotInMainThread() - if (searchBarViewModel.isSearchingFun()) { - searchBarViewModel.invalidateData() - } - } - } - } - - LaunchedEffect(Unit) { - // Wait for text changes to stop for 300 ms before firing off search. - withContext(Dispatchers.IO) { - searchTextChanges - .receiveAsFlow() - .filter { it.isNotBlank() } - .distinctUntilChanged() - .debounce(300) - .collectLatest { - if (it.length >= 2) { - NostrSearchEventOrUserDataSource.search(it.trim()) - } - - searchBarViewModel.invalidateData() - - // makes sure to show the top of the search - launch(Dispatchers.Main) { listState.animateScrollToItem(0) } + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { + checkNotInMainThread() + if (searchBarViewModel.isSearchingFun()) { + searchBarViewModel.invalidateData() + } + } } } - } - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Join Start") - NostrSearchEventOrUserDataSource.start() - searchBarViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Join Stop") - NostrSearchEventOrUserDataSource.clear() - NostrSearchEventOrUserDataSource.stop() - } + LaunchedEffect(Unit) { + // Wait for text changes to stop for 300 ms before firing off search. + withContext(Dispatchers.IO) { + searchTextChanges + .receiveAsFlow() + .filter { it.isNotBlank() } + .distinctUntilChanged() + .debounce(300) + .collectLatest { + if (it.length >= 2) { + NostrSearchEventOrUserDataSource.search(it.trim()) + } + + searchBarViewModel.invalidateData() + + // makes sure to show the top of the search + launch(Dispatchers.Main) { listState.animateScrollToItem(0) } + } + } } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + DisposableEffect(lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Join Start") + NostrSearchEventOrUserDataSource.start() + searchBarViewModel.invalidateData() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Join Stop") + NostrSearchEventOrUserDataSource.clear() + NostrSearchEventOrUserDataSource.stop() + } + } - // LAST ROW - SearchEditTextForJoin(searchBarViewModel, searchTextChanges) + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } - RenderSearchResults(searchBarViewModel, listState, accountViewModel, nav) + // LAST ROW + SearchEditTextForJoin(searchBarViewModel, searchTextChanges) + + RenderSearchResults(searchBarViewModel, listState, accountViewModel, nav) } @OptIn(ExperimentalComposeUiApi::class) @Composable private fun SearchEditTextForJoin( - searchBarViewModel: SearchBarViewModel, - searchTextChanges: Channel, + searchBarViewModel: SearchBarViewModel, + searchTextChanges: Channel, ) { - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - // initialize focus reference to be able to request focus programmatically - val focusRequester = remember { FocusRequester() } - val keyboardController = LocalSoftwareKeyboardController.current + // initialize focus reference to be able to request focus programmatically + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current - LaunchedEffect(Unit) { - launch { - delay(100) - focusRequester.requestFocus() - } - } - - Row( - modifier = Modifier.padding(horizontal = 10.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.channel_list_user_or_group_id)) }, - value = searchBarViewModel.searchValue, - onValueChange = { - searchBarViewModel.updateSearchValue(it) - scope.launch(Dispatchers.IO) { searchTextChanges.trySend(it) } - }, - leadingIcon = { SearchIcon(modifier = Size20Modifier, Color.Unspecified) }, - modifier = - Modifier.weight(1f, true) - .defaultMinSize(minHeight = 20.dp) - .focusRequester(focusRequester) - .onFocusChanged { - if (it.isFocused) { - keyboardController?.show() - } - }, - placeholder = { - Text( - text = stringResource(R.string.channel_list_user_or_group_id_demo), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - trailingIcon = { - if (searchBarViewModel.isSearching) { - IconButton( - onClick = { - searchBarViewModel.clear() - NostrSearchEventOrUserDataSource.clear() - }, - ) { - Icon( - imageVector = Icons.Default.Clear, - contentDescription = stringResource(R.string.clear), - ) - } + LaunchedEffect(Unit) { + launch { + delay(100) + focusRequester.requestFocus() } - }, - ) - } + } + + Row( + modifier = Modifier.padding(horizontal = 10.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.channel_list_user_or_group_id)) }, + value = searchBarViewModel.searchValue, + onValueChange = { + searchBarViewModel.updateSearchValue(it) + scope.launch(Dispatchers.IO) { searchTextChanges.trySend(it) } + }, + leadingIcon = { SearchIcon(modifier = Size20Modifier, Color.Unspecified) }, + modifier = + Modifier.weight(1f, true) + .defaultMinSize(minHeight = 20.dp) + .focusRequester(focusRequester) + .onFocusChanged { + if (it.isFocused) { + keyboardController?.show() + } + }, + placeholder = { + Text( + text = stringResource(R.string.channel_list_user_or_group_id_demo), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + if (searchBarViewModel.isSearching) { + IconButton( + onClick = { + searchBarViewModel.clear() + NostrSearchEventOrUserDataSource.clear() + }, + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(R.string.clear), + ) + } + } + }, + ) + } } @Composable private fun RenderSearchResults( - searchBarViewModel: SearchBarViewModel, - listState: LazyListState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + searchBarViewModel: SearchBarViewModel, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (searchBarViewModel.isSearching) { - val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle() - val channels by searchBarViewModel.searchResultsChannels.collectAsStateWithLifecycle() + if (searchBarViewModel.isSearching) { + val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle() + val channels by searchBarViewModel.searchResultsChannels.collectAsStateWithLifecycle() - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } + val automaticallyShowProfilePicture = + remember { + accountViewModel.settings.showProfilePictures.value + } - Row( - modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(vertical = 10.dp), - ) { - LazyColumn( - modifier = Modifier.fillMaxHeight(), - contentPadding = FeedPadding, - state = listState, - ) { - itemsIndexed( - users, - key = { _, item -> "u" + item.pubkeyHex }, - ) { _, item -> - UserComposeForChat(item, accountViewModel) { - accountViewModel.createChatRoomFor(item) { nav("Room/$it") } + Row( + modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(vertical = 10.dp), + ) { + LazyColumn( + modifier = Modifier.fillMaxHeight(), + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed( + users, + key = { _, item -> "u" + item.pubkeyHex }, + ) { _, item -> + UserComposeForChat(item, accountViewModel) { + accountViewModel.createChatRoomFor(item) { nav("Room/$it") } - searchBarViewModel.clear() - } + searchBarViewModel.clear() + } + } + + itemsIndexed( + channels, + key = { _, item -> "c" + item.idHex }, + ) { _, item -> + RenderChannel(item, automaticallyShowProfilePicture) { + nav("Channel/${item.idHex}") + searchBarViewModel.clear() + } + } + } } - - itemsIndexed( - channels, - key = { _, item -> "c" + item.idHex }, - ) { _, item -> - RenderChannel(item, automaticallyShowProfilePicture) { - nav("Channel/${item.idHex}") - searchBarViewModel.clear() - } - } - } } - } } @Composable private fun RenderChannel( - item: com.vitorpamplona.amethyst.model.Channel, - loadProfilePicture: Boolean, - onClick: () -> Unit, + item: com.vitorpamplona.amethyst.model.Channel, + loadProfilePicture: Boolean, + onClick: () -> Unit, ) { - val hasNewMessages = remember { mutableStateOf(false) } + val hasNewMessages = remember { mutableStateOf(false) } - ChannelName( - channelIdHex = item.idHex, - channelPicture = item.profilePicture(), - channelTitle = { - Text( - item.toBestDisplayName(), - fontWeight = FontWeight.Bold, - ) - }, - channelLastTime = null, - channelLastContent = item.summary(), - hasNewMessages, - onClick = onClick, - loadProfilePicture = loadProfilePicture, - ) + ChannelName( + channelIdHex = item.idHex, + channelPicture = item.profilePicture(), + channelTitle = { + Text( + item.toBestDisplayName(), + fontWeight = FontWeight.Bold, + ) + }, + channelLastTime = null, + channelLastContent = item.summary(), + hasNewMessages, + onClick = onClick, + loadProfilePicture = loadProfilePicture, + ) } @Composable fun UserComposeForChat( - baseUser: User, - accountViewModel: AccountViewModel, - onClick: () -> Unit, + baseUser: User, + accountViewModel: AccountViewModel, + onClick: () -> Unit, ) { - Column( - modifier = - Modifier.clickable( - onClick = onClick, - ), - ) { - Row( - modifier = - Modifier.padding( - start = 12.dp, - end = 12.dp, - top = 10.dp, - ), - verticalAlignment = Alignment.CenterVertically, + Column( + modifier = + Modifier.clickable( + onClick = onClick, + ), ) { - ClickableUserPicture(baseUser, Size55dp, accountViewModel) + Row( + modifier = + Modifier.padding( + start = 12.dp, + end = 12.dp, + top = 10.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + ClickableUserPicture(baseUser, Size55dp, accountViewModel) - Column( - modifier = Modifier.padding(start = 10.dp).weight(1f), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser) } + Column( + modifier = Modifier.padding(start = 10.dp).weight(1f), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser) } - DisplayUserAboutInfo(baseUser) - } + DisplayUserAboutInfo(baseUser) + } + } + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = DividerThickness, + ) } - - Divider( - modifier = Modifier.padding(top = 10.dp), - thickness = DividerThickness, - ) - } } @Composable private fun DisplayUserAboutInfo(baseUser: User) { - val baseUserState by baseUser.live().metadata.observeAsState() - val about by remember(baseUserState) { derivedStateOf { baseUserState?.user?.info?.about ?: "" } } + val baseUserState by baseUser.live().metadata.observeAsState() + val about by remember(baseUserState) { derivedStateOf { baseUserState?.user?.info?.about ?: "" } } - Text( - text = about, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Text( + text = about, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt index 407a092b8..104e0ca66 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt @@ -52,101 +52,101 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable fun NewChannelView( - onClose: () -> Unit, - accountViewModel: AccountViewModel, - channel: PublicChatChannel? = null, + onClose: () -> Unit, + accountViewModel: AccountViewModel, + channel: PublicChatChannel? = null, ) { - val postViewModel: NewChannelViewModel = viewModel() - postViewModel.load(accountViewModel.account, channel) + val postViewModel: NewChannelViewModel = viewModel() + postViewModel.load(accountViewModel.account, channel) - Dialog( - onDismissRequest = { onClose() }, - properties = - DialogProperties( - dismissOnClickOutside = false, - ), - ) { - Surface { - Column( - modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CloseButton( - onPress = { - postViewModel.clear() - onClose() - }, - ) + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + dismissOnClickOutside = false, + ), + ) { + Surface { + Column( + modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton( + onPress = { + postViewModel.clear() + onClose() + }, + ) - PostButton( - onPost = { - postViewModel.create() - onClose() - }, - postViewModel.channelName.value.text.isNotBlank(), - ) + PostButton( + onPost = { + postViewModel.create() + onClose() + }, + postViewModel.channelName.value.text.isNotBlank(), + ) + } + + Spacer(modifier = Modifier.height(15.dp)) + + OutlinedTextField( + label = { Text(text = stringResource(R.string.channel_name)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.channelName.value, + onValueChange = { postViewModel.channelName.value = it }, + placeholder = { + Text( + text = stringResource(R.string.my_awesome_group), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) + + Spacer(modifier = Modifier.height(15.dp)) + + OutlinedTextField( + label = { Text(text = stringResource(R.string.picture_url)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.channelPicture.value, + onValueChange = { postViewModel.channelPicture.value = it }, + placeholder = { + Text( + text = "http://mygroup.com/logo.jpg", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) + + Spacer(modifier = Modifier.height(15.dp)) + + OutlinedTextField( + label = { Text(text = stringResource(R.string.description)) }, + modifier = Modifier.fillMaxWidth().height(100.dp), + value = postViewModel.channelDescription.value, + onValueChange = { postViewModel.channelDescription.value = it }, + placeholder = { + Text( + text = stringResource(R.string.about_us), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + maxLines = 10, + ) + } } - - Spacer(modifier = Modifier.height(15.dp)) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.channel_name)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.channelName.value, - onValueChange = { postViewModel.channelName.value = it }, - placeholder = { - Text( - text = stringResource(R.string.my_awesome_group), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - ) - - Spacer(modifier = Modifier.height(15.dp)) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.picture_url)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.channelPicture.value, - onValueChange = { postViewModel.channelPicture.value = it }, - placeholder = { - Text( - text = "http://mygroup.com/logo.jpg", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - - Spacer(modifier = Modifier.height(15.dp)) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.description)) }, - modifier = Modifier.fillMaxWidth().height(100.dp), - value = postViewModel.channelDescription.value, - onValueChange = { postViewModel.channelDescription.value = it }, - placeholder = { - Text( - text = stringResource(R.string.about_us), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - maxLines = 10, - ) - } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt index 5b6c171bd..29fa75b87 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt @@ -30,52 +30,52 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class NewChannelViewModel : ViewModel() { - private var account: Account? = null - private var originalChannel: PublicChatChannel? = null + private var account: Account? = null + private var originalChannel: PublicChatChannel? = null - val channelName = mutableStateOf(TextFieldValue()) - val channelPicture = mutableStateOf(TextFieldValue()) - val channelDescription = mutableStateOf(TextFieldValue()) + val channelName = mutableStateOf(TextFieldValue()) + val channelPicture = mutableStateOf(TextFieldValue()) + val channelDescription = mutableStateOf(TextFieldValue()) - fun load( - account: Account, - channel: PublicChatChannel?, - ) { - this.account = account - if (channel != null) { - originalChannel = channel - channelName.value = TextFieldValue(channel.info.name ?: "") - channelPicture.value = TextFieldValue(channel.info.picture ?: "") - channelDescription.value = TextFieldValue(channel.info.about ?: "") - } - } - - fun create() { - viewModelScope.launch(Dispatchers.IO) { - account?.let { account -> - if (originalChannel == null) { - account.sendCreateNewChannel( - channelName.value.text, - channelDescription.value.text, - channelPicture.value.text, - ) - } else { - account.sendChangeChannel( - channelName.value.text, - channelDescription.value.text, - channelPicture.value.text, - originalChannel!!, - ) + fun load( + account: Account, + channel: PublicChatChannel?, + ) { + this.account = account + if (channel != null) { + originalChannel = channel + channelName.value = TextFieldValue(channel.info.name ?: "") + channelPicture.value = TextFieldValue(channel.info.picture ?: "") + channelDescription.value = TextFieldValue(channel.info.about ?: "") } - } - - clear() } - } - fun clear() { - channelName.value = TextFieldValue() - channelPicture.value = TextFieldValue() - channelDescription.value = TextFieldValue() - } + fun create() { + viewModelScope.launch(Dispatchers.IO) { + account?.let { account -> + if (originalChannel == null) { + account.sendCreateNewChannel( + channelName.value.text, + channelDescription.value.text, + channelPicture.value.text, + ) + } else { + account.sendChangeChannel( + channelName.value.text, + channelDescription.value.text, + channelPicture.value.text, + originalChannel!!, + ) + } + } + + clear() + } + } + + fun clear() { + channelName.value = TextFieldValue() + channelPicture.value = TextFieldValue() + channelDescription.value = TextFieldValue() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index 2c6e1b67e..985915881 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -40,289 +40,289 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch data class ServerOption( - val server: Nip96MediaServers.ServerName, - val isNip95: Boolean, + val server: Nip96MediaServers.ServerName, + val isNip95: Boolean, ) @Stable open class NewMediaModel : ViewModel() { - var account: Account? = null + var account: Account? = null - var isUploadingImage by mutableStateOf(false) - var mediaType by mutableStateOf(null) + var isUploadingImage by mutableStateOf(false) + var mediaType by mutableStateOf(null) - var selectedServer by mutableStateOf(null) - var alt by mutableStateOf("") - var sensitiveContent by mutableStateOf(false) + var selectedServer by mutableStateOf(null) + var alt by mutableStateOf("") + var sensitiveContent by mutableStateOf(false) - // Images and Videos - var galleryUri by mutableStateOf(null) + // Images and Videos + var galleryUri by mutableStateOf(null) - var uploadingPercentage = mutableStateOf(0.0f) - var uploadingDescription = mutableStateOf(null) + var uploadingPercentage = mutableStateOf(0.0f) + var uploadingDescription = mutableStateOf(null) - var onceUploaded: () -> Unit = {} - var onError: (String) -> Unit = {} + var onceUploaded: () -> Unit = {} + var onError: (String) -> Unit = {} - open fun load( - account: Account, - uri: Uri, - contentType: String?, - onError: (String) -> Unit, - ) { - this.account = account - this.galleryUri = uri - this.mediaType = contentType - this.selectedServer = ServerOption(defaultServer(), false) - this.onError = onError - } + open fun load( + account: Account, + uri: Uri, + contentType: String?, + onError: (String) -> Unit, + ) { + this.account = account + this.galleryUri = uri + this.mediaType = contentType + this.selectedServer = ServerOption(defaultServer(), false) + this.onError = onError + } - fun upload( - context: Context, - relayList: List? = null, - ) { - isUploadingImage = true + fun upload( + context: Context, + relayList: List? = null, + ) { + isUploadingImage = true - val contentResolver = context.contentResolver - val myGalleryUri = galleryUri ?: return - val serverToUse = selectedServer ?: return + val contentResolver = context.contentResolver + val myGalleryUri = galleryUri ?: return + val serverToUse = selectedServer ?: return - val contentType = contentResolver.getType(myGalleryUri) + val contentType = contentResolver.getType(myGalleryUri) - viewModelScope.launch(Dispatchers.IO) { - uploadingPercentage.value = 0.1f - uploadingDescription.value = "Compress" - MediaCompressor() - .compress( - myGalleryUri, - contentType, - context.applicationContext, - onReady = { fileUri, contentType, size -> - if (serverToUse.isNip95) { - uploadingPercentage.value = 0.2f - uploadingDescription.value = "Loading" - contentResolver.openInputStream(fileUri)?.use { - createNIP95Record( - it.readBytes(), - contentType, - alt, - sensitiveContent, - relayList = relayList, - context, + viewModelScope.launch(Dispatchers.IO) { + uploadingPercentage.value = 0.1f + uploadingDescription.value = "Compress" + MediaCompressor() + .compress( + myGalleryUri, + contentType, + context.applicationContext, + onReady = { fileUri, contentType, size -> + if (serverToUse.isNip95) { + uploadingPercentage.value = 0.2f + uploadingDescription.value = "Loading" + contentResolver.openInputStream(fileUri)?.use { + createNIP95Record( + it.readBytes(), + contentType, + alt, + sensitiveContent, + relayList = relayList, + context, + ) + } + ?: run { + viewModelScope.launch { + onError(context.getString(R.string.could_not_open_the_compressed_file)) + isUploadingImage = false + uploadingPercentage.value = 0.00f + uploadingDescription.value = null + } + } + } else { + uploadingPercentage.value = 0.2f + uploadingDescription.value = "Uploading" + viewModelScope.launch(Dispatchers.IO) { + try { + val result = + Nip96Uploader(account) + .uploadImage( + uri = fileUri, + contentType = contentType, + size = size, + alt = alt, + sensitiveContent = if (sensitiveContent) "" else null, + server = serverToUse.server, + contentResolver = contentResolver, + onProgress = { percent: Float -> + uploadingPercentage.value = 0.2f + (0.2f * percent) + }, + ) + + createNIP94Record( + uploadingResult = result, + localContentType = contentType, + alt = alt, + sensitiveContent = sensitiveContent, + relayList = relayList, + context, + ) + } catch (e: Exception) { + isUploadingImage = false + uploadingPercentage.value = 0.00f + uploadingDescription.value = null + onError(context.getString(R.string.failed_to_upload_media, e.message)) + } + } + } + }, + onError = { + isUploadingImage = false + uploadingPercentage.value = 0.00f + uploadingDescription.value = null + onError(context.getString(R.string.error_when_compressing_media, it)) + }, ) - } - ?: run { - viewModelScope.launch { - onError(context.getString(R.string.could_not_open_the_compressed_file)) - isUploadingImage = false - uploadingPercentage.value = 0.00f - uploadingDescription.value = null - } - } - } else { - uploadingPercentage.value = 0.2f - uploadingDescription.value = "Uploading" - viewModelScope.launch(Dispatchers.IO) { - try { - val result = - Nip96Uploader(account) - .uploadImage( - uri = fileUri, - contentType = contentType, - size = size, - alt = alt, - sensitiveContent = if (sensitiveContent) "" else null, - server = serverToUse.server, - contentResolver = contentResolver, - onProgress = { percent: Float -> - uploadingPercentage.value = 0.2f + (0.2f * percent) - }, - ) + } + } - createNIP94Record( - uploadingResult = result, - localContentType = contentType, - alt = alt, - sensitiveContent = sensitiveContent, - relayList = relayList, - context, - ) - } catch (e: Exception) { - isUploadingImage = false - uploadingPercentage.value = 0.00f - uploadingDescription.value = null - onError(context.getString(R.string.failed_to_upload_media, e.message)) - } - } - } - }, - onError = { - isUploadingImage = false + open fun cancel() { + galleryUri = null + isUploadingImage = false + mediaType = null + uploadingDescription.value = null + uploadingPercentage.value = 0.0f + + alt = "" + selectedServer = ServerOption(defaultServer(), false) + } + + fun canPost(): Boolean { + return !isUploadingImage && galleryUri != null && selectedServer != null + } + + suspend fun createNIP94Record( + uploadingResult: Nip96Uploader.PartialEvent, + localContentType: String?, + alt: String, + sensitiveContent: Boolean, + relayList: List? = null, + context: Context, + ) { + uploadingPercentage.value = 0.40f + uploadingDescription.value = "Server Processing" + // Images don't seem to be ready immediately after upload + + val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) + val remoteMimeType = + uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null } + val originalHash = + uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "ox" }?.get(1)?.ifBlank { null } + val dim = + uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null } + val magnet = + uploadingResult.tags + ?.firstOrNull { it.size > 1 && it[0] == "magnet" } + ?.get(1) + ?.ifBlank { null } + + if (imageUrl.isNullOrBlank()) { + Log.e("ImageDownload", "Couldn't download image from server") + cancel() uploadingPercentage.value = 0.00f uploadingDescription.value = null - onError(context.getString(R.string.error_when_compressing_media, it)) - }, - ) - } - } - - open fun cancel() { - galleryUri = null - isUploadingImage = false - mediaType = null - uploadingDescription.value = null - uploadingPercentage.value = 0.0f - - alt = "" - selectedServer = ServerOption(defaultServer(), false) - } - - fun canPost(): Boolean { - return !isUploadingImage && galleryUri != null && selectedServer != null - } - - suspend fun createNIP94Record( - uploadingResult: Nip96Uploader.PartialEvent, - localContentType: String?, - alt: String, - sensitiveContent: Boolean, - relayList: List? = null, - context: Context, - ) { - uploadingPercentage.value = 0.40f - uploadingDescription.value = "Server Processing" - // Images don't seem to be ready immediately after upload - - val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) - val remoteMimeType = - uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null } - val originalHash = - uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "ox" }?.get(1)?.ifBlank { null } - val dim = - uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null } - val magnet = - uploadingResult.tags - ?.firstOrNull { it.size > 1 && it[0] == "magnet" } - ?.get(1) - ?.ifBlank { null } - - if (imageUrl.isNullOrBlank()) { - Log.e("ImageDownload", "Couldn't download image from server") - cancel() - uploadingPercentage.value = 0.00f - uploadingDescription.value = null - isUploadingImage = false - onError(context.getString(R.string.server_did_not_provide_a_url_after_uploading)) - return - } - - uploadingDescription.value = "Downloading" - uploadingPercentage.value = 0.60f - - val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl) - - if (imageData != null) { - uploadingPercentage.value = 0.80f - uploadingDescription.value = "Hashing" - - FileHeader.prepare( - data = imageData, - mimeType = remoteMimeType ?: localContentType, - dimPrecomputed = dim, - onReady = { - uploadingPercentage.value = 0.90f - uploadingDescription.value = "Sending" - account?.sendHeader( - imageUrl, - magnet, - it, - alt, - sensitiveContent, - originalHash, - relayList, - ) { - uploadingPercentage.value = 1.00f isUploadingImage = false - onceUploaded() + onError(context.getString(R.string.server_did_not_provide_a_url_after_uploading)) + return + } + + uploadingDescription.value = "Downloading" + uploadingPercentage.value = 0.60f + + val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl) + + if (imageData != null) { + uploadingPercentage.value = 0.80f + uploadingDescription.value = "Hashing" + + FileHeader.prepare( + data = imageData, + mimeType = remoteMimeType ?: localContentType, + dimPrecomputed = dim, + onReady = { + uploadingPercentage.value = 0.90f + uploadingDescription.value = "Sending" + account?.sendHeader( + imageUrl, + magnet, + it, + alt, + sensitiveContent, + originalHash, + relayList, + ) { + uploadingPercentage.value = 1.00f + isUploadingImage = false + onceUploaded() + cancel() + } + }, + onError = { + cancel() + uploadingPercentage.value = 0.00f + uploadingDescription.value = null + isUploadingImage = false + onError(context.getString(R.string.could_not_prepare_local_file_to_upload, it)) + }, + ) + } else { + Log.e("ImageDownload", "Couldn't download image from server") cancel() - } - }, - onError = { - cancel() - uploadingPercentage.value = 0.00f - uploadingDescription.value = null - isUploadingImage = false - onError(context.getString(R.string.could_not_prepare_local_file_to_upload, it)) - }, - ) - } else { - Log.e("ImageDownload", "Couldn't download image from server") - cancel() - uploadingPercentage.value = 0.00f - uploadingDescription.value = null - isUploadingImage = false - onError(context.getString(R.string.could_not_download_from_the_server)) - } - } - - fun createNIP95Record( - bytes: ByteArray, - mimeType: String?, - alt: String, - sensitiveContent: Boolean, - relayList: List? = null, - context: Context, - ) { - if (bytes.size > 80000) { - viewModelScope.launch { - onError("Media is too big for NIP-95") - isUploadingImage = false - uploadingPercentage.value = 0.00f - uploadingDescription.value = null - } - return - } - - uploadingPercentage.value = 0.30f - uploadingDescription.value = "Hashing" - - viewModelScope.launch(Dispatchers.IO) { - FileHeader.prepare( - bytes, - mimeType, - null, - onReady = { - uploadingDescription.value = "Signing" - uploadingPercentage.value = 0.40f - account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 -> - uploadingDescription.value = "Sending" - uploadingPercentage.value = 0.60f - account?.consumeAndSendNip95(nip95.first, nip95.second, relayList) - - uploadingPercentage.value = 1.00f + uploadingPercentage.value = 0.00f + uploadingDescription.value = null isUploadingImage = false - onceUploaded() - cancel() - } - }, - onError = { - uploadingDescription.value = null - uploadingPercentage.value = 0.00f - isUploadingImage = false - cancel() - onError(context.getString(R.string.could_not_prepare_local_file_to_upload, it)) - }, - ) + onError(context.getString(R.string.could_not_download_from_the_server)) + } } - } - fun isImage() = mediaType?.startsWith("image") + fun createNIP95Record( + bytes: ByteArray, + mimeType: String?, + alt: String, + sensitiveContent: Boolean, + relayList: List? = null, + context: Context, + ) { + if (bytes.size > 80000) { + viewModelScope.launch { + onError("Media is too big for NIP-95") + isUploadingImage = false + uploadingPercentage.value = 0.00f + uploadingDescription.value = null + } + return + } - fun isVideo() = mediaType?.startsWith("video") + uploadingPercentage.value = 0.30f + uploadingDescription.value = "Hashing" - fun defaultServer() = account?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0] + viewModelScope.launch(Dispatchers.IO) { + FileHeader.prepare( + bytes, + mimeType, + null, + onReady = { + uploadingDescription.value = "Signing" + uploadingPercentage.value = 0.40f + account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 -> + uploadingDescription.value = "Sending" + uploadingPercentage.value = 0.60f + account?.consumeAndSendNip95(nip95.first, nip95.second, relayList) - fun onceUploaded(onceUploaded: () -> Unit) { - this.onceUploaded = onceUploaded - } + uploadingPercentage.value = 1.00f + isUploadingImage = false + onceUploaded() + cancel() + } + }, + onError = { + uploadingDescription.value = null + uploadingPercentage.value = 0.00f + isUploadingImage = false + cancel() + onError(context.getString(R.string.could_not_prepare_local_file_to_upload, it)) + }, + ) + } + } + + fun isImage() = mediaType?.startsWith("image") + + fun isVideo() = mediaType?.startsWith("video") + + fun defaultServer() = account?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0] + + fun onceUploaded(onceUploaded: () -> Unit) { + this.onceUploaded = onceUploaded + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index 838ba5dac..0fde17743 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -77,233 +77,234 @@ import kotlinx.coroutines.launch @Composable fun NewMediaView( - uri: Uri, - onClose: () -> Unit, - postViewModel: NewMediaModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + uri: Uri, + onClose: () -> Unit, + postViewModel: NewMediaModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val account = accountViewModel.account - val resolver = LocalContext.current.contentResolver - val context = LocalContext.current + val account = accountViewModel.account + val resolver = LocalContext.current.contentResolver + val context = LocalContext.current - val scroolState = rememberScrollState() + val scroolState = rememberScrollState() - LaunchedEffect(uri) { - val mediaType = resolver.getType(uri) ?: "" - postViewModel.load(account, uri, mediaType) { - accountViewModel.toast(context.getString(R.string.failed_to_upload_media_no_details), it) + LaunchedEffect(uri) { + val mediaType = resolver.getType(uri) ?: "" + postViewModel.load(account, uri, mediaType) { + accountViewModel.toast(context.getString(R.string.failed_to_upload_media_no_details), it) + } } - } - var showRelaysDialog by remember { mutableStateOf(false) } - var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() } + var showRelaysDialog by remember { mutableStateOf(false) } + var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() } - Dialog( - onDismissRequest = { onClose() }, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - decorFitsSystemWindows = false, - ), - ) { - Surface( - modifier = Modifier.fillMaxWidth(), + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false, + ), ) { - if (showRelaysDialog) { - RelaySelectionDialog( - preSelectedList = relayList, - onClose = { showRelaysDialog = false }, - onPost = { relayList = it }, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - Column( - modifier = - Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp) - .fillMaxWidth() - .fillMaxHeight() - .imePadding(), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + Surface( + modifier = Modifier.fillMaxWidth(), ) { - CloseButton( - onPress = { - postViewModel.cancel() - onClose() - }, - ) - - Box { - IconButton( - modifier = Modifier.align(Alignment.Center), - onClick = { showRelaysDialog = true }, - ) { - Icon( - painter = painterResource(R.drawable.relays), - contentDescription = null, - modifier = Modifier.height(25.dp), - tint = MaterialTheme.colorScheme.onBackground, - ) + if (showRelaysDialog) { + RelaySelectionDialog( + preSelectedList = relayList, + onClose = { showRelaysDialog = false }, + onPost = { relayList = it }, + accountViewModel = accountViewModel, + nav = nav, + ) } - } - PostButton( - onPost = { - onClose() - postViewModel.upload(context, relayList) - postViewModel.selectedServer?.let { - if (!it.isNip95) { - account.changeDefaultFileServer(it.server) + Column( + modifier = + Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp) + .fillMaxWidth() + .fillMaxHeight() + .imePadding(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton( + onPress = { + postViewModel.cancel() + onClose() + }, + ) + + Box { + IconButton( + modifier = Modifier.align(Alignment.Center), + onClick = { showRelaysDialog = true }, + ) { + Icon( + painter = painterResource(R.drawable.relays), + contentDescription = null, + modifier = Modifier.height(25.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) + } + } + + PostButton( + onPost = { + onClose() + postViewModel.upload(context, relayList) + postViewModel.selectedServer?.let { + if (!it.isNip95) { + account.changeDefaultFileServer(it.server) + } + } + }, + isActive = postViewModel.canPost(), + ) } - } - }, - isActive = postViewModel.canPost(), - ) - } - Row( - modifier = Modifier.fillMaxWidth().weight(1f), - ) { - Column( - modifier = Modifier.fillMaxWidth().verticalScroll(scroolState), - ) { - ImageVideoPost(postViewModel, accountViewModel) - } + Row( + modifier = Modifier.fillMaxWidth().weight(1f), + ) { + Column( + modifier = Modifier.fillMaxWidth().verticalScroll(scroolState), + ) { + ImageVideoPost(postViewModel, accountViewModel) + } + } + } } - } } - } } @Composable fun ImageVideoPost( - postViewModel: NewMediaModel, - accountViewModel: AccountViewModel, + postViewModel: NewMediaModel, + accountViewModel: AccountViewModel, ) { - val fileServers = - Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } + - listOf( - ServerOption( - Nip96MediaServers.ServerName( - "NIP95", - stringResource(id = R.string.upload_server_relays_nip95), - ), - true, - ), - ) + val fileServers = + Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } + + listOf( + ServerOption( + Nip96MediaServers.ServerName( + "NIP95", + stringResource(id = R.string.upload_server_relays_nip95), + ), + true, + ), + ) - val fileServerOptions = remember { - fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() - } - val resolver = LocalContext.current.contentResolver - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier.fillMaxWidth() - .padding(bottom = 10.dp) - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), - ) { - if (postViewModel.isImage() == true) { - AsyncImage( - model = postViewModel.galleryUri.toString(), - contentDescription = postViewModel.galleryUri.toString(), - contentScale = ContentScale.FillWidth, - modifier = - Modifier.padding(top = 4.dp) - .fillMaxWidth() - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), - ) - } else if (postViewModel.isVideo() == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - var bitmap by remember { mutableStateOf(null) } - - LaunchedEffect(key1 = postViewModel.galleryUri) { - launch(Dispatchers.IO) { - postViewModel.galleryUri?.let { - try { - bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null) - } catch (e: Exception) { - Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e) - } - } + val fileServerOptions = + remember { + fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() } - } + val resolver = LocalContext.current.contentResolver - bitmap?.let { - Image( - bitmap = it.asImageBitmap(), - contentDescription = "some useful description", - contentScale = ContentScale.FillWidth, - modifier = Modifier.padding(top = 4.dp).fillMaxWidth(), - ) - } - } else { - postViewModel.galleryUri?.let { - VideoView( - videoUri = it.toString(), - roundedCorner = false, - accountViewModel = accountViewModel, - ) - } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .padding(bottom = 10.dp) + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + ) { + if (postViewModel.isImage() == true) { + AsyncImage( + model = postViewModel.galleryUri.toString(), + contentDescription = postViewModel.galleryUri.toString(), + contentScale = ContentScale.FillWidth, + modifier = + Modifier.padding(top = 4.dp) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + ) + } else if (postViewModel.isVideo() == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + var bitmap by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = postViewModel.galleryUri) { + launch(Dispatchers.IO) { + postViewModel.galleryUri?.let { + try { + bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null) + } catch (e: Exception) { + Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e) + } + } + } + } + + bitmap?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = "some useful description", + contentScale = ContentScale.FillWidth, + modifier = Modifier.padding(top = 4.dp).fillMaxWidth(), + ) + } + } else { + postViewModel.galleryUri?.let { + VideoView( + videoUri = it.toString(), + roundedCorner = false, + accountViewModel = accountViewModel, + ) + } + } } - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - TextSpinner( - label = stringResource(id = R.string.file_server), - placeholder = - fileServers - .firstOrNull { it.server == accountViewModel.account.defaultFileServer } - ?.server - ?.name - ?: fileServers[0].server.name, - options = fileServerOptions, - onSelect = { postViewModel.selectedServer = fileServers[it] }, - modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).weight(1f), - ) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - SettingSwitchItem( - checked = postViewModel.sensitiveContent, - onCheckedChange = { postViewModel.sensitiveContent = it }, - title = R.string.add_sensitive_content_label, - description = R.string.add_sensitive_content_description, - ) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.content_description)) }, - modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), - value = postViewModel.alt, - onValueChange = { postViewModel.alt = it }, - placeholder = { - Text( - text = stringResource(R.string.content_description_example), - color = MaterialTheme.colorScheme.placeholderText, + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + TextSpinner( + label = stringResource(id = R.string.file_server), + placeholder = + fileServers + .firstOrNull { it.server == accountViewModel.account.defaultFileServer } + ?.server + ?.name + ?: fileServers[0].server.name, + options = fileServerOptions, + onSelect = { postViewModel.selectedServer = fileServers[it] }, + modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).weight(1f), ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - ) - } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + SettingSwitchItem( + checked = postViewModel.sensitiveContent, + onCheckedChange = { postViewModel.sensitiveContent = it }, + title = R.string.add_sensitive_content_label, + description = R.string.add_sensitive_content_description, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.content_description)) }, + modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + value = postViewModel.alt, + onValueChange = { postViewModel.alt = it }, + placeholder = { + Text( + text = stringResource(R.string.content_description_example), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt index 7cc18bba5..8299421e9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt @@ -31,182 +31,182 @@ import com.vitorpamplona.quartz.encoders.bechToBytes import com.vitorpamplona.quartz.encoders.toNpub class NewMessageTagger( - var message: String, - var pTags: List? = null, - var eTags: List? = null, - var channelHex: String? = null, - var dao: Dao, + var message: String, + var pTags: List? = null, + var eTags: List? = null, + var channelHex: String? = null, + var dao: Dao, ) { - val directMentions = mutableSetOf() + val directMentions = mutableSetOf() - fun addUserToMentions(user: User) { - directMentions.add(user.pubkeyHex) - pTags = if (pTags?.contains(user) == true) pTags else pTags?.plus(user) ?: listOf(user) - } - - fun addNoteToReplyTos(note: Note) { - directMentions.add(note.idHex) - - note.author?.let { addUserToMentions(it) } - eTags = if (eTags?.contains(note) == true) eTags else eTags?.plus(note) ?: listOf(note) - } - - fun tagIndex(user: User): Int { - // Postr Events assembles replies before mentions in the tag order - return (if (channelHex != null) 1 else 0) + (eTags?.size ?: 0) + (pTags?.indexOf(user) ?: 0) - } - - fun tagIndex(note: Note): Int { - // Postr Events assembles replies before mentions in the tag order - return (if (channelHex != null) 1 else 0) + (eTags?.indexOf(note) ?: 0) - } - - suspend fun run() { - // adds all references to mentions and reply tos - message.split('\n').forEach { paragraph: String -> - paragraph.split(' ').forEach { word: String -> - val results = parseDirtyWordForKey(word) - - if (results?.key?.type == Nip19.Type.USER) { - addUserToMentions(dao.getOrCreateUser(results.key.hex)) - } else if (results?.key?.type == Nip19.Type.NOTE) { - addNoteToReplyTos(dao.getOrCreateNote(results.key.hex)) - } else if (results?.key?.type == Nip19.Type.EVENT) { - addNoteToReplyTos(dao.getOrCreateNote(results.key.hex)) - } else if (results?.key?.type == Nip19.Type.ADDRESS) { - val note = dao.checkGetOrCreateAddressableNote(results.key.hex) - if (note != null) { - addNoteToReplyTos(note) - } - } - } + fun addUserToMentions(user: User) { + directMentions.add(user.pubkeyHex) + pTags = if (pTags?.contains(user) == true) pTags else pTags?.plus(user) ?: listOf(user) } - // Tags the text in the correct order. - message = - message - .split('\n') - .map { paragraph: String -> - paragraph - .split(' ') - .map { word: String -> - val results = parseDirtyWordForKey(word) - if (results?.key?.type == Nip19.Type.USER) { - val user = dao.getOrCreateUser(results.key.hex) + fun addNoteToReplyTos(note: Note) { + directMentions.add(note.idHex) - getNostrAddress(user.pubkeyNpub(), results.restOfWord) - } else if (results?.key?.type == Nip19.Type.NOTE) { - val note = dao.getOrCreateNote(results.key.hex) + note.author?.let { addUserToMentions(it) } + eTags = if (eTags?.contains(note) == true) eTags else eTags?.plus(note) ?: listOf(note) + } - getNostrAddress(note.toNEvent(), results.restOfWord) - } else if (results?.key?.type == Nip19.Type.EVENT) { - val note = dao.getOrCreateNote(results.key.hex) + fun tagIndex(user: User): Int { + // Postr Events assembles replies before mentions in the tag order + return (if (channelHex != null) 1 else 0) + (eTags?.size ?: 0) + (pTags?.indexOf(user) ?: 0) + } - getNostrAddress(note.toNEvent(), results.restOfWord) - } else if (results?.key?.type == Nip19.Type.ADDRESS) { - val note = dao.checkGetOrCreateAddressableNote(results.key.hex) - if (note != null) { - getNostrAddress(note.idNote(), results.restOfWord) - } else { - word + fun tagIndex(note: Note): Int { + // Postr Events assembles replies before mentions in the tag order + return (if (channelHex != null) 1 else 0) + (eTags?.indexOf(note) ?: 0) + } + + suspend fun run() { + // adds all references to mentions and reply tos + message.split('\n').forEach { paragraph: String -> + paragraph.split(' ').forEach { word: String -> + val results = parseDirtyWordForKey(word) + + if (results?.key?.type == Nip19.Type.USER) { + addUserToMentions(dao.getOrCreateUser(results.key.hex)) + } else if (results?.key?.type == Nip19.Type.NOTE) { + addNoteToReplyTos(dao.getOrCreateNote(results.key.hex)) + } else if (results?.key?.type == Nip19.Type.EVENT) { + addNoteToReplyTos(dao.getOrCreateNote(results.key.hex)) + } else if (results?.key?.type == Nip19.Type.ADDRESS) { + val note = dao.checkGetOrCreateAddressableNote(results.key.hex) + if (note != null) { + addNoteToReplyTos(note) + } } - } else { - word - } } - .joinToString(" ") } - .joinToString("\n") - } - fun getNostrAddress( - bechAddress: String, - restOfTheWord: String, - ): String { - return if (restOfTheWord.isEmpty()) { - "nostr:$bechAddress" - } else { - if (Bech32.ALPHABET.contains(restOfTheWord.get(0), true)) { - "nostr:$bechAddress $restOfTheWord" - } else { - "nostr:${bechAddress}$restOfTheWord" - } - } - } + // Tags the text in the correct order. + message = + message + .split('\n') + .map { paragraph: String -> + paragraph + .split(' ') + .map { word: String -> + val results = parseDirtyWordForKey(word) + if (results?.key?.type == Nip19.Type.USER) { + val user = dao.getOrCreateUser(results.key.hex) - @Immutable data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String) + getNostrAddress(user.pubkeyNpub(), results.restOfWord) + } else if (results?.key?.type == Nip19.Type.NOTE) { + val note = dao.getOrCreateNote(results.key.hex) - fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? { - var key = mightBeAKey - if (key.startsWith("nostr:", true)) { - key = key.substring("nostr:".length) + getNostrAddress(note.toNEvent(), results.restOfWord) + } else if (results?.key?.type == Nip19.Type.EVENT) { + val note = dao.getOrCreateNote(results.key.hex) + + getNostrAddress(note.toNEvent(), results.restOfWord) + } else if (results?.key?.type == Nip19.Type.ADDRESS) { + val note = dao.checkGetOrCreateAddressableNote(results.key.hex) + if (note != null) { + getNostrAddress(note.idNote(), results.restOfWord) + } else { + word + } + } else { + word + } + } + .joinToString(" ") + } + .joinToString("\n") } - key = key.removePrefix("@") - - try { - if (key.startsWith("nsec1", true)) { - if (key.length < 63) { - return null + fun getNostrAddress( + bechAddress: String, + restOfTheWord: String, + ): String { + return if (restOfTheWord.isEmpty()) { + "nostr:$bechAddress" + } else { + if (Bech32.ALPHABET.contains(restOfTheWord.get(0), true)) { + "nostr:$bechAddress $restOfTheWord" + } else { + "nostr:${bechAddress}$restOfTheWord" + } } - - val keyB32 = key.substring(0, 63) - val restOfWord = key.substring(63) - // Converts to npub - val pubkey = - Nip19.uriToRoute(KeyPair(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null - - return DirtyKeyInfo(pubkey, restOfWord) - } else if (key.startsWith("npub1", true)) { - if (key.length < 63) { - return null - } - - val keyB32 = key.substring(0, 63) - val restOfWord = key.substring(63) - - val pubkey = Nip19.uriToRoute(keyB32) ?: return null - - return DirtyKeyInfo(pubkey, restOfWord) - } else if (key.startsWith("note1", true)) { - if (key.length < 63) { - return null - } - - val keyB32 = key.substring(0, 63) - val restOfWord = key.substring(63) - - val noteId = Nip19.uriToRoute(keyB32) ?: return null - - return DirtyKeyInfo(noteId, restOfWord) - } else if (key.startsWith("nprofile", true)) { - val pubkeyRelay = Nip19.uriToRoute(key) ?: return null - - return DirtyKeyInfo(pubkeyRelay, pubkeyRelay.additionalChars) - } else if (key.startsWith("nevent1", true)) { - val noteRelayId = Nip19.uriToRoute(key) ?: return null - - return DirtyKeyInfo(noteRelayId, noteRelayId.additionalChars) - } else if (key.startsWith("naddr1", true)) { - val address = Nip19.uriToRoute(key) ?: return null - - return DirtyKeyInfo( - address, - address.additionalChars, - ) // no way to know when they address ends and dirt begins - } - } catch (e: Exception) { - e.printStackTrace() } - return null - } + @Immutable data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String) + + fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? { + var key = mightBeAKey + if (key.startsWith("nostr:", true)) { + key = key.substring("nostr:".length) + } + + key = key.removePrefix("@") + + try { + if (key.startsWith("nsec1", true)) { + if (key.length < 63) { + return null + } + + val keyB32 = key.substring(0, 63) + val restOfWord = key.substring(63) + // Converts to npub + val pubkey = + Nip19.uriToRoute(KeyPair(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null + + return DirtyKeyInfo(pubkey, restOfWord) + } else if (key.startsWith("npub1", true)) { + if (key.length < 63) { + return null + } + + val keyB32 = key.substring(0, 63) + val restOfWord = key.substring(63) + + val pubkey = Nip19.uriToRoute(keyB32) ?: return null + + return DirtyKeyInfo(pubkey, restOfWord) + } else if (key.startsWith("note1", true)) { + if (key.length < 63) { + return null + } + + val keyB32 = key.substring(0, 63) + val restOfWord = key.substring(63) + + val noteId = Nip19.uriToRoute(keyB32) ?: return null + + return DirtyKeyInfo(noteId, restOfWord) + } else if (key.startsWith("nprofile", true)) { + val pubkeyRelay = Nip19.uriToRoute(key) ?: return null + + return DirtyKeyInfo(pubkeyRelay, pubkeyRelay.additionalChars) + } else if (key.startsWith("nevent1", true)) { + val noteRelayId = Nip19.uriToRoute(key) ?: return null + + return DirtyKeyInfo(noteRelayId, noteRelayId.additionalChars) + } else if (key.startsWith("naddr1", true)) { + val address = Nip19.uriToRoute(key) ?: return null + + return DirtyKeyInfo( + address, + address.additionalChars, + ) // no way to know when they address ends and dirt begins + } + } catch (e: Exception) { + e.printStackTrace() + } + + return null + } } interface Dao { - suspend fun getOrCreateUser(hex: String): User + suspend fun getOrCreateUser(hex: String): User - suspend fun getOrCreateNote(hex: String): Note + suspend fun getOrCreateNote(hex: String): Note - suspend fun checkGetOrCreateAddressableNote(hex: String): Note? + suspend fun checkGetOrCreateAddressableNote(hex: String): Note? } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt index 6224716ee..39f16194c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt @@ -46,61 +46,61 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable fun NewPollClosing(pollViewModel: NewPostViewModel) { - var text by rememberSaveable { mutableStateOf("") } + var text by rememberSaveable { mutableStateOf("") } - pollViewModel.isValidClosedAt.value = true - if (text.isNotEmpty()) { - try { - val int = text.toInt() - if (int < 0) { - pollViewModel.isValidClosedAt.value = false - } else { - pollViewModel.closedAt = int - } - } catch (e: Exception) { - pollViewModel.isValidClosedAt.value = false + pollViewModel.isValidClosedAt.value = true + if (text.isNotEmpty()) { + try { + val int = text.toInt() + if (int < 0) { + pollViewModel.isValidClosedAt.value = false + } else { + pollViewModel.closedAt = int + } + } catch (e: Exception) { + pollViewModel.isValidClosedAt.value = false + } } - } - val colorInValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.error, - unfocusedBorderColor = Color.Red, - ) - val colorValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText, - ) + val colorInValid = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.error, + unfocusedBorderColor = Color.Red, + ) + val colorValid = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText, + ) - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - OutlinedTextField( - value = text, - onValueChange = { text = it }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.width(150.dp), - colors = if (pollViewModel.isValidClosedAt.value) colorValid else colorInValid, - label = { - Text( - text = stringResource(R.string.poll_closing_time), - color = MaterialTheme.colorScheme.placeholderText, + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.width(150.dp), + colors = if (pollViewModel.isValidClosedAt.value) colorValid else colorInValid, + label = { + Text( + text = stringResource(R.string.poll_closing_time), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + placeholder = { + Text( + text = stringResource(R.string.poll_closing_time_days), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, ) - }, - placeholder = { - Text( - text = stringResource(R.string.poll_closing_time_days), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - } + } } @Preview @Composable fun NewPollClosingPreview() { - NewPollClosing(NewPostViewModel()) + NewPollClosing(NewPostViewModel()) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt index 11af117e2..271c0f346 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt @@ -46,61 +46,61 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable fun NewPollConsensusThreshold(pollViewModel: NewPostViewModel) { - var text by rememberSaveable { mutableStateOf("") } + var text by rememberSaveable { mutableStateOf("") } - pollViewModel.isValidConsensusThreshold.value = true - if (text.isNotEmpty()) { - try { - val int = text.toInt() - if (int < 0 || int > 100) { - pollViewModel.isValidConsensusThreshold.value = false - } else { - pollViewModel.consensusThreshold = int - } - } catch (e: Exception) { - pollViewModel.isValidConsensusThreshold.value = false + pollViewModel.isValidConsensusThreshold.value = true + if (text.isNotEmpty()) { + try { + val int = text.toInt() + if (int < 0 || int > 100) { + pollViewModel.isValidConsensusThreshold.value = false + } else { + pollViewModel.consensusThreshold = int + } + } catch (e: Exception) { + pollViewModel.isValidConsensusThreshold.value = false + } } - } - val colorInValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.error, - unfocusedBorderColor = Color.Red, - ) - val colorValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText, - ) + val colorInValid = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.error, + unfocusedBorderColor = Color.Red, + ) + val colorValid = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText, + ) - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - OutlinedTextField( - value = text, - onValueChange = { text = it }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.width(150.dp), - colors = if (pollViewModel.isValidConsensusThreshold.value) colorValid else colorInValid, - label = { - Text( - text = stringResource(R.string.poll_consensus_threshold), - color = MaterialTheme.colorScheme.placeholderText, + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.width(150.dp), + colors = if (pollViewModel.isValidConsensusThreshold.value) colorValid else colorInValid, + label = { + Text( + text = stringResource(R.string.poll_consensus_threshold), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + placeholder = { + Text( + text = stringResource(R.string.poll_consensus_threshold_percent), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, ) - }, - placeholder = { - Text( - text = stringResource(R.string.poll_consensus_threshold_percent), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - } + } } @Preview @Composable fun NewPollConsensusThresholdPreview() { - NewPollConsensusThreshold(NewPostViewModel()) + NewPollConsensusThreshold(NewPostViewModel()) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt index 58bd075f6..e078a506f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt @@ -39,50 +39,50 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable fun NewPollOption( - pollViewModel: NewPostViewModel, - optionIndex: Int, + pollViewModel: NewPostViewModel, + optionIndex: Int, ) { - Row { - val deleteIcon: @Composable (() -> Unit) = { - IconButton( - onClick = { pollViewModel.pollOptions.remove(optionIndex) }, - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.clear), - ) - } - } + Row { + val deleteIcon: @Composable (() -> Unit) = { + IconButton( + onClick = { pollViewModel.pollOptions.remove(optionIndex) }, + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.clear), + ) + } + } - OutlinedTextField( - modifier = Modifier.weight(1F), - value = pollViewModel.pollOptions[optionIndex] ?: "", - onValueChange = { pollViewModel.pollOptions[optionIndex] = it }, - label = { - Text( - text = stringResource(R.string.poll_option_index).format(optionIndex + 1), - color = MaterialTheme.colorScheme.placeholderText, + OutlinedTextField( + modifier = Modifier.weight(1F), + value = pollViewModel.pollOptions[optionIndex] ?: "", + onValueChange = { pollViewModel.pollOptions[optionIndex] = it }, + label = { + Text( + text = stringResource(R.string.poll_option_index).format(optionIndex + 1), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + placeholder = { + Text( + text = stringResource(R.string.poll_option_description), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + // colors = if (pollViewModel.pollOptions[optionIndex]?.isNotEmpty() == true) colorValid else + // colorInValid, + trailingIcon = if (optionIndex > 1) deleteIcon else null, ) - }, - placeholder = { - Text( - text = stringResource(R.string.poll_option_description), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - // colors = if (pollViewModel.pollOptions[optionIndex]?.isNotEmpty() == true) colorValid else - // colorInValid, - trailingIcon = if (optionIndex > 1) deleteIcon else null, - ) - } + } } @Preview @Composable fun NewPollOptionPreview() { - NewPollOption(NewPostViewModel(), 0) + NewPollOption(NewPostViewModel(), 0) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt index d9398411c..b969ba8bc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt @@ -49,53 +49,53 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @OptIn(ExperimentalComposeUiApi::class) @Composable fun NewPollPrimaryDescription(pollViewModel: NewPostViewModel) { - // initialize focus reference to be able to request focus programmatically - val focusRequester = remember { FocusRequester() } - val keyboardController = LocalSoftwareKeyboardController.current + // initialize focus reference to be able to request focus programmatically + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current - var isInputValid = true - if (pollViewModel.message.text.isEmpty()) { - isInputValid = false - } + var isInputValid = true + if (pollViewModel.message.text.isEmpty()) { + isInputValid = false + } - val colorInValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.error, - unfocusedBorderColor = Color.Red, + val colorInValid = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.error, + unfocusedBorderColor = Color.Red, + ) + val colorValid = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText, + ) + + OutlinedTextField( + value = pollViewModel.message, + onValueChange = { pollViewModel.updateMessage(it) }, + label = { + Text( + text = stringResource(R.string.poll_primary_description), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + modifier = + Modifier.fillMaxWidth().padding(top = 8.dp).focusRequester(focusRequester).onFocusChanged { + if (it.isFocused) { + keyboardController?.show() + } + }, + placeholder = { + Text( + text = stringResource(R.string.poll_primary_description), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + colors = if (isInputValid) colorValid else colorInValid, + visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), ) - val colorValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText, - ) - - OutlinedTextField( - value = pollViewModel.message, - onValueChange = { pollViewModel.updateMessage(it) }, - label = { - Text( - text = stringResource(R.string.poll_primary_description), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - modifier = - Modifier.fillMaxWidth().padding(top = 8.dp).focusRequester(focusRequester).onFocusChanged { - if (it.isFocused) { - keyboardController?.show() - } - }, - placeholder = { - Text( - text = stringResource(R.string.poll_primary_description), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - colors = if (isInputValid) colorValid else colorInValid, - visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt index f53b41630..a5c02bdcf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt @@ -34,32 +34,32 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable fun NewPollRecipientsField( - pollViewModel: NewPostViewModel, - account: Account, + pollViewModel: NewPostViewModel, + account: Account, ) { - // if no recipients, add user's pubkey - if (pollViewModel.zapRecipients.isEmpty()) { - pollViewModel.zapRecipients.add(account.userProfile().pubkeyHex) - } + // if no recipients, add user's pubkey + if (pollViewModel.zapRecipients.isEmpty()) { + pollViewModel.zapRecipients.add(account.userProfile().pubkeyHex) + } - // TODO allow add multiple recipients and check input validity + // TODO allow add multiple recipients and check input validity - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = pollViewModel.zapRecipients[0], - onValueChange = { /* TODO */}, - enabled = false, - label = { - Text( - text = stringResource(R.string.poll_zap_recipients), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - placeholder = { - Text( - text = stringResource(R.string.poll_zap_recipients), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = pollViewModel.zapRecipients[0], + onValueChange = { /* TODO */ }, + enabled = false, + label = { + Text( + text = stringResource(R.string.poll_zap_recipients), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + placeholder = { + Text( + text = stringResource(R.string.poll_zap_recipients), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt index 4e766fc37..8e6b85fe7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt @@ -44,82 +44,82 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) { - val colorInValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.error, - unfocusedBorderColor = Color.Red, - ) - val colorValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText, - ) - - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - OutlinedTextField( - value = pollViewModel.valueMinimum?.toString() ?: "", - onValueChange = { pollViewModel.updateMinZapAmountForPoll(it) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.weight(1f), - colors = if (pollViewModel.isValidvalueMinimum.value) colorValid else colorInValid, - label = { - Text( - text = stringResource(R.string.poll_zap_value_min), - color = MaterialTheme.colorScheme.placeholderText, + val colorInValid = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.error, + unfocusedBorderColor = Color.Red, ) - }, - placeholder = { - Text( - text = stringResource(R.string.sats), - color = MaterialTheme.colorScheme.placeholderText, + val colorValid = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText, ) - }, - ) - Spacer(modifier = DoubleHorzSpacer) - - OutlinedTextField( - value = pollViewModel.valueMaximum?.toString() ?: "", - onValueChange = { pollViewModel.updateMaxZapAmountForPoll(it) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.weight(1f), - colors = if (pollViewModel.isValidvalueMaximum.value) colorValid else colorInValid, - label = { - Text( - text = stringResource(R.string.poll_zap_value_max), - color = MaterialTheme.colorScheme.placeholderText, + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + OutlinedTextField( + value = pollViewModel.valueMinimum?.toString() ?: "", + onValueChange = { pollViewModel.updateMinZapAmountForPoll(it) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + colors = if (pollViewModel.isValidvalueMinimum.value) colorValid else colorInValid, + label = { + Text( + text = stringResource(R.string.poll_zap_value_min), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + placeholder = { + Text( + text = stringResource(R.string.sats), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, ) - }, - placeholder = { - Text( - text = stringResource(R.string.sats), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - } - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - Text( - text = stringResource(R.string.poll_zap_value_min_max_explainer), - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(vertical = 10.dp), - ) - } + Spacer(modifier = DoubleHorzSpacer) + + OutlinedTextField( + value = pollViewModel.valueMaximum?.toString() ?: "", + onValueChange = { pollViewModel.updateMaxZapAmountForPoll(it) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + colors = if (pollViewModel.isValidvalueMaximum.value) colorValid else colorInValid, + label = { + Text( + text = stringResource(R.string.poll_zap_value_max), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + placeholder = { + Text( + text = stringResource(R.string.sats), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) + } + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.poll_zap_value_min_max_explainer), + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(vertical = 10.dp), + ) + } } @Preview @Composable fun NewPollVoteValueRangePreview() { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - NewPollVoteValueRange(NewPostViewModel()) - } + Column( + modifier = Modifier.fillMaxWidth(), + ) { + NewPollVoteValueRange(NewPostViewModel()) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 158c12fb5..22b8b5a8a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -177,7 +177,6 @@ import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.toImmutableListOfLists -import java.lang.Math.round import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers @@ -185,1624 +184,1628 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.lang.Math.round @OptIn(ExperimentalMaterial3Api::class) @Composable fun NewPostView( - onClose: () -> Unit, - baseReplyTo: Note? = null, - quote: Note? = null, - enableMessageInterface: Boolean = false, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + onClose: () -> Unit, + baseReplyTo: Note? = null, + quote: Note? = null, + enableMessageInterface: Boolean = false, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val postViewModel: NewPostViewModel = viewModel() - postViewModel.wantsDirectMessage = enableMessageInterface + val postViewModel: NewPostViewModel = viewModel() + postViewModel.wantsDirectMessage = enableMessageInterface - val context = LocalContext.current + val context = LocalContext.current - val scrollState = rememberScrollState() - val scope = rememberCoroutineScope() - var showRelaysDialog by remember { mutableStateOf(false) } - var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() } + val scrollState = rememberScrollState() + val scope = rememberCoroutineScope() + var showRelaysDialog by remember { mutableStateOf(false) } + var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() } - LaunchedEffect(Unit) { - postViewModel.load(accountViewModel, baseReplyTo, quote) + LaunchedEffect(Unit) { + postViewModel.load(accountViewModel, baseReplyTo, quote) - launch(Dispatchers.IO) { - postViewModel.imageUploadingError.collect { error -> - withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() } - } - } - } - - DisposableEffect(Unit) { - NostrSearchEventOrUserDataSource.start() - - onDispose { - NostrSearchEventOrUserDataSource.clear() - NostrSearchEventOrUserDataSource.stop() - } - } - - Dialog( - onDismissRequest = { onClose() }, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - decorFitsSystemWindows = false, - ), - ) { - if (showRelaysDialog) { - RelaySelectionDialog( - preSelectedList = relayList, - onClose = { showRelaysDialog = false }, - onPost = { relayList = it }, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - Scaffold( - topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = StdHorzSpacer) - - Box { - IconButton( - modifier = Modifier.align(Alignment.Center), - onClick = { showRelaysDialog = true }, - ) { - Icon( - painter = painterResource(R.drawable.relays), - contentDescription = null, - modifier = Modifier.height(25.dp), - tint = MaterialTheme.colorScheme.onBackground, - ) - } - } - PostButton( - onPost = { - postViewModel.sendPost(relayList = relayList) - scope.launch { - delay(100) - onClose() - } - }, - isActive = postViewModel.canPost(), - ) + launch(Dispatchers.IO) { + postViewModel.imageUploadingError.collect { error -> + withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() } + } + } + } + + DisposableEffect(Unit) { + NostrSearchEventOrUserDataSource.start() + + onDispose { + NostrSearchEventOrUserDataSource.clear() + NostrSearchEventOrUserDataSource.stop() + } + } + + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false, + ), + ) { + if (showRelaysDialog) { + RelaySelectionDialog( + preSelectedList = relayList, + onClose = { showRelaysDialog = false }, + onPost = { relayList = it }, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = StdHorzSpacer) + + Box { + IconButton( + modifier = Modifier.align(Alignment.Center), + onClick = { showRelaysDialog = true }, + ) { + Icon( + painter = painterResource(R.drawable.relays), + contentDescription = null, + modifier = Modifier.height(25.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) + } + } + PostButton( + onPost = { + postViewModel.sendPost(relayList = relayList) + scope.launch { + delay(100) + onClose() + } + }, + isActive = postViewModel.canPost(), + ) + } + }, + navigationIcon = { + Row { + Spacer(modifier = StdHorzSpacer) + CloseButton( + onPress = { + postViewModel.cancel() + scope.launch { + delay(100) + onClose() + } + }, + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + ) { pad -> + Surface( + modifier = + Modifier.padding( + start = Size10dp, + top = pad.calculateTopPadding(), + end = Size10dp, + bottom = pad.calculateBottomPadding(), + ) + .fillMaxSize(), + ) { + Column( + modifier = Modifier.fillMaxWidth().fillMaxHeight(), + ) { + Column( + modifier = Modifier.imePadding().weight(1f), + ) { + Row( + modifier = Modifier.fillMaxWidth().weight(1f), + ) { + Column( + modifier = Modifier.fillMaxWidth().verticalScroll(scrollState), + ) { + postViewModel.originalNote?.let { + Row(Modifier.heightIn(max = 200.dp)) { + NoteCompose( + baseNote = it, + makeItShort = true, + unPackReply = false, + isQuotedNote = true, + modifier = MaterialTheme.colorScheme.replyModifier, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = StdVertSpacer) + } + } + + Row { + Notifying(postViewModel.pTags?.toImmutableList()) { + postViewModel.removeFromReplyList(it) + } + } + + if (enableMessageInterface) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + SendDirectMessageTo(postViewModel = postViewModel) + } + } + + if (postViewModel.wantsProduct) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + SellProduct(postViewModel = postViewModel) + } + } + + MessageField(postViewModel) + + if (postViewModel.wantsPoll) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + PollField(postViewModel) + } + } + + if (postViewModel.wantsToMarkAsSensitive) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + ContentSensitivityExplainer(postViewModel) + } + } + + if (postViewModel.wantsToAddGeoHash) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + LocationAsHash(postViewModel) + } + } + + if (postViewModel.wantsForwardZapTo) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp), + ) { + FowardZapTo(postViewModel, accountViewModel) + } + } + + val url = postViewModel.contentToAddUrl + if (url != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + ImageVideoDescription( + url, + accountViewModel.account.defaultFileServer, + onAdd = { alt, server, sensitiveContent -> + postViewModel.upload(url, alt, sensitiveContent, false, server, context) + if (!server.isNip95) { + accountViewModel.account.changeDefaultFileServer(server.server) + } + }, + onCancel = { postViewModel.contentToAddUrl = null }, + onError = { scope.launch { postViewModel.imageUploadingError.emit(it) } }, + accountViewModel = accountViewModel, + ) + } + } + + val user = postViewModel.account?.userProfile() + val lud16 = user?.info?.lnAddress() + + if (lud16 != null && postViewModel.wantsInvoice) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + Column(Modifier.fillMaxWidth()) { + InvoiceRequest( + lud16, + user.pubkeyHex, + accountViewModel.account, + stringResource(id = R.string.lightning_invoice), + stringResource(id = R.string.lightning_create_and_add_invoice), + onSuccess = { + postViewModel.message = + TextFieldValue(postViewModel.message.text + "\n\n" + it) + postViewModel.wantsInvoice = false + }, + onClose = { postViewModel.wantsInvoice = false }, + onError = { title, message -> accountViewModel.toast(title, message) }, + ) + } + } + } + + if (lud16 != null && postViewModel.wantsZapraiser) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + ZapRaiserRequest( + stringResource(id = R.string.zapraiser), + postViewModel, + ) + } + } + + val myUrlPreview = postViewModel.urlPreview + if (myUrlPreview != null) { + Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) { + if (isValidURL(myUrlPreview)) { + val removedParamsFromUrl = + removeQueryParamsForExtensionComparison(myUrlPreview) + if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { + AsyncImage( + model = myUrlPreview, + contentDescription = myUrlPreview, + contentScale = ContentScale.FillWidth, + modifier = + Modifier.padding(top = 4.dp) + .fillMaxWidth() + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + ) + } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { + VideoView( + myUrlPreview, + roundedCorner = true, + accountViewModel = accountViewModel, + ) + } else { + LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel) + } + } else if (startsWithNIP19Scheme(myUrlPreview)) { + val bgColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(bgColor) } + + BechLink( + myUrlPreview, + true, + backgroundColor, + accountViewModel, + nav, + ) + } else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) { + LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel) + } + } + } + } + } + + val userSuggestions = postViewModel.userSuggestions + if (userSuggestions.isNotEmpty()) { + LazyColumn( + contentPadding = + PaddingValues( + top = 10.dp, + ), + modifier = Modifier.heightIn(0.dp, 300.dp), + ) { + itemsIndexed( + userSuggestions, + key = { _, item -> item.pubkeyHex }, + ) { _, item -> + UserLine(item, accountViewModel) { postViewModel.autocompleteWithUser(item) } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth().height(50.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UploadFromGallery( + isUploading = postViewModel.isUploadingImage, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier, + ) { + postViewModel.selectImage(it) + } + + if (postViewModel.canUsePoll) { + // These should be hashtag recommendations the user selects in the future. + // val hashtag = stringResource(R.string.poll_hashtag) + // postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag) + AddPollButton(postViewModel.wantsPoll) { + postViewModel.wantsPoll = !postViewModel.wantsPoll + if (postViewModel.wantsPoll) { + postViewModel.wantsProduct = false + } + } + } + + AddClassifiedsButton(postViewModel) { + postViewModel.wantsProduct = !postViewModel.wantsProduct + if (postViewModel.wantsProduct) { + postViewModel.wantsPoll = false + } + } + + if (postViewModel.canAddInvoice) { + AddLnInvoiceButton(postViewModel.wantsInvoice) { + postViewModel.wantsInvoice = !postViewModel.wantsInvoice + } + } + + if (postViewModel.canAddZapRaiser) { + AddZapraiserButton(postViewModel.wantsZapraiser) { + postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser + } + } + + MarkAsSensitive(postViewModel) { + postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive + } + + AddGeoHash(postViewModel) { + postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash + } + + ForwardZapTo(postViewModel) { + postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo + } + } + } + } } - }, - navigationIcon = { - Row { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - postViewModel.cancel() - scope.launch { - delay(100) - onClose() - } - }, - ) - } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) - }, - ) { pad -> - Surface( - modifier = - Modifier.padding( - start = Size10dp, - top = pad.calculateTopPadding(), - end = Size10dp, - bottom = pad.calculateBottomPadding(), - ) - .fillMaxSize(), - ) { - Column( - modifier = Modifier.fillMaxWidth().fillMaxHeight(), - ) { - Column( - modifier = Modifier.imePadding().weight(1f), - ) { - Row( - modifier = Modifier.fillMaxWidth().weight(1f), - ) { - Column( - modifier = Modifier.fillMaxWidth().verticalScroll(scrollState), - ) { - postViewModel.originalNote?.let { - Row(Modifier.heightIn(max = 200.dp)) { - NoteCompose( - baseNote = it, - makeItShort = true, - unPackReply = false, - isQuotedNote = true, - modifier = MaterialTheme.colorScheme.replyModifier, - accountViewModel = accountViewModel, - nav = nav, - ) - Spacer(modifier = StdVertSpacer) - } - } - - Row { - Notifying(postViewModel.pTags?.toImmutableList()) { - postViewModel.removeFromReplyList(it) - } - } - - if (enableMessageInterface) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), - ) { - SendDirectMessageTo(postViewModel = postViewModel) - } - } - - if (postViewModel.wantsProduct) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), - ) { - SellProduct(postViewModel = postViewModel) - } - } - - MessageField(postViewModel) - - if (postViewModel.wantsPoll) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), - ) { - PollField(postViewModel) - } - } - - if (postViewModel.wantsToMarkAsSensitive) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), - ) { - ContentSensitivityExplainer(postViewModel) - } - } - - if (postViewModel.wantsToAddGeoHash) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), - ) { - LocationAsHash(postViewModel) - } - } - - if (postViewModel.wantsForwardZapTo) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp), - ) { - FowardZapTo(postViewModel, accountViewModel) - } - } - - val url = postViewModel.contentToAddUrl - if (url != null) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), - ) { - ImageVideoDescription( - url, - accountViewModel.account.defaultFileServer, - onAdd = { alt, server, sensitiveContent -> - postViewModel.upload(url, alt, sensitiveContent, false, server, context) - if (!server.isNip95) { - accountViewModel.account.changeDefaultFileServer(server.server) - } - }, - onCancel = { postViewModel.contentToAddUrl = null }, - onError = { scope.launch { postViewModel.imageUploadingError.emit(it) } }, - accountViewModel = accountViewModel, - ) - } - } - - val user = postViewModel.account?.userProfile() - val lud16 = user?.info?.lnAddress() - - if (lud16 != null && postViewModel.wantsInvoice) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), - ) { - Column(Modifier.fillMaxWidth()) { - InvoiceRequest( - lud16, - user.pubkeyHex, - accountViewModel.account, - stringResource(id = R.string.lightning_invoice), - stringResource(id = R.string.lightning_create_and_add_invoice), - onSuccess = { - postViewModel.message = - TextFieldValue(postViewModel.message.text + "\n\n" + it) - postViewModel.wantsInvoice = false - }, - onClose = { postViewModel.wantsInvoice = false }, - onError = { title, message -> accountViewModel.toast(title, message) }, - ) - } - } - } - - if (lud16 != null && postViewModel.wantsZapraiser) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), - ) { - ZapRaiserRequest( - stringResource(id = R.string.zapraiser), - postViewModel, - ) - } - } - - val myUrlPreview = postViewModel.urlPreview - if (myUrlPreview != null) { - Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) { - if (isValidURL(myUrlPreview)) { - val removedParamsFromUrl = - removeQueryParamsForExtensionComparison(myUrlPreview) - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - AsyncImage( - model = myUrlPreview, - contentDescription = myUrlPreview, - contentScale = ContentScale.FillWidth, - modifier = - Modifier.padding(top = 4.dp) - .fillMaxWidth() - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder, - ), - ) - } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { - VideoView( - myUrlPreview, - roundedCorner = true, - accountViewModel = accountViewModel, - ) - } else { - LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel) - } - } else if (startsWithNIP19Scheme(myUrlPreview)) { - val bgColor = MaterialTheme.colorScheme.background - val backgroundColor = remember { mutableStateOf(bgColor) } - - BechLink( - myUrlPreview, - true, - backgroundColor, - accountViewModel, - nav, - ) - } else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) { - LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel) - } - } - } - } - } - - val userSuggestions = postViewModel.userSuggestions - if (userSuggestions.isNotEmpty()) { - LazyColumn( - contentPadding = - PaddingValues( - top = 10.dp, - ), - modifier = Modifier.heightIn(0.dp, 300.dp), - ) { - itemsIndexed( - userSuggestions, - key = { _, item -> item.pubkeyHex }, - ) { _, item -> - UserLine(item, accountViewModel) { postViewModel.autocompleteWithUser(item) } - } - } - } - - Row( - modifier = Modifier.fillMaxWidth().height(50.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - UploadFromGallery( - isUploading = postViewModel.isUploadingImage, - tint = MaterialTheme.colorScheme.onBackground, - modifier = Modifier, - ) { - postViewModel.selectImage(it) - } - - if (postViewModel.canUsePoll) { - // These should be hashtag recommendations the user selects in the future. - // val hashtag = stringResource(R.string.poll_hashtag) - // postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag) - AddPollButton(postViewModel.wantsPoll) { - postViewModel.wantsPoll = !postViewModel.wantsPoll - if (postViewModel.wantsPoll) { - postViewModel.wantsProduct = false - } - } - } - - AddClassifiedsButton(postViewModel) { - postViewModel.wantsProduct = !postViewModel.wantsProduct - if (postViewModel.wantsProduct) { - postViewModel.wantsPoll = false - } - } - - if (postViewModel.canAddInvoice) { - AddLnInvoiceButton(postViewModel.wantsInvoice) { - postViewModel.wantsInvoice = !postViewModel.wantsInvoice - } - } - - if (postViewModel.canAddZapRaiser) { - AddZapraiserButton(postViewModel.wantsZapraiser) { - postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser - } - } - - MarkAsSensitive(postViewModel) { - postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive - } - - AddGeoHash(postViewModel) { - postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash - } - - ForwardZapTo(postViewModel) { - postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo - } - } - } } - } } - } } @Composable private fun PollField(postViewModel: NewPostViewModel) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - postViewModel.pollOptions.values.forEachIndexed { index, _ -> - NewPollOption(postViewModel, index) - } - - NewPollVoteValueRange(postViewModel) - - Button( - onClick = { postViewModel.pollOptions[postViewModel.pollOptions.size] = "" }, - border = - BorderStroke( - 1.dp, - MaterialTheme.colorScheme.placeholderText, - ), - colors = - ButtonDefaults.outlinedButtonColors( - containerColor = MaterialTheme.colorScheme.placeholderText, - ), + Column( + modifier = Modifier.fillMaxWidth(), ) { - Image( - painterResource(id = android.R.drawable.ic_input_add), - contentDescription = "Add poll option button", - modifier = Size18Modifier, - ) + postViewModel.pollOptions.values.forEachIndexed { index, _ -> + NewPollOption(postViewModel, index) + } + + NewPollVoteValueRange(postViewModel) + + Button( + onClick = { postViewModel.pollOptions[postViewModel.pollOptions.size] = "" }, + border = + BorderStroke( + 1.dp, + MaterialTheme.colorScheme.placeholderText, + ), + colors = + ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.placeholderText, + ), + ) { + Image( + painterResource(id = android.R.drawable.ic_input_add), + contentDescription = "Add poll option button", + modifier = Size18Modifier, + ) + } } - } } @OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) @Composable private fun MessageField(postViewModel: NewPostViewModel) { - val focusRequester = remember { FocusRequester() } - val keyboardController = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current - LaunchedEffect(Unit) { - launch { - delay(200) - focusRequester.requestFocus() + LaunchedEffect(Unit) { + launch { + delay(200) + focusRequester.requestFocus() + } } - } - OutlinedTextField( - value = postViewModel.message, - onValueChange = { postViewModel.updateMessage(it) }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - modifier = - Modifier.fillMaxWidth() - .border( - width = 1.dp, - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(8.dp), - ) - .focusRequester(focusRequester) - .onFocusChanged { - if (it.isFocused) { - keyboardController?.show() - } + OutlinedTextField( + value = postViewModel.message, + onValueChange = { postViewModel.updateMessage(it) }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + modifier = + Modifier.fillMaxWidth() + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(8.dp), + ) + .focusRequester(focusRequester) + .onFocusChanged { + if (it.isFocused) { + keyboardController?.show() + } + }, + placeholder = { + Text( + text = + if (postViewModel.wantsProduct) { + stringResource(R.string.description) + } else { + stringResource(R.string.what_s_on_your_mind) + }, + color = MaterialTheme.colorScheme.placeholderText, + ) }, - placeholder = { - Text( - text = - if (postViewModel.wantsProduct) { - stringResource(R.string.description) - } else { - stringResource(R.string.what_s_on_your_mind) - }, - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - ), - visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - ) + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + ), + visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) } @Composable fun ContentSensitivityExplainer(postViewModel: NewPostViewModel) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + Column( + modifier = Modifier.fillMaxWidth(), ) { - Box( - Modifier.height(20.dp).width(25.dp), - ) { - Icon( - imageVector = Icons.Default.VisibilityOff, - contentDescription = stringResource(id = R.string.content_warning), - modifier = Modifier.size(18.dp).align(Alignment.BottomStart), - tint = Color.Red, - ) - Icon( - imageVector = Icons.Rounded.Warning, - contentDescription = stringResource(id = R.string.content_warning), - modifier = Modifier.size(10.dp).align(Alignment.TopEnd), - tint = Color.Yellow, - ) - } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + ) { + Box( + Modifier.height(20.dp).width(25.dp), + ) { + Icon( + imageVector = Icons.Default.VisibilityOff, + contentDescription = stringResource(id = R.string.content_warning), + modifier = Modifier.size(18.dp).align(Alignment.BottomStart), + tint = Color.Red, + ) + Icon( + imageVector = Icons.Rounded.Warning, + contentDescription = stringResource(id = R.string.content_warning), + modifier = Modifier.size(10.dp).align(Alignment.TopEnd), + tint = Color.Yellow, + ) + } - Text( - text = stringResource(R.string.add_sensitive_content_label), - fontSize = 20.sp, - fontWeight = FontWeight.W500, - modifier = Modifier.padding(start = 10.dp), - ) + Text( + text = stringResource(R.string.add_sensitive_content_label), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) + } + + Divider() + + Text( + text = stringResource(R.string.add_sensitive_content_explainer), + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(vertical = 10.dp), + ) } - - Divider() - - Text( - text = stringResource(R.string.add_sensitive_content_explainer), - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(vertical = 10.dp), - ) - } } @Composable fun SendDirectMessageTo(postViewModel: NewPostViewModel) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(R.string.messages_new_message_to), - fontSize = Font14SP, - fontWeight = FontWeight.W500, - ) - - MyTextField( - value = postViewModel.toUsers, - onValueChange = { postViewModel.updateToUsers(it) }, + Column( modifier = Modifier.fillMaxWidth(), - placeholder = { - Text( - text = stringResource(R.string.messages_new_message_to_caption), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - visualTransformation = - UrlUserTagTransformation( - MaterialTheme.colorScheme.primary, - ), - colors = - OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent, - ), - ) - } - - Divider() - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), ) { - Text( - text = stringResource(R.string.messages_new_message_subject), - fontSize = Font14SP, - fontWeight = FontWeight.W500, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.messages_new_message_to), + fontSize = Font14SP, + fontWeight = FontWeight.W500, + ) - MyTextField( - value = postViewModel.subject, - onValueChange = { postViewModel.updateSubject(it) }, - modifier = Modifier.fillMaxWidth(), - placeholder = { - Text( - text = stringResource(R.string.messages_new_message_subject_caption), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - visualTransformation = - UrlUserTagTransformation( - MaterialTheme.colorScheme.primary, - ), - colors = - OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent, - ), - ) + MyTextField( + value = postViewModel.toUsers, + onValueChange = { postViewModel.updateToUsers(it) }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringResource(R.string.messages_new_message_to_caption), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + visualTransformation = + UrlUserTagTransformation( + MaterialTheme.colorScheme.primary, + ), + colors = + OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + ), + ) + } + + Divider() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.messages_new_message_subject), + fontSize = Font14SP, + fontWeight = FontWeight.W500, + ) + + MyTextField( + value = postViewModel.subject, + onValueChange = { postViewModel.updateSubject(it) }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringResource(R.string.messages_new_message_subject_caption), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + visualTransformation = + UrlUserTagTransformation( + MaterialTheme.colorScheme.primary, + ), + colors = + OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + ), + ) + } + + Divider() } - - Divider() - } } @Composable fun SellProduct(postViewModel: NewPostViewModel) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(R.string.classifieds_title), - fontSize = Font14SP, - fontWeight = FontWeight.W500, - ) - - MyTextField( - value = postViewModel.title, - onValueChange = { postViewModel.title = it }, + Column( modifier = Modifier.fillMaxWidth(), - placeholder = { - Text( - text = stringResource(R.string.classifieds_title_placeholder), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - visualTransformation = - UrlUserTagTransformation( - MaterialTheme.colorScheme.primary, - ), - colors = - OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent, - ), - ) - } - - Divider() - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), ) { - Text( - text = stringResource(R.string.classifieds_price), - fontSize = Font14SP, - fontWeight = FontWeight.W500, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.classifieds_title), + fontSize = Font14SP, + fontWeight = FontWeight.W500, + ) - MyTextField( - modifier = Modifier.fillMaxWidth(), - value = postViewModel.price, - onValueChange = { - runCatching { - if (it.text.isEmpty()) { - postViewModel.price = TextFieldValue("") - } else if (it.text.toLongOrNull() != null) { - postViewModel.price = it + MyTextField( + value = postViewModel.title, + onValueChange = { postViewModel.title = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringResource(R.string.classifieds_title_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + visualTransformation = + UrlUserTagTransformation( + MaterialTheme.colorScheme.primary, + ), + colors = + OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + ), + ) + } + + Divider() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.classifieds_price), + fontSize = Font14SP, + fontWeight = FontWeight.W500, + ) + + MyTextField( + modifier = Modifier.fillMaxWidth(), + value = postViewModel.price, + onValueChange = { + runCatching { + if (it.text.isEmpty()) { + postViewModel.price = TextFieldValue("") + } else if (it.text.toLongOrNull() != null) { + postViewModel.price = it + } + } + }, + placeholder = { + Text( + text = "1000", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number, + ), + singleLine = true, + colors = + OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + ), + ) + } + + Divider() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.classifieds_condition), + fontSize = Font14SP, + fontWeight = FontWeight.W500, + ) + + val conditionTypes = + listOf( + Triple( + ClassifiedsEvent.CONDITION.NEW, + stringResource(id = R.string.classifieds_condition_new), + stringResource(id = R.string.classifieds_condition_new_explainer), + ), + Triple( + ClassifiedsEvent.CONDITION.USED_LIKE_NEW, + stringResource(id = R.string.classifieds_condition_like_new), + stringResource(id = R.string.classifieds_condition_like_new_explainer), + ), + Triple( + ClassifiedsEvent.CONDITION.USED_GOOD, + stringResource(id = R.string.classifieds_condition_good), + stringResource(id = R.string.classifieds_condition_good_explainer), + ), + Triple( + ClassifiedsEvent.CONDITION.USED_FAIR, + stringResource(id = R.string.classifieds_condition_fair), + stringResource(id = R.string.classifieds_condition_fair_explainer), + ), + ) + + val conditionOptions = + remember { + conditionTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() + } + + TextSpinner( + placeholder = conditionTypes.filter { it.first == postViewModel.condition }.first().second, + options = conditionOptions, + onSelect = { postViewModel.condition = conditionTypes[it].first }, + modifier = Modifier.weight(1f).padding(end = 5.dp, bottom = 1.dp), + ) { currentOption, modifier -> + MyTextField( + value = TextFieldValue(currentOption), + onValueChange = {}, + readOnly = true, + modifier = modifier, + singleLine = true, + colors = + OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + ), + ) } - } - }, - placeholder = { - Text( - text = "1000", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - keyboardType = KeyboardType.Number, - ), - singleLine = true, - colors = - OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent, - ), - ) + } + + Divider() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.classifieds_category), + fontSize = Font14SP, + fontWeight = FontWeight.W500, + ) + + val categoryList = + listOf( + R.string.classifieds_category_clothing, + R.string.classifieds_category_accessories, + R.string.classifieds_category_electronics, + R.string.classifieds_category_furniture, + R.string.classifieds_category_collectibles, + R.string.classifieds_category_books, + R.string.classifieds_category_pets, + R.string.classifieds_category_sports, + R.string.classifieds_category_fitness, + R.string.classifieds_category_art, + R.string.classifieds_category_crafts, + R.string.classifieds_category_home, + R.string.classifieds_category_office, + R.string.classifieds_category_food, + R.string.classifieds_category_misc, + R.string.classifieds_category_other, + ) + + val categoryTypes = categoryList.map { Triple(it, stringResource(id = it), null) } + + val categoryOptions = + remember { + categoryTypes.map { TitleExplainer(it.second, null) }.toImmutableList() + } + TextSpinner( + placeholder = + categoryTypes.filter { it.second == postViewModel.category.text }.firstOrNull()?.second + ?: "", + options = categoryOptions, + onSelect = { postViewModel.category = TextFieldValue(categoryTypes[it].second) }, + modifier = Modifier.weight(1f).padding(end = 5.dp, bottom = 1.dp), + ) { currentOption, modifier -> + MyTextField( + value = TextFieldValue(currentOption), + onValueChange = {}, + readOnly = true, + modifier = modifier, + singleLine = true, + colors = + OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + ), + ) + } + } + + Divider() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.classifieds_location), + fontSize = Font14SP, + fontWeight = FontWeight.W500, + ) + + MyTextField( + value = postViewModel.locationText, + onValueChange = { postViewModel.locationText = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringResource(R.string.classifieds_location_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + visualTransformation = + UrlUserTagTransformation( + MaterialTheme.colorScheme.primary, + ), + colors = + OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + ), + ) + } + + Divider() } - - Divider() - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(R.string.classifieds_condition), - fontSize = Font14SP, - fontWeight = FontWeight.W500, - ) - - val conditionTypes = - listOf( - Triple( - ClassifiedsEvent.CONDITION.NEW, - stringResource(id = R.string.classifieds_condition_new), - stringResource(id = R.string.classifieds_condition_new_explainer), - ), - Triple( - ClassifiedsEvent.CONDITION.USED_LIKE_NEW, - stringResource(id = R.string.classifieds_condition_like_new), - stringResource(id = R.string.classifieds_condition_like_new_explainer), - ), - Triple( - ClassifiedsEvent.CONDITION.USED_GOOD, - stringResource(id = R.string.classifieds_condition_good), - stringResource(id = R.string.classifieds_condition_good_explainer), - ), - Triple( - ClassifiedsEvent.CONDITION.USED_FAIR, - stringResource(id = R.string.classifieds_condition_fair), - stringResource(id = R.string.classifieds_condition_fair_explainer), - ), - ) - - val conditionOptions = remember { - conditionTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() - } - - TextSpinner( - placeholder = conditionTypes.filter { it.first == postViewModel.condition }.first().second, - options = conditionOptions, - onSelect = { postViewModel.condition = conditionTypes[it].first }, - modifier = Modifier.weight(1f).padding(end = 5.dp, bottom = 1.dp), - ) { currentOption, modifier -> - MyTextField( - value = TextFieldValue(currentOption), - onValueChange = {}, - readOnly = true, - modifier = modifier, - singleLine = true, - colors = - OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent, - ), - ) - } - } - - Divider() - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(R.string.classifieds_category), - fontSize = Font14SP, - fontWeight = FontWeight.W500, - ) - - val categoryList = - listOf( - R.string.classifieds_category_clothing, - R.string.classifieds_category_accessories, - R.string.classifieds_category_electronics, - R.string.classifieds_category_furniture, - R.string.classifieds_category_collectibles, - R.string.classifieds_category_books, - R.string.classifieds_category_pets, - R.string.classifieds_category_sports, - R.string.classifieds_category_fitness, - R.string.classifieds_category_art, - R.string.classifieds_category_crafts, - R.string.classifieds_category_home, - R.string.classifieds_category_office, - R.string.classifieds_category_food, - R.string.classifieds_category_misc, - R.string.classifieds_category_other, - ) - - val categoryTypes = categoryList.map { Triple(it, stringResource(id = it), null) } - - val categoryOptions = remember { - categoryTypes.map { TitleExplainer(it.second, null) }.toImmutableList() - } - TextSpinner( - placeholder = - categoryTypes.filter { it.second == postViewModel.category.text }.firstOrNull()?.second - ?: "", - options = categoryOptions, - onSelect = { postViewModel.category = TextFieldValue(categoryTypes[it].second) }, - modifier = Modifier.weight(1f).padding(end = 5.dp, bottom = 1.dp), - ) { currentOption, modifier -> - MyTextField( - value = TextFieldValue(currentOption), - onValueChange = {}, - readOnly = true, - modifier = modifier, - singleLine = true, - colors = - OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent, - ), - ) - } - } - - Divider() - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(R.string.classifieds_location), - fontSize = Font14SP, - fontWeight = FontWeight.W500, - ) - - MyTextField( - value = postViewModel.locationText, - onValueChange = { postViewModel.locationText = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { - Text( - text = stringResource(R.string.classifieds_location_placeholder), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - visualTransformation = - UrlUserTagTransformation( - MaterialTheme.colorScheme.primary, - ), - colors = - OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent, - ), - ) - } - - Divider() - } } @Composable fun FowardZapTo( - postViewModel: NewPostViewModel, - accountViewModel: AccountViewModel, + postViewModel: NewPostViewModel, + accountViewModel: AccountViewModel, ) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + Column( + modifier = Modifier.fillMaxWidth(), ) { - Box( - Modifier.height(20.dp).width(25.dp), - ) { - Icon( - imageVector = Icons.Outlined.Bolt, - contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier.size(20.dp).align(Alignment.CenterStart), - tint = BitcoinOrange, - ) - Icon( - imageVector = Icons.Outlined.ArrowForwardIos, - contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), - tint = BitcoinOrange, - ) - } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + ) { + Box( + Modifier.height(20.dp).width(25.dp), + ) { + Icon( + imageVector = Icons.Outlined.Bolt, + contentDescription = stringResource(id = R.string.zaps), + modifier = Modifier.size(20.dp).align(Alignment.CenterStart), + tint = BitcoinOrange, + ) + Icon( + imageVector = Icons.Outlined.ArrowForwardIos, + contentDescription = stringResource(id = R.string.zaps), + modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), + tint = BitcoinOrange, + ) + } - Text( - text = stringResource(R.string.zap_split_title), - fontSize = 20.sp, - fontWeight = FontWeight.W500, - modifier = Modifier.padding(start = 10.dp), - ) - } - - Divider() - - Text( - text = stringResource(R.string.zap_split_explainer), - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(vertical = 10.dp), - ) - - postViewModel.forwardZapTo.items.forEachIndexed { index, splitItem -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size10dp), - ) { - BaseUserPicture(splitItem.key, Size55dp, accountViewModel = accountViewModel) - - Spacer(modifier = DoubleHorzSpacer) - - Column(modifier = Modifier.weight(1f)) { - UsernameDisplay(splitItem.key, showPlayButton = false) - Text( - text = String.format("%.0f%%", splitItem.percentage * 100), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - ) + Text( + text = stringResource(R.string.zap_split_title), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) } - Spacer(modifier = DoubleHorzSpacer) + Divider() - Slider( - value = splitItem.percentage, - onValueChange = { sliderValue -> - val rounded = (round(sliderValue * 20)) / 20.0f - postViewModel.updateZapPercentage(index, rounded) - }, - modifier = Modifier.weight(1.5f), - ) - } - } - - OutlinedTextField( - value = postViewModel.forwardZapToEditting, - onValueChange = { postViewModel.updateZapForwardTo(it) }, - label = { Text(text = stringResource(R.string.zap_split_search_and_add_user)) }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text( - text = stringResource(R.string.zap_split_search_and_add_user_placeholder), - color = MaterialTheme.colorScheme.placeholderText, + text = stringResource(R.string.zap_split_explainer), + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(vertical = 10.dp), ) - }, - singleLine = true, - visualTransformation = - UrlUserTagTransformation( - MaterialTheme.colorScheme.primary, - ), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - ) - } + + postViewModel.forwardZapTo.items.forEachIndexed { index, splitItem -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size10dp), + ) { + BaseUserPicture(splitItem.key, Size55dp, accountViewModel = accountViewModel) + + Spacer(modifier = DoubleHorzSpacer) + + Column(modifier = Modifier.weight(1f)) { + UsernameDisplay(splitItem.key, showPlayButton = false) + Text( + text = String.format("%.0f%%", splitItem.percentage * 100), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) + } + + Spacer(modifier = DoubleHorzSpacer) + + Slider( + value = splitItem.percentage, + onValueChange = { sliderValue -> + val rounded = (round(sliderValue * 20)) / 20.0f + postViewModel.updateZapPercentage(index, rounded) + }, + modifier = Modifier.weight(1.5f), + ) + } + } + + OutlinedTextField( + value = postViewModel.forwardZapToEditting, + onValueChange = { postViewModel.updateZapForwardTo(it) }, + label = { Text(text = stringResource(R.string.zap_split_search_and_add_user)) }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringResource(R.string.zap_split_search_and_add_user_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + visualTransformation = + UrlUserTagTransformation( + MaterialTheme.colorScheme.primary, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) + } } @OptIn(ExperimentalPermissionsApi::class) @Composable fun LocationAsHash(postViewModel: NewPostViewModel) { - val context = LocalContext.current + val context = LocalContext.current - val locationPermissionState = - rememberPermissionState( - Manifest.permission.ACCESS_COARSE_LOCATION, - ) - - if (locationPermissionState.status.isGranted) { - var locationDescriptionFlow by remember(postViewModel) { mutableStateOf?>(null) } - - DisposableEffect(key1 = Unit) { - postViewModel.startLocation(context = context) - locationDescriptionFlow = postViewModel.location - - onDispose { postViewModel.stopLocation() } - } - - Column( - modifier = Modifier.fillMaxWidth(), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), - ) { - Box( - Modifier.height(20.dp).width(20.dp), - ) { - Icon( - imageVector = Icons.Default.LocationOn, - null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } - - Text( - text = stringResource(R.string.geohash_title), - fontSize = 20.sp, - fontWeight = FontWeight.W500, - modifier = Modifier.padding(start = 10.dp), + val locationPermissionState = + rememberPermissionState( + Manifest.permission.ACCESS_COARSE_LOCATION, ) - locationDescriptionFlow?.let { geoLocation -> DisplayLocationObserver(geoLocation) } - } + if (locationPermissionState.status.isGranted) { + var locationDescriptionFlow by remember(postViewModel) { mutableStateOf?>(null) } - Divider() + DisposableEffect(key1 = Unit) { + postViewModel.startLocation(context = context) + locationDescriptionFlow = postViewModel.location - Text( - text = stringResource(R.string.geohash_explainer), - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(vertical = 10.dp), - ) + onDispose { postViewModel.stopLocation() } + } + + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + ) { + Box( + Modifier.height(20.dp).width(20.dp), + ) { + Icon( + imageVector = Icons.Default.LocationOn, + null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + + Text( + text = stringResource(R.string.geohash_title), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) + + locationDescriptionFlow?.let { geoLocation -> DisplayLocationObserver(geoLocation) } + } + + Divider() + + Text( + text = stringResource(R.string.geohash_explainer), + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(vertical = 10.dp), + ) + } + } else { + LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() } } - } else { - LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() } - } } @Composable fun DisplayLocationObserver(geoLocation: Flow) { - val location by geoLocation.collectAsStateWithLifecycle(null) + val location by geoLocation.collectAsStateWithLifecycle(null) - location?.let { DisplayLocationInTitle(geohash = it) } + location?.let { DisplayLocationInTitle(geohash = it) } } @Composable fun DisplayLocationInTitle(geohash: String) { - val context = LocalContext.current + val context = LocalContext.current - var cityName by remember(geohash) { mutableStateOf(geohash) } + var cityName by remember(geohash) { mutableStateOf(geohash) } - LaunchedEffect(key1 = geohash) { - launch(Dispatchers.IO) { - val newCityName = - ReverseGeoLocationUtil().execute(geohash.toGeoHash().toLocation(), context)?.ifBlank { - null + LaunchedEffect(key1 = geohash) { + launch(Dispatchers.IO) { + val newCityName = + ReverseGeoLocationUtil().execute(geohash.toGeoHash().toLocation(), context)?.ifBlank { + null + } + + if (newCityName != null && newCityName != cityName) { + cityName = newCityName + } } - - if (newCityName != null && newCityName != cityName) { - cityName = newCityName - } } - } - if (geohash != "s0000") { - Text( - text = cityName, - fontSize = 20.sp, - fontWeight = FontWeight.W500, - modifier = Modifier.padding(start = Size5dp), - ) - } else { - Spacer(modifier = StdHorzSpacer) - LoadingAnimation() - } + if (geohash != "s0000") { + Text( + text = cityName, + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = Size5dp), + ) + } else { + Spacer(modifier = StdHorzSpacer) + LoadingAnimation() + } } @OptIn(ExperimentalLayoutApi::class) @Composable fun Notifying( - baseMentions: ImmutableList?, - onClick: (User) -> Unit, + baseMentions: ImmutableList?, + onClick: (User) -> Unit, ) { - val mentions = baseMentions?.toSet() + val mentions = baseMentions?.toSet() - FlowRow(horizontalArrangement = Arrangement.spacedBy(5.dp)) { - if (!mentions.isNullOrEmpty()) { - Text( - stringResource(R.string.reply_notify), - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.align(CenterVertically), - ) - - mentions.forEachIndexed { idx, user -> - val innerUserState by user.live().metadata.observeAsState() - innerUserState?.user?.let { myUser -> - val tags = - remember(innerUserState) { myUser.info?.latestMetadata?.tags?.toImmutableListOfLists() } - - Button( - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.mediumImportanceLink, - ), - onClick = { onClick(myUser) }, - ) { - CreateTextWithEmoji( - text = remember(innerUserState) { "โœ– ${myUser.toBestDisplayName()}" }, - tags = tags, - color = Color.White, - textAlign = TextAlign.Center, + FlowRow(horizontalArrangement = Arrangement.spacedBy(5.dp)) { + if (!mentions.isNullOrEmpty()) { + Text( + stringResource(R.string.reply_notify), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.align(CenterVertically), ) - } + + mentions.forEachIndexed { idx, user -> + val innerUserState by user.live().metadata.observeAsState() + innerUserState?.user?.let { myUser -> + val tags = + remember(innerUserState) { myUser.info?.latestMetadata?.tags?.toImmutableListOfLists() } + + Button( + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.mediumImportanceLink, + ), + onClick = { onClick(myUser) }, + ) { + CreateTextWithEmoji( + text = remember(innerUserState) { "โœ– ${myUser.toBestDisplayName()}" }, + tags = tags, + color = Color.White, + textAlign = TextAlign.Center, + ) + } + } + } } - } } - } } @Composable private fun AddPollButton( - isPollActive: Boolean, - onClick: () -> Unit, + isPollActive: Boolean, + onClick: () -> Unit, ) { - IconButton( - onClick = { onClick() }, - ) { - if (!isPollActive) { - PollIcon() - } else { - RegularPostIcon() + IconButton( + onClick = { onClick() }, + ) { + if (!isPollActive) { + PollIcon() + } else { + RegularPostIcon() + } } - } } @Composable private fun AddZapraiserButton( - isLnInvoiceActive: Boolean, - onClick: () -> Unit, + isLnInvoiceActive: Boolean, + onClick: () -> Unit, ) { - IconButton( - onClick = { onClick() }, - ) { - Box( - Modifier.height(20.dp).width(25.dp), + IconButton( + onClick = { onClick() }, ) { - if (!isLnInvoiceActive) { - Icon( - imageVector = Icons.Default.ShowChart, - null, - modifier = Modifier.size(20.dp).align(Alignment.TopStart), - tint = MaterialTheme.colorScheme.onBackground, - ) - Icon( - imageVector = Icons.Default.Bolt, - contentDescription = stringResource(R.string.zaps), - modifier = Modifier.size(13.dp).align(Alignment.BottomEnd), - tint = MaterialTheme.colorScheme.onBackground, - ) - } else { - Icon( - imageVector = Icons.Default.ShowChart, - null, - modifier = Modifier.size(20.dp).align(Alignment.TopStart), - tint = BitcoinOrange, - ) - Icon( - imageVector = Icons.Default.Bolt, - contentDescription = stringResource(R.string.zaps), - modifier = Modifier.size(13.dp).align(Alignment.BottomEnd), - tint = BitcoinOrange, - ) - } + Box( + Modifier.height(20.dp).width(25.dp), + ) { + if (!isLnInvoiceActive) { + Icon( + imageVector = Icons.Default.ShowChart, + null, + modifier = Modifier.size(20.dp).align(Alignment.TopStart), + tint = MaterialTheme.colorScheme.onBackground, + ) + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(13.dp).align(Alignment.BottomEnd), + tint = MaterialTheme.colorScheme.onBackground, + ) + } else { + Icon( + imageVector = Icons.Default.ShowChart, + null, + modifier = Modifier.size(20.dp).align(Alignment.TopStart), + tint = BitcoinOrange, + ) + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(13.dp).align(Alignment.BottomEnd), + tint = BitcoinOrange, + ) + } + } } - } } @Composable fun AddGeoHash( - postViewModel: NewPostViewModel, - onClick: () -> Unit, + postViewModel: NewPostViewModel, + onClick: () -> Unit, ) { - IconButton( - onClick = { onClick() }, - ) { - if (!postViewModel.wantsToAddGeoHash) { - Icon( - imageVector = Icons.Default.LocationOff, - null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onBackground, - ) - } else { - Icon( - imageVector = Icons.Default.LocationOn, - null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary, - ) + IconButton( + onClick = { onClick() }, + ) { + if (!postViewModel.wantsToAddGeoHash) { + Icon( + imageVector = Icons.Default.LocationOff, + null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) + } else { + Icon( + imageVector = Icons.Default.LocationOn, + null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } } - } } @Composable private fun AddLnInvoiceButton( - isLnInvoiceActive: Boolean, - onClick: () -> Unit, + isLnInvoiceActive: Boolean, + onClick: () -> Unit, ) { - IconButton( - onClick = { onClick() }, - ) { - if (!isLnInvoiceActive) { - Icon( - imageVector = Icons.Default.CurrencyBitcoin, - null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onBackground, - ) - } else { - Icon( - imageVector = Icons.Default.CurrencyBitcoin, - null, - modifier = Modifier.size(20.dp), - tint = BitcoinOrange, - ) + IconButton( + onClick = { onClick() }, + ) { + if (!isLnInvoiceActive) { + Icon( + imageVector = Icons.Default.CurrencyBitcoin, + null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) + } else { + Icon( + imageVector = Icons.Default.CurrencyBitcoin, + null, + modifier = Modifier.size(20.dp), + tint = BitcoinOrange, + ) + } } - } } @Composable private fun ForwardZapTo( - postViewModel: NewPostViewModel, - onClick: () -> Unit, + postViewModel: NewPostViewModel, + onClick: () -> Unit, ) { - IconButton( - onClick = { onClick() }, - ) { - Box( - Modifier.height(20.dp).width(25.dp), + IconButton( + onClick = { onClick() }, ) { - if (!postViewModel.wantsForwardZapTo) { - Icon( - imageVector = Icons.Default.Bolt, - contentDescription = stringResource(R.string.zaps), - modifier = Modifier.size(20.dp).align(Alignment.CenterStart), - tint = MaterialTheme.colorScheme.onBackground, - ) - Icon( - imageVector = Icons.Default.ArrowForwardIos, - contentDescription = stringResource(R.string.zaps), - modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), - tint = MaterialTheme.colorScheme.onBackground, - ) - } else { - Icon( - imageVector = Icons.Outlined.Bolt, - contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier.size(20.dp).align(Alignment.CenterStart), - tint = BitcoinOrange, - ) - Icon( - imageVector = Icons.Outlined.ArrowForwardIos, - contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), - tint = BitcoinOrange, - ) - } + Box( + Modifier.height(20.dp).width(25.dp), + ) { + if (!postViewModel.wantsForwardZapTo) { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(20.dp).align(Alignment.CenterStart), + tint = MaterialTheme.colorScheme.onBackground, + ) + Icon( + imageVector = Icons.Default.ArrowForwardIos, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), + tint = MaterialTheme.colorScheme.onBackground, + ) + } else { + Icon( + imageVector = Icons.Outlined.Bolt, + contentDescription = stringResource(id = R.string.zaps), + modifier = Modifier.size(20.dp).align(Alignment.CenterStart), + tint = BitcoinOrange, + ) + Icon( + imageVector = Icons.Outlined.ArrowForwardIos, + contentDescription = stringResource(id = R.string.zaps), + modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), + tint = BitcoinOrange, + ) + } + } } - } } @Composable private fun AddClassifiedsButton( - postViewModel: NewPostViewModel, - onClick: () -> Unit, + postViewModel: NewPostViewModel, + onClick: () -> Unit, ) { - IconButton( - onClick = { onClick() }, - ) { - if (!postViewModel.wantsProduct) { - Icon( - imageVector = Icons.Default.Sell, - contentDescription = stringResource(R.string.classifieds), - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onBackground, - ) - } else { - Icon( - imageVector = Icons.Default.Sell, - contentDescription = stringResource(id = R.string.classifieds), - modifier = Modifier.size(20.dp), - tint = BitcoinOrange, - ) + IconButton( + onClick = { onClick() }, + ) { + if (!postViewModel.wantsProduct) { + Icon( + imageVector = Icons.Default.Sell, + contentDescription = stringResource(R.string.classifieds), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) + } else { + Icon( + imageVector = Icons.Default.Sell, + contentDescription = stringResource(id = R.string.classifieds), + modifier = Modifier.size(20.dp), + tint = BitcoinOrange, + ) + } } - } } @Composable private fun MarkAsSensitive( - postViewModel: NewPostViewModel, - onClick: () -> Unit, + postViewModel: NewPostViewModel, + onClick: () -> Unit, ) { - IconButton( - onClick = { onClick() }, - ) { - Box( - Modifier.height(20.dp).width(23.dp), + IconButton( + onClick = { onClick() }, ) { - if (!postViewModel.wantsToMarkAsSensitive) { - Icon( - imageVector = Icons.Default.Visibility, - contentDescription = stringResource(R.string.content_warning), - modifier = Modifier.size(18.dp).align(Alignment.BottomStart), - tint = MaterialTheme.colorScheme.onBackground, - ) - Icon( - imageVector = Icons.Rounded.Warning, - contentDescription = stringResource(R.string.content_warning), - modifier = Modifier.size(10.dp).align(Alignment.TopEnd), - tint = MaterialTheme.colorScheme.onBackground, - ) - } else { - Icon( - imageVector = Icons.Default.VisibilityOff, - contentDescription = stringResource(id = R.string.content_warning), - modifier = Modifier.size(18.dp).align(Alignment.BottomStart), - tint = Color.Red, - ) - Icon( - imageVector = Icons.Rounded.Warning, - contentDescription = stringResource(id = R.string.content_warning), - modifier = Modifier.size(10.dp).align(Alignment.TopEnd), - tint = Color.Yellow, - ) - } + Box( + Modifier.height(20.dp).width(23.dp), + ) { + if (!postViewModel.wantsToMarkAsSensitive) { + Icon( + imageVector = Icons.Default.Visibility, + contentDescription = stringResource(R.string.content_warning), + modifier = Modifier.size(18.dp).align(Alignment.BottomStart), + tint = MaterialTheme.colorScheme.onBackground, + ) + Icon( + imageVector = Icons.Rounded.Warning, + contentDescription = stringResource(R.string.content_warning), + modifier = Modifier.size(10.dp).align(Alignment.TopEnd), + tint = MaterialTheme.colorScheme.onBackground, + ) + } else { + Icon( + imageVector = Icons.Default.VisibilityOff, + contentDescription = stringResource(id = R.string.content_warning), + modifier = Modifier.size(18.dp).align(Alignment.BottomStart), + tint = Color.Red, + ) + Icon( + imageVector = Icons.Rounded.Warning, + contentDescription = stringResource(id = R.string.content_warning), + modifier = Modifier.size(10.dp).align(Alignment.TopEnd), + tint = Color.Yellow, + ) + } + } } - } } @Composable fun CloseButton(onPress: () -> Unit) { - OutlinedButton( - onClick = onPress, - contentPadding = PaddingValues(horizontal = Size5dp), - ) { - CloseIcon() - } + OutlinedButton( + onClick = onPress, + contentPadding = PaddingValues(horizontal = Size5dp), + ) { + CloseIcon() + } } @Composable fun PostButton( - onPost: () -> Unit = {}, - isActive: Boolean, - modifier: Modifier = Modifier, + onPost: () -> Unit = {}, + isActive: Boolean, + modifier: Modifier = Modifier, ) { - Button( - modifier = modifier, - enabled = isActive, - onClick = onPost, - ) { - Text(text = stringResource(R.string.post)) - } + Button( + modifier = modifier, + enabled = isActive, + onClick = onPost, + ) { + Text(text = stringResource(R.string.post)) + } } @Composable fun SaveButton( - onPost: () -> Unit = {}, - isActive: Boolean, - modifier: Modifier = Modifier, + onPost: () -> Unit = {}, + isActive: Boolean, + modifier: Modifier = Modifier, ) { - Button( - enabled = isActive, - modifier = modifier, - onClick = onPost, - ) { - Text(text = stringResource(R.string.save)) - } + Button( + enabled = isActive, + modifier = modifier, + onClick = onPost, + ) { + Text(text = stringResource(R.string.save)) + } } @Composable fun CreateButton( - onPost: () -> Unit = {}, - isActive: Boolean, - modifier: Modifier = Modifier, + onPost: () -> Unit = {}, + isActive: Boolean, + modifier: Modifier = Modifier, ) { - Button( - modifier = modifier, - onClick = { - if (isActive) { - onPost() - } - }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = if (isActive) MaterialTheme.colorScheme.primary else Color.Gray, - ), - ) { - Text(text = stringResource(R.string.create), color = Color.White) - } + Button( + modifier = modifier, + onClick = { + if (isActive) { + onPost() + } + }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = if (isActive) MaterialTheme.colorScheme.primary else Color.Gray, + ), + ) { + Text(text = stringResource(R.string.create), color = Color.White) + } } @Composable fun ImageVideoDescription( - uri: Uri, - defaultServer: Nip96MediaServers.ServerName, - onAdd: (String, ServerOption, Boolean) -> Unit, - onCancel: () -> Unit, - onError: (String) -> Unit, - accountViewModel: AccountViewModel, + uri: Uri, + defaultServer: Nip96MediaServers.ServerName, + onAdd: (String, ServerOption, Boolean) -> Unit, + onCancel: () -> Unit, + onError: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - val resolver = LocalContext.current.contentResolver - val mediaType = resolver.getType(uri) ?: "" + val resolver = LocalContext.current.contentResolver + val mediaType = resolver.getType(uri) ?: "" - val isImage = mediaType.startsWith("image") - val isVideo = mediaType.startsWith("video") + val isImage = mediaType.startsWith("image") + val isVideo = mediaType.startsWith("video") - val fileServers = - Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } + - listOf( - ServerOption( - Nip96MediaServers.ServerName( - "NIP95", - stringResource(id = R.string.upload_server_relays_nip95), - ), - true, - ), - ) + val fileServers = + Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } + + listOf( + ServerOption( + Nip96MediaServers.ServerName( + "NIP95", + stringResource(id = R.string.upload_server_relays_nip95), + ), + true, + ), + ) - val fileServerOptions = remember { - fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() - } + val fileServerOptions = + remember { + fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() + } - var selectedServer by remember { mutableStateOf(ServerOption(defaultServer, false)) } - var message by remember { mutableStateOf("") } - var sensitiveContent by remember { mutableStateOf(false) } + var selectedServer by remember { mutableStateOf(ServerOption(defaultServer, false)) } + var message by remember { mutableStateOf("") } + var sensitiveContent by remember { mutableStateOf(false) } - Column( - modifier = - Modifier.fillMaxWidth() - .padding(start = 30.dp, end = 30.dp) - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder, - ), - ) { Column( - modifier = Modifier.fillMaxWidth().padding(30.dp), + modifier = + Modifier.fillMaxWidth() + .padding(start = 30.dp, end = 30.dp) + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), - ) { - Text( - text = - stringResource( - if (isImage) { - R.string.content_description_add_image - } else { - if (isVideo) { - R.string.content_description_add_video - } else { - R.string.content_description_add_document + Column( + modifier = Modifier.fillMaxWidth().padding(30.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + ) { + Text( + text = + stringResource( + if (isImage) { + R.string.content_description_add_image + } else { + if (isVideo) { + R.string.content_description_add_video + } else { + R.string.content_description_add_document + } + }, + ), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = + Modifier.padding(start = 10.dp) + .weight(1.0f) + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + ) + + IconButton( + modifier = Modifier.size(30.dp).padding(end = 5.dp), + onClick = onCancel, + ) { + CancelIcon() } - }, - ), - fontSize = 20.sp, - fontWeight = FontWeight.W500, - modifier = - Modifier.padding(start = 10.dp) - .weight(1.0f) - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), - ) - - IconButton( - modifier = Modifier.size(30.dp).padding(end = 5.dp), - onClick = onCancel, - ) { - CancelIcon() - } - } - - Divider() - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier.fillMaxWidth() - .padding(bottom = 10.dp) - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), - ) { - if (mediaType.startsWith("image")) { - AsyncImage( - model = uri.toString(), - contentDescription = uri.toString(), - contentScale = ContentScale.FillWidth, - modifier = - Modifier.padding(top = 4.dp) - .fillMaxWidth() - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), - ) - } else if ( - mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - ) { - var bitmap by remember { mutableStateOf(null) } - - LaunchedEffect(key1 = uri) { - launch(Dispatchers.IO) { - try { - bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null) - } catch (e: Exception) { - onError("Unable to load thumbnail") - Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e) - } } - } - bitmap?.let { - Image( - bitmap = it.asImageBitmap(), - contentDescription = "some useful description", - contentScale = ContentScale.FillWidth, - modifier = Modifier.padding(top = 4.dp).fillMaxWidth(), - ) - } - } else { - VideoView(uri.toString(), roundedCorner = true, accountViewModel = accountViewModel) + Divider() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .padding(bottom = 10.dp) + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + ) { + if (mediaType.startsWith("image")) { + AsyncImage( + model = uri.toString(), + contentDescription = uri.toString(), + contentScale = ContentScale.FillWidth, + modifier = + Modifier.padding(top = 4.dp) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + ) + } else if ( + mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + ) { + var bitmap by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = uri) { + launch(Dispatchers.IO) { + try { + bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null) + } catch (e: Exception) { + onError("Unable to load thumbnail") + Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e) + } + } + } + + bitmap?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = "some useful description", + contentScale = ContentScale.FillWidth, + modifier = Modifier.padding(top = 4.dp).fillMaxWidth(), + ) + } + } else { + VideoView(uri.toString(), roundedCorner = true, accountViewModel = accountViewModel) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + TextSpinner( + label = stringResource(id = R.string.file_server), + placeholder = + fileServers + .firstOrNull { it.server == accountViewModel.account.defaultFileServer } + ?.server + ?.name + ?: fileServers[0].server.name, + options = fileServerOptions, + onSelect = { selectedServer = fileServers[it] }, + modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).weight(1f), + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + SettingSwitchItem( + checked = sensitiveContent, + onCheckedChange = { sensitiveContent = it }, + title = R.string.add_sensitive_content_label, + description = R.string.add_sensitive_content_description, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.content_description)) }, + modifier = + Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + value = message, + onValueChange = { message = it }, + placeholder = { + Text( + text = stringResource(R.string.content_description_example), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + } + + Button( + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + onClick = { onAdd(message, selectedServer, sensitiveContent) }, + shape = QuoteBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(text = stringResource(R.string.add_content), color = Color.White, fontSize = 20.sp) + } } - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - TextSpinner( - label = stringResource(id = R.string.file_server), - placeholder = - fileServers - .firstOrNull { it.server == accountViewModel.account.defaultFileServer } - ?.server - ?.name - ?: fileServers[0].server.name, - options = fileServerOptions, - onSelect = { selectedServer = fileServers[it] }, - modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).weight(1f), - ) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - SettingSwitchItem( - checked = sensitiveContent, - onCheckedChange = { sensitiveContent = it }, - title = R.string.add_sensitive_content_label, - description = R.string.add_sensitive_content_description, - ) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.content_description)) }, - modifier = - Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), - value = message, - onValueChange = { message = it }, - placeholder = { - Text( - text = stringResource(R.string.content_description_example), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - ) - } - - Button( - modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), - onClick = { onAdd(message, selectedServer, sensitiveContent) }, - shape = QuoteBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text(text = stringResource(R.string.add_content), color = Color.White, fontSize = 20.sp) - } } - } } @Composable fun SettingSwitchItem( - modifier: Modifier = Modifier, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - title: Int, - description: Int, - enabled: Boolean = true, + modifier: Modifier = Modifier, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + title: Int, + description: Int, + enabled: Boolean = true, ) { - Row( - modifier = - modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - .toggleable( - value = checked, - enabled = enabled, - role = Role.Switch, - onValueChange = onCheckedChange, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier.weight(1.0f), - verticalArrangement = Arrangement.spacedBy(3.dp), + Row( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .toggleable( + value = checked, + enabled = enabled, + role = Role.Switch, + onValueChange = onCheckedChange, + ), + verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = stringResource(id = title), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = stringResource(id = description), - style = MaterialTheme.typography.bodySmall, - color = Color.Gray, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } + Column( + modifier = Modifier.weight(1.0f), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + text = stringResource(id = title), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(id = description), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } - Switch( - checked = checked, - onCheckedChange = null, - enabled = enabled, - ) - } + Switch( + checked = checked, + onCheckedChange = null, + enabled = enabled, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index f14f218c0..7e16320f4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -63,7 +63,6 @@ import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.ZapSplitSetup import com.vitorpamplona.quartz.events.findURLs -import java.net.URLEncoder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.BufferOverflow @@ -71,578 +70,579 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch +import java.net.URLEncoder enum class UserSuggestionAnchor { - MAIN_MESSAGE, - FORWARD_ZAPS, - TO_USERS, + MAIN_MESSAGE, + FORWARD_ZAPS, + TO_USERS, } @Stable open class NewPostViewModel() : ViewModel() { - var accountViewModel: AccountViewModel? = null - var account: Account? = null - var requiresNIP24: Boolean = false + var accountViewModel: AccountViewModel? = null + var account: Account? = null + var requiresNIP24: Boolean = false - var originalNote: Note? = null + var originalNote: Note? = null - var pTags by mutableStateOf?>(null) - var eTags by mutableStateOf?>(null) + var pTags by mutableStateOf?>(null) + var eTags by mutableStateOf?>(null) - var nip94attachments by mutableStateOf>(emptyList()) - var nip95attachments by - mutableStateOf>>(emptyList()) + var nip94attachments by mutableStateOf>(emptyList()) + var nip95attachments by + mutableStateOf>>(emptyList()) - var message by mutableStateOf(TextFieldValue("")) - var urlPreview by mutableStateOf(null) - var isUploadingImage by mutableStateOf(false) - val imageUploadingError = - MutableSharedFlow(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST) + var message by mutableStateOf(TextFieldValue("")) + var urlPreview by mutableStateOf(null) + var isUploadingImage by mutableStateOf(false) + val imageUploadingError = + MutableSharedFlow(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST) - var userSuggestions by mutableStateOf>(emptyList()) - var userSuggestionAnchor: TextRange? = null - var userSuggestionsMainMessage: UserSuggestionAnchor? = null + var userSuggestions by mutableStateOf>(emptyList()) + var userSuggestionAnchor: TextRange? = null + var userSuggestionsMainMessage: UserSuggestionAnchor? = null - // DMs - var wantsDirectMessage by mutableStateOf(false) - var toUsers by mutableStateOf(TextFieldValue("")) - var subject by mutableStateOf(TextFieldValue("")) + // DMs + var wantsDirectMessage by mutableStateOf(false) + var toUsers by mutableStateOf(TextFieldValue("")) + var subject by mutableStateOf(TextFieldValue("")) - // Images and Videos - var contentToAddUrl by mutableStateOf(null) + // Images and Videos + var contentToAddUrl by mutableStateOf(null) - // Polls - var canUsePoll by mutableStateOf(false) - var wantsPoll by mutableStateOf(false) - var zapRecipients = mutableStateListOf() - var pollOptions = newStateMapPollOptions() - var valueMaximum by mutableStateOf(null) - var valueMinimum by mutableStateOf(null) - var consensusThreshold: Int? = null - var closedAt: Int? = null + // Polls + var canUsePoll by mutableStateOf(false) + var wantsPoll by mutableStateOf(false) + var zapRecipients = mutableStateListOf() + var pollOptions = newStateMapPollOptions() + var valueMaximum by mutableStateOf(null) + var valueMinimum by mutableStateOf(null) + var consensusThreshold: Int? = null + var closedAt: Int? = null - var isValidRecipients = mutableStateOf(true) - var isValidvalueMaximum = mutableStateOf(true) - var isValidvalueMinimum = mutableStateOf(true) - var isValidConsensusThreshold = mutableStateOf(true) - var isValidClosedAt = mutableStateOf(true) + var isValidRecipients = mutableStateOf(true) + var isValidvalueMaximum = mutableStateOf(true) + var isValidvalueMinimum = mutableStateOf(true) + var isValidConsensusThreshold = mutableStateOf(true) + var isValidClosedAt = mutableStateOf(true) - // Classifieds - var wantsProduct by mutableStateOf(false) - var title by mutableStateOf(TextFieldValue("")) - var price by mutableStateOf(TextFieldValue("")) - var locationText by mutableStateOf(TextFieldValue("")) - var category by mutableStateOf(TextFieldValue("")) - var condition by - mutableStateOf(ClassifiedsEvent.CONDITION.USED_LIKE_NEW) + // Classifieds + var wantsProduct by mutableStateOf(false) + var title by mutableStateOf(TextFieldValue("")) + var price by mutableStateOf(TextFieldValue("")) + var locationText by mutableStateOf(TextFieldValue("")) + var category by mutableStateOf(TextFieldValue("")) + var condition by + mutableStateOf(ClassifiedsEvent.CONDITION.USED_LIKE_NEW) - // Invoices - var canAddInvoice by mutableStateOf(false) - var wantsInvoice by mutableStateOf(false) + // Invoices + var canAddInvoice by mutableStateOf(false) + var wantsInvoice by mutableStateOf(false) - // Forward Zap to - var wantsForwardZapTo by mutableStateOf(false) - var forwardZapTo by mutableStateOf>(Split()) - var forwardZapToEditting by mutableStateOf(TextFieldValue("")) + // Forward Zap to + var wantsForwardZapTo by mutableStateOf(false) + var forwardZapTo by mutableStateOf>(Split()) + var forwardZapToEditting by mutableStateOf(TextFieldValue("")) - // NSFW, Sensitive - var wantsToMarkAsSensitive by mutableStateOf(false) + // NSFW, Sensitive + var wantsToMarkAsSensitive by mutableStateOf(false) - // GeoHash - var wantsToAddGeoHash by mutableStateOf(false) - var locUtil: LocationUtil? = null - var location: Flow? = null + // GeoHash + var wantsToAddGeoHash by mutableStateOf(false) + var locUtil: LocationUtil? = null + var location: Flow? = null - // ZapRaiser - var canAddZapRaiser by mutableStateOf(false) - var wantsZapraiser by mutableStateOf(false) - var zapRaiserAmount by mutableStateOf(null) + // ZapRaiser + var canAddZapRaiser by mutableStateOf(false) + var wantsZapraiser by mutableStateOf(false) + var zapRaiserAmount by mutableStateOf(null) - // NIP24 Wrapped DMs / Group messages - var nip24 by mutableStateOf(false) + // NIP24 Wrapped DMs / Group messages + var nip24 by mutableStateOf(false) - open fun load( - accountViewModel: AccountViewModel, - replyingTo: Note?, - quote: Note?, - ) { - this.accountViewModel = accountViewModel - this.account = accountViewModel.account + open fun load( + accountViewModel: AccountViewModel, + replyingTo: Note?, + quote: Note?, + ) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account - originalNote = replyingTo - replyingTo?.let { replyNote -> - if (replyNote.event is BaseTextNoteEvent) { - this.eTags = (replyNote.replyTo ?: emptyList()).plus(replyNote) - } else { - this.eTags = listOf(replyNote) - } - - if (replyNote.event !is CommunityDefinitionEvent) { - replyNote.author?.let { replyUser -> - val currentMentions = - (replyNote.event as? TextNoteEvent)?.mentions()?.map { LocalCache.getOrCreateUser(it) } - ?: emptyList() - - if (currentMentions.contains(replyUser)) { - this.pTags = currentMentions - } else { - this.pTags = currentMentions.plus(replyUser) - } - } - } - } - ?: run { - eTags = null - pTags = null - } - - quote?.let { - message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}") - urlPreview = findUrlInMessage() - } - - canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null - canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null - canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null - contentToAddUrl = null - - wantsForwardZapTo = false - wantsToMarkAsSensitive = false - wantsToAddGeoHash = false - wantsZapraiser = false - zapRaiserAmount = null - forwardZapTo = Split() - forwardZapToEditting = TextFieldValue("") - } - - fun sendPost(relayList: List? = null) { - viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) } - } - - suspend fun innerSendPost(relayList: List? = null) { - if (accountViewModel == null) { - cancel() - return - } - - val tagger = - NewMessageTagger(message.text, pTags, eTags, originalNote?.channelHex(), accountViewModel!!) - tagger.run() - - val toUsersTagger = NewMessageTagger(toUsers.text, null, null, null, accountViewModel!!) - toUsersTagger.run() - val dmUsers = toUsersTagger.pTags - - val zapReceiver = - if (wantsForwardZapTo) { - forwardZapTo.items.map { - ZapSplitSetup( - lnAddressOrPubKeyHex = it.key.pubkeyHex, - relay = it.key.relaysBeingUsed.keys.firstOrNull(), - weight = it.percentage.toDouble(), - isLnAddress = false, - ) - } - } else { - null - } - - val geoLocation = locUtil?.locationStateFlow?.value - val geoHash = - if (wantsToAddGeoHash && geoLocation != null) { - geoLocation.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() - } else { - null - } - - val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null - - nip95attachments.forEach { - if (eTags?.contains(LocalCache.getNoteIfExists(it.second.id)) == true) { - account?.sendNip95(it.first, it.second, relayList) - } - } - - val urls = findURLs(tagger.message) - val usedAttachments = nip94attachments.filter { it.urls().intersect(urls).isNotEmpty() } - usedAttachments.forEach { account?.sendHeader(it, relayList, {}) } - - if (originalNote?.channelHex() != null) { - if (originalNote is AddressableEvent && originalNote?.address() != null) { - account?.sendLiveMessage( - tagger.message, - originalNote?.address()!!, - tagger.eTags, - tagger.pTags, - zapReceiver, - wantsToMarkAsSensitive, - localZapRaiserAmount, - geoHash, - nip94attachments = usedAttachments, - ) - } else { - account?.sendChannelMessage( - tagger.message, - tagger.channelHex!!, - tagger.eTags, - tagger.pTags, - zapReceiver, - wantsToMarkAsSensitive, - localZapRaiserAmount, - geoHash, - nip94attachments = usedAttachments, - ) - } - } else if (originalNote?.event is PrivateDmEvent) { - account?.sendPrivateMessage( - tagger.message, - originalNote!!.author!!, - originalNote!!, - tagger.pTags, - zapReceiver, - wantsToMarkAsSensitive, - localZapRaiserAmount, - geoHash, - ) - } else if (originalNote?.event is ChatMessageEvent) { - val receivers = - (originalNote?.event as ChatMessageEvent) - .recipientsPubKey() - .plus(originalNote?.author?.pubkeyHex) - .filterNotNull() - .toSet() - .toList() - - account?.sendNIP24PrivateMessage( - message = tagger.message, - toUsers = receivers, - subject = subject.text.ifBlank { null }, - replyingTo = originalNote!!, - mentions = tagger.pTags, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, - zapReceiver = zapReceiver, - zapRaiserAmount = localZapRaiserAmount, - geohash = geoHash, - ) - } else if (!dmUsers.isNullOrEmpty()) { - if (nip24 || dmUsers.size > 1) { - account?.sendNIP24PrivateMessage( - message = tagger.message, - toUsers = dmUsers.map { it.pubkeyHex }, - subject = subject.text.ifBlank { null }, - replyingTo = tagger.eTags?.firstOrNull(), - mentions = tagger.pTags, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, - zapReceiver = zapReceiver, - zapRaiserAmount = localZapRaiserAmount, - geohash = geoHash, - ) - } else { - account?.sendPrivateMessage( - message = tagger.message, - toUser = dmUsers.first().pubkeyHex, - replyingTo = originalNote, - mentions = tagger.pTags, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, - zapReceiver = zapReceiver, - zapRaiserAmount = localZapRaiserAmount, - geohash = geoHash, - ) - } - } else { - if (wantsPoll) { - account?.sendPoll( - tagger.message, - tagger.eTags, - tagger.pTags, - pollOptions, - valueMaximum, - valueMinimum, - consensusThreshold, - closedAt, - zapReceiver, - wantsToMarkAsSensitive, - localZapRaiserAmount, - relayList, - geoHash, - nip94attachments = usedAttachments, - ) - } else if (wantsProduct) { - account?.sendClassifieds( - title = title.text, - price = Price(price.text, "SATS", null), - condition = condition, - message = tagger.message, - replyTo = tagger.eTags, - mentions = tagger.pTags, - location = locationText.text, - category = category.text, - directMentions = tagger.directMentions, - zapReceiver = zapReceiver, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = localZapRaiserAmount, - relayList = relayList, - geohash = geoHash, - nip94attachments = usedAttachments, - ) - } else { - // adds markers - val rootId = - (originalNote?.event as? TextNoteEvent)?.root() // if it has a marker as root - ?: originalNote - ?.replyTo - ?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true } - ?.idHex // if it has loaded events with zero replies in the reply list - ?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root. - ?: originalNote?.idHex - - val replyId = originalNote?.idHex - - account?.sendPost( - message = tagger.message, - replyTo = tagger.eTags, - mentions = tagger.pTags, - tags = null, - zapReceiver = zapReceiver, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = localZapRaiserAmount, - replyingTo = replyId, - root = rootId, - directMentions = tagger.directMentions, - relayList = relayList, - geohash = geoHash, - nip94attachments = usedAttachments, - ) - } - } - - cancel() - } - - fun upload( - galleryUri: Uri, - alt: String?, - sensitiveContent: Boolean, - isPrivate: Boolean = false, - server: ServerOption, - context: Context, - ) { - isUploadingImage = true - contentToAddUrl = null - - val contentResolver = context.contentResolver - val contentType = contentResolver.getType(galleryUri) - - viewModelScope.launch(Dispatchers.IO) { - MediaCompressor() - .compress( - galleryUri, - contentType, - context.applicationContext, - onReady = { fileUri, contentType, size -> - if (server.isNip95) { - contentResolver.openInputStream(fileUri)?.use { - createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent) - } + originalNote = replyingTo + replyingTo?.let { replyNote -> + if (replyNote.event is BaseTextNoteEvent) { + this.eTags = (replyNote.replyTo ?: emptyList()).plus(replyNote) } else { - viewModelScope.launch(Dispatchers.IO) { - try { - val result = - Nip96Uploader(account) - .uploadImage( - uri = fileUri, - contentType = contentType, - size = size, - alt = alt, - sensitiveContent = if (sensitiveContent) "" else null, - server = server.server, - contentResolver = contentResolver, - onProgress = {}, - ) - - if (!isPrivate) { - createNIP94Record( - uploadingResult = result, - localContentType = contentType, - alt = alt, - sensitiveContent = sensitiveContent, - ) - } else { - noNIP94( - uploadingResult = result, - localContentType = contentType, - alt = alt, - sensitiveContent = sensitiveContent, - ) - } - } catch (e: Exception) { - Log.e( - "ImageUploader", - "Failed to upload ${e.message}", - e, - ) - isUploadingImage = false - viewModelScope.launch { - imageUploadingError.emit("Failed to upload: ${e.message}") - } - } - } + this.eTags = listOf(replyNote) } - }, - onError = { - isUploadingImage = false - viewModelScope.launch { imageUploadingError.emit(it) } - }, - ) - } - } - open fun cancel() { - message = TextFieldValue("") - toUsers = TextFieldValue("") - subject = TextFieldValue("") + if (replyNote.event !is CommunityDefinitionEvent) { + replyNote.author?.let { replyUser -> + val currentMentions = + (replyNote.event as? TextNoteEvent)?.mentions()?.map { LocalCache.getOrCreateUser(it) } + ?: emptyList() - contentToAddUrl = null - urlPreview = null - isUploadingImage = false - pTags = null - - wantsDirectMessage = false - - wantsPoll = false - zapRecipients = mutableStateListOf() - pollOptions = newStateMapPollOptions() - valueMaximum = null - valueMinimum = null - consensusThreshold = null - closedAt = null - - wantsInvoice = false - wantsZapraiser = false - zapRaiserAmount = null - - wantsProduct = false - condition = ClassifiedsEvent.CONDITION.USED_LIKE_NEW - price = TextFieldValue("") - - wantsForwardZapTo = false - wantsToMarkAsSensitive = false - wantsToAddGeoHash = false - forwardZapTo = Split() - forwardZapToEditting = TextFieldValue("") - - userSuggestions = emptyList() - userSuggestionAnchor = null - userSuggestionsMainMessage = null - - NostrSearchEventOrUserDataSource.clear() - } - - open fun findUrlInMessage(): String? { - return message.text.split('\n').firstNotNullOfOrNull { paragraph -> - paragraph.split(' ').firstOrNull { word: String -> - isValidURL(word) || noProtocolUrlValidator.matcher(word).matches() - } - } - } - - open fun removeFromReplyList(userToRemove: User) { - pTags = pTags?.filter { it != userToRemove } - } - - open fun updateMessage(it: TextFieldValue) { - message = it - urlPreview = findUrlInMessage() - - if (it.selection.collapsed) { - val lastWord = - it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") - userSuggestionAnchor = it.selection - userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE - if (lastWord.startsWith("@") && lastWord.length > 2) { - NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) - viewModelScope.launch(Dispatchers.IO) { - userSuggestions = - LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) - .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })) - .reversed() + if (currentMentions.contains(replyUser)) { + this.pTags = currentMentions + } else { + this.pTags = currentMentions.plus(replyUser) + } + } + } } - } else { - NostrSearchEventOrUserDataSource.clear() - userSuggestions = emptyList() - } - } - } + ?: run { + eTags = null + pTags = null + } - open fun updateToUsers(it: TextFieldValue) { - toUsers = it - - if (it.selection.collapsed) { - val lastWord = - it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") - userSuggestionAnchor = it.selection - userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS - if (lastWord.startsWith("@") && lastWord.length > 2) { - NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) - viewModelScope.launch(Dispatchers.IO) { - userSuggestions = - LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) - .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })) - .reversed() + quote?.let { + message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}") + urlPreview = findUrlInMessage() } - } else { - NostrSearchEventOrUserDataSource.clear() - userSuggestions = emptyList() - } - } - } - open fun updateSubject(it: TextFieldValue) { - subject = it - } + canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null + canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null + canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null + contentToAddUrl = null - open fun updateZapForwardTo(it: TextFieldValue) { - forwardZapToEditting = it - if (it.selection.collapsed) { - val lastWord = it.text - userSuggestionAnchor = it.selection - userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS - if (lastWord.length > 2) { - NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) - viewModelScope.launch(Dispatchers.IO) { - userSuggestions = - LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) - .sortedWith( - compareBy( - { account?.isFollowing(it) }, - { it.toBestDisplayName() }, - ), - ) - .reversed() - } - } else { - NostrSearchEventOrUserDataSource.clear() - userSuggestions = emptyList() - } - } - } - - open fun autocompleteWithUser(item: User) { - userSuggestionAnchor?.let { - if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { - val lastWord = - message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") - val lastWordStart = it.end - lastWord.length - val wordToInsert = "@${item.pubkeyNpub()}" - - message = - TextFieldValue( - message.text.replaceRange(lastWordStart, it.end, wordToInsert), - TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length), - ) - } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { - forwardZapTo.addItem(item) + wantsForwardZapTo = false + wantsToMarkAsSensitive = false + wantsToAddGeoHash = false + wantsZapraiser = false + zapRaiserAmount = null + forwardZapTo = Split() forwardZapToEditting = TextFieldValue("") + } + + fun sendPost(relayList: List? = null) { + viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) } + } + + suspend fun innerSendPost(relayList: List? = null) { + if (accountViewModel == null) { + cancel() + return + } + + val tagger = + NewMessageTagger(message.text, pTags, eTags, originalNote?.channelHex(), accountViewModel!!) + tagger.run() + + val toUsersTagger = NewMessageTagger(toUsers.text, null, null, null, accountViewModel!!) + toUsersTagger.run() + val dmUsers = toUsersTagger.pTags + + val zapReceiver = + if (wantsForwardZapTo) { + forwardZapTo.items.map { + ZapSplitSetup( + lnAddressOrPubKeyHex = it.key.pubkeyHex, + relay = it.key.relaysBeingUsed.keys.firstOrNull(), + weight = it.percentage.toDouble(), + isLnAddress = false, + ) + } + } else { + null + } + + val geoLocation = locUtil?.locationStateFlow?.value + val geoHash = + if (wantsToAddGeoHash && geoLocation != null) { + geoLocation.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() + } else { + null + } + + val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null + + nip95attachments.forEach { + if (eTags?.contains(LocalCache.getNoteIfExists(it.second.id)) == true) { + account?.sendNip95(it.first, it.second, relayList) + } + } + + val urls = findURLs(tagger.message) + val usedAttachments = nip94attachments.filter { it.urls().intersect(urls).isNotEmpty() } + usedAttachments.forEach { account?.sendHeader(it, relayList, {}) } + + if (originalNote?.channelHex() != null) { + if (originalNote is AddressableEvent && originalNote?.address() != null) { + account?.sendLiveMessage( + tagger.message, + originalNote?.address()!!, + tagger.eTags, + tagger.pTags, + zapReceiver, + wantsToMarkAsSensitive, + localZapRaiserAmount, + geoHash, + nip94attachments = usedAttachments, + ) + } else { + account?.sendChannelMessage( + tagger.message, + tagger.channelHex!!, + tagger.eTags, + tagger.pTags, + zapReceiver, + wantsToMarkAsSensitive, + localZapRaiserAmount, + geoHash, + nip94attachments = usedAttachments, + ) + } + } else if (originalNote?.event is PrivateDmEvent) { + account?.sendPrivateMessage( + tagger.message, + originalNote!!.author!!, + originalNote!!, + tagger.pTags, + zapReceiver, + wantsToMarkAsSensitive, + localZapRaiserAmount, + geoHash, + ) + } else if (originalNote?.event is ChatMessageEvent) { + val receivers = + (originalNote?.event as ChatMessageEvent) + .recipientsPubKey() + .plus(originalNote?.author?.pubkeyHex) + .filterNotNull() + .toSet() + .toList() + + account?.sendNIP24PrivateMessage( + message = tagger.message, + toUsers = receivers, + subject = subject.text.ifBlank { null }, + replyingTo = originalNote!!, + mentions = tagger.pTags, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapReceiver = zapReceiver, + zapRaiserAmount = localZapRaiserAmount, + geohash = geoHash, + ) + } else if (!dmUsers.isNullOrEmpty()) { + if (nip24 || dmUsers.size > 1) { + account?.sendNIP24PrivateMessage( + message = tagger.message, + toUsers = dmUsers.map { it.pubkeyHex }, + subject = subject.text.ifBlank { null }, + replyingTo = tagger.eTags?.firstOrNull(), + mentions = tagger.pTags, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapReceiver = zapReceiver, + zapRaiserAmount = localZapRaiserAmount, + geohash = geoHash, + ) + } else { + account?.sendPrivateMessage( + message = tagger.message, + toUser = dmUsers.first().pubkeyHex, + replyingTo = originalNote, + mentions = tagger.pTags, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapReceiver = zapReceiver, + zapRaiserAmount = localZapRaiserAmount, + geohash = geoHash, + ) + } + } else { + if (wantsPoll) { + account?.sendPoll( + tagger.message, + tagger.eTags, + tagger.pTags, + pollOptions, + valueMaximum, + valueMinimum, + consensusThreshold, + closedAt, + zapReceiver, + wantsToMarkAsSensitive, + localZapRaiserAmount, + relayList, + geoHash, + nip94attachments = usedAttachments, + ) + } else if (wantsProduct) { + account?.sendClassifieds( + title = title.text, + price = Price(price.text, "SATS", null), + condition = condition, + message = tagger.message, + replyTo = tagger.eTags, + mentions = tagger.pTags, + location = locationText.text, + category = category.text, + directMentions = tagger.directMentions, + zapReceiver = zapReceiver, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = localZapRaiserAmount, + relayList = relayList, + geohash = geoHash, + nip94attachments = usedAttachments, + ) + } else { + // adds markers + val rootId = + (originalNote?.event as? TextNoteEvent)?.root() // if it has a marker as root + ?: originalNote + ?.replyTo + ?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true } + ?.idHex // if it has loaded events with zero replies in the reply list + ?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root. + ?: originalNote?.idHex + + val replyId = originalNote?.idHex + + account?.sendPost( + message = tagger.message, + replyTo = tagger.eTags, + mentions = tagger.pTags, + tags = null, + zapReceiver = zapReceiver, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = localZapRaiserAmount, + replyingTo = replyId, + root = rootId, + directMentions = tagger.directMentions, + relayList = relayList, + geohash = geoHash, + nip94attachments = usedAttachments, + ) + } + } + + cancel() + } + + fun upload( + galleryUri: Uri, + alt: String?, + sensitiveContent: Boolean, + isPrivate: Boolean = false, + server: ServerOption, + context: Context, + ) { + isUploadingImage = true + contentToAddUrl = null + + val contentResolver = context.contentResolver + val contentType = contentResolver.getType(galleryUri) + + viewModelScope.launch(Dispatchers.IO) { + MediaCompressor() + .compress( + galleryUri, + contentType, + context.applicationContext, + onReady = { fileUri, contentType, size -> + if (server.isNip95) { + contentResolver.openInputStream(fileUri)?.use { + createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent) + } + } else { + viewModelScope.launch(Dispatchers.IO) { + try { + val result = + Nip96Uploader(account) + .uploadImage( + uri = fileUri, + contentType = contentType, + size = size, + alt = alt, + sensitiveContent = if (sensitiveContent) "" else null, + server = server.server, + contentResolver = contentResolver, + onProgress = {}, + ) + + if (!isPrivate) { + createNIP94Record( + uploadingResult = result, + localContentType = contentType, + alt = alt, + sensitiveContent = sensitiveContent, + ) + } else { + noNIP94( + uploadingResult = result, + localContentType = contentType, + alt = alt, + sensitiveContent = sensitiveContent, + ) + } + } catch (e: Exception) { + Log.e( + "ImageUploader", + "Failed to upload ${e.message}", + e, + ) + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload: ${e.message}") + } + } + } + } + }, + onError = { + isUploadingImage = false + viewModelScope.launch { imageUploadingError.emit(it) } + }, + ) + } + } + + open fun cancel() { + message = TextFieldValue("") + toUsers = TextFieldValue("") + subject = TextFieldValue("") + + contentToAddUrl = null + urlPreview = null + isUploadingImage = false + pTags = null + + wantsDirectMessage = false + + wantsPoll = false + zapRecipients = mutableStateListOf() + pollOptions = newStateMapPollOptions() + valueMaximum = null + valueMinimum = null + consensusThreshold = null + closedAt = null + + wantsInvoice = false + wantsZapraiser = false + zapRaiserAmount = null + + wantsProduct = false + condition = ClassifiedsEvent.CONDITION.USED_LIKE_NEW + price = TextFieldValue("") + + wantsForwardZapTo = false + wantsToMarkAsSensitive = false + wantsToAddGeoHash = false + forwardZapTo = Split() + forwardZapToEditting = TextFieldValue("") + + userSuggestions = emptyList() + userSuggestionAnchor = null + userSuggestionsMainMessage = null + + NostrSearchEventOrUserDataSource.clear() + } + + open fun findUrlInMessage(): String? { + return message.text.split('\n').firstNotNullOfOrNull { paragraph -> + paragraph.split(' ').firstOrNull { word: String -> + isValidURL(word) || noProtocolUrlValidator.matcher(word).matches() + } + } + } + + open fun removeFromReplyList(userToRemove: User) { + pTags = pTags?.filter { it != userToRemove } + } + + open fun updateMessage(it: TextFieldValue) { + message = it + urlPreview = findUrlInMessage() + + if (it.selection.collapsed) { + val lastWord = + it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") + userSuggestionAnchor = it.selection + userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE + if (lastWord.startsWith("@") && lastWord.length > 2) { + NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) + viewModelScope.launch(Dispatchers.IO) { + userSuggestions = + LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) + .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })) + .reversed() + } + } else { + NostrSearchEventOrUserDataSource.clear() + userSuggestions = emptyList() + } + } + } + + open fun updateToUsers(it: TextFieldValue) { + toUsers = it + + if (it.selection.collapsed) { + val lastWord = + it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") + userSuggestionAnchor = it.selection + userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS + if (lastWord.startsWith("@") && lastWord.length > 2) { + NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) + viewModelScope.launch(Dispatchers.IO) { + userSuggestions = + LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) + .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })) + .reversed() + } + } else { + NostrSearchEventOrUserDataSource.clear() + userSuggestions = emptyList() + } + } + } + + open fun updateSubject(it: TextFieldValue) { + subject = it + } + + open fun updateZapForwardTo(it: TextFieldValue) { + forwardZapToEditting = it + if (it.selection.collapsed) { + val lastWord = it.text + userSuggestionAnchor = it.selection + userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS + if (lastWord.length > 2) { + NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) + viewModelScope.launch(Dispatchers.IO) { + userSuggestions = + LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) + .sortedWith( + compareBy( + { account?.isFollowing(it) }, + { it.toBestDisplayName() }, + ), + ) + .reversed() + } + } else { + NostrSearchEventOrUserDataSource.clear() + userSuggestions = emptyList() + } + } + } + + open fun autocompleteWithUser(item: User) { + userSuggestionAnchor?.let { + if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { + val lastWord = + message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") + val lastWordStart = it.end - lastWord.length + val wordToInsert = "@${item.pubkeyNpub()}" + + message = + TextFieldValue( + message.text.replaceRange(lastWordStart, it.end, wordToInsert), + TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length), + ) + } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { + forwardZapTo.addItem(item) + forwardZapToEditting = TextFieldValue("") /* val lastWord = forwardZapToEditting.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") val lastWordStart = it.end - lastWord.length @@ -653,342 +653,352 @@ open class NewPostViewModel() : ViewModel() { forwardZapToEditting.text.replaceRange(lastWordStart, it.end, wordToInsert), TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) )*/ - } else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) { - val lastWord = - toUsers.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") - val lastWordStart = it.end - lastWord.length - val wordToInsert = "@${item.pubkeyNpub()}" + } else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) { + val lastWord = + toUsers.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") + val lastWordStart = it.end - lastWord.length + val wordToInsert = "@${item.pubkeyNpub()}" - toUsers = - TextFieldValue( - toUsers.text.replaceRange(lastWordStart, it.end, wordToInsert), - TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length), - ) - } + toUsers = + TextFieldValue( + toUsers.text.replaceRange(lastWordStart, it.end, wordToInsert), + TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length), + ) + } - userSuggestionAnchor = null - userSuggestionsMainMessage = null - userSuggestions = emptyList() - } - } - - private fun newStateMapPollOptions(): SnapshotStateMap { - return mutableStateMapOf(Pair(0, ""), Pair(1, "")) - } - - fun canPost(): Boolean { - return message.text.isNotBlank() && - !isUploadingImage && - !wantsInvoice && - (!wantsZapraiser || zapRaiserAmount != null) && - (!wantsDirectMessage || !toUsers.text.isNullOrBlank()) && - (!wantsPoll || - (pollOptions.values.all { it.isNotEmpty() } && - isValidvalueMinimum.value && - isValidvalueMaximum.value)) && - (!wantsProduct || - (!title.text.isNullOrBlank() && - !price.text.isNullOrBlank() && - !category.text.isNullOrBlank())) && - contentToAddUrl == null - } - - fun includePollHashtagInMessage( - include: Boolean, - hashtag: String, - ) { - if (include) { - updateMessage(TextFieldValue(message.text + " $hashtag")) - } else { - updateMessage( - TextFieldValue( - message.text.replace(" $hashtag", "").replace(hashtag, ""), - ), - ) - } - } - - suspend fun createNIP94Record( - uploadingResult: Nip96Uploader.PartialEvent, - localContentType: String?, - alt: String?, - sensitiveContent: Boolean, - ) { - // Images don't seem to be ready immediately after upload - val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) - val remoteMimeType = - uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null } - val originalHash = - uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "ox" }?.get(1)?.ifBlank { null } - val dim = - uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null } - val magnet = - uploadingResult.tags - ?.firstOrNull { it.size > 1 && it[0] == "magnet" } - ?.get(1) - ?.ifBlank { null } - - if (imageUrl.isNullOrBlank()) { - Log.e("ImageDownload", "Couldn't download image from server") - cancel() - isUploadingImage = false - viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") } - return + userSuggestionAnchor = null + userSuggestionsMainMessage = null + userSuggestions = emptyList() + } } - FileHeader.prepare( - fileUrl = imageUrl, - mimeType = remoteMimeType ?: localContentType, - dimPrecomputed = dim, - onReady = { header: FileHeader -> - account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { - event, - -> - isUploadingImage = false - nip94attachments = nip94attachments + event - val contentWarning = if (sensitiveContent) "" else null - message = - TextFieldValue( - message.text + - "\n" + - addInlineMetadataAsNIP54( - imageUrl, - header.dim, - header.mimeType, - alt, - header.blurHash, - header.hash, - contentWarning, + private fun newStateMapPollOptions(): SnapshotStateMap { + return mutableStateMapOf(Pair(0, ""), Pair(1, "")) + } + + fun canPost(): Boolean { + return message.text.isNotBlank() && + !isUploadingImage && + !wantsInvoice && + (!wantsZapraiser || zapRaiserAmount != null) && + (!wantsDirectMessage || !toUsers.text.isNullOrBlank()) && + ( + !wantsPoll || + ( + pollOptions.values.all { it.isNotEmpty() } && + isValidvalueMinimum.value && + isValidvalueMaximum.value + ) + ) && + ( + !wantsProduct || + ( + !title.text.isNullOrBlank() && + !price.text.isNullOrBlank() && + !category.text.isNullOrBlank() + ) + ) && + contentToAddUrl == null + } + + fun includePollHashtagInMessage( + include: Boolean, + hashtag: String, + ) { + if (include) { + updateMessage(TextFieldValue(message.text + " $hashtag")) + } else { + updateMessage( + TextFieldValue( + message.text.replace(" $hashtag", "").replace(hashtag, ""), ), ) - urlPreview = findUrlInMessage() } - }, - onError = { - isUploadingImage = false - viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") } - }, - ) - } - - suspend fun noNIP94( - uploadingResult: Nip96Uploader.PartialEvent, - localContentType: String?, - alt: String?, - sensitiveContent: Boolean, - ) { - // Images don't seem to be ready immediately after upload - val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) - val remoteMimeType = - uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null } - val dim = - uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null } - - if (imageUrl.isNullOrBlank()) { - Log.e("ImageDownload", "Couldn't download image from server") - cancel() - isUploadingImage = false - viewModelScope.launch { imageUploadingError.emit("Server failed to return a url") } - return } - FileHeader.prepare( - fileUrl = imageUrl, - mimeType = remoteMimeType ?: localContentType, - dimPrecomputed = dim, - onReady = { header: FileHeader -> - isUploadingImage = false - val contentWarning = if (sensitiveContent) "" else null - message = - TextFieldValue( - message.text + - "\n" + - addInlineMetadataAsNIP54( - imageUrl, - header.dim, - header.mimeType, - alt, - header.blurHash, - header.hash, - contentWarning, - ), - ) - urlPreview = findUrlInMessage() - }, - onError = { - isUploadingImage = false - viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") } - }, - ) - } - - fun addInlineMetadataAsNIP54( - imageUrl: String, - dim: String?, - m: String?, - alt: String?, - blurHash: String?, - x: String?, - sensitiveContent: String?, - ): String { - val extension = - listOfNotNull( - m?.ifBlank { null }?.let { "m=${URLEncoder.encode(it, "utf-8")}" }, - dim?.ifBlank { null }?.let { "dim=${URLEncoder.encode(it, "utf-8")}" }, - alt?.ifBlank { null }?.let { "alt=${URLEncoder.encode(it, "utf-8")}" }, - blurHash?.ifBlank { null }?.let { "blurhash=${URLEncoder.encode(it, "utf-8")}" }, - x?.ifBlank { null }?.let { "x=${URLEncoder.encode(it, "utf-8")}" }, - sensitiveContent - ?.ifBlank { null } - ?.let { "content-warning=${URLEncoder.encode(it, "utf-8")}" }, - ) - .joinToString("&") - - return if (imageUrl.contains("#")) { - "$imageUrl&$extension" - } else { - "$imageUrl#$extension" - } - } - - fun createNIP95Record( - bytes: ByteArray, - mimeType: String?, - alt: String?, - sensitiveContent: Boolean, - ) { - if (bytes.size > 80000) { - viewModelScope.launch { - imageUploadingError.emit("Media is too big for NIP-95") - isUploadingImage = false - } - return - } - - viewModelScope.launch(Dispatchers.IO) { - FileHeader.prepare( - bytes, - mimeType, - null, - onReady = { - account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 -> - nip95attachments = nip95attachments + nip95 - val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } + suspend fun createNIP94Record( + uploadingResult: Nip96Uploader.PartialEvent, + localContentType: String?, + alt: String?, + sensitiveContent: Boolean, + ) { + // Images don't seem to be ready immediately after upload + val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) + val remoteMimeType = + uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null } + val originalHash = + uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "ox" }?.get(1)?.ifBlank { null } + val dim = + uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null } + val magnet = + uploadingResult.tags + ?.firstOrNull { it.size > 1 && it[0] == "magnet" } + ?.get(1) + ?.ifBlank { null } + if (imageUrl.isNullOrBlank()) { + Log.e("ImageDownload", "Couldn't download image from server") + cancel() isUploadingImage = false - - note?.let { message = TextFieldValue(message.text + "\nnostr:" + it.toNEvent()) } - - urlPreview = findUrlInMessage() - } - }, - onError = { - isUploadingImage = false - viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") } - }, - ) - } - } - - fun selectImage(uri: Uri) { - contentToAddUrl = uri - } - - @OptIn(ExperimentalCoroutinesApi::class) - fun startLocation(context: Context) { - locUtil = LocationUtil(context) - locUtil?.let { - location = - it.locationStateFlow.mapLatest { it.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() } - } - viewModelScope.launch(Dispatchers.IO) { locUtil?.start() } - } - - fun stopLocation() { - viewModelScope.launch(Dispatchers.IO) { locUtil?.stop() } - location = null - locUtil = null - } - - override fun onCleared() { - super.onCleared() - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - viewModelScope.launch(Dispatchers.IO) { locUtil?.stop() } - location = null - locUtil = null - } - - fun toggleNIP04And24() { - if (requiresNIP24) { - nip24 = true - } else { - nip24 = !nip24 - } - } - - fun updateMinZapAmountForPoll(textMin: String) { - if (textMin.isNotEmpty()) { - try { - val int = textMin.toInt() - if (int < 1) { - valueMinimum = null - } else { - valueMinimum = int + viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") } + return } - } catch (e: Exception) {} - } else { - valueMinimum = null + + FileHeader.prepare( + fileUrl = imageUrl, + mimeType = remoteMimeType ?: localContentType, + dimPrecomputed = dim, + onReady = { header: FileHeader -> + account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { + event, + -> + isUploadingImage = false + nip94attachments = nip94attachments + event + val contentWarning = if (sensitiveContent) "" else null + message = + TextFieldValue( + message.text + + "\n" + + addInlineMetadataAsNIP54( + imageUrl, + header.dim, + header.mimeType, + alt, + header.blurHash, + header.hash, + contentWarning, + ), + ) + urlPreview = findUrlInMessage() + } + }, + onError = { + isUploadingImage = false + viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") } + }, + ) } - checkMinMax() - } + suspend fun noNIP94( + uploadingResult: Nip96Uploader.PartialEvent, + localContentType: String?, + alt: String?, + sensitiveContent: Boolean, + ) { + // Images don't seem to be ready immediately after upload + val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) + val remoteMimeType = + uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null } + val dim = + uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null } - fun updateMaxZapAmountForPoll(textMax: String) { - if (textMax.isNotEmpty()) { - try { - val int = textMax.toInt() - if (int < 1) { - valueMaximum = null - } else { - valueMaximum = int + if (imageUrl.isNullOrBlank()) { + Log.e("ImageDownload", "Couldn't download image from server") + cancel() + isUploadingImage = false + viewModelScope.launch { imageUploadingError.emit("Server failed to return a url") } + return } - } catch (e: Exception) {} - } else { - valueMaximum = null + + FileHeader.prepare( + fileUrl = imageUrl, + mimeType = remoteMimeType ?: localContentType, + dimPrecomputed = dim, + onReady = { header: FileHeader -> + isUploadingImage = false + val contentWarning = if (sensitiveContent) "" else null + message = + TextFieldValue( + message.text + + "\n" + + addInlineMetadataAsNIP54( + imageUrl, + header.dim, + header.mimeType, + alt, + header.blurHash, + header.hash, + contentWarning, + ), + ) + urlPreview = findUrlInMessage() + }, + onError = { + isUploadingImage = false + viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") } + }, + ) } - checkMinMax() - } + fun addInlineMetadataAsNIP54( + imageUrl: String, + dim: String?, + m: String?, + alt: String?, + blurHash: String?, + x: String?, + sensitiveContent: String?, + ): String { + val extension = + listOfNotNull( + m?.ifBlank { null }?.let { "m=${URLEncoder.encode(it, "utf-8")}" }, + dim?.ifBlank { null }?.let { "dim=${URLEncoder.encode(it, "utf-8")}" }, + alt?.ifBlank { null }?.let { "alt=${URLEncoder.encode(it, "utf-8")}" }, + blurHash?.ifBlank { null }?.let { "blurhash=${URLEncoder.encode(it, "utf-8")}" }, + x?.ifBlank { null }?.let { "x=${URLEncoder.encode(it, "utf-8")}" }, + sensitiveContent + ?.ifBlank { null } + ?.let { "content-warning=${URLEncoder.encode(it, "utf-8")}" }, + ) + .joinToString("&") - fun checkMinMax() { - if ((valueMinimum ?: 0) > (valueMaximum ?: Int.MAX_VALUE)) { - isValidvalueMinimum.value = false - isValidvalueMaximum.value = false - } else { - isValidvalueMinimum.value = true - isValidvalueMaximum.value = true + return if (imageUrl.contains("#")) { + "$imageUrl&$extension" + } else { + "$imageUrl#$extension" + } } - } - fun updateZapPercentage( - index: Int, - sliderValue: Float, - ) { - forwardZapTo.updatePercentage(index, sliderValue) - } + fun createNIP95Record( + bytes: ByteArray, + mimeType: String?, + alt: String?, + sensitiveContent: Boolean, + ) { + if (bytes.size > 80000) { + viewModelScope.launch { + imageUploadingError.emit("Media is too big for NIP-95") + isUploadingImage = false + } + return + } + + viewModelScope.launch(Dispatchers.IO) { + FileHeader.prepare( + bytes, + mimeType, + null, + onReady = { + account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 -> + nip95attachments = nip95attachments + nip95 + val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } + + isUploadingImage = false + + note?.let { message = TextFieldValue(message.text + "\nnostr:" + it.toNEvent()) } + + urlPreview = findUrlInMessage() + } + }, + onError = { + isUploadingImage = false + viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") } + }, + ) + } + } + + fun selectImage(uri: Uri) { + contentToAddUrl = uri + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun startLocation(context: Context) { + locUtil = LocationUtil(context) + locUtil?.let { + location = + it.locationStateFlow.mapLatest { it.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() } + } + viewModelScope.launch(Dispatchers.IO) { locUtil?.start() } + } + + fun stopLocation() { + viewModelScope.launch(Dispatchers.IO) { locUtil?.stop() } + location = null + locUtil = null + } + + override fun onCleared() { + super.onCleared() + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + viewModelScope.launch(Dispatchers.IO) { locUtil?.stop() } + location = null + locUtil = null + } + + fun toggleNIP04And24() { + if (requiresNIP24) { + nip24 = true + } else { + nip24 = !nip24 + } + } + + fun updateMinZapAmountForPoll(textMin: String) { + if (textMin.isNotEmpty()) { + try { + val int = textMin.toInt() + if (int < 1) { + valueMinimum = null + } else { + valueMinimum = int + } + } catch (e: Exception) { + } + } else { + valueMinimum = null + } + + checkMinMax() + } + + fun updateMaxZapAmountForPoll(textMax: String) { + if (textMax.isNotEmpty()) { + try { + val int = textMax.toInt() + if (int < 1) { + valueMaximum = null + } else { + valueMaximum = int + } + } catch (e: Exception) { + } + } else { + valueMaximum = null + } + + checkMinMax() + } + + fun checkMinMax() { + if ((valueMinimum ?: 0) > (valueMaximum ?: Int.MAX_VALUE)) { + isValidvalueMinimum.value = false + isValidvalueMaximum.value = false + } else { + isValidvalueMinimum.value = true + isValidvalueMaximum.value = true + } + } + + fun updateZapPercentage( + index: Int, + sliderValue: Float, + ) { + forwardZapTo.updatePercentage(index, sliderValue) + } } enum class GeohashPrecision(val digits: Int) { - KM_5000_X_5000(1), // 5,000km ร— 5,000km - KM_1250_X_625(2), // 1,250km ร— 625km - KM_156_X_156(3), // 156km ร— 156km - KM_39_X_19(4), // 39.1km ร— 19.5km - KM_5_X_5(5), // 4.89km ร— 4.89km - M_1000_X_600(6), // 1.22km ร— 0.61km - M_153_X_153(7), // 153m ร— 153m - M_38_X_19(8), // 38.2m ร— 19.1m - M_5_X_5(9), // 4.77m ร— 4.77m - MM_1000_X_1000(10), // 1.19m ร— 0.596m - MM_149_X_149(11), // 149mm ร— 149mm - MM_37_X_18(12), // 37.2mm ร— 18.6mm + KM_5000_X_5000(1), // 5,000km ร— 5,000km + KM_1250_X_625(2), // 1,250km ร— 625km + KM_156_X_156(3), // 156km ร— 156km + KM_39_X_19(4), // 39.1km ร— 19.5km + KM_5_X_5(5), // 4.89km ร— 4.89km + M_1000_X_600(6), // 1.22km ร— 0.61km + M_153_X_153(7), // 153m ร— 153m + M_38_X_19(8), // 38.2m ร— 19.1m + M_5_X_5(9), // 4.77m ร— 4.77m + MM_1000_X_1000(10), // 1.19m ร— 0.596m + MM_149_X_149(11), // 149mm ร— 149mm + MM_37_X_18(12), // 37.2mm ร— 18.6mm } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt index 85b6e1973..53449fc1c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt @@ -101,839 +101,839 @@ import com.vitorpamplona.amethyst.ui.theme.allGoodColor import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.warningColor -import java.lang.Math.round import kotlinx.coroutines.launch +import java.lang.Math.round @OptIn(ExperimentalMaterial3Api::class) @Composable fun NewRelayListView( - onClose: () -> Unit, - accountViewModel: AccountViewModel, - relayToAdd: String = "", - nav: (String) -> Unit, + onClose: () -> Unit, + accountViewModel: AccountViewModel, + relayToAdd: String = "", + nav: (String) -> Unit, ) { - val postViewModel: NewRelayListViewModel = viewModel() - val feedState by postViewModel.relays.collectAsStateWithLifecycle() + val postViewModel: NewRelayListViewModel = viewModel() + val feedState by postViewModel.relays.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { postViewModel.load(accountViewModel.account) } + LaunchedEffect(Unit) { postViewModel.load(accountViewModel.account) } - Dialog( - onDismissRequest = onClose, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Scaffold( - topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Scaffold( + topBar = { + TopAppBar( + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = StdHorzSpacer) + + Button( + onClick = { + postViewModel.deleteAll() + defaultRelays.forEach { postViewModel.addRelay(it) } + postViewModel.loadRelayDocuments() + }, + ) { + Text(stringResource(R.string.default_relays)) + } + + SaveButton( + onPost = { + postViewModel.create() + onClose() + }, + true, + ) + } + }, + navigationIcon = { + Spacer(modifier = StdHorzSpacer) + CloseButton( + onPress = { + postViewModel.clear() + onClose() + }, + ) + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + ) { pad -> + Column( + modifier = + Modifier.padding( + 16.dp, + pad.calculateTopPadding(), + 16.dp, + pad.calculateBottomPadding(), + ), + verticalArrangement = Arrangement.SpaceAround, ) { - Spacer(modifier = StdHorzSpacer) + Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { + LazyColumn( + contentPadding = FeedPadding, + ) { + itemsIndexed(feedState, key = { _, item -> item.url }) { index, item -> + ServerConfig( + item, + onToggleDownload = { postViewModel.toggleDownload(it) }, + onToggleUpload = { postViewModel.toggleUpload(it) }, + onToggleFollows = { postViewModel.toggleFollows(it) }, + onTogglePrivateDMs = { postViewModel.toggleMessages(it) }, + onTogglePublicChats = { postViewModel.togglePublicChats(it) }, + onToggleGlobal = { postViewModel.toggleGlobal(it) }, + onToggleSearch = { postViewModel.toggleSearch(it) }, + onDelete = { postViewModel.deleteRelay(it) }, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } - Button( - onClick = { - postViewModel.deleteAll() - defaultRelays.forEach { postViewModel.addRelay(it) } - postViewModel.loadRelayDocuments() - }, - ) { - Text(stringResource(R.string.default_relays)) - } + Spacer(modifier = StdVertSpacer) - SaveButton( - onPost = { - postViewModel.create() - onClose() - }, - true, - ) + EditableServerConfig(relayToAdd) { postViewModel.addRelay(it) } } - }, - navigationIcon = { - Spacer(modifier = StdHorzSpacer) - CloseButton( - onPress = { - postViewModel.clear() - onClose() - }, - ) - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) - }, - ) { pad -> - Column( - modifier = - Modifier.padding( - 16.dp, - pad.calculateTopPadding(), - 16.dp, - pad.calculateBottomPadding(), - ), - verticalArrangement = Arrangement.SpaceAround, - ) { - Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { - LazyColumn( - contentPadding = FeedPadding, - ) { - itemsIndexed(feedState, key = { _, item -> item.url }) { index, item -> - ServerConfig( - item, - onToggleDownload = { postViewModel.toggleDownload(it) }, - onToggleUpload = { postViewModel.toggleUpload(it) }, - onToggleFollows = { postViewModel.toggleFollows(it) }, - onTogglePrivateDMs = { postViewModel.toggleMessages(it) }, - onTogglePublicChats = { postViewModel.togglePublicChats(it) }, - onToggleGlobal = { postViewModel.toggleGlobal(it) }, - onToggleSearch = { postViewModel.toggleSearch(it) }, - onDelete = { postViewModel.deleteRelay(it) }, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } } - - Spacer(modifier = StdVertSpacer) - - EditableServerConfig(relayToAdd) { postViewModel.addRelay(it) } - } } - } } @Composable fun ServerConfigHeader() { - Column(Modifier.fillMaxWidth()) { - Row(verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { + Column(Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.relay_address), - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.relay_address), + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Column(Modifier.weight(1.4f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(modifier = Modifier.size(30.dp)) + + Text( + text = stringResource(R.string.bytes), + maxLines = 1, + fontSize = Font14SP, + modifier = Modifier.weight(1.2f), + color = MaterialTheme.colorScheme.placeholderText, + ) + + Spacer(modifier = Modifier.size(5.dp)) + + Text( + text = stringResource(id = R.string.bytes), + maxLines = 1, + fontSize = Font14SP, + modifier = Modifier.weight(1.2f), + color = MaterialTheme.colorScheme.placeholderText, + ) + + Spacer(modifier = Modifier.size(5.dp)) + + Text( + text = stringResource(R.string.errors), + maxLines = 1, + fontSize = Font14SP, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.placeholderText, + ) + + Spacer(modifier = Modifier.size(5.dp)) + + Text( + text = stringResource(R.string.spam), + maxLines = 1, + fontSize = Font14SP, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.placeholderText, + ) + + Spacer(modifier = Modifier.size(2.dp)) + } + } } - } - Column(Modifier.weight(1.4f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Spacer(modifier = Modifier.size(30.dp)) - - Text( - text = stringResource(R.string.bytes), - maxLines = 1, - fontSize = Font14SP, - modifier = Modifier.weight(1.2f), - color = MaterialTheme.colorScheme.placeholderText, - ) - - Spacer(modifier = Modifier.size(5.dp)) - - Text( - text = stringResource(id = R.string.bytes), - maxLines = 1, - fontSize = Font14SP, - modifier = Modifier.weight(1.2f), - color = MaterialTheme.colorScheme.placeholderText, - ) - - Spacer(modifier = Modifier.size(5.dp)) - - Text( - text = stringResource(R.string.errors), - maxLines = 1, - fontSize = Font14SP, - modifier = Modifier.weight(1f), - color = MaterialTheme.colorScheme.placeholderText, - ) - - Spacer(modifier = Modifier.size(5.dp)) - - Text( - text = stringResource(R.string.spam), - maxLines = 1, - fontSize = Font14SP, - modifier = Modifier.weight(1f), - color = MaterialTheme.colorScheme.placeholderText, - ) - - Spacer(modifier = Modifier.size(2.dp)) - } - } + Divider( + thickness = DividerThickness, + ) } - - Divider( - thickness = DividerThickness, - ) - } } @Preview @Composable fun ServerConfigPreview() { - ServerConfigClickableLine( - loadProfilePicture = true, - item = - RelaySetupInfo( - url = "nostr.mom", - read = true, - write = true, - errorCount = 23, - downloadCountInBytes = 10000, - uploadCountInBytes = 10000000, - spamCount = 10, - feedTypes = Constants.activeTypesGlobalChats, - paidRelay = true, - ), - onDelete = {}, - onToggleDownload = {}, - onToggleUpload = {}, - onToggleFollows = {}, - onTogglePrivateDMs = {}, - onTogglePublicChats = {}, - onToggleGlobal = {}, - onToggleSearch = {}, - onClick = {}, - ) + ServerConfigClickableLine( + loadProfilePicture = true, + item = + RelaySetupInfo( + url = "nostr.mom", + read = true, + write = true, + errorCount = 23, + downloadCountInBytes = 10000, + uploadCountInBytes = 10000000, + spamCount = 10, + feedTypes = Constants.activeTypesGlobalChats, + paidRelay = true, + ), + onDelete = {}, + onToggleDownload = {}, + onToggleUpload = {}, + onToggleFollows = {}, + onTogglePrivateDMs = {}, + onTogglePublicChats = {}, + onToggleGlobal = {}, + onToggleSearch = {}, + onClick = {}, + ) } @Composable fun ServerConfig( - item: RelaySetupInfo, - onToggleDownload: (RelaySetupInfo) -> Unit, - onToggleUpload: (RelaySetupInfo) -> Unit, - onToggleFollows: (RelaySetupInfo) -> Unit, - onTogglePrivateDMs: (RelaySetupInfo) -> Unit, - onTogglePublicChats: (RelaySetupInfo) -> Unit, - onToggleGlobal: (RelaySetupInfo) -> Unit, - onToggleSearch: (RelaySetupInfo) -> Unit, - onDelete: (RelaySetupInfo) -> Unit, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + item: RelaySetupInfo, + onToggleDownload: (RelaySetupInfo) -> Unit, + onToggleUpload: (RelaySetupInfo) -> Unit, + onToggleFollows: (RelaySetupInfo) -> Unit, + onTogglePrivateDMs: (RelaySetupInfo) -> Unit, + onTogglePublicChats: (RelaySetupInfo) -> Unit, + onToggleGlobal: (RelaySetupInfo) -> Unit, + onToggleSearch: (RelaySetupInfo) -> Unit, + onDelete: (RelaySetupInfo) -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) } - val context = LocalContext.current + var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) } + val context = LocalContext.current - relayInfo?.let { - RelayInformationDialog( - onClose = { relayInfo = null }, - relayInfo = it.relayInfo, - relayBriefInfo = it.relayBriefInfo, - accountViewModel = accountViewModel, - nav = nav, - ) - } + relayInfo?.let { + RelayInformationDialog( + onClose = { relayInfo = null }, + relayInfo = it.relayInfo, + relayBriefInfo = it.relayBriefInfo, + accountViewModel = accountViewModel, + nav = nav, + ) + } - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } + val automaticallyShowProfilePicture = + remember { + accountViewModel.settings.showProfilePictures.value + } - ServerConfigClickableLine( - item = item, - loadProfilePicture = automaticallyShowProfilePicture, - onToggleDownload = onToggleDownload, - onToggleUpload = onToggleUpload, - onToggleFollows = onToggleFollows, - onTogglePrivateDMs = onTogglePrivateDMs, - onTogglePublicChats = onTogglePublicChats, - onToggleGlobal = onToggleGlobal, - onToggleSearch = onToggleSearch, - onDelete = onDelete, - onClick = { - accountViewModel.retrieveRelayDocument( - item.url, - onInfo = { relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it) }, - onError = { url, errorCode, exceptionMessage -> - val msg = - when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> - context.getString( - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> - context.getString( - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> - context.getString( - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> - context.getString( - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - } + ServerConfigClickableLine( + item = item, + loadProfilePicture = automaticallyShowProfilePicture, + onToggleDownload = onToggleDownload, + onToggleUpload = onToggleUpload, + onToggleFollows = onToggleFollows, + onTogglePrivateDMs = onTogglePrivateDMs, + onTogglePublicChats = onTogglePublicChats, + onToggleGlobal = onToggleGlobal, + onToggleSearch = onToggleSearch, + onDelete = onDelete, + onClick = { + accountViewModel.retrieveRelayDocument( + item.url, + onInfo = { relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it) }, + onError = { url, errorCode, exceptionMessage -> + val msg = + when (errorCode) { + Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + } - accountViewModel.toast( - context.getString(R.string.unable_to_download_relay_document), - msg, - ) + accountViewModel.toast( + context.getString(R.string.unable_to_download_relay_document), + msg, + ) + }, + ) }, - ) - }, - ) + ) } @Composable fun ServerConfigClickableLine( - item: RelaySetupInfo, - loadProfilePicture: Boolean, - onToggleDownload: (RelaySetupInfo) -> Unit, - onToggleUpload: (RelaySetupInfo) -> Unit, - onToggleFollows: (RelaySetupInfo) -> Unit, - onTogglePrivateDMs: (RelaySetupInfo) -> Unit, - onTogglePublicChats: (RelaySetupInfo) -> Unit, - onToggleGlobal: (RelaySetupInfo) -> Unit, - onToggleSearch: (RelaySetupInfo) -> Unit, - onDelete: (RelaySetupInfo) -> Unit, - onClick: () -> Unit, + item: RelaySetupInfo, + loadProfilePicture: Boolean, + onToggleDownload: (RelaySetupInfo) -> Unit, + onToggleUpload: (RelaySetupInfo) -> Unit, + onToggleFollows: (RelaySetupInfo) -> Unit, + onTogglePrivateDMs: (RelaySetupInfo) -> Unit, + onTogglePublicChats: (RelaySetupInfo) -> Unit, + onToggleGlobal: (RelaySetupInfo) -> Unit, + onToggleSearch: (RelaySetupInfo) -> Unit, + onDelete: (RelaySetupInfo) -> Unit, + onClick: () -> Unit, ) { - Column(Modifier.fillMaxWidth()) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 5.dp), - ) { - Column(Modifier.clickable(onClick = onClick)) { - RenderRelayIcon( - item.briefInfo.displayUrl, - item.briefInfo.favIcon, - loadProfilePicture, - MaterialTheme.colorScheme.largeRelayIconModifier, + Column(Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 5.dp), + ) { + Column(Modifier.clickable(onClick = onClick)) { + RenderRelayIcon( + item.briefInfo.displayUrl, + item.briefInfo.favIcon, + loadProfilePicture, + MaterialTheme.colorScheme.largeRelayIconModifier, + ) + } + + Spacer(modifier = HalfHorzPadding) + + Column(Modifier.weight(1f)) { + FirstLine(item, onClick, onDelete, ReactionRowHeightChat.fillMaxWidth()) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = ReactionRowHeightChat.fillMaxWidth(), + ) { + RenderActiveToggles( + item = item, + onToggleFollows = onToggleFollows, + onTogglePrivateDMs = onTogglePrivateDMs, + onTogglePublicChats = onTogglePublicChats, + onToggleGlobal = onToggleGlobal, + onToggleSearch = onToggleSearch, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = ReactionRowHeightChat.fillMaxWidth(), + ) { + RenderStatusRow( + item = item, + onToggleDownload = onToggleDownload, + onToggleUpload = onToggleUpload, + modifier = HalfStartPadding.weight(1f), + ) + } + } + } + + Divider( + thickness = DividerThickness, ) - } - - Spacer(modifier = HalfHorzPadding) - - Column(Modifier.weight(1f)) { - FirstLine(item, onClick, onDelete, ReactionRowHeightChat.fillMaxWidth()) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = ReactionRowHeightChat.fillMaxWidth(), - ) { - RenderActiveToggles( - item = item, - onToggleFollows = onToggleFollows, - onTogglePrivateDMs = onTogglePrivateDMs, - onTogglePublicChats = onTogglePublicChats, - onToggleGlobal = onToggleGlobal, - onToggleSearch = onToggleSearch, - ) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = ReactionRowHeightChat.fillMaxWidth(), - ) { - RenderStatusRow( - item = item, - onToggleDownload = onToggleDownload, - onToggleUpload = onToggleUpload, - modifier = HalfStartPadding.weight(1f), - ) - } - } } - - Divider( - thickness = DividerThickness, - ) - } } @Composable @OptIn(ExperimentalFoundationApi::class) private fun RenderStatusRow( - item: RelaySetupInfo, - onToggleDownload: (RelaySetupInfo) -> Unit, - onToggleUpload: (RelaySetupInfo) -> Unit, - modifier: Modifier, + item: RelaySetupInfo, + onToggleDownload: (RelaySetupInfo) -> Unit, + onToggleUpload: (RelaySetupInfo) -> Unit, + modifier: Modifier, ) { - val scope = rememberCoroutineScope() - val context = LocalContext.current + val scope = rememberCoroutineScope() + val context = LocalContext.current - Icon( - imageVector = Icons.Default.Download, - contentDescription = stringResource(R.string.read_from_relay), - modifier = - Modifier.size(15.dp) - .combinedClickable( - onClick = { onToggleDownload(item) }, - onLongClick = { - scope.launch { - Toast.makeText( - context, - context.getString(R.string.read_from_relay), - Toast.LENGTH_SHORT, + Icon( + imageVector = Icons.Default.Download, + contentDescription = stringResource(R.string.read_from_relay), + modifier = + Modifier.size(15.dp) + .combinedClickable( + onClick = { onToggleDownload(item) }, + onLongClick = { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.read_from_relay), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ), + tint = + if (item.read) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.32f, ) - .show() - } - }, - ), - tint = - if (item.read) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) + }, + ) - Text( - text = countToHumanReadableBytes(item.downloadCountInBytes), - maxLines = 1, - fontSize = 12.sp, - modifier = modifier, - color = MaterialTheme.colorScheme.placeholderText, - ) + Text( + text = countToHumanReadableBytes(item.downloadCountInBytes), + maxLines = 1, + fontSize = 12.sp, + modifier = modifier, + color = MaterialTheme.colorScheme.placeholderText, + ) - Icon( - imageVector = Icons.Default.Upload, - stringResource(R.string.write_to_relay), - modifier = - Modifier.size(15.dp) - .combinedClickable( - onClick = { onToggleUpload(item) }, - onLongClick = { - scope.launch { - Toast.makeText( - context, - context.getString(R.string.write_to_relay), - Toast.LENGTH_SHORT, + Icon( + imageVector = Icons.Default.Upload, + stringResource(R.string.write_to_relay), + modifier = + Modifier.size(15.dp) + .combinedClickable( + onClick = { onToggleUpload(item) }, + onLongClick = { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.write_to_relay), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ), + tint = + if (item.write) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.32f, ) - .show() - } - }, - ), - tint = - if (item.write) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) + }, + ) - Text( - text = countToHumanReadableBytes(item.uploadCountInBytes), - maxLines = 1, - fontSize = 12.sp, - modifier = modifier, - color = MaterialTheme.colorScheme.placeholderText, - ) + Text( + text = countToHumanReadableBytes(item.uploadCountInBytes), + maxLines = 1, + fontSize = 12.sp, + modifier = modifier, + color = MaterialTheme.colorScheme.placeholderText, + ) - Icon( - imageVector = Icons.Default.SyncProblem, - stringResource(R.string.errors), - modifier = - Modifier.size(15.dp) - .combinedClickable( - onClick = {}, - onLongClick = { - scope.launch { - Toast.makeText( - context, - context.getString(R.string.errors), - Toast.LENGTH_SHORT, - ) - .show() - } - }, - ), - tint = - if (item.errorCount > 0) { - MaterialTheme.colorScheme.warningColor - } else { - MaterialTheme.colorScheme.allGoodColor - }, - ) + Icon( + imageVector = Icons.Default.SyncProblem, + stringResource(R.string.errors), + modifier = + Modifier.size(15.dp) + .combinedClickable( + onClick = {}, + onLongClick = { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.errors), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ), + tint = + if (item.errorCount > 0) { + MaterialTheme.colorScheme.warningColor + } else { + MaterialTheme.colorScheme.allGoodColor + }, + ) - Text( - text = countToHumanReadable(item.errorCount, "errors"), - maxLines = 1, - fontSize = 12.sp, - modifier = modifier, - color = MaterialTheme.colorScheme.placeholderText, - ) + Text( + text = countToHumanReadable(item.errorCount, "errors"), + maxLines = 1, + fontSize = 12.sp, + modifier = modifier, + color = MaterialTheme.colorScheme.placeholderText, + ) - Icon( - imageVector = Icons.Default.DeleteSweep, - stringResource(R.string.spam), - modifier = - Modifier.size(15.dp) - .combinedClickable( - onClick = {}, - onLongClick = { - scope.launch { - Toast.makeText( - context, - context.getString(R.string.spam), - Toast.LENGTH_SHORT, - ) - .show() - } - }, - ), - tint = - if (item.spamCount > 0) { - MaterialTheme.colorScheme.warningColor - } else { - MaterialTheme.colorScheme.allGoodColor - }, - ) + Icon( + imageVector = Icons.Default.DeleteSweep, + stringResource(R.string.spam), + modifier = + Modifier.size(15.dp) + .combinedClickable( + onClick = {}, + onLongClick = { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.spam), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ), + tint = + if (item.spamCount > 0) { + MaterialTheme.colorScheme.warningColor + } else { + MaterialTheme.colorScheme.allGoodColor + }, + ) - Text( - text = countToHumanReadable(item.spamCount, "spam"), - maxLines = 1, - fontSize = 12.sp, - modifier = modifier, - color = MaterialTheme.colorScheme.placeholderText, - ) + Text( + text = countToHumanReadable(item.spamCount, "spam"), + maxLines = 1, + fontSize = 12.sp, + modifier = modifier, + color = MaterialTheme.colorScheme.placeholderText, + ) } @Composable @OptIn(ExperimentalFoundationApi::class) private fun RenderActiveToggles( - item: RelaySetupInfo, - onToggleFollows: (RelaySetupInfo) -> Unit, - onTogglePrivateDMs: (RelaySetupInfo) -> Unit, - onTogglePublicChats: (RelaySetupInfo) -> Unit, - onToggleGlobal: (RelaySetupInfo) -> Unit, - onToggleSearch: (RelaySetupInfo) -> Unit, + item: RelaySetupInfo, + onToggleFollows: (RelaySetupInfo) -> Unit, + onTogglePrivateDMs: (RelaySetupInfo) -> Unit, + onTogglePublicChats: (RelaySetupInfo) -> Unit, + onToggleGlobal: (RelaySetupInfo) -> Unit, + onToggleSearch: (RelaySetupInfo) -> Unit, ) { - val scope = rememberCoroutineScope() - val context = LocalContext.current + val scope = rememberCoroutineScope() + val context = LocalContext.current - Text( - text = stringResource(id = R.string.active_for), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(start = 2.dp, end = 5.dp), - fontSize = 14.sp, - ) + Text( + text = stringResource(id = R.string.active_for), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(start = 2.dp, end = 5.dp), + fontSize = 14.sp, + ) - IconButton( - modifier = Size30Modifier, - onClick = { onToggleFollows(item) }, - ) { - Icon( - painterResource(R.drawable.ic_home), - stringResource(R.string.home_feed), - modifier = - Modifier.padding(horizontal = 5.dp) - .size(15.dp) - .combinedClickable( - onClick = { onToggleFollows(item) }, - onLongClick = { - scope.launch { - Toast.makeText( - context, - context.getString(R.string.home_feed), - Toast.LENGTH_SHORT, - ) - .show() - } - }, - ), - tint = - if (item.feedTypes.contains(FeedType.FOLLOWS)) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - } - IconButton( - modifier = Size30Modifier, - onClick = { onTogglePrivateDMs(item) }, - ) { - Icon( - painterResource(R.drawable.ic_dm), - stringResource(R.string.private_message_feed), - modifier = - Modifier.padding(horizontal = 5.dp) - .size(15.dp) - .combinedClickable( - onClick = { onTogglePrivateDMs(item) }, - onLongClick = { - scope.launch { - Toast.makeText( - context, - context.getString(R.string.private_message_feed), - Toast.LENGTH_SHORT, - ) - .show() - } - }, - ), - tint = - if (item.feedTypes.contains(FeedType.PRIVATE_DMS)) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - } - IconButton( - modifier = Size30Modifier, - onClick = { onTogglePublicChats(item) }, - ) { - Icon( - imageVector = Icons.Default.Groups, - contentDescription = stringResource(R.string.public_chat_feed), - modifier = - Modifier.padding(horizontal = 5.dp) - .size(15.dp) - .combinedClickable( - onClick = { onTogglePublicChats(item) }, - onLongClick = { - scope.launch { - Toast.makeText( - context, - context.getString(R.string.public_chat_feed), - Toast.LENGTH_SHORT, - ) - .show() - } - }, - ), - tint = - if (item.feedTypes.contains(FeedType.PUBLIC_CHATS)) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - } - IconButton( - modifier = Size30Modifier, - onClick = { onToggleGlobal(item) }, - ) { - Icon( - imageVector = Icons.Default.Public, - stringResource(R.string.global_feed), - modifier = - Modifier.padding(horizontal = 5.dp) - .size(15.dp) - .combinedClickable( - onClick = { onToggleGlobal(item) }, - onLongClick = { - scope.launch { - Toast.makeText( - context, - context.getString(R.string.global_feed), - Toast.LENGTH_SHORT, - ) - .show() - } - }, - ), - tint = - if (item.feedTypes.contains(FeedType.GLOBAL)) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - } + IconButton( + modifier = Size30Modifier, + onClick = { onToggleFollows(item) }, + ) { + Icon( + painterResource(R.drawable.ic_home), + stringResource(R.string.home_feed), + modifier = + Modifier.padding(horizontal = 5.dp) + .size(15.dp) + .combinedClickable( + onClick = { onToggleFollows(item) }, + onLongClick = { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.home_feed), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ), + tint = + if (item.feedTypes.contains(FeedType.FOLLOWS)) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.32f, + ) + }, + ) + } + IconButton( + modifier = Size30Modifier, + onClick = { onTogglePrivateDMs(item) }, + ) { + Icon( + painterResource(R.drawable.ic_dm), + stringResource(R.string.private_message_feed), + modifier = + Modifier.padding(horizontal = 5.dp) + .size(15.dp) + .combinedClickable( + onClick = { onTogglePrivateDMs(item) }, + onLongClick = { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.private_message_feed), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ), + tint = + if (item.feedTypes.contains(FeedType.PRIVATE_DMS)) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.32f, + ) + }, + ) + } + IconButton( + modifier = Size30Modifier, + onClick = { onTogglePublicChats(item) }, + ) { + Icon( + imageVector = Icons.Default.Groups, + contentDescription = stringResource(R.string.public_chat_feed), + modifier = + Modifier.padding(horizontal = 5.dp) + .size(15.dp) + .combinedClickable( + onClick = { onTogglePublicChats(item) }, + onLongClick = { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.public_chat_feed), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ), + tint = + if (item.feedTypes.contains(FeedType.PUBLIC_CHATS)) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.32f, + ) + }, + ) + } + IconButton( + modifier = Size30Modifier, + onClick = { onToggleGlobal(item) }, + ) { + Icon( + imageVector = Icons.Default.Public, + stringResource(R.string.global_feed), + modifier = + Modifier.padding(horizontal = 5.dp) + .size(15.dp) + .combinedClickable( + onClick = { onToggleGlobal(item) }, + onLongClick = { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.global_feed), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ), + tint = + if (item.feedTypes.contains(FeedType.GLOBAL)) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.32f, + ) + }, + ) + } - IconButton( - modifier = Size30Modifier, - onClick = { onToggleSearch(item) }, - ) { - Icon( - imageVector = Icons.Default.Search, - stringResource(R.string.search_feed), - modifier = - Modifier.padding(horizontal = 5.dp) - .size(15.dp) - .combinedClickable( - onClick = { onToggleSearch(item) }, - onLongClick = { - scope.launch { - Toast.makeText( - context, - context.getString(R.string.search_feed), - Toast.LENGTH_SHORT, - ) - .show() - } - }, - ), - tint = - if (item.feedTypes.contains(FeedType.SEARCH)) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - } + IconButton( + modifier = Size30Modifier, + onClick = { onToggleSearch(item) }, + ) { + Icon( + imageVector = Icons.Default.Search, + stringResource(R.string.search_feed), + modifier = + Modifier.padding(horizontal = 5.dp) + .size(15.dp) + .combinedClickable( + onClick = { onToggleSearch(item) }, + onLongClick = { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.search_feed), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ), + tint = + if (item.feedTypes.contains(FeedType.SEARCH)) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.32f, + ) + }, + ) + } } @Composable private fun FirstLine( - item: RelaySetupInfo, - onClick: () -> Unit, - onDelete: (RelaySetupInfo) -> Unit, - modifier: Modifier, + item: RelaySetupInfo, + onClick: () -> Unit, + onDelete: (RelaySetupInfo) -> Unit, + modifier: Modifier, ) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { - Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { - Text( - text = item.briefInfo.displayUrl, - modifier = Modifier.clickable(onClick = onClick), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { + Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { + Text( + text = item.briefInfo.displayUrl, + modifier = Modifier.clickable(onClick = onClick), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) - if (item.paidRelay) { - Icon( - imageVector = Icons.Default.Paid, - null, - modifier = Modifier.padding(start = 5.dp, top = 1.dp).size(14.dp), - tint = MaterialTheme.colorScheme.allGoodColor, - ) - } - } + if (item.paidRelay) { + Icon( + imageVector = Icons.Default.Paid, + null, + modifier = Modifier.padding(start = 5.dp, top = 1.dp).size(14.dp), + tint = MaterialTheme.colorScheme.allGoodColor, + ) + } + } - IconButton( - modifier = Modifier.size(30.dp), - onClick = { onDelete(item) }, - ) { - Icon( - imageVector = Icons.Default.Cancel, - null, - modifier = Modifier.padding(start = 10.dp).size(15.dp), - tint = WarningColor, - ) + IconButton( + modifier = Modifier.size(30.dp), + onClick = { onDelete(item) }, + ) { + Icon( + imageVector = Icons.Default.Cancel, + null, + modifier = Modifier.padding(start = 10.dp).size(15.dp), + tint = WarningColor, + ) + } } - } } @Composable fun EditableServerConfig( - relayToAdd: String, - onNewRelay: (RelaySetupInfo) -> Unit, + relayToAdd: String, + onNewRelay: (RelaySetupInfo) -> Unit, ) { - var url by remember { mutableStateOf(relayToAdd) } - var read by remember { mutableStateOf(true) } - var write by remember { mutableStateOf(true) } + var url by remember { mutableStateOf(relayToAdd) } + var read by remember { mutableStateOf(true) } + var write by remember { mutableStateOf(true) } - Row(verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.add_a_relay)) }, - modifier = Modifier.weight(1f), - value = url, - onValueChange = { url = it }, - placeholder = { - Text( - text = "server.com", - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - ) - }, - singleLine = true, - ) - - IconButton(onClick = { read = !read }) { - Icon( - imageVector = Icons.Default.Download, - null, - modifier = Modifier.size(Size35dp).padding(horizontal = 5.dp), - tint = - if (read) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.placeholderText - }, - ) - } - - IconButton(onClick = { write = !write }) { - Icon( - imageVector = Icons.Default.Upload, - null, - modifier = Modifier.size(Size35dp).padding(horizontal = 5.dp), - tint = - if (write) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.placeholderText - }, - ) - } - - Button( - onClick = { - if (url.isNotBlank() && url != "/") { - var addedWSS = - if (!url.startsWith("wss://") && !url.startsWith("ws://")) "wss://$url" else url - if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1) - onNewRelay(RelaySetupInfo(addedWSS, read, write, feedTypes = FeedType.values().toSet())) - url = "" - write = true - read = true - } - }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = - if (url.isNotBlank()) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.placeholderText + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.add_a_relay)) }, + modifier = Modifier.weight(1f), + value = url, + onValueChange = { url = it }, + placeholder = { + Text( + text = "server.com", + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) }, - ), - ) { - Text(text = stringResource(id = R.string.add), color = Color.White) + singleLine = true, + ) + + IconButton(onClick = { read = !read }) { + Icon( + imageVector = Icons.Default.Download, + null, + modifier = Modifier.size(Size35dp).padding(horizontal = 5.dp), + tint = + if (read) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.placeholderText + }, + ) + } + + IconButton(onClick = { write = !write }) { + Icon( + imageVector = Icons.Default.Upload, + null, + modifier = Modifier.size(Size35dp).padding(horizontal = 5.dp), + tint = + if (write) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.placeholderText + }, + ) + } + + Button( + onClick = { + if (url.isNotBlank() && url != "/") { + var addedWSS = + if (!url.startsWith("wss://") && !url.startsWith("ws://")) "wss://$url" else url + if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1) + onNewRelay(RelaySetupInfo(addedWSS, read, write, feedTypes = FeedType.values().toSet())) + url = "" + write = true + read = true + } + }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = + if (url.isNotBlank()) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.placeholderText + }, + ), + ) { + Text(text = stringResource(id = R.string.add), color = Color.White) + } } - } } fun countToHumanReadableBytes(counter: Int) = - when { - counter >= 1000000000 -> "${round(counter / 1000000000f)} GB" - counter >= 1000000 -> "${round(counter / 1000000f)} MB" - counter >= 1000 -> "${round(counter / 1000f)} KB" - else -> "$counter" - } + when { + counter >= 1000000000 -> "${round(counter / 1000000000f)} GB" + counter >= 1000000 -> "${round(counter / 1000000f)} MB" + counter >= 1000 -> "${round(counter / 1000f)} KB" + else -> "$counter" + } fun countToHumanReadable( - counter: Int, - str: String, -) = - when { + counter: Int, + str: String, +) = when { counter >= 1000000000 -> "${round(counter / 1000000000f)}G $str" counter >= 1000000 -> "${round(counter / 1000000f)}M $str" counter >= 1000 -> "${round(counter / 1000f)}K $str" else -> "$counter $str" - } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt index 0fe5da245..ea01571cb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt @@ -37,190 +37,190 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class NewRelayListViewModel : ViewModel() { - private lateinit var account: Account + private lateinit var account: Account - private val _relays = MutableStateFlow>(emptyList()) - val relays = _relays.asStateFlow() + private val _relays = MutableStateFlow>(emptyList()) + val relays = _relays.asStateFlow() - fun load(account: Account) { - this.account = account - clear() - loadRelayDocuments() - } - - fun create() { - relays.let { - viewModelScope.launch(Dispatchers.IO) { - account.saveRelayList(it.value) + fun load(account: Account) { + this.account = account clear() - } + loadRelayDocuments() } - } - fun loadRelayDocuments() { - viewModelScope.launch(Dispatchers.IO) { - _relays.value.forEach { item -> - Nip11CachedRetriever.loadRelayInfo( - dirtyUrl = item.url, - onInfo = { togglePaidRelay(item, it.limitation?.payment_required ?: false) }, - onError = { url, errorCode, exceptionMessage -> }, - ) - } - } - } - - fun clear() { - _relays.update { - var relayFile = account.userProfile().latestContactList?.relays() - - if (relayFile != null) { - // Ugly, but forces nostr.band as the only search-supporting relay today. - // TODO: Remove when search becomes more available. - - val needsSearchRelay = - relayFile.none { it.key.removeSuffix("/") in Constants.forcedRelaysForSearchSet } && - relayFile.none { - account.localRelays - .filter { localRelay -> localRelay.url == it.key } - .firstOrNull() - ?.feedTypes - ?.contains(FeedType.SEARCH) - ?: false + fun create() { + relays.let { + viewModelScope.launch(Dispatchers.IO) { + account.saveRelayList(it.value) + clear() } - - if (needsSearchRelay) { - relayFile = - relayFile + - Constants.forcedRelayForSearch.map { - Pair( - it.url, - ContactListEvent.ReadWrite(it.read, it.write), - ) - } } - - relayFile - .map { - val liveRelay = RelayPool.getRelay(it.key) - val localInfoFeedTypes = - account.localRelays - .filter { localRelay -> localRelay.url == it.key } - .firstOrNull() - ?.feedTypes - ?: Constants.defaultRelays - .filter { defaultRelay -> defaultRelay.url == it.key } - .firstOrNull() - ?.feedTypes - ?: FeedType.values().toSet().toImmutableSet() - - val errorCounter = liveRelay?.errorCounter ?: 0 - val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0 - val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0 - val spamCounter = liveRelay?.spamCounter ?: 0 - - RelaySetupInfo( - it.key, - it.value.read, - it.value.write, - errorCounter, - eventDownloadCounter, - eventUploadCounter, - spamCounter, - localInfoFeedTypes, - ) - } - .sortedBy { it.downloadCountInBytes } - .reversed() - } else { - account.localRelays - .map { - val liveRelay = RelayPool.getRelay(it.url) - - val errorCounter = liveRelay?.errorCounter ?: 0 - val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0 - val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0 - val spamCounter = liveRelay?.spamCounter ?: 0 - - RelaySetupInfo( - it.url, - it.read, - it.write, - errorCounter, - eventDownloadCounter, - eventUploadCounter, - spamCounter, - it.feedTypes, - ) - } - .sortedBy { it.downloadCountInBytes } - .reversed() - } } - } - fun addRelay(relay: RelaySetupInfo) { - if (relays.value.any { it.url == relay.url }) return + fun loadRelayDocuments() { + viewModelScope.launch(Dispatchers.IO) { + _relays.value.forEach { item -> + Nip11CachedRetriever.loadRelayInfo( + dirtyUrl = item.url, + onInfo = { togglePaidRelay(item, it.limitation?.payment_required ?: false) }, + onError = { url, errorCode, exceptionMessage -> }, + ) + } + } + } - _relays.update { it.plus(relay) } - } + fun clear() { + _relays.update { + var relayFile = account.userProfile().latestContactList?.relays() - fun deleteRelay(relay: RelaySetupInfo) { - _relays.update { it.minus(relay) } - } + if (relayFile != null) { + // Ugly, but forces nostr.band as the only search-supporting relay today. + // TODO: Remove when search becomes more available. - fun deleteAll() { - _relays.update { relays -> emptyList() } - } + val needsSearchRelay = + relayFile.none { it.key.removeSuffix("/") in Constants.forcedRelaysForSearchSet } && + relayFile.none { + account.localRelays + .filter { localRelay -> localRelay.url == it.key } + .firstOrNull() + ?.feedTypes + ?.contains(FeedType.SEARCH) + ?: false + } - fun toggleDownload(relay: RelaySetupInfo) { - _relays.update { it.updated(relay, relay.copy(read = !relay.read)) } - } + if (needsSearchRelay) { + relayFile = + relayFile + + Constants.forcedRelayForSearch.map { + Pair( + it.url, + ContactListEvent.ReadWrite(it.read, it.write), + ) + } + } - fun toggleUpload(relay: RelaySetupInfo) { - _relays.update { it.updated(relay, relay.copy(write = !relay.write)) } - } + relayFile + .map { + val liveRelay = RelayPool.getRelay(it.key) + val localInfoFeedTypes = + account.localRelays + .filter { localRelay -> localRelay.url == it.key } + .firstOrNull() + ?.feedTypes + ?: Constants.defaultRelays + .filter { defaultRelay -> defaultRelay.url == it.key } + .firstOrNull() + ?.feedTypes + ?: FeedType.values().toSet().toImmutableSet() - fun toggleFollows(relay: RelaySetupInfo) { - val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.FOLLOWS) - _relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) } - } + val errorCounter = liveRelay?.errorCounter ?: 0 + val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0 + val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0 + val spamCounter = liveRelay?.spamCounter ?: 0 - fun toggleMessages(relay: RelaySetupInfo) { - val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PRIVATE_DMS) - _relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) } - } + RelaySetupInfo( + it.key, + it.value.read, + it.value.write, + errorCounter, + eventDownloadCounter, + eventUploadCounter, + spamCounter, + localInfoFeedTypes, + ) + } + .sortedBy { it.downloadCountInBytes } + .reversed() + } else { + account.localRelays + .map { + val liveRelay = RelayPool.getRelay(it.url) - fun togglePublicChats(relay: RelaySetupInfo) { - val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PUBLIC_CHATS) - _relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) } - } + val errorCounter = liveRelay?.errorCounter ?: 0 + val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0 + val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0 + val spamCounter = liveRelay?.spamCounter ?: 0 - fun toggleGlobal(relay: RelaySetupInfo) { - val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.GLOBAL) - _relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) } - } + RelaySetupInfo( + it.url, + it.read, + it.write, + errorCounter, + eventDownloadCounter, + eventUploadCounter, + spamCounter, + it.feedTypes, + ) + } + .sortedBy { it.downloadCountInBytes } + .reversed() + } + } + } - fun toggleSearch(relay: RelaySetupInfo) { - val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.SEARCH) - _relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) } - } + fun addRelay(relay: RelaySetupInfo) { + if (relays.value.any { it.url == relay.url }) return - fun togglePaidRelay( - relay: RelaySetupInfo, - paid: Boolean, - ) { - _relays.update { it.updated(relay, relay.copy(paidRelay = paid)) } - } + _relays.update { it.plus(relay) } + } + + fun deleteRelay(relay: RelaySetupInfo) { + _relays.update { it.minus(relay) } + } + + fun deleteAll() { + _relays.update { relays -> emptyList() } + } + + fun toggleDownload(relay: RelaySetupInfo) { + _relays.update { it.updated(relay, relay.copy(read = !relay.read)) } + } + + fun toggleUpload(relay: RelaySetupInfo) { + _relays.update { it.updated(relay, relay.copy(write = !relay.write)) } + } + + fun toggleFollows(relay: RelaySetupInfo) { + val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.FOLLOWS) + _relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) } + } + + fun toggleMessages(relay: RelaySetupInfo) { + val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PRIVATE_DMS) + _relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) } + } + + fun togglePublicChats(relay: RelaySetupInfo) { + val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PUBLIC_CHATS) + _relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) } + } + + fun toggleGlobal(relay: RelaySetupInfo) { + val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.GLOBAL) + _relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) } + } + + fun toggleSearch(relay: RelaySetupInfo) { + val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.SEARCH) + _relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) } + } + + fun togglePaidRelay( + relay: RelaySetupInfo, + paid: Boolean, + ) { + _relays.update { it.updated(relay, relay.copy(paidRelay = paid)) } + } } fun Iterable.updated( - old: T, - new: T, + old: T, + new: T, ): List = map { if (it == old) new else it } fun togglePresenceInSet( - set: Set, - item: T, + set: Set, + item: T, ): Set { - return if (set.contains(item)) set.minus(item) else set.plus(item) + return if (set.contains(item)) set.minus(item) else set.plus(item) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt index 1d2ecd910..8ff81dfdc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt @@ -55,254 +55,254 @@ import kotlinx.coroutines.withContext @Composable fun NewUserMetadataView( - onClose: () -> Unit, - account: Account, + onClose: () -> Unit, + account: Account, ) { - val postViewModel: NewUserMetadataViewModel = viewModel() - val context = LocalContext.current + val postViewModel: NewUserMetadataViewModel = viewModel() + val context = LocalContext.current - LaunchedEffect(Unit) { - postViewModel.load(account) + LaunchedEffect(Unit) { + postViewModel.load(account) - launch(Dispatchers.IO) { - postViewModel.imageUploadingError.collect { error -> - withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() } - } - } - } - - Dialog( - onDismissRequest = { onClose() }, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - ), - ) { - Surface { - Column( - modifier = Modifier.padding(10.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CloseButton( - onPress = { - postViewModel.clear() - onClose() - }, - ) - - SaveButton( - onPost = { - postViewModel.create() - onClose() - }, - true, - ) + launch(Dispatchers.IO) { + postViewModel.imageUploadingError.collect { error -> + withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() } + } + } + } + + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + ), + ) { + Surface { + Column( + modifier = Modifier.padding(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton( + onPress = { + postViewModel.clear() + onClose() + }, + ) + + SaveButton( + onPost = { + postViewModel.create() + onClose() + }, + true, + ) + } + + Column( + modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()), + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.display_name)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.displayName.value, + onValueChange = { postViewModel.displayName.value = it }, + placeholder = { + Text( + text = stringResource(R.string.my_display_name), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + OutlinedTextField( + label = { Text(text = stringResource(R.string.about_me)) }, + modifier = Modifier.fillMaxWidth().height(100.dp), + value = postViewModel.about.value, + onValueChange = { postViewModel.about.value = it }, + placeholder = { + Text( + text = stringResource(id = R.string.about_me), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + maxLines = 10, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + OutlinedTextField( + label = { Text(text = stringResource(R.string.avatar_url)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.picture.value, + onValueChange = { postViewModel.picture.value = it }, + placeholder = { + Text( + text = "https://mywebsite.com/me.jpg", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + leadingIcon = { + UploadFromGallery( + isUploading = postViewModel.isUploadingImageForPicture, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(start = 5.dp), + ) { + postViewModel.uploadForPicture(it, context) + } + }, + singleLine = true, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + OutlinedTextField( + label = { Text(text = stringResource(R.string.banner_url)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.banner.value, + onValueChange = { postViewModel.banner.value = it }, + placeholder = { + Text( + text = "https://mywebsite.com/mybanner.jpg", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + leadingIcon = { + UploadFromGallery( + isUploading = postViewModel.isUploadingImageForBanner, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(start = 5.dp), + ) { + postViewModel.uploadForBanner(it, context) + } + }, + singleLine = true, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + OutlinedTextField( + label = { Text(text = stringResource(R.string.website_url)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.website.value, + onValueChange = { postViewModel.website.value = it }, + placeholder = { + Text( + text = "https://mywebsite.com", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + OutlinedTextField( + label = { Text(text = stringResource(R.string.nip_05)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.nip05.value, + onValueChange = { postViewModel.nip05.value = it }, + placeholder = { + Text( + text = "_@mywebsite.com", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + ) + + Spacer(modifier = Modifier.height(10.dp)) + OutlinedTextField( + label = { Text(text = stringResource(R.string.ln_address)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.lnAddress.value, + onValueChange = { postViewModel.lnAddress.value = it }, + placeholder = { + Text( + text = "me@mylightiningnode.com", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + OutlinedTextField( + label = { Text(text = stringResource(R.string.ln_url_outdated)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.lnURL.value, + onValueChange = { postViewModel.lnURL.value = it }, + placeholder = { + Text( + text = stringResource(R.string.lnurl), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + OutlinedTextField( + label = { Text(text = stringResource(R.string.twitter)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.twitter.value, + onValueChange = { postViewModel.twitter.value = it }, + placeholder = { + Text( + text = stringResource(R.string.twitter_proof_url_template), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + OutlinedTextField( + label = { Text(text = stringResource(R.string.mastodon)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.mastodon.value, + onValueChange = { postViewModel.mastodon.value = it }, + placeholder = { + Text( + text = stringResource(R.string.mastodon_proof_url_template), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + OutlinedTextField( + label = { Text(text = stringResource(R.string.github)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.github.value, + onValueChange = { postViewModel.github.value = it }, + placeholder = { + Text( + text = stringResource(R.string.github_proof_url_template), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) + } + } } - - Column( - modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()), - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.display_name)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.displayName.value, - onValueChange = { postViewModel.displayName.value = it }, - placeholder = { - Text( - text = stringResource(R.string.my_display_name), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - singleLine = true, - ) - - Spacer(modifier = Modifier.height(10.dp)) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.about_me)) }, - modifier = Modifier.fillMaxWidth().height(100.dp), - value = postViewModel.about.value, - onValueChange = { postViewModel.about.value = it }, - placeholder = { - Text( - text = stringResource(id = R.string.about_me), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - maxLines = 10, - ) - - Spacer(modifier = Modifier.height(10.dp)) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.avatar_url)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.picture.value, - onValueChange = { postViewModel.picture.value = it }, - placeholder = { - Text( - text = "https://mywebsite.com/me.jpg", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - leadingIcon = { - UploadFromGallery( - isUploading = postViewModel.isUploadingImageForPicture, - tint = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(start = 5.dp), - ) { - postViewModel.uploadForPicture(it, context) - } - }, - singleLine = true, - ) - - Spacer(modifier = Modifier.height(10.dp)) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.banner_url)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.banner.value, - onValueChange = { postViewModel.banner.value = it }, - placeholder = { - Text( - text = "https://mywebsite.com/mybanner.jpg", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - leadingIcon = { - UploadFromGallery( - isUploading = postViewModel.isUploadingImageForBanner, - tint = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(start = 5.dp), - ) { - postViewModel.uploadForBanner(it, context) - } - }, - singleLine = true, - ) - - Spacer(modifier = Modifier.height(10.dp)) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.website_url)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.website.value, - onValueChange = { postViewModel.website.value = it }, - placeholder = { - Text( - text = "https://mywebsite.com", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - singleLine = true, - ) - - Spacer(modifier = Modifier.height(10.dp)) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.nip_05)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.nip05.value, - onValueChange = { postViewModel.nip05.value = it }, - placeholder = { - Text( - text = "_@mywebsite.com", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - singleLine = true, - ) - - Spacer(modifier = Modifier.height(10.dp)) - OutlinedTextField( - label = { Text(text = stringResource(R.string.ln_address)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.lnAddress.value, - onValueChange = { postViewModel.lnAddress.value = it }, - placeholder = { - Text( - text = "me@mylightiningnode.com", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - singleLine = true, - ) - - Spacer(modifier = Modifier.height(10.dp)) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.ln_url_outdated)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.lnURL.value, - onValueChange = { postViewModel.lnURL.value = it }, - placeholder = { - Text( - text = stringResource(R.string.lnurl), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - - Spacer(modifier = Modifier.height(10.dp)) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.twitter)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.twitter.value, - onValueChange = { postViewModel.twitter.value = it }, - placeholder = { - Text( - text = stringResource(R.string.twitter_proof_url_template), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - - Spacer(modifier = Modifier.height(10.dp)) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.mastodon)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.mastodon.value, - onValueChange = { postViewModel.mastodon.value = it }, - placeholder = { - Text( - text = stringResource(R.string.mastodon_proof_url_template), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - - Spacer(modifier = Modifier.height(10.dp)) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.github)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.github.value, - onValueChange = { postViewModel.github.value = it }, - placeholder = { - Text( - text = stringResource(R.string.github_proof_url_template), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - } - } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 32a6d636c..92e4dfd75 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -35,220 +35,220 @@ import com.vitorpamplona.amethyst.ui.components.MediaCompressor import com.vitorpamplona.quartz.events.GitHubIdentity import com.vitorpamplona.quartz.events.MastodonIdentity import com.vitorpamplona.quartz.events.TwitterIdentity -import java.io.ByteArrayInputStream -import java.io.StringWriter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch +import java.io.ByteArrayInputStream +import java.io.StringWriter class NewUserMetadataViewModel : ViewModel() { - private lateinit var account: Account + private lateinit var account: Account - // val userName = mutableStateOf("") - val displayName = mutableStateOf("") - val about = mutableStateOf("") + // val userName = mutableStateOf("") + val displayName = mutableStateOf("") + val about = mutableStateOf("") - val picture = mutableStateOf("") - val banner = mutableStateOf("") + val picture = mutableStateOf("") + val banner = mutableStateOf("") - val website = mutableStateOf("") - val nip05 = mutableStateOf("") - val lnAddress = mutableStateOf("") - val lnURL = mutableStateOf("") + val website = mutableStateOf("") + val nip05 = mutableStateOf("") + val lnAddress = mutableStateOf("") + val lnURL = mutableStateOf("") - val twitter = mutableStateOf("") - val github = mutableStateOf("") - val mastodon = mutableStateOf("") + val twitter = mutableStateOf("") + val github = mutableStateOf("") + val mastodon = mutableStateOf("") - var isUploadingImageForPicture by mutableStateOf(false) - var isUploadingImageForBanner by mutableStateOf(false) - val imageUploadingError = - MutableSharedFlow(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST) + var isUploadingImageForPicture by mutableStateOf(false) + var isUploadingImageForBanner by mutableStateOf(false) + val imageUploadingError = + MutableSharedFlow(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST) - fun load(account: Account) { - this.account = account + fun load(account: Account) { + this.account = account - account.userProfile().let { - // userName.value = it.bestUsername() ?: "" - displayName.value = it.bestDisplayName() ?: "" - about.value = it.info?.about ?: "" - picture.value = it.info?.picture ?: "" - banner.value = it.info?.banner ?: "" - website.value = it.info?.website ?: "" - nip05.value = it.info?.nip05 ?: "" - lnAddress.value = it.info?.lud16 ?: "" - lnURL.value = it.info?.lud06 ?: "" + account.userProfile().let { + // userName.value = it.bestUsername() ?: "" + displayName.value = it.bestDisplayName() ?: "" + about.value = it.info?.about ?: "" + picture.value = it.info?.picture ?: "" + banner.value = it.info?.banner ?: "" + website.value = it.info?.website ?: "" + nip05.value = it.info?.nip05 ?: "" + lnAddress.value = it.info?.lud16 ?: "" + lnURL.value = it.info?.lud06 ?: "" - twitter.value = "" - github.value = "" - mastodon.value = "" + twitter.value = "" + github.value = "" + mastodon.value = "" - // TODO: Validate Telegram input, somehow. - it.info?.latestMetadata?.identityClaims()?.forEach { - when (it) { - is TwitterIdentity -> twitter.value = it.toProofUrl() - is GitHubIdentity -> github.value = it.toProofUrl() - is MastodonIdentity -> mastodon.value = it.toProofUrl() - } - } - } - } - - fun create() { - // Tries to not delete any existing attribute that we do not work with. - val latest = account.userProfile().info?.latestMetadata - val currentJson = - if (latest != null) { - ObjectMapper() - .readTree( - ByteArrayInputStream(latest.content.toByteArray(Charsets.UTF_8)), - ) as ObjectNode - } else { - ObjectMapper().createObjectNode() - } - currentJson.put("name", displayName.value.trim()) - currentJson.put("display_name", displayName.value.trim()) - currentJson.put("picture", picture.value.trim()) - currentJson.put("banner", banner.value.trim()) - currentJson.put("website", website.value.trim()) - currentJson.put("about", about.value.trim()) - currentJson.put("nip05", nip05.value.trim()) - currentJson.put("lud16", lnAddress.value.trim()) - currentJson.put("lud06", lnURL.value.trim()) - - var claims = latest?.identityClaims() ?: emptyList() - - if (twitter.value.isBlank()) { - // delete twitter - claims = claims.filter { it !is TwitterIdentity } - } - - if (github.value.isBlank()) { - // delete github - claims = claims.filter { it !is GitHubIdentity } - } - - if (mastodon.value.isBlank()) { - // delete mastodon - claims = claims.filter { it !is MastodonIdentity } - } - - // Updates while keeping other identities intact - val newClaims = - listOfNotNull( - TwitterIdentity.parseProofUrl(twitter.value), - GitHubIdentity.parseProofUrl(github.value), - MastodonIdentity.parseProofUrl(mastodon.value), - ) + - claims.filter { it !is TwitterIdentity && it !is GitHubIdentity && it !is MastodonIdentity } - - val writer = StringWriter() - ObjectMapper().writeValue(writer, currentJson) - - viewModelScope.launch(Dispatchers.IO) { - account.sendNewUserMetadata(writer.buffer.toString(), displayName.value.trim(), newClaims) - } - clear() - } - - fun clear() { - // userName.value = "" - displayName.value = "" - about.value = "" - picture.value = "" - banner.value = "" - website.value = "" - nip05.value = "" - lnAddress.value = "" - lnURL.value = "" - twitter.value = "" - github.value = "" - mastodon.value = "" - } - - fun uploadForPicture( - uri: Uri, - context: Context, - ) { - viewModelScope.launch(Dispatchers.IO) { - upload( - uri, - context, - onUploading = { isUploadingImageForPicture = it }, - onUploaded = { picture.value = it }, - ) - } - } - - fun uploadForBanner( - uri: Uri, - context: Context, - ) { - viewModelScope.launch(Dispatchers.IO) { - upload( - uri, - context, - onUploading = { isUploadingImageForBanner = it }, - onUploaded = { banner.value = it }, - ) - } - } - - private suspend fun upload( - galleryUri: Uri, - context: Context, - onUploading: (Boolean) -> Unit, - onUploaded: (String) -> Unit, - ) { - onUploading(true) - - val contentResolver = context.contentResolver - - MediaCompressor() - .compress( - galleryUri, - contentResolver.getType(galleryUri), - context.applicationContext, - onReady = { fileUri, contentType, size -> - viewModelScope.launch(Dispatchers.IO) { - try { - val result = - Nip96Uploader(account) - .uploadImage( - uri = fileUri, - contentType = contentType, - size = size, - alt = null, - sensitiveContent = null, - server = account.defaultFileServer, - contentResolver = contentResolver, - onProgress = {}, - ) - - val url = result.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) - - if (url != null) { - onUploading(false) - onUploaded(url) - } else { - onUploading(false) - viewModelScope.launch { - imageUploadingError.emit("Failed to upload the image / video") + // TODO: Validate Telegram input, somehow. + it.info?.latestMetadata?.identityClaims()?.forEach { + when (it) { + is TwitterIdentity -> twitter.value = it.toProofUrl() + is GitHubIdentity -> github.value = it.toProofUrl() + is MastodonIdentity -> mastodon.value = it.toProofUrl() } - } - } catch (e: Exception) { - onUploading(false) - viewModelScope.launch { - imageUploadingError.emit("Failed to upload the image / video") - } } - } - }, - onError = { - onUploading(false) - viewModelScope.launch { imageUploadingError.emit(it) } - }, - ) - } + } + } + + fun create() { + // Tries to not delete any existing attribute that we do not work with. + val latest = account.userProfile().info?.latestMetadata + val currentJson = + if (latest != null) { + ObjectMapper() + .readTree( + ByteArrayInputStream(latest.content.toByteArray(Charsets.UTF_8)), + ) as ObjectNode + } else { + ObjectMapper().createObjectNode() + } + currentJson.put("name", displayName.value.trim()) + currentJson.put("display_name", displayName.value.trim()) + currentJson.put("picture", picture.value.trim()) + currentJson.put("banner", banner.value.trim()) + currentJson.put("website", website.value.trim()) + currentJson.put("about", about.value.trim()) + currentJson.put("nip05", nip05.value.trim()) + currentJson.put("lud16", lnAddress.value.trim()) + currentJson.put("lud06", lnURL.value.trim()) + + var claims = latest?.identityClaims() ?: emptyList() + + if (twitter.value.isBlank()) { + // delete twitter + claims = claims.filter { it !is TwitterIdentity } + } + + if (github.value.isBlank()) { + // delete github + claims = claims.filter { it !is GitHubIdentity } + } + + if (mastodon.value.isBlank()) { + // delete mastodon + claims = claims.filter { it !is MastodonIdentity } + } + + // Updates while keeping other identities intact + val newClaims = + listOfNotNull( + TwitterIdentity.parseProofUrl(twitter.value), + GitHubIdentity.parseProofUrl(github.value), + MastodonIdentity.parseProofUrl(mastodon.value), + ) + + claims.filter { it !is TwitterIdentity && it !is GitHubIdentity && it !is MastodonIdentity } + + val writer = StringWriter() + ObjectMapper().writeValue(writer, currentJson) + + viewModelScope.launch(Dispatchers.IO) { + account.sendNewUserMetadata(writer.buffer.toString(), displayName.value.trim(), newClaims) + } + clear() + } + + fun clear() { + // userName.value = "" + displayName.value = "" + about.value = "" + picture.value = "" + banner.value = "" + website.value = "" + nip05.value = "" + lnAddress.value = "" + lnURL.value = "" + twitter.value = "" + github.value = "" + mastodon.value = "" + } + + fun uploadForPicture( + uri: Uri, + context: Context, + ) { + viewModelScope.launch(Dispatchers.IO) { + upload( + uri, + context, + onUploading = { isUploadingImageForPicture = it }, + onUploaded = { picture.value = it }, + ) + } + } + + fun uploadForBanner( + uri: Uri, + context: Context, + ) { + viewModelScope.launch(Dispatchers.IO) { + upload( + uri, + context, + onUploading = { isUploadingImageForBanner = it }, + onUploaded = { banner.value = it }, + ) + } + } + + private suspend fun upload( + galleryUri: Uri, + context: Context, + onUploading: (Boolean) -> Unit, + onUploaded: (String) -> Unit, + ) { + onUploading(true) + + val contentResolver = context.contentResolver + + MediaCompressor() + .compress( + galleryUri, + contentResolver.getType(galleryUri), + context.applicationContext, + onReady = { fileUri, contentType, size -> + viewModelScope.launch(Dispatchers.IO) { + try { + val result = + Nip96Uploader(account) + .uploadImage( + uri = fileUri, + contentType = contentType, + size = size, + alt = null, + sensitiveContent = null, + server = account.defaultFileServer, + contentResolver = contentResolver, + onProgress = {}, + ) + + val url = result.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) + + if (url != null) { + onUploading(false) + onUploaded(url) + } else { + onUploading(false) + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + } catch (e: Exception) { + onUploading(false) + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + } + }, + onError = { + onUploading(false) + viewModelScope.launch { imageUploadingError.emit(it) } + }, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt index 4fb274088..d5ff4f92e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt @@ -48,47 +48,47 @@ import com.vitorpamplona.quartz.events.EmptyTagList @Composable fun NotifyRequestDialog( - title: String, - textContent: String, - buttonColors: ButtonColors = ButtonDefaults.buttonColors(), - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - onDismiss: () -> Unit, + title: String, + textContent: String, + buttonColors: ButtonColors = ButtonDefaults.buttonColors(), + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onDismiss: () -> Unit, ) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(title) }, - text = { - val defaultBackground = MaterialTheme.colorScheme.background - val background = remember { mutableStateOf(defaultBackground) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + val defaultBackground = MaterialTheme.colorScheme.background + val background = remember { mutableStateOf(defaultBackground) } - TranslatableRichTextViewer( - textContent, - canPreview = true, - Modifier.fillMaxWidth(), - EmptyTagList, - background, - accountViewModel, - nav, - ) - }, - confirmButton = { - Button( - onClick = onDismiss, - colors = buttonColors, - contentPadding = PaddingValues(horizontal = Size16dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Outlined.Done, - contentDescription = null, - ) - Spacer(StdHorzSpacer) - Text(stringResource(R.string.error_dialog_button_ok)) - } - } - }, - ) + TranslatableRichTextViewer( + textContent, + canPreview = true, + Modifier.fillMaxWidth(), + EmptyTagList, + background, + accountViewModel, + nav, + ) + }, + confirmButton = { + Button( + onClick = onDismiss, + colors = buttonColors, + contentPadding = PaddingValues(horizontal = Size16dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Done, + contentDescription = null, + ) + Spacer(StdHorzSpacer) + Text(stringResource(R.string.error_dialog_button_ok)) + } + } + }, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelayInformationDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelayInformationDialog.kt index 10acfb265..96f45a127 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelayInformationDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelayInformationDialog.kt @@ -63,264 +63,265 @@ import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier @OptIn(ExperimentalLayoutApi::class) @Composable fun RelayInformationDialog( - onClose: () -> Unit, - relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo, - relayInfo: RelayInformation, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + onClose: () -> Unit, + relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo, + relayInfo: RelayInformation, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } - - Dialog( - onDismissRequest = { onClose() }, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - ), - ) { - Surface { - val scrollState = rememberScrollState() - - Column( - modifier = Modifier.padding(10.dp).fillMaxSize().verticalScroll(scrollState), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CloseButton(onPress = { onClose() }) + val automaticallyShowProfilePicture = + remember { + accountViewModel.settings.showProfilePictures.value } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = StdPadding.fillMaxWidth(), - ) { - Column { - RenderRelayIcon( - relayBriefInfo.displayUrl, - relayBriefInfo.favIcon, - automaticallyShowProfilePicture, - MaterialTheme.colorScheme.largeRelayIconModifier, - ) - } + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + ), + ) { + Surface { + val scrollState = rememberScrollState() - Spacer(modifier = DoubleHorzSpacer) + Column( + modifier = Modifier.padding(10.dp).fillMaxSize().verticalScroll(scrollState), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton(onPress = { onClose() }) + } - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Row { Title(relayInfo.name?.trim() ?: "") } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = StdPadding.fillMaxWidth(), + ) { + Column { + RenderRelayIcon( + relayBriefInfo.displayUrl, + relayBriefInfo.favIcon, + automaticallyShowProfilePicture, + MaterialTheme.colorScheme.largeRelayIconModifier, + ) + } - Row { SubtitleContent(relayInfo.description?.trim() ?: "") } - } - } + Spacer(modifier = DoubleHorzSpacer) - Section(stringResource(R.string.owner)) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Row { Title(relayInfo.name?.trim() ?: "") } - relayInfo.pubkey?.let { DisplayOwnerInformation(it, accountViewModel, nav) } + Row { SubtitleContent(relayInfo.description?.trim() ?: "") } + } + } - Section(stringResource(R.string.software)) + Section(stringResource(R.string.owner)) - DisplaySoftwareInformation(relayInfo) + relayInfo.pubkey?.let { DisplayOwnerInformation(it, accountViewModel, nav) } - Section(stringResource(R.string.version)) + Section(stringResource(R.string.software)) - SectionContent(relayInfo.version ?: "") + DisplaySoftwareInformation(relayInfo) - Section(stringResource(R.string.contact)) + Section(stringResource(R.string.version)) - Box(modifier = Modifier.padding(start = 10.dp)) { - relayInfo.contact?.let { - if (it.startsWith("https:")) { - ClickableUrl(urlText = it, url = it) - } else if (it.startsWith("mailto:") || it.contains('@')) { - ClickableEmail(it) - } else { - SectionContent(it) + SectionContent(relayInfo.version ?: "") + + Section(stringResource(R.string.contact)) + + Box(modifier = Modifier.padding(start = 10.dp)) { + relayInfo.contact?.let { + if (it.startsWith("https:")) { + ClickableUrl(urlText = it, url = it) + } else if (it.startsWith("mailto:") || it.contains('@')) { + ClickableEmail(it) + } else { + SectionContent(it) + } + } + } + + Section(stringResource(R.string.supports)) + + DisplaySupportedNips(relayInfo) + + relayInfo.fees?.admission?.let { + if (it.isNotEmpty()) { + Section(stringResource(R.string.admission_fees)) + + it.forEach { item -> SectionContent("${item.amount?.div(1000) ?: 0} sats") } + } + } + + relayInfo.payments_url?.let { + Section(stringResource(R.string.payments_url)) + + Box(modifier = Modifier.padding(start = 10.dp)) { + ClickableUrl( + urlText = it, + url = it, + ) + } + } + + relayInfo.limitation?.let { + Section(stringResource(R.string.limitations)) + val authRequired = it.auth_required ?: false + val authRequiredText = + if (authRequired) stringResource(R.string.yes) else stringResource(R.string.no) + val paymentRequired = it.payment_required ?: false + val paymentRequiredText = + if (paymentRequired) stringResource(R.string.yes) else stringResource(R.string.no) + + Column { + SectionContent( + "${stringResource(R.string.message_length)}: ${it.max_message_length ?: 0}", + ) + SectionContent( + "${stringResource(R.string.subscriptions)}: ${it.max_subscriptions ?: 0}", + ) + SectionContent("${stringResource(R.string.filters)}: ${it.max_subscriptions ?: 0}") + SectionContent( + "${stringResource(R.string.subscription_id_length)}: ${it.max_subid_length ?: 0}", + ) + SectionContent("${stringResource(R.string.minimum_prefix)}: ${it.min_prefix ?: 0}") + SectionContent( + "${stringResource(R.string.maximum_event_tags)}: ${it.max_event_tags ?: 0}", + ) + SectionContent( + "${stringResource(R.string.content_length)}: ${it.max_content_length ?: 0}", + ) + SectionContent("${stringResource(R.string.minimum_pow)}: ${it.min_pow_difficulty ?: 0}") + SectionContent("${stringResource(R.string.auth)}: $authRequiredText") + SectionContent("${stringResource(R.string.payment)}: $paymentRequiredText") + } + } + + relayInfo.relay_countries?.let { + Section(stringResource(R.string.countries)) + + FlowRow { it.forEach { item -> SectionContent(item) } } + } + + relayInfo.language_tags?.let { + Section(stringResource(R.string.languages)) + + FlowRow { it.forEach { item -> SectionContent(item) } } + } + + relayInfo.tags?.let { + Section(stringResource(R.string.tags)) + + FlowRow { it.forEach { item -> SectionContent(item) } } + } + + relayInfo.posting_policy?.let { + Section(stringResource(R.string.posting_policy)) + + Box(Modifier.padding(10.dp)) { + ClickableUrl( + it, + it, + ) + } + } } - } } - - Section(stringResource(R.string.supports)) - - DisplaySupportedNips(relayInfo) - - relayInfo.fees?.admission?.let { - if (it.isNotEmpty()) { - Section(stringResource(R.string.admission_fees)) - - it.forEach { item -> SectionContent("${item.amount?.div(1000) ?: 0} sats") } - } - } - - relayInfo.payments_url?.let { - Section(stringResource(R.string.payments_url)) - - Box(modifier = Modifier.padding(start = 10.dp)) { - ClickableUrl( - urlText = it, - url = it, - ) - } - } - - relayInfo.limitation?.let { - Section(stringResource(R.string.limitations)) - val authRequired = it.auth_required ?: false - val authRequiredText = - if (authRequired) stringResource(R.string.yes) else stringResource(R.string.no) - val paymentRequired = it.payment_required ?: false - val paymentRequiredText = - if (paymentRequired) stringResource(R.string.yes) else stringResource(R.string.no) - - Column { - SectionContent( - "${stringResource(R.string.message_length)}: ${it.max_message_length ?: 0}", - ) - SectionContent( - "${stringResource(R.string.subscriptions)}: ${it.max_subscriptions ?: 0}", - ) - SectionContent("${stringResource(R.string.filters)}: ${it.max_subscriptions ?: 0}") - SectionContent( - "${stringResource(R.string.subscription_id_length)}: ${it.max_subid_length ?: 0}", - ) - SectionContent("${stringResource(R.string.minimum_prefix)}: ${it.min_prefix ?: 0}") - SectionContent( - "${stringResource(R.string.maximum_event_tags)}: ${it.max_event_tags ?: 0}", - ) - SectionContent( - "${stringResource(R.string.content_length)}: ${it.max_content_length ?: 0}", - ) - SectionContent("${stringResource(R.string.minimum_pow)}: ${it.min_pow_difficulty ?: 0}") - SectionContent("${stringResource(R.string.auth)}: $authRequiredText") - SectionContent("${stringResource(R.string.payment)}: $paymentRequiredText") - } - } - - relayInfo.relay_countries?.let { - Section(stringResource(R.string.countries)) - - FlowRow { it.forEach { item -> SectionContent(item) } } - } - - relayInfo.language_tags?.let { - Section(stringResource(R.string.languages)) - - FlowRow { it.forEach { item -> SectionContent(item) } } - } - - relayInfo.tags?.let { - Section(stringResource(R.string.tags)) - - FlowRow { it.forEach { item -> SectionContent(item) } } - } - - relayInfo.posting_policy?.let { - Section(stringResource(R.string.posting_policy)) - - Box(Modifier.padding(10.dp)) { - ClickableUrl( - it, - it, - ) - } - } - } } - } } @Composable @OptIn(ExperimentalLayoutApi::class) private fun DisplaySupportedNips(relayInfo: RelayInformation) { - FlowRow { - relayInfo.supported_nips?.forEach { item -> - val text = item.toString().padStart(2, '0') - Box(Modifier.padding(10.dp)) { - ClickableUrl( - urlText = text, - url = "https://github.com/nostr-protocol/nips/blob/master/$text.md", - ) - } - } + FlowRow { + relayInfo.supported_nips?.forEach { item -> + val text = item.toString().padStart(2, '0') + Box(Modifier.padding(10.dp)) { + ClickableUrl( + urlText = text, + url = "https://github.com/nostr-protocol/nips/blob/master/$text.md", + ) + } + } - relayInfo.supported_nip_extensions?.forEach { item -> - val text = item.padStart(2, '0') - Box(Modifier.padding(10.dp)) { - ClickableUrl( - urlText = text, - url = "https://github.com/nostr-protocol/nips/blob/master/$text.md", - ) - } + relayInfo.supported_nip_extensions?.forEach { item -> + val text = item.padStart(2, '0') + Box(Modifier.padding(10.dp)) { + ClickableUrl( + urlText = text, + url = "https://github.com/nostr-protocol/nips/blob/master/$text.md", + ) + } + } } - } } @Composable private fun DisplaySoftwareInformation(relayInfo: RelayInformation) { - val url = (relayInfo.software ?: "").replace("git+", "") - Box(modifier = Modifier.padding(start = 10.dp)) { - ClickableUrl( - urlText = url, - url = url, - ) - } + val url = (relayInfo.software ?: "").replace("git+", "") + Box(modifier = Modifier.padding(start = 10.dp)) { + ClickableUrl( + urlText = url, + url = url, + ) + } } @Composable private fun DisplayOwnerInformation( - userHex: String, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + userHex: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LoadUser(baseUserHex = userHex, accountViewModel) { - Crossfade(it) { - if (it != null) { - UserCompose( - baseUser = it, - accountViewModel = accountViewModel, - showDiviser = false, - nav = nav, - ) - } + LoadUser(baseUserHex = userHex, accountViewModel) { + Crossfade(it) { + if (it != null) { + UserCompose( + baseUser = it, + accountViewModel = accountViewModel, + showDiviser = false, + nav = nav, + ) + } + } } - } } @Composable fun Title(text: String) { - Text( - text = text, - fontWeight = FontWeight.Bold, - fontSize = 24.sp, - ) + Text( + text = text, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + ) } @Composable fun SubtitleContent(text: String) { - Text( - text = text, - ) + Text( + text = text, + ) } @Composable fun Section(text: String) { - Spacer(modifier = DoubleVertSpacer) - Text( - text = text, - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - ) - Spacer(modifier = DoubleVertSpacer) + Spacer(modifier = DoubleVertSpacer) + Text( + text = text, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + ) + Spacer(modifier = DoubleVertSpacer) } @Composable fun SectionContent(text: String) { - Text( - modifier = Modifier.padding(start = 10.dp), - text = text, - ) + Text( + modifier = Modifier.padding(start = 10.dp), + text = text, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt index 1ac87acbd..a82544c26 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt @@ -56,198 +56,198 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList data class RelayList( - val relay: Relay, - val relayInfo: RelayBriefInfoCache.RelayBriefInfo, - val isSelected: Boolean, + val relay: Relay, + val relayInfo: RelayBriefInfoCache.RelayBriefInfo, + val isSelected: Boolean, ) data class RelayInfoDialog( - val relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo, - val relayInfo: RelayInformation, + val relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo, + val relayInfo: RelayInformation, ) @Composable fun RelaySelectionDialog( - preSelectedList: ImmutableList, - onClose: () -> Unit, - onPost: (list: ImmutableList) -> Unit, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + preSelectedList: ImmutableList, + onClose: () -> Unit, + onPost: (list: ImmutableList) -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val context = LocalContext.current + val context = LocalContext.current - var relays by remember { - mutableStateOf( - accountViewModel.account.activeWriteRelays().map { - RelayList( - relay = it, - relayInfo = RelayBriefInfoCache.RelayBriefInfo(it.url), - isSelected = preSelectedList.any { relay -> it.url == relay.url }, - ) - }, - ) - } - - val hasSelectedRelay by remember { derivedStateOf { relays.any { it.isSelected } } } - - var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) } - - relayInfo?.let { - RelayInformationDialog( - onClose = { relayInfo = null }, - relayInfo = it.relayInfo, - relayBriefInfo = it.relayBriefInfo, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - var selected by remember { mutableStateOf(true) } - - Dialog( - onDismissRequest = { onClose() }, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - decorFitsSystemWindows = false, - ), - ) { - Surface( - modifier = Modifier.fillMaxWidth().fillMaxHeight(), - ) { - Column( - modifier = - Modifier.fillMaxWidth().fillMaxHeight().padding(start = 10.dp, end = 10.dp, top = 10.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CloseButton( - onPress = { onClose() }, - ) - - SaveButton( - onPost = { - val selectedRelays = relays.filter { it.isSelected } - onPost(selectedRelays.map { it.relay }.toImmutableList()) - onClose() - }, - isActive = hasSelectedRelay, - ) - } - - RelaySwitch( - text = context.getString(R.string.select_deselect_all), - checked = selected, - onClick = { - selected = !selected - relays = relays.mapIndexed { _, item -> item.copy(isSelected = selected) } - }, - ) - - LazyColumn( - contentPadding = FeedPadding, - ) { - itemsIndexed( - relays, - key = { _, item -> item.relay.url }, - ) { index, item -> - RelaySwitch( - text = item.relayInfo.displayUrl, - checked = item.isSelected, - onClick = { - relays = - relays.mapIndexed { j, item -> - if (index == j) { - item.copy(isSelected = !item.isSelected) - } else { - item - } - } - }, - onLongPress = { - accountViewModel.retrieveRelayDocument( - item.relay.url, - onInfo = { - relayInfo = - RelayInfoDialog( - RelayBriefInfoCache.RelayBriefInfo( - item.relay.url, - ), - it, - ) - }, - onError = { url, errorCode, exceptionMessage -> - val msg = - when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> - context.getString( - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> - context.getString( - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> - context.getString( - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> - context.getString( - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - } - - accountViewModel.toast( - context.getString(R.string.unable_to_download_relay_document), - msg, - ) - }, + var relays by remember { + mutableStateOf( + accountViewModel.account.activeWriteRelays().map { + RelayList( + relay = it, + relayInfo = RelayBriefInfoCache.RelayBriefInfo(it.url), + isSelected = preSelectedList.any { relay -> it.url == relay.url }, ) - }, - ) - } - } - } + }, + ) + } + + val hasSelectedRelay by remember { derivedStateOf { relays.any { it.isSelected } } } + + var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) } + + relayInfo?.let { + RelayInformationDialog( + onClose = { relayInfo = null }, + relayInfo = it.relayInfo, + relayBriefInfo = it.relayBriefInfo, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + var selected by remember { mutableStateOf(true) } + + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false, + ), + ) { + Surface( + modifier = Modifier.fillMaxWidth().fillMaxHeight(), + ) { + Column( + modifier = + Modifier.fillMaxWidth().fillMaxHeight().padding(start = 10.dp, end = 10.dp, top = 10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton( + onPress = { onClose() }, + ) + + SaveButton( + onPost = { + val selectedRelays = relays.filter { it.isSelected } + onPost(selectedRelays.map { it.relay }.toImmutableList()) + onClose() + }, + isActive = hasSelectedRelay, + ) + } + + RelaySwitch( + text = context.getString(R.string.select_deselect_all), + checked = selected, + onClick = { + selected = !selected + relays = relays.mapIndexed { _, item -> item.copy(isSelected = selected) } + }, + ) + + LazyColumn( + contentPadding = FeedPadding, + ) { + itemsIndexed( + relays, + key = { _, item -> item.relay.url }, + ) { index, item -> + RelaySwitch( + text = item.relayInfo.displayUrl, + checked = item.isSelected, + onClick = { + relays = + relays.mapIndexed { j, item -> + if (index == j) { + item.copy(isSelected = !item.isSelected) + } else { + item + } + } + }, + onLongPress = { + accountViewModel.retrieveRelayDocument( + item.relay.url, + onInfo = { + relayInfo = + RelayInfoDialog( + RelayBriefInfoCache.RelayBriefInfo( + item.relay.url, + ), + it, + ) + }, + onError = { url, errorCode, exceptionMessage -> + val msg = + when (errorCode) { + Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + } + + accountViewModel.toast( + context.getString(R.string.unable_to_download_relay_document), + msg, + ) + }, + ) + }, + ) + } + } + } + } } - } } @OptIn(ExperimentalFoundationApi::class) @Composable fun RelaySwitch( - text: String, - checked: Boolean, - onClick: () -> Unit, - onLongPress: () -> Unit = {}, + text: String, + checked: Boolean, + onClick: () -> Unit, + onLongPress: () -> Unit = {}, ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier.combinedClickable( - onClick = onClick, - onLongClick = onLongPress, - ), - ) { - Text( - modifier = Modifier.weight(1f), - text = text, - ) - Switch( - checked = checked, - onCheckedChange = { onClick() }, - ) - } + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.combinedClickable( + onClick = onClick, + onLongClick = onLongPress, + ), + ) { + Text( + modifier = Modifier.weight(1f), + text = text, + ) + Switch( + checked = checked, + onCheckedChange = { onClick() }, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt index 4ea5f1b05..ad2ad7830 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt @@ -34,8 +34,8 @@ import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.theme.ButtonBorder -import java.io.File import kotlinx.coroutines.launch +import java.io.File /** * A button to save the remote image to the gallery. May require a storage permission. @@ -45,120 +45,120 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalPermissionsApi::class) @Composable fun SaveToGallery(url: String) { - val localContext = LocalContext.current - val scope = rememberCoroutineScope() + val localContext = LocalContext.current + val scope = rememberCoroutineScope() - fun saveImage() { - ImageSaver.saveImage( - context = localContext, - url = url, - onSuccess = { - scope.launch { - Toast.makeText( - localContext, - localContext.getString(R.string.image_saved_to_the_gallery), - Toast.LENGTH_SHORT, - ) - .show() - } - }, - onError = { - scope.launch { - Toast.makeText( - localContext, - localContext.getString(R.string.failed_to_save_the_image), - Toast.LENGTH_SHORT, - ) - .show() - } - }, - ) - } - - val writeStoragePermissionState = - rememberPermissionState( - Manifest.permission.WRITE_EXTERNAL_STORAGE, - ) { isGranted -> - if (isGranted) { - saveImage() - } + fun saveImage() { + ImageSaver.saveImage( + context = localContext, + url = url, + onSuccess = { + scope.launch { + Toast.makeText( + localContext, + localContext.getString(R.string.image_saved_to_the_gallery), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + onError = { + scope.launch { + Toast.makeText( + localContext, + localContext.getString(R.string.failed_to_save_the_image), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ) } - OutlinedButton( - onClick = { - if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || - writeStoragePermissionState.status.isGranted - ) { - saveImage() - } else { - writeStoragePermissionState.launchPermissionRequest() - } - }, - ) { - Text(text = stringResource(id = R.string.save)) - } + val writeStoragePermissionState = + rememberPermissionState( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + ) { isGranted -> + if (isGranted) { + saveImage() + } + } + + OutlinedButton( + onClick = { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || + writeStoragePermissionState.status.isGranted + ) { + saveImage() + } else { + writeStoragePermissionState.launchPermissionRequest() + } + }, + ) { + Text(text = stringResource(id = R.string.save)) + } } @OptIn(ExperimentalPermissionsApi::class) @Composable fun SaveToGallery( - localFile: File, - mimeType: String?, + localFile: File, + mimeType: String?, ) { - val localContext = LocalContext.current - val scope = rememberCoroutineScope() + val localContext = LocalContext.current + val scope = rememberCoroutineScope() - fun saveImage() { - ImageSaver.saveImage( - context = localContext, - localFile = localFile, - mimeType = mimeType, - onSuccess = { - scope.launch { - Toast.makeText( - localContext, - localContext.getString(R.string.image_saved_to_the_gallery), - Toast.LENGTH_SHORT, - ) - .show() - } - }, - onError = { - scope.launch { - Toast.makeText( - localContext, - localContext.getString(R.string.failed_to_save_the_image), - Toast.LENGTH_SHORT, - ) - .show() - } - }, - ) - } - - val writeStoragePermissionState = - rememberPermissionState( - Manifest.permission.WRITE_EXTERNAL_STORAGE, - ) { isGranted -> - if (isGranted) { - saveImage() - } + fun saveImage() { + ImageSaver.saveImage( + context = localContext, + localFile = localFile, + mimeType = mimeType, + onSuccess = { + scope.launch { + Toast.makeText( + localContext, + localContext.getString(R.string.image_saved_to_the_gallery), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + onError = { + scope.launch { + Toast.makeText( + localContext, + localContext.getString(R.string.failed_to_save_the_image), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ) } - OutlinedButton( - onClick = { - if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || - writeStoragePermissionState.status.isGranted - ) { - saveImage() - } else { - writeStoragePermissionState.launchPermissionRequest() - } - }, - shape = ButtonBorder, - ) { - Text(text = stringResource(id = R.string.save)) - } + val writeStoragePermissionState = + rememberPermissionState( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + ) { isGranted -> + if (isGranted) { + saveImage() + } + } + + OutlinedButton( + onClick = { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || + writeStoragePermissionState.status.isGranted + ) { + saveImage() + } else { + writeStoragePermissionState.launchPermissionRequest() + } + }, + shape = ButtonBorder, + ) { + Text(text = stringResource(id = R.string.save)) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt index aa326c53e..89a9e98be 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt @@ -58,145 +58,145 @@ import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.GetMediaActivityResultContract -import java.util.concurrent.atomic.AtomicBoolean import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +import java.util.concurrent.atomic.AtomicBoolean @OptIn(ExperimentalPermissionsApi::class) @Composable fun UploadFromGallery( - isUploading: Boolean, - tint: Color, - modifier: Modifier, - onImageChosen: (Uri) -> Unit, + isUploading: Boolean, + tint: Color, + modifier: Modifier, + onImageChosen: (Uri) -> Unit, ) { - val cameraPermissionState = - rememberPermissionState( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - android.Manifest.permission.READ_MEDIA_IMAGES - } else { - android.Manifest.permission.READ_EXTERNAL_STORAGE - }, - ) + val cameraPermissionState = + rememberPermissionState( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + android.Manifest.permission.READ_MEDIA_IMAGES + } else { + android.Manifest.permission.READ_EXTERNAL_STORAGE + }, + ) - if (cameraPermissionState.status.isGranted) { - var showGallerySelect by remember { mutableStateOf(false) } - if (showGallerySelect) { - GallerySelect( - onImageUri = { uri -> - showGallerySelect = false - if (uri != null) { - onImageChosen(uri) - } - }, - ) + if (cameraPermissionState.status.isGranted) { + var showGallerySelect by remember { mutableStateOf(false) } + if (showGallerySelect) { + GallerySelect( + onImageUri = { uri -> + showGallerySelect = false + if (uri != null) { + onImageChosen(uri) + } + }, + ) + } + + UploadBoxButton(isUploading, tint, modifier) { showGallerySelect = true } + } else { + UploadBoxButton(isUploading, tint, modifier) { cameraPermissionState.launchPermissionRequest() } } - - UploadBoxButton(isUploading, tint, modifier) { showGallerySelect = true } - } else { - UploadBoxButton(isUploading, tint, modifier) { cameraPermissionState.launchPermissionRequest() } - } } @Composable private fun UploadBoxButton( - isUploading: Boolean, - tint: Color, - modifier: Modifier, - onClick: () -> Unit, + isUploading: Boolean, + tint: Color, + modifier: Modifier, + onClick: () -> Unit, ) { - Box { - IconButton( - modifier = modifier.align(Alignment.Center), - enabled = !isUploading, - onClick = { onClick() }, - ) { - if (!isUploading) { - Icon( - imageVector = Icons.Default.AddPhotoAlternate, - contentDescription = stringResource(id = R.string.upload_image), - modifier = Modifier.height(25.dp), - tint = tint, - ) - } else { - LoadingAnimation() - } + Box { + IconButton( + modifier = modifier.align(Alignment.Center), + enabled = !isUploading, + onClick = { onClick() }, + ) { + if (!isUploading) { + Icon( + imageVector = Icons.Default.AddPhotoAlternate, + contentDescription = stringResource(id = R.string.upload_image), + modifier = Modifier.height(25.dp), + tint = tint, + ) + } else { + LoadingAnimation() + } + } } - } } val DefaultAnimationColors = - listOf( - Color(0xFF5851D8), - Color(0xFF833AB4), - Color(0xFFC13584), - Color(0xFFE1306C), - Color(0xFFFD1D1D), - Color(0xFFF56040), - Color(0xFFF77737), - Color(0xFFFCAF45), - Color(0xFFFFDC80), - Color(0xFF5851D8), + listOf( + Color(0xFF5851D8), + Color(0xFF833AB4), + Color(0xFFC13584), + Color(0xFFE1306C), + Color(0xFFFD1D1D), + Color(0xFFF56040), + Color(0xFFF77737), + Color(0xFFFCAF45), + Color(0xFFFFDC80), + Color(0xFF5851D8), ) - .toImmutableList() + .toImmutableList() @Composable fun LoadingAnimation( - indicatorSize: Dp = 20.dp, - circleColors: ImmutableList = DefaultAnimationColors, - animationDuration: Int = 1000, + indicatorSize: Dp = 20.dp, + circleColors: ImmutableList = DefaultAnimationColors, + animationDuration: Int = 1000, ) { - val infiniteTransition = rememberInfiniteTransition() + val infiniteTransition = rememberInfiniteTransition() - val rotateAnimation by - infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = - infiniteRepeatable( - animation = - tween( - durationMillis = animationDuration, - easing = LinearEasing, - ), - ), + val rotateAnimation by + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = + infiniteRepeatable( + animation = + tween( + durationMillis = animationDuration, + easing = LinearEasing, + ), + ), + ) + + CircularProgressIndicator( + modifier = + Modifier.size(size = indicatorSize) + .rotate(degrees = rotateAnimation) + .border( + width = 4.dp, + brush = Brush.sweepGradient(circleColors), + shape = CircleShape, + ), + progress = 1f, + strokeWidth = 1.dp, + color = MaterialTheme.colorScheme.background, ) - - CircularProgressIndicator( - modifier = - Modifier.size(size = indicatorSize) - .rotate(degrees = rotateAnimation) - .border( - width = 4.dp, - brush = Brush.sweepGradient(circleColors), - shape = CircleShape, - ), - progress = 1f, - strokeWidth = 1.dp, - color = MaterialTheme.colorScheme.background, - ) } @Composable fun GallerySelect(onImageUri: (Uri?) -> Unit = {}) { - var hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) } - val launcher = - rememberLauncherForActivityResult( - contract = GetMediaActivityResultContract(), - onResult = { uri: Uri? -> - onImageUri(uri) - hasLaunched.set(false) - }, - ) + var hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) } + val launcher = + rememberLauncherForActivityResult( + contract = GetMediaActivityResultContract(), + onResult = { uri: Uri? -> + onImageUri(uri) + hasLaunched.set(false) + }, + ) - @Composable - fun LaunchGallery() { - SideEffect { - if (!hasLaunched.getAndSet(true)) { - launcher.launch("*/*") - } + @Composable + fun LaunchGallery() { + SideEffect { + if (!hasLaunched.getAndSet(true)) { + launcher.launch("*/*") + } + } } - } - LaunchGallery() + LaunchGallery() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt index 5d8182f5b..32218b39a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt @@ -38,153 +38,154 @@ import kotlin.math.roundToInt data class RangesChanges(val original: TextRange, val modified: TextRange) class UrlUserTagTransformation(val color: Color) : VisualTransformation { - override fun filter(text: AnnotatedString): TransformedText { - return buildAnnotatedStringWithUrlHighlighting(text, color) - } + override fun filter(text: AnnotatedString): TransformedText { + return buildAnnotatedStringWithUrlHighlighting(text, color) + } } fun buildAnnotatedStringWithUrlHighlighting( - text: AnnotatedString, - color: Color, + text: AnnotatedString, + color: Color, ): TransformedText { - val substitutions = mutableListOf() + val substitutions = mutableListOf() - val newText = buildAnnotatedString { - val builderBefore = StringBuilder() // important to correctly measure Tag start and end - val builderAfter = StringBuilder() // important to correctly measure Tag start and end - append( - text - .split('\n') - .map { paragraph: String -> - paragraph - .split(' ') - .map { word: String -> - try { - if (word.startsWith("@npub") && word.length >= 64) { - val keyB32 = word.substring(0, 64) - val restOfWord = word.substring(64) + val newText = + buildAnnotatedString { + val builderBefore = StringBuilder() // important to correctly measure Tag start and end + val builderAfter = StringBuilder() // important to correctly measure Tag start and end + append( + text + .split('\n') + .map { paragraph: String -> + paragraph + .split(' ') + .map { word: String -> + try { + if (word.startsWith("@npub") && word.length >= 64) { + val keyB32 = word.substring(0, 64) + val restOfWord = word.substring(64) - val startIndex = builderBefore.toString().length + val startIndex = builderBefore.toString().length - builderBefore.append( - "$keyB32$restOfWord ", - ) // accounts for the \n at the end of each paragraph + builderBefore.append( + "$keyB32$restOfWord ", + ) // accounts for the \n at the end of each paragraph - val endIndex = startIndex + keyB32.length + val endIndex = startIndex + keyB32.length - val key = decodePublicKey(keyB32.removePrefix("@")) - val user = LocalCache.getOrCreateUser(key.toHexKey()) + val key = decodePublicKey(keyB32.removePrefix("@")) + val user = LocalCache.getOrCreateUser(key.toHexKey()) - val newWord = "@${user.toBestDisplayName()}" - val startNew = builderAfter.toString().length + val newWord = "@${user.toBestDisplayName()}" + val startNew = builderAfter.toString().length - builderAfter.append( - "$newWord$restOfWord ", - ) // accounts for the \n at the end of each paragraph + builderAfter.append( + "$newWord$restOfWord ", + ) // accounts for the \n at the end of each paragraph - substitutions.add( - RangesChanges( - TextRange(startIndex, endIndex), - TextRange(startNew, startNew + newWord.length), - ), - ) - newWord + restOfWord - } else if (Patterns.WEB_URL.matcher(word).matches()) { - val startIndex = builderBefore.toString().length - val endIndex = startIndex + word.length + substitutions.add( + RangesChanges( + TextRange(startIndex, endIndex), + TextRange(startNew, startNew + newWord.length), + ), + ) + newWord + restOfWord + } else if (Patterns.WEB_URL.matcher(word).matches()) { + val startIndex = builderBefore.toString().length + val endIndex = startIndex + word.length - val startNew = builderAfter.toString().length - val endNew = startNew + word.length + val startNew = builderAfter.toString().length + val endNew = startNew + word.length - substitutions.add( - RangesChanges( - TextRange(startIndex, endIndex), - TextRange(startNew, endNew), - ), - ) + substitutions.add( + RangesChanges( + TextRange(startIndex, endIndex), + TextRange(startNew, endNew), + ), + ) - builderBefore.append("$word ") - builderAfter.append("$word ") - word - } else { - builderBefore.append("$word ") - builderAfter.append("$word ") - word - } - } catch (e: Exception) { - // if it can't parse the key, don't try to change. - builderBefore.append("$word ") - builderAfter.append("$word ") - word - } + builderBefore.append("$word ") + builderAfter.append("$word ") + word + } else { + builderBefore.append("$word ") + builderAfter.append("$word ") + word + } + } catch (e: Exception) { + // if it can't parse the key, don't try to change. + builderBefore.append("$word ") + builderAfter.append("$word ") + word + } + } + .joinToString(" ") + } + .joinToString("\n"), + ) + + substitutions.forEach { + addStyle( + style = + SpanStyle( + color = color, + textDecoration = TextDecoration.None, + ), + start = it.modified.start, + end = it.modified.end, + ) } - .joinToString(" ") } - .joinToString("\n"), + + val numberOffsetTranslator = + object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + val inInsideRange = + substitutions + .filter { offset > it.original.start && offset < it.original.end } + .firstOrNull() + + if (inInsideRange != null) { + val percentInRange = + (offset - inInsideRange.original.start) / (inInsideRange.original.length.toFloat()) + return (inInsideRange.modified.start + inInsideRange.modified.length * percentInRange) + .roundToInt() + } + + val lastRangeThrough = substitutions.lastOrNull { offset >= it.original.end } + + if (lastRangeThrough != null) { + return lastRangeThrough.modified.end + (offset - lastRangeThrough.original.end) + } else { + return offset + } + } + + override fun transformedToOriginal(offset: Int): Int { + val inInsideRange = + substitutions + .filter { offset > it.modified.start && offset < it.modified.end } + .firstOrNull() + + if (inInsideRange != null) { + val percentInRange = + (offset - inInsideRange.modified.start) / (inInsideRange.modified.length.toFloat()) + return (inInsideRange.original.start + inInsideRange.original.length * percentInRange) + .roundToInt() + } + + val lastRangeThrough = substitutions.lastOrNull { offset >= it.modified.end } + + if (lastRangeThrough != null) { + return lastRangeThrough.original.end + (offset - lastRangeThrough.modified.end) + } else { + return offset + } + } + } + + return TransformedText( + newText, + numberOffsetTranslator, ) - - substitutions.forEach { - addStyle( - style = - SpanStyle( - color = color, - textDecoration = TextDecoration.None, - ), - start = it.modified.start, - end = it.modified.end, - ) - } - } - - val numberOffsetTranslator = - object : OffsetMapping { - override fun originalToTransformed(offset: Int): Int { - val inInsideRange = - substitutions - .filter { offset > it.original.start && offset < it.original.end } - .firstOrNull() - - if (inInsideRange != null) { - val percentInRange = - (offset - inInsideRange.original.start) / (inInsideRange.original.length.toFloat()) - return (inInsideRange.modified.start + inInsideRange.modified.length * percentInRange) - .roundToInt() - } - - val lastRangeThrough = substitutions.lastOrNull { offset >= it.original.end } - - if (lastRangeThrough != null) { - return lastRangeThrough.modified.end + (offset - lastRangeThrough.original.end) - } else { - return offset - } - } - - override fun transformedToOriginal(offset: Int): Int { - val inInsideRange = - substitutions - .filter { offset > it.modified.start && offset < it.modified.end } - .firstOrNull() - - if (inInsideRange != null) { - val percentInRange = - (offset - inInsideRange.modified.start) / (inInsideRange.modified.length.toFloat()) - return (inInsideRange.original.start + inInsideRange.original.length * percentInRange) - .roundToInt() - } - - val lastRangeThrough = substitutions.lastOrNull { offset >= it.modified.end } - - if (lastRangeThrough != null) { - return lastRangeThrough.original.end + (offset - lastRangeThrough.modified.end) - } else { - return offset - } - } - } - - return TransformedText( - newText, - numberOffsetTranslator, - ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/ChannelFabColumn.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/ChannelFabColumn.kt index 6effa5898..a9a386ca2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/ChannelFabColumn.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/ChannelFabColumn.kt @@ -50,83 +50,83 @@ import com.vitorpamplona.amethyst.ui.theme.Size55Modifier @Composable fun ChannelFabColumn( - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var isOpen by remember { mutableStateOf(false) } + var isOpen by remember { mutableStateOf(false) } - var wantsToSendNewMessage by remember { mutableStateOf(false) } + var wantsToSendNewMessage by remember { mutableStateOf(false) } - var wantsToCreateChannel by remember { mutableStateOf(false) } + var wantsToCreateChannel by remember { mutableStateOf(false) } - if (wantsToCreateChannel) { - NewChannelView({ wantsToCreateChannel = false }, accountViewModel = accountViewModel) - } - - if (wantsToSendNewMessage) { - NewPostView( - { wantsToSendNewMessage = false }, - enableMessageInterface = true, - accountViewModel = accountViewModel, - nav = nav, - ) - // JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, accountViewModel = - // accountViewModel, nav = nav) - } - - Column { - if (isOpen) { - FloatingActionButton( - onClick = { - wantsToSendNewMessage = true - isOpen = false - }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) { - Text( - text = stringResource(R.string.messages_new_message), - color = Color.White, - textAlign = TextAlign.Center, - fontSize = Font12SP, - ) - } - - Spacer(modifier = Modifier.height(20.dp)) - - FloatingActionButton( - onClick = { - wantsToCreateChannel = true - isOpen = false - }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) { - Text( - text = stringResource(R.string.messages_create_public_chat), - color = Color.White, - textAlign = TextAlign.Center, - fontSize = Font12SP, - ) - } - - Spacer(modifier = Modifier.height(20.dp)) + if (wantsToCreateChannel) { + NewChannelView({ wantsToCreateChannel = false }, accountViewModel = accountViewModel) } - FloatingActionButton( - onClick = { isOpen = !isOpen }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) { - Icon( - imageVector = Icons.Outlined.Add, - contentDescription = stringResource(R.string.messages_create_public_chat), - modifier = Modifier.size(26.dp), - tint = Color.White, - ) + if (wantsToSendNewMessage) { + NewPostView( + { wantsToSendNewMessage = false }, + enableMessageInterface = true, + accountViewModel = accountViewModel, + nav = nav, + ) + // JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, accountViewModel = + // accountViewModel, nav = nav) + } + + Column { + if (isOpen) { + FloatingActionButton( + onClick = { + wantsToSendNewMessage = true + isOpen = false + }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Text( + text = stringResource(R.string.messages_new_message), + color = Color.White, + textAlign = TextAlign.Center, + fontSize = Font12SP, + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + FloatingActionButton( + onClick = { + wantsToCreateChannel = true + isOpen = false + }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Text( + text = stringResource(R.string.messages_create_public_chat), + color = Color.White, + textAlign = TextAlign.Center, + fontSize = Font12SP, + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + } + + FloatingActionButton( + onClick = { isOpen = !isOpen }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = stringResource(R.string.messages_create_public_chat), + modifier = Modifier.size(26.dp), + tint = Color.White, + ) + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewChannelButton.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewChannelButton.kt index 4e5c8512e..0b92bd7fa 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewChannelButton.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewChannelButton.kt @@ -45,25 +45,25 @@ import com.vitorpamplona.amethyst.ui.theme.ZeroPadding @Composable fun NewChannelButton(accountViewModel: AccountViewModel) { - var wantsToPost by remember { mutableStateOf(false) } + var wantsToPost by remember { mutableStateOf(false) } - if (wantsToPost) { - NewChannelView({ wantsToPost = false }, accountViewModel = accountViewModel) - } + if (wantsToPost) { + NewChannelView({ wantsToPost = false }, accountViewModel = accountViewModel) + } - OutlinedButton( - onClick = { wantsToPost = true }, - modifier = Size55Modifier, - shape = CircleShape, - colors = - ButtonDefaults.outlinedButtonColors(containerColor = MaterialTheme.colorScheme.primary), - contentPadding = ZeroPadding, - ) { - Icon( - imageVector = Icons.Outlined.Add, - contentDescription = stringResource(R.string.new_channel), - modifier = Modifier.size(26.dp), - tint = Color.White, - ) - } + OutlinedButton( + onClick = { wantsToPost = true }, + modifier = Size55Modifier, + shape = CircleShape, + colors = + ButtonDefaults.outlinedButtonColors(containerColor = MaterialTheme.colorScheme.primary), + contentPadding = ZeroPadding, + ) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = stringResource(R.string.new_channel), + modifier = Modifier.size(26.dp), + tint = Color.White, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewCommunityNoteButton.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewCommunityNoteButton.kt index 1cd6cc020..39d724aa4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewCommunityNoteButton.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewCommunityNoteButton.kt @@ -43,38 +43,38 @@ import com.vitorpamplona.amethyst.ui.theme.Size55Modifier @Composable fun NewCommunityNoteButton( - communityIdHex: String, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + communityIdHex: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LoadNote(baseNoteHex = communityIdHex, accountViewModel) { - it?.let { NewCommunityNoteButton(it, accountViewModel, nav) } - } + LoadNote(baseNoteHex = communityIdHex, accountViewModel) { + it?.let { NewCommunityNoteButton(it, accountViewModel, nav) } + } } @Composable fun NewCommunityNoteButton( - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var wantsToPost by remember { mutableStateOf(false) } + var wantsToPost by remember { mutableStateOf(false) } - if (wantsToPost) { - NewPostView({ wantsToPost = false }, note, accountViewModel = accountViewModel, nav = nav) - } + if (wantsToPost) { + NewPostView({ wantsToPost = false }, note, accountViewModel = accountViewModel, nav = nav) + } - FloatingActionButton( - onClick = { wantsToPost = true }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) { - Icon( - painter = painterResource(R.drawable.ic_compose), - null, - modifier = Modifier.size(26.dp), - tint = Color.White, - ) - } + FloatingActionButton( + onClick = { wantsToPost = true }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + painter = painterResource(R.drawable.ic_compose), + null, + modifier = Modifier.size(26.dp), + tint = Color.White, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewImageButton.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewImageButton.kt index 20e11249b..6ef527715 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewImageButton.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewImageButton.kt @@ -68,101 +68,101 @@ import kotlinx.coroutines.withContext @OptIn(ExperimentalPermissionsApi::class) @Composable fun NewImageButton( - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - navScrollToTop: (Route, Boolean) -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + navScrollToTop: (Route, Boolean) -> Unit, ) { - var wantsToPost by remember { mutableStateOf(false) } + var wantsToPost by remember { mutableStateOf(false) } - var pickedURI by remember { mutableStateOf(null) } + var pickedURI by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - val postViewModel: NewMediaModel = viewModel() - postViewModel.onceUploaded { - scope.launch(Dispatchers.Default) { - delay(500) - withContext(Dispatchers.Main) { navScrollToTop(Route.Video, true) } + val postViewModel: NewMediaModel = viewModel() + postViewModel.onceUploaded { + scope.launch(Dispatchers.Default) { + delay(500) + withContext(Dispatchers.Main) { navScrollToTop(Route.Video, true) } + } } - } - if (wantsToPost) { - val cameraPermissionState = - rememberPermissionState( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - Manifest.permission.READ_MEDIA_IMAGES + if (wantsToPost) { + val cameraPermissionState = + rememberPermissionState( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Manifest.permission.READ_MEDIA_IMAGES + } else { + Manifest.permission.READ_EXTERNAL_STORAGE + }, + ) + + if (cameraPermissionState.status.isGranted) { + var showGallerySelect by remember { mutableStateOf(false) } + if (showGallerySelect) { + GallerySelect( + onImageUri = { uri -> + wantsToPost = false + showGallerySelect = false + pickedURI = uri + }, + ) + } + + showGallerySelect = true } else { - Manifest.permission.READ_EXTERNAL_STORAGE - }, - ) + LaunchedEffect(key1 = accountViewModel) { cameraPermissionState.launchPermissionRequest() } + } + } - if (cameraPermissionState.status.isGranted) { - var showGallerySelect by remember { mutableStateOf(false) } - if (showGallerySelect) { - GallerySelect( - onImageUri = { uri -> - wantsToPost = false - showGallerySelect = false - pickedURI = uri - }, + pickedURI?.let { + NewMediaView( + uri = it, + onClose = { pickedURI = null }, + postViewModel = postViewModel, + accountViewModel = accountViewModel, + nav = nav, ) - } + } - showGallerySelect = true + if (postViewModel.isUploadingImage) { + ShowProgress(postViewModel) } else { - LaunchedEffect(key1 = accountViewModel) { cameraPermissionState.launchPermissionRequest() } + FloatingActionButton( + onClick = { wantsToPost = true }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + painter = painterResource(R.drawable.ic_compose), + null, + modifier = Modifier.size(26.dp), + tint = Color.White, + ) + } } - } - - pickedURI?.let { - NewMediaView( - uri = it, - onClose = { pickedURI = null }, - postViewModel = postViewModel, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (postViewModel.isUploadingImage) { - ShowProgress(postViewModel) - } else { - FloatingActionButton( - onClick = { wantsToPost = true }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) { - Icon( - painter = painterResource(R.drawable.ic_compose), - null, - modifier = Modifier.size(26.dp), - tint = Color.White, - ) - } - } } @Composable private fun ShowProgress(postViewModel: NewMediaModel) { - Box(Modifier.size(55.dp), contentAlignment = Alignment.Center) { - CircularProgressIndicator( - progress = - animateFloatAsState( - targetValue = postViewModel.uploadingPercentage.value, - animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, - ) - .value, - modifier = Size55Modifier.clip(CircleShape).background(MaterialTheme.colorScheme.background), - strokeWidth = 5.dp, - ) - postViewModel.uploadingDescription.value?.let { - Text( - it, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 10.sp, - textAlign = TextAlign.Center, - ) + Box(Modifier.size(55.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + progress = + animateFloatAsState( + targetValue = postViewModel.uploadingPercentage.value, + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + ) + .value, + modifier = Size55Modifier.clip(CircleShape).background(MaterialTheme.colorScheme.background), + strokeWidth = 5.dp, + ) + postViewModel.uploadingDescription.value?.let { + Text( + it, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 10.sp, + textAlign = TextAlign.Center, + ) + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt index a729ea4ef..d128bed2e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt @@ -41,26 +41,26 @@ import com.vitorpamplona.amethyst.ui.theme.Size55Modifier @Composable fun NewNoteButton( - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var wantsToPost by remember { mutableStateOf(false) } + var wantsToPost by remember { mutableStateOf(false) } - if (wantsToPost) { - NewPostView({ wantsToPost = false }, accountViewModel = accountViewModel, nav = nav) - } + if (wantsToPost) { + NewPostView({ wantsToPost = false }, accountViewModel = accountViewModel, nav = nav) + } - FloatingActionButton( - onClick = { wantsToPost = true }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) { - Icon( - painter = painterResource(R.drawable.ic_compose), - null, - modifier = Modifier.size(26.dp), - tint = Color.White, - ) - } + FloatingActionButton( + onClick = { wantsToPost = true }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + painter = painterResource(R.drawable.ic_compose), + null, + modifier = Modifier.size(26.dp), + tint = Color.White, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt index e9338a3c3..9ad036ff6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt @@ -66,142 +66,142 @@ private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F @Composable fun AudioWaveformReadOnly( - modifier: Modifier = Modifier, - style: DrawStyle = Fill, - waveformBrush: Brush = SolidColor(Color.White), - progressBrush: Brush = SolidColor(Color.Blue), - waveformAlignment: WaveformAlignment = WaveformAlignment.Center, - amplitudeType: AmplitudeType = AmplitudeType.Avg, - onProgressChangeFinished: (() -> Unit)? = null, - spikeAnimationSpec: AnimationSpec = tween(500), - spikeWidth: Dp = 3.dp, - spikeRadius: Dp = 2.dp, - spikePadding: Dp = 2.dp, - progress: Float = 0F, - amplitudes: List, - onProgressChange: (Float) -> Unit, + modifier: Modifier = Modifier, + style: DrawStyle = Fill, + waveformBrush: Brush = SolidColor(Color.White), + progressBrush: Brush = SolidColor(Color.Blue), + waveformAlignment: WaveformAlignment = WaveformAlignment.Center, + amplitudeType: AmplitudeType = AmplitudeType.Avg, + onProgressChangeFinished: (() -> Unit)? = null, + spikeAnimationSpec: AnimationSpec = tween(500), + spikeWidth: Dp = 3.dp, + spikeRadius: Dp = 2.dp, + spikePadding: Dp = 2.dp, + progress: Float = 0F, + amplitudes: List, + onProgressChange: (Float) -> Unit, ) { - val backgroundColor = MaterialTheme.colorScheme.background - val progressState = remember(progress) { progress.coerceIn(MIN_PROGRESS, MAX_PROGRESS) } - val spikeWidthState = - remember(spikeWidth) { spikeWidth.coerceIn(MinSpikeWidthDp, MaxSpikeWidthDp) } - val spikePaddingState = - remember(spikePadding) { spikePadding.coerceIn(MinSpikePaddingDp, MaxSpikePaddingDp) } - val spikeRadiusState = - remember(spikeRadius) { spikeRadius.coerceIn(MinSpikeRadiusDp, MaxSpikeRadiusDp) } - val spikeTotalWidthState = - remember(spikeWidth, spikePadding) { spikeWidthState + spikePaddingState } - var canvasSize by remember { mutableStateOf(Size(0f, 0f)) } - var spikes by remember { mutableStateOf(0F) } - val spikesAmplitudes = - remember(amplitudes, spikes, amplitudeType) { - amplitudes.toDrawableAmplitudes( - amplitudeType = amplitudeType, - spikes = spikes.toInt(), - minHeight = MIN_SPIKE_HEIGHT, - maxHeight = canvasSize.height.coerceAtLeast(MIN_SPIKE_HEIGHT), - ) - } - .map { animateFloatAsState(it, spikeAnimationSpec).value } - Canvas( - modifier = - Modifier.fillMaxWidth() - .requiredHeight(48.dp) - .graphicsLayer(alpha = DEFAULT_GRAPHICS_LAYER_ALPHA) - .then(modifier), - ) { - canvasSize = size - spikes = size.width / spikeTotalWidthState.toPx() - spikesAmplitudes.forEachIndexed { index, amplitude -> - drawRoundRect( - brush = waveformBrush, - topLeft = - Offset( - x = index * spikeTotalWidthState.toPx(), - y = - when (waveformAlignment) { - WaveformAlignment.Top -> 0F - WaveformAlignment.Bottom -> size.height - amplitude - WaveformAlignment.Center -> size.height / 2F - amplitude / 2F - }, - ), - size = - Size( - width = spikeWidthState.toPx(), - height = amplitude, - ), - cornerRadius = CornerRadius(spikeRadiusState.toPx(), spikeRadiusState.toPx()), - style = style, - ) - drawRect( - brush = progressBrush, - size = - Size( - width = progressState * size.width, - height = size.height, - ), - blendMode = BlendMode.SrcAtop, - ) + val backgroundColor = MaterialTheme.colorScheme.background + val progressState = remember(progress) { progress.coerceIn(MIN_PROGRESS, MAX_PROGRESS) } + val spikeWidthState = + remember(spikeWidth) { spikeWidth.coerceIn(MinSpikeWidthDp, MaxSpikeWidthDp) } + val spikePaddingState = + remember(spikePadding) { spikePadding.coerceIn(MinSpikePaddingDp, MaxSpikePaddingDp) } + val spikeRadiusState = + remember(spikeRadius) { spikeRadius.coerceIn(MinSpikeRadiusDp, MaxSpikeRadiusDp) } + val spikeTotalWidthState = + remember(spikeWidth, spikePadding) { spikeWidthState + spikePaddingState } + var canvasSize by remember { mutableStateOf(Size(0f, 0f)) } + var spikes by remember { mutableStateOf(0F) } + val spikesAmplitudes = + remember(amplitudes, spikes, amplitudeType) { + amplitudes.toDrawableAmplitudes( + amplitudeType = amplitudeType, + spikes = spikes.toInt(), + minHeight = MIN_SPIKE_HEIGHT, + maxHeight = canvasSize.height.coerceAtLeast(MIN_SPIKE_HEIGHT), + ) + } + .map { animateFloatAsState(it, spikeAnimationSpec).value } + Canvas( + modifier = + Modifier.fillMaxWidth() + .requiredHeight(48.dp) + .graphicsLayer(alpha = DEFAULT_GRAPHICS_LAYER_ALPHA) + .then(modifier), + ) { + canvasSize = size + spikes = size.width / spikeTotalWidthState.toPx() + spikesAmplitudes.forEachIndexed { index, amplitude -> + drawRoundRect( + brush = waveformBrush, + topLeft = + Offset( + x = index * spikeTotalWidthState.toPx(), + y = + when (waveformAlignment) { + WaveformAlignment.Top -> 0F + WaveformAlignment.Bottom -> size.height - amplitude + WaveformAlignment.Center -> size.height / 2F - amplitude / 2F + }, + ), + size = + Size( + width = spikeWidthState.toPx(), + height = amplitude, + ), + cornerRadius = CornerRadius(spikeRadiusState.toPx(), spikeRadiusState.toPx()), + style = style, + ) + drawRect( + brush = progressBrush, + size = + Size( + width = progressState * size.width, + height = size.height, + ), + blendMode = BlendMode.SrcAtop, + ) + } } - } } private fun List.toDrawableAmplitudes( - amplitudeType: AmplitudeType, - spikes: Int, - minHeight: Float, - maxHeight: Float, + amplitudeType: AmplitudeType, + spikes: Int, + minHeight: Float, + maxHeight: Float, ): List { - val amplitudes = map(Int::toFloat) - if (amplitudes.isEmpty() || spikes == 0) { - return List(spikes) { minHeight } - } - val transform = { data: List -> - when (amplitudeType) { - AmplitudeType.Avg -> data.average() - AmplitudeType.Max -> data.max() - AmplitudeType.Min -> data.min() - } - .toFloat() - .coerceIn(minHeight, maxHeight) - } - return when { - spikes > amplitudes.count() -> amplitudes.fillToSize(spikes, transform) - else -> amplitudes.chunkToSize(spikes, transform) - }.normalize(minHeight, maxHeight) + val amplitudes = map(Int::toFloat) + if (amplitudes.isEmpty() || spikes == 0) { + return List(spikes) { minHeight } + } + val transform = { data: List -> + when (amplitudeType) { + AmplitudeType.Avg -> data.average() + AmplitudeType.Max -> data.max() + AmplitudeType.Min -> data.min() + } + .toFloat() + .coerceIn(minHeight, maxHeight) + } + return when { + spikes > amplitudes.count() -> amplitudes.fillToSize(spikes, transform) + else -> amplitudes.chunkToSize(spikes, transform) + }.normalize(minHeight, maxHeight) } internal fun Iterable.fillToSize( - size: Int, - transform: (List) -> T, + size: Int, + transform: (List) -> T, ): List { - val capacity = ceil(size.safeDiv(count())).roundToInt() - return map { data -> List(capacity) { data } }.flatten().chunkToSize(size, transform) + val capacity = ceil(size.safeDiv(count())).roundToInt() + return map { data -> List(capacity) { data } }.flatten().chunkToSize(size, transform) } internal fun Iterable.chunkToSize( - size: Int, - transform: (List) -> T, + size: Int, + transform: (List) -> T, ): List { - val chunkSize = count() / size - val remainder = count() % size - val remainderIndex = ceil(count().safeDiv(remainder)).roundToInt() - val chunkIteration = - filterIndexed { index, _ -> remainderIndex == 0 || index % remainderIndex != 0 } - .chunked(chunkSize, transform) - return when (size) { - chunkIteration.count() -> chunkIteration - else -> chunkIteration.chunkToSize(size, transform) - } + val chunkSize = count() / size + val remainder = count() % size + val remainderIndex = ceil(count().safeDiv(remainder)).roundToInt() + val chunkIteration = + filterIndexed { index, _ -> remainderIndex == 0 || index % remainderIndex != 0 } + .chunked(chunkSize, transform) + return when (size) { + chunkIteration.count() -> chunkIteration + else -> chunkIteration.chunkToSize(size, transform) + } } internal fun Iterable.normalize( - min: Float, - max: Float, + min: Float, + max: Float, ): List { - return map { (max - min) * ((it - min()) / (max() - min())) + min } + return map { (max - min) * ((it - min()) / (max() - min())) + min } } private fun Int.safeDiv(value: Int): Float { - return if (value == 0) return 0F else this / value.toFloat() + return if (value == 0) return 0F else this / value.toFloat() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt index fa2a0e6b5..eec719159 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt @@ -22,8 +22,6 @@ package com.vitorpamplona.amethyst.ui.components import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.service.checkNotInMainThread -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -33,94 +31,96 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.atomic.AtomicBoolean /** This class is designed to have a waiting time between two calls of invalidate */ @Stable class BundledUpdate( - val delay: Long, - val dispatcher: CoroutineDispatcher = Dispatchers.Default, + val delay: Long, + val dispatcher: CoroutineDispatcher = Dispatchers.Default, ) { - val scope = CoroutineScope(dispatcher + SupervisorJob()) + val scope = CoroutineScope(dispatcher + SupervisorJob()) - private var onlyOneInBlock = AtomicBoolean() - private var invalidatesAgain = false + private var onlyOneInBlock = AtomicBoolean() + private var invalidatesAgain = false - fun invalidate( - ignoreIfDoing: Boolean = false, - onUpdate: suspend () -> Unit, - ) { - if (onlyOneInBlock.getAndSet(true)) { - if (!ignoreIfDoing) { - invalidatesAgain = true - } - return + fun invalidate( + ignoreIfDoing: Boolean = false, + onUpdate: suspend () -> Unit, + ) { + if (onlyOneInBlock.getAndSet(true)) { + if (!ignoreIfDoing) { + invalidatesAgain = true + } + return + } + + scope.launch(dispatcher) { + try { + onUpdate() + delay(delay) + if (invalidatesAgain) { + onUpdate() + } + } finally { + withContext(NonCancellable) { + invalidatesAgain = false + onlyOneInBlock.set(false) + } + } + } } - scope.launch(dispatcher) { - try { - onUpdate() - delay(delay) - if (invalidatesAgain) { - onUpdate() - } - } finally { - withContext(NonCancellable) { - invalidatesAgain = false - onlyOneInBlock.set(false) - } - } + fun cancel() { + scope.cancel() } - } - - fun cancel() { - scope.cancel() - } } /** This class is designed to have a waiting time between two calls of invalidate */ @Stable class BundledInsert( - val delay: Long, - val dispatcher: CoroutineDispatcher = Dispatchers.Default, + val delay: Long, + val dispatcher: CoroutineDispatcher = Dispatchers.Default, ) { - val scope = CoroutineScope(dispatcher + SupervisorJob()) + val scope = CoroutineScope(dispatcher + SupervisorJob()) - private var onlyOneInBlock = AtomicBoolean() - private var queue = LinkedBlockingQueue() + private var onlyOneInBlock = AtomicBoolean() + private var queue = LinkedBlockingQueue() - fun invalidateList( - newObject: T, - onUpdate: suspend (Set) -> Unit, - ) { - checkNotInMainThread() + fun invalidateList( + newObject: T, + onUpdate: suspend (Set) -> Unit, + ) { + checkNotInMainThread() - queue.put(newObject) - if (onlyOneInBlock.getAndSet(true)) { - return - } - - scope.launch(dispatcher) { - try { - val mySet = mutableSetOf() - queue.drainTo(mySet) - if (mySet.isNotEmpty()) { - onUpdate(mySet) + queue.put(newObject) + if (onlyOneInBlock.getAndSet(true)) { + return } - delay(delay) + scope.launch(dispatcher) { + try { + val mySet = mutableSetOf() + queue.drainTo(mySet) + if (mySet.isNotEmpty()) { + onUpdate(mySet) + } - val mySet2 = mutableSetOf() - queue.drainTo(mySet2) - if (mySet2.isNotEmpty()) { - onUpdate(mySet2) + delay(delay) + + val mySet2 = mutableSetOf() + queue.drainTo(mySet2) + if (mySet2.isNotEmpty()) { + onUpdate(mySet2) + } + } finally { + withContext(NonCancellable) { onlyOneInBlock.set(false) } + } } - } finally { - withContext(NonCancellable) { onlyOneInBlock.set(false) } - } } - } - fun cancel() { - scope.cancel() - } + fun cancel() { + scope.cancel() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt index a81f9d368..d71e64e82 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt @@ -88,294 +88,294 @@ import kotlinx.coroutines.launch @Composable fun CashuPreview( - cashutoken: String, - accountViewModel: AccountViewModel, + cashutoken: String, + accountViewModel: AccountViewModel, ) { - var cachuData by remember { - mutableStateOf>(GenericLoadable.Loading()) - } - - LaunchedEffect(key1 = cashutoken) { - launch(Dispatchers.IO) { - val newCachuData = CashuProcessor().parse(cashutoken) - launch(Dispatchers.Main) { cachuData = newCachuData } + var cachuData by remember { + mutableStateOf>(GenericLoadable.Loading()) } - } - Crossfade(targetState = cachuData, label = "CashuPreview(") { - when (it) { - is GenericLoadable.Loaded -> CashuPreview(it.loaded, accountViewModel) - is GenericLoadable.Error -> - Text( - text = "$cashutoken ", - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - ) - else -> {} + LaunchedEffect(key1 = cashutoken) { + launch(Dispatchers.IO) { + val newCachuData = CashuProcessor().parse(cashutoken) + launch(Dispatchers.Main) { cachuData = newCachuData } + } + } + + Crossfade(targetState = cachuData, label = "CashuPreview(") { + when (it) { + is GenericLoadable.Loaded -> CashuPreview(it.loaded, accountViewModel) + is GenericLoadable.Error -> + Text( + text = "$cashutoken ", + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) + else -> {} + } } - } } @Composable fun CashuPreview( - token: CashuToken, - accountViewModel: AccountViewModel, + token: CashuToken, + accountViewModel: AccountViewModel, ) { - CashuPreviewNew(token, accountViewModel::meltCashu, accountViewModel::toast) + CashuPreviewNew(token, accountViewModel::meltCashu, accountViewModel::toast) } @Composable @Preview() fun CashuPreviewPreview() { - val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() + val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() - sharedPreferencesViewModel.init() - sharedPreferencesViewModel.updateTheme(ThemeType.DARK) + sharedPreferencesViewModel.init() + sharedPreferencesViewModel.updateTheme(ThemeType.DARK) - AmethystTheme(sharedPrefsViewModel = sharedPreferencesViewModel) { - Column { - CashuPreview( - token = CashuToken("token", "mint", 32400, TextNode("")), - melt = { token, context, onDone -> }, - toast = { title, message -> }, - ) + AmethystTheme(sharedPrefsViewModel = sharedPreferencesViewModel) { + Column { + CashuPreview( + token = CashuToken("token", "mint", 32400, TextNode("")), + melt = { token, context, onDone -> }, + toast = { title, message -> }, + ) - CashuPreviewNew( - token = CashuToken("token", "mint", 32400, TextNode("")), - melt = { token, context, onDone -> }, - toast = { title, message -> }, - ) + CashuPreviewNew( + token = CashuToken("token", "mint", 32400, TextNode("")), + melt = { token, context, onDone -> }, + toast = { title, message -> }, + ) + } } - } } @Composable fun CashuPreview( - token: CashuToken, - melt: (CashuToken, Context, (String, String) -> Unit) -> Unit, - toast: (String, String) -> Unit, + token: CashuToken, + melt: (CashuToken, Context, (String, String) -> Unit) -> Unit, + toast: (String, String) -> Unit, ) { - val context = LocalContext.current - val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current - Column( - modifier = - Modifier.fillMaxWidth() - .padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp) - .clip(shape = QuoteBorder) - .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder), - ) { Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), + modifier = + Modifier.fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp) + .clip(shape = QuoteBorder) + .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), - ) { - Icon( - painter = painterResource(R.drawable.cashu), - null, - modifier = Size20Modifier, - tint = Color.Unspecified, - ) - - Text( - text = stringResource(R.string.cashu), - fontSize = 20.sp, - fontWeight = FontWeight.W500, - modifier = Modifier.padding(start = 10.dp), - ) - } - - Divider() - - Text( - text = "${token.totalAmount} ${stringResource(id = R.string.sats)}", - fontSize = 25.sp, - fontWeight = FontWeight.W500, - modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), - ) - - Row( - modifier = Modifier.padding(top = 5.dp).fillMaxWidth(), - ) { - var isRedeeming by remember { mutableStateOf(false) } - - Button( - onClick = { - isRedeeming = true - melt(token, context) { title, message -> - toast(title, message) - isRedeeming = false - } - }, - shape = QuoteBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), + Column( + modifier = Modifier.fillMaxWidth().padding(20.dp), ) { - if (isRedeeming) { - LoadingAnimation() - } else { - ZapIcon(Size20Modifier, tint = Color.White) - } - Spacer(DoubleHorzSpacer) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + ) { + Icon( + painter = painterResource(R.drawable.cashu), + null, + modifier = Size20Modifier, + tint = Color.Unspecified, + ) - Text( - stringResource(id = R.string.cashu_redeem_to_zap), - color = Color.White, - fontSize = 16.sp, - ) + Text( + text = stringResource(R.string.cashu), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) + } + + Divider() + + Text( + text = "${token.totalAmount} ${stringResource(id = R.string.sats)}", + fontSize = 25.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + ) + + Row( + modifier = Modifier.padding(top = 5.dp).fillMaxWidth(), + ) { + var isRedeeming by remember { mutableStateOf(false) } + + Button( + onClick = { + isRedeeming = true + melt(token, context) { title, message -> + toast(title, message) + isRedeeming = false + } + }, + shape = QuoteBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + if (isRedeeming) { + LoadingAnimation() + } else { + ZapIcon(Size20Modifier, tint = Color.White) + } + Spacer(DoubleHorzSpacer) + + Text( + stringResource(id = R.string.cashu_redeem_to_zap), + color = Color.White, + fontSize = 16.sp, + ) + } + } + + Spacer(modifier = StdHorzSpacer) + Button( + onClick = { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("cashu://${token.token}")) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + + startActivity(context, intent, null) + } catch (e: Exception) { + toast("Cashu", context.getString(R.string.cashu_no_wallet_found)) + } + }, + shape = QuoteBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + CashuIcon(Size20Modifier) + Spacer(DoubleHorzSpacer) + Text( + stringResource(id = R.string.cashu_redeem_to_cashu), + color = Color.White, + fontSize = 16.sp, + ) + } + Spacer(modifier = StdHorzSpacer) + Button( + onClick = { + // Copying the token to clipboard + clipboardManager.setText(AnnotatedString(token.token)) + }, + shape = QuoteBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + CopyIcon(Size20Modifier, Color.White) + Spacer(DoubleHorzSpacer) + Text(stringResource(id = R.string.cashu_copy_token), color = Color.White, fontSize = 16.sp) + } + Spacer(modifier = StdHorzSpacer) } - } - - Spacer(modifier = StdHorzSpacer) - Button( - onClick = { - try { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("cashu://${token.token}")) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - - startActivity(context, intent, null) - } catch (e: Exception) { - toast("Cashu", context.getString(R.string.cashu_no_wallet_found)) - } - }, - shape = QuoteBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - CashuIcon(Size20Modifier) - Spacer(DoubleHorzSpacer) - Text( - stringResource(id = R.string.cashu_redeem_to_cashu), - color = Color.White, - fontSize = 16.sp, - ) - } - Spacer(modifier = StdHorzSpacer) - Button( - onClick = { - // Copying the token to clipboard - clipboardManager.setText(AnnotatedString(token.token)) - }, - shape = QuoteBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - CopyIcon(Size20Modifier, Color.White) - Spacer(DoubleHorzSpacer) - Text(stringResource(id = R.string.cashu_copy_token), color = Color.White, fontSize = 16.sp) - } - Spacer(modifier = StdHorzSpacer) } - } } @Composable fun CashuPreviewNew( - token: CashuToken, - melt: (CashuToken, Context, (String, String) -> Unit) -> Unit, - toast: (String, String) -> Unit, + token: CashuToken, + melt: (CashuToken, Context, (String, String) -> Unit) -> Unit, + toast: (String, String) -> Unit, ) { - val context = LocalContext.current - val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current - Card( - modifier = - Modifier.fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp) - .clip(shape = QuoteBorder), - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth().padding(10.dp), + Card( + modifier = + Modifier.fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp) + .clip(shape = QuoteBorder), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - painter = painterResource(R.drawable.cashu), - null, - modifier = Modifier.size(13.dp), - tint = Color.Unspecified, - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth().padding(10.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.cashu), + null, + modifier = Modifier.size(13.dp), + tint = Color.Unspecified, + ) - Text( - text = stringResource(R.string.cashu), - fontSize = 12.sp, - modifier = Modifier.padding(start = 5.dp, bottom = 1.dp), - ) - } - - Text( - text = "${token.totalAmount} ${stringResource(id = R.string.sats)}", - fontSize = 20.sp, - ) - - Row(modifier = Modifier.padding(top = 5.dp)) { - var isRedeeming by remember { mutableStateOf(false) } - - FilledTonalButton( - onClick = { - isRedeeming = true - melt(token, context) { title, message -> - toast(title, message) - isRedeeming = false + Text( + text = stringResource(R.string.cashu), + fontSize = 12.sp, + modifier = Modifier.padding(start = 5.dp, bottom = 1.dp), + ) } - }, - shape = SmallishBorder, - ) { - if (isRedeeming) { - LoadingAnimation() - } else { - ZapIcon(Size20Modifier, tint = MaterialTheme.colorScheme.onBackground) - } - Spacer(StdHorzSpacer) - Text( - "Redeem", - color = MaterialTheme.colorScheme.onBackground, - fontSize = 16.sp, - ) - } + Text( + text = "${token.totalAmount} ${stringResource(id = R.string.sats)}", + fontSize = 20.sp, + ) - Spacer(modifier = StdHorzSpacer) + Row(modifier = Modifier.padding(top = 5.dp)) { + var isRedeeming by remember { mutableStateOf(false) } - FilledTonalButton( - onClick = { - try { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("cashu://${token.token}")) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + FilledTonalButton( + onClick = { + isRedeeming = true + melt(token, context) { title, message -> + toast(title, message) + isRedeeming = false + } + }, + shape = SmallishBorder, + ) { + if (isRedeeming) { + LoadingAnimation() + } else { + ZapIcon(Size20Modifier, tint = MaterialTheme.colorScheme.onBackground) + } + Spacer(StdHorzSpacer) - startActivity(context, intent, null) - } catch (e: Exception) { - toast("Cashu", context.getString(R.string.cashu_no_wallet_found)) + Text( + "Redeem", + color = MaterialTheme.colorScheme.onBackground, + fontSize = 16.sp, + ) + } + + Spacer(modifier = StdHorzSpacer) + + FilledTonalButton( + onClick = { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("cashu://${token.token}")) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + + startActivity(context, intent, null) + } catch (e: Exception) { + toast("Cashu", context.getString(R.string.cashu_no_wallet_found)) + } + }, + shape = SmallishBorder, + contentPadding = PaddingValues(0.dp), + ) { + OpenInNewIcon(Size18Modifier, tint = MaterialTheme.colorScheme.onBackground) + } + + Spacer(modifier = StdHorzSpacer) + + FilledTonalButton( + onClick = { + // Copying the token to clipboard + clipboardManager.setText(AnnotatedString(token.token)) + }, + shape = SmallishBorder, + contentPadding = PaddingValues(0.dp), + ) { + CopyIcon(Size18Modifier, tint = MaterialTheme.colorScheme.onBackground) + } } - }, - shape = SmallishBorder, - contentPadding = PaddingValues(0.dp), - ) { - OpenInNewIcon(Size18Modifier, tint = MaterialTheme.colorScheme.onBackground) } - - Spacer(modifier = StdHorzSpacer) - - FilledTonalButton( - onClick = { - // Copying the token to clipboard - clipboardManager.setText(AnnotatedString(token.token)) - }, - shape = SmallishBorder, - contentPadding = PaddingValues(0.dp), - ) { - CopyIcon(Size18Modifier, tint = MaterialTheme.colorScheme.onBackground) - } - } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt index bd44dfb1d..7faddad2f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt @@ -33,31 +33,31 @@ import androidx.compose.ui.text.AnnotatedString @Composable fun ClickableEmail(email: String) { - val stripped = email.replaceFirst("mailto:", "") - val context = LocalContext.current + val stripped = email.replaceFirst("mailto:", "") + val context = LocalContext.current - ClickableText( - text = remember { AnnotatedString(stripped) }, - onClick = { runCatching { context.sendMail(stripped) } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - ) + ClickableText( + text = remember { AnnotatedString(stripped) }, + onClick = { runCatching { context.sendMail(stripped) } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + ) } fun Context.sendMail( - to: String, - subject: String? = null, + to: String, + subject: String? = null, ) { - try { - val intent = Intent(Intent.ACTION_SEND) - intent.type = "vnd.android.cursor.item/email" // or "message/rfc822" - intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(to)) - if (subject != null) { - intent.putExtra(Intent.EXTRA_SUBJECT, subject) + try { + val intent = Intent(Intent.ACTION_SEND) + intent.type = "vnd.android.cursor.item/email" // or "message/rfc822" + intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(to)) + if (subject != null) { + intent.putExtra(Intent.EXTRA_SUBJECT, subject) + } + startActivity(intent) + } catch (e: ActivityNotFoundException) { + // TODO: Handle case where no email app is available + } catch (t: Throwable) { + // TODO: Handle potential other type of exceptions } - startActivity(intent) - } catch (e: ActivityNotFoundException) { - // TODO: Handle case where no email app is available - } catch (t: Throwable) { - // TODO: Handle potential other type of exceptions - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt index 75f4b2112..92ee3fea5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt @@ -30,12 +30,12 @@ import com.vitorpamplona.amethyst.ui.note.toShortenHex @Composable fun ClickableNoteTag( - baseNote: Note, - nav: (String) -> Unit, + baseNote: Note, + nav: (String) -> Unit, ) { - ClickableText( - text = AnnotatedString("@${baseNote.idNote().toShortenHex()}"), - onClick = { nav("Note/${baseNote.idHex}") }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - ) + ClickableText( + text = AnnotatedString("@${baseNote.idNote().toShortenHex()}"), + onClick = { nav("Note/${baseNote.idHex}") }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt index 52a2fc376..e52aa1c3d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt @@ -33,20 +33,20 @@ import androidx.compose.ui.text.AnnotatedString @Composable fun ClickablePhone(phone: String) { - val context = LocalContext.current + val context = LocalContext.current - ClickableText( - text = remember { AnnotatedString(phone) }, - onClick = { runCatching { context.dial(phone) } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - ) + ClickableText( + text = remember { AnnotatedString(phone) }, + onClick = { runCatching { context.dial(phone) } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + ) } fun Context.dial(phone: String) { - try { - val intent = Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", phone, null)) - startActivity(intent) - } catch (t: Throwable) { - // TODO: Handle potential exceptions - } + try { + val intent = Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", phone, null)) + startActivity(intent) + } catch (t: Throwable) { + // TODO: Handle potential exceptions + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index d0fc842a9..dab4b350c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -79,502 +79,502 @@ import kotlinx.coroutines.launch @Composable fun ClickableRoute( - nip19: Nip19.Return, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + nip19: Nip19.Return, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - when (nip19.type) { - Nip19.Type.USER -> { - DisplayUser(nip19, accountViewModel, nav) + when (nip19.type) { + Nip19.Type.USER -> { + DisplayUser(nip19, accountViewModel, nav) + } + Nip19.Type.ADDRESS -> { + DisplayAddress(nip19, accountViewModel, nav) + } + Nip19.Type.NOTE -> { + DisplayNote(nip19, accountViewModel, nav) + } + Nip19.Type.EVENT -> { + DisplayEvent(nip19, accountViewModel, nav) + } + else -> { + Text( + remember { "@${nip19.hex}${nip19.additionalChars}" }, + ) + } } - Nip19.Type.ADDRESS -> { - DisplayAddress(nip19, accountViewModel, nav) - } - Nip19.Type.NOTE -> { - DisplayNote(nip19, accountViewModel, nav) - } - Nip19.Type.EVENT -> { - DisplayEvent(nip19, accountViewModel, nav) - } - else -> { - Text( - remember { "@${nip19.hex}${nip19.additionalChars}" }, - ) - } - } } @Composable private fun DisplayEvent( - nip19: Nip19.Return, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + nip19: Nip19.Return, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LoadNote(nip19.hex, accountViewModel) { - if (it != null) { - DisplayNoteLink(it, nip19, accountViewModel, nav) - } else { - CreateClickableText( - clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" }, - suffix = nip19.additionalChars, - route = remember(nip19) { "Event/${nip19.hex}" }, - nav = nav, - ) + LoadNote(nip19.hex, accountViewModel) { + if (it != null) { + DisplayNoteLink(it, nip19, accountViewModel, nav) + } else { + CreateClickableText( + clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" }, + suffix = nip19.additionalChars, + route = remember(nip19) { "Event/${nip19.hex}" }, + nav = nav, + ) + } } - } } @Composable private fun DisplayNote( - nip19: Nip19.Return, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + nip19: Nip19.Return, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LoadNote(nip19.hex, accountViewModel = accountViewModel) { - if (it != null) { - DisplayNoteLink(it, nip19, accountViewModel, nav) - } else { - CreateClickableText( - clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" }, - suffix = nip19.additionalChars, - route = remember(nip19) { "Event/${nip19.hex}" }, - nav = nav, - ) + LoadNote(nip19.hex, accountViewModel = accountViewModel) { + if (it != null) { + DisplayNoteLink(it, nip19, accountViewModel, nav) + } else { + CreateClickableText( + clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" }, + suffix = nip19.additionalChars, + route = remember(nip19) { "Event/${nip19.hex}" }, + nav = nav, + ) + } } - } } @Composable private fun DisplayNoteLink( - it: Note, - nip19: Nip19.Return, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + it: Note, + nip19: Nip19.Return, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteState by it.live().metadata.observeAsState() + val noteState by it.live().metadata.observeAsState() - val note = remember(noteState) { noteState?.note } ?: return + val note = remember(noteState) { noteState?.note } ?: return - val channelHex = remember(noteState) { note.channelHex() } - val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" } - val addedCharts = remember { "${nip19.additionalChars}" } + val channelHex = remember(noteState) { note.channelHex() } + val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" } + val addedCharts = remember { "${nip19.additionalChars}" } - if (note.event is ChannelCreateEvent || nip19.kind == ChannelCreateEvent.KIND) { - CreateClickableText( - clickablePart = noteIdDisplayNote, - suffix = addedCharts, - route = remember(noteState) { "Channel/${nip19.hex}" }, - nav = nav, - ) - } else if (note.event is PrivateDmEvent || nip19.kind == PrivateDmEvent.KIND) { - CreateClickableText( - clickablePart = noteIdDisplayNote, - suffix = addedCharts, - route = - remember(noteState) { (note.author?.pubkeyHex ?: nip19.hex).let { "RoomByAuthor/$it" } }, - nav = nav, - ) - } else if (channelHex != null) { - LoadChannel(baseChannelHex = channelHex, accountViewModel) { baseChannel -> - val channelState by baseChannel.live.observeAsState() - val channelDisplayName by - remember(channelState) { - derivedStateOf { channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote } + if (note.event is ChannelCreateEvent || nip19.kind == ChannelCreateEvent.KIND) { + CreateClickableText( + clickablePart = noteIdDisplayNote, + suffix = addedCharts, + route = remember(noteState) { "Channel/${nip19.hex}" }, + nav = nav, + ) + } else if (note.event is PrivateDmEvent || nip19.kind == PrivateDmEvent.KIND) { + CreateClickableText( + clickablePart = noteIdDisplayNote, + suffix = addedCharts, + route = + remember(noteState) { (note.author?.pubkeyHex ?: nip19.hex).let { "RoomByAuthor/$it" } }, + nav = nav, + ) + } else if (channelHex != null) { + LoadChannel(baseChannelHex = channelHex, accountViewModel) { baseChannel -> + val channelState by baseChannel.live.observeAsState() + val channelDisplayName by + remember(channelState) { + derivedStateOf { channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote } + } + + CreateClickableText( + clickablePart = channelDisplayName, + suffix = addedCharts, + route = remember(noteState) { "Channel/${baseChannel.idHex}" }, + nav = nav, + ) } - - CreateClickableText( - clickablePart = channelDisplayName, - suffix = addedCharts, - route = remember(noteState) { "Channel/${baseChannel.idHex}" }, - nav = nav, - ) + } else { + CreateClickableText( + clickablePart = noteIdDisplayNote, + suffix = addedCharts, + route = remember(noteState) { "Event/${nip19.hex}" }, + nav = nav, + ) } - } else { - CreateClickableText( - clickablePart = noteIdDisplayNote, - suffix = addedCharts, - route = remember(noteState) { "Event/${nip19.hex}" }, - nav = nav, - ) - } } @Composable private fun DisplayAddress( - nip19: Nip19.Return, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + nip19: Nip19.Return, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var noteBase by remember(nip19) { mutableStateOf(accountViewModel.getNoteIfExists(nip19.hex)) } + var noteBase by remember(nip19) { mutableStateOf(accountViewModel.getNoteIfExists(nip19.hex)) } - if (noteBase == null) { - LaunchedEffect(key1 = nip19.hex) { - accountViewModel.checkGetOrCreateAddressableNote(nip19.hex) { noteBase = it } + if (noteBase == null) { + LaunchedEffect(key1 = nip19.hex) { + accountViewModel.checkGetOrCreateAddressableNote(nip19.hex) { noteBase = it } + } } - } - noteBase?.let { - val noteState by it.live().metadata.observeAsState() + noteBase?.let { + val noteState by it.live().metadata.observeAsState() - val route = remember(noteState) { "Note/${nip19.hex}" } - val displayName = remember(noteState) { "@${noteState?.note?.idDisplayNote()}" } - val addedCharts = remember { "${nip19.additionalChars}" } + val route = remember(noteState) { "Note/${nip19.hex}" } + val displayName = remember(noteState) { "@${noteState?.note?.idDisplayNote()}" } + val addedCharts = remember { "${nip19.additionalChars}" } - CreateClickableText( - clickablePart = displayName, - suffix = addedCharts, - route = route, - nav = nav, - ) - } + CreateClickableText( + clickablePart = displayName, + suffix = addedCharts, + route = route, + nav = nav, + ) + } - if (noteBase == null) { - Text( - remember { "@${nip19.hex}${nip19.additionalChars}" }, - ) - } + if (noteBase == null) { + Text( + remember { "@${nip19.hex}${nip19.additionalChars}" }, + ) + } } @Composable private fun DisplayUser( - nip19: Nip19.Return, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + nip19: Nip19.Return, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var userBase by - remember(nip19) { - mutableStateOf( - accountViewModel.getUserIfExists(nip19.hex), - ) + var userBase by + remember(nip19) { + mutableStateOf( + accountViewModel.getUserIfExists(nip19.hex), + ) + } + + if (userBase == null) { + LaunchedEffect(key1 = nip19.hex) { + accountViewModel.checkGetOrCreateUser(nip19.hex) { userBase = it } + } } - if (userBase == null) { - LaunchedEffect(key1 = nip19.hex) { - accountViewModel.checkGetOrCreateUser(nip19.hex) { userBase = it } + userBase?.let { RenderUserAsClickableText(it, nip19, nav) } + + if (userBase == null) { + Text( + remember { "@${nip19.hex}${nip19.additionalChars}" }, + ) } - } - - userBase?.let { RenderUserAsClickableText(it, nip19, nav) } - - if (userBase == null) { - Text( - remember { "@${nip19.hex}${nip19.additionalChars}" }, - ) - } } @Composable private fun RenderUserAsClickableText( - baseUser: User, - nip19: Nip19.Return, - nav: (String) -> Unit, + baseUser: User, + nip19: Nip19.Return, + nav: (String) -> Unit, ) { - val userState by baseUser.live().metadata.observeAsState() - val route = remember { "User/${baseUser.pubkeyHex}" } + val userState by baseUser.live().metadata.observeAsState() + val route = remember { "User/${baseUser.pubkeyHex}" } - val userDisplayName by - remember(userState) { derivedStateOf { userState?.user?.toBestDisplayName() } } + val userDisplayName by + remember(userState) { derivedStateOf { userState?.user?.toBestDisplayName() } } - val userTags by - remember(userState) { - derivedStateOf { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } + val userTags by + remember(userState) { + derivedStateOf { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } + } + + val addedCharts = remember(nip19) { "${nip19.additionalChars}" } + + userDisplayName?.let { + CreateClickableTextWithEmoji( + clickablePart = it, + suffix = addedCharts, + maxLines = 1, + route = route, + nav = nav, + tags = userTags, + ) } - - val addedCharts = remember(nip19) { "${nip19.additionalChars}" } - - userDisplayName?.let { - CreateClickableTextWithEmoji( - clickablePart = it, - suffix = addedCharts, - maxLines = 1, - route = route, - nav = nav, - tags = userTags, - ) - } } @Composable fun CreateClickableText( - clickablePart: String, - suffix: String?, - maxLines: Int = Int.MAX_VALUE, - overrideColor: Color? = null, - fontWeight: FontWeight = FontWeight.Normal, - route: String, - nav: (String) -> Unit, + clickablePart: String, + suffix: String?, + maxLines: Int = Int.MAX_VALUE, + overrideColor: Color? = null, + fontWeight: FontWeight = FontWeight.Normal, + route: String, + nav: (String) -> Unit, ) { - val currentStyle = LocalTextStyle.current - val primaryColor = MaterialTheme.colorScheme.primary - val onBackgroundColor = MaterialTheme.colorScheme.onBackground + val currentStyle = LocalTextStyle.current + val primaryColor = MaterialTheme.colorScheme.primary + val onBackgroundColor = MaterialTheme.colorScheme.onBackground - val clickablePartStyle = - remember(primaryColor, overrideColor) { - currentStyle - .copy(color = overrideColor ?: primaryColor, fontWeight = fontWeight) - .toSpanStyle() - } - - val nonClickablePartStyle = - remember(onBackgroundColor, overrideColor) { - currentStyle - .copy(color = overrideColor ?: onBackgroundColor, fontWeight = fontWeight) - .toSpanStyle() - } - - val text = - remember(clickablePartStyle, nonClickablePartStyle, clickablePart, suffix) { - buildAnnotatedString { - withStyle(clickablePartStyle) { append(clickablePart) } - if (!suffix.isNullOrBlank()) { - withStyle(nonClickablePartStyle) { append(suffix) } + val clickablePartStyle = + remember(primaryColor, overrideColor) { + currentStyle + .copy(color = overrideColor ?: primaryColor, fontWeight = fontWeight) + .toSpanStyle() } - } - } - ClickableText( - text = text, - maxLines = maxLines, - onClick = { nav(route) }, - ) + val nonClickablePartStyle = + remember(onBackgroundColor, overrideColor) { + currentStyle + .copy(color = overrideColor ?: onBackgroundColor, fontWeight = fontWeight) + .toSpanStyle() + } + + val text = + remember(clickablePartStyle, nonClickablePartStyle, clickablePart, suffix) { + buildAnnotatedString { + withStyle(clickablePartStyle) { append(clickablePart) } + if (!suffix.isNullOrBlank()) { + withStyle(nonClickablePartStyle) { append(suffix) } + } + } + } + + ClickableText( + text = text, + maxLines = maxLines, + onClick = { nav(route) }, + ) } @Composable fun CreateTextWithEmoji( - text: String, - tags: ImmutableListOfLists?, - color: Color = Color.Unspecified, - textAlign: TextAlign? = null, - fontWeight: FontWeight? = null, - fontSize: TextUnit = TextUnit.Unspecified, - maxLines: Int = Int.MAX_VALUE, - overflow: TextOverflow = TextOverflow.Clip, - modifier: Modifier = Modifier, + text: String, + tags: ImmutableListOfLists?, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + fontWeight: FontWeight? = null, + fontSize: TextUnit = TextUnit.Unspecified, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, + modifier: Modifier = Modifier, ) { - var emojiList by remember(text) { mutableStateOf>(persistentListOf()) } + var emojiList by remember(text) { mutableStateOf>(persistentListOf()) } - LaunchedEffect(key1 = text) { - launch(Dispatchers.Default) { - val emojis = - tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } - ?: emptyMap() + LaunchedEffect(key1 = text) { + launch(Dispatchers.Default) { + val emojis = + tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } + ?: emptyMap() - if (emojis.isNotEmpty()) { - val newEmojiList = assembleAnnotatedList(text, emojis) - if (newEmojiList.isNotEmpty()) { - emojiList = newEmojiList.toImmutableList() + if (emojis.isNotEmpty()) { + val newEmojiList = assembleAnnotatedList(text, emojis) + if (newEmojiList.isNotEmpty()) { + emojiList = newEmojiList.toImmutableList() + } + } } - } } - } - val textColor = - color.takeOrElse { LocalTextStyle.current.color.takeOrElse { LocalContentColor.current } } + val textColor = + color.takeOrElse { LocalTextStyle.current.color.takeOrElse { LocalContentColor.current } } - if (emojiList.isEmpty()) { - Text( - text = text, - color = textColor, - textAlign = textAlign, - fontWeight = fontWeight, - fontSize = fontSize, - maxLines = maxLines, - overflow = overflow, - modifier = modifier, - ) - } else { - val style = - LocalTextStyle.current - .merge( - TextStyle( + if (emojiList.isEmpty()) { + Text( + text = text, color = textColor, textAlign = textAlign, fontWeight = fontWeight, fontSize = fontSize, - ), + maxLines = maxLines, + overflow = overflow, + modifier = modifier, ) - .toSpanStyle() + } else { + val style = + LocalTextStyle.current + .merge( + TextStyle( + color = textColor, + textAlign = textAlign, + fontWeight = fontWeight, + fontSize = fontSize, + ), + ) + .toSpanStyle() - InLineIconRenderer(emojiList, style, fontSize, maxLines, overflow, modifier) - } + InLineIconRenderer(emojiList, style, fontSize, maxLines, overflow, modifier) + } } @Composable fun CreateTextWithEmoji( - text: String, - emojis: ImmutableMap, - color: Color = Color.Unspecified, - textAlign: TextAlign? = null, - fontWeight: FontWeight? = null, - fontSize: TextUnit = TextUnit.Unspecified, - maxLines: Int = Int.MAX_VALUE, - overflow: TextOverflow = TextOverflow.Clip, - modifier: Modifier = Modifier, + text: String, + emojis: ImmutableMap, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + fontWeight: FontWeight? = null, + fontSize: TextUnit = TextUnit.Unspecified, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, + modifier: Modifier = Modifier, ) { - var emojiList by remember(text) { mutableStateOf>(persistentListOf()) } + var emojiList by remember(text) { mutableStateOf>(persistentListOf()) } - if (emojis.isNotEmpty()) { - LaunchedEffect(key1 = text) { - launch(Dispatchers.Default) { - val newEmojiList = assembleAnnotatedList(text, emojis) - if (newEmojiList.isNotEmpty()) { - emojiList = newEmojiList.toImmutableList() + if (emojis.isNotEmpty()) { + LaunchedEffect(key1 = text) { + launch(Dispatchers.Default) { + val newEmojiList = assembleAnnotatedList(text, emojis) + if (newEmojiList.isNotEmpty()) { + emojiList = newEmojiList.toImmutableList() + } + } } - } } - } - val textColor = - color.takeOrElse { LocalTextStyle.current.color.takeOrElse { LocalContentColor.current } } + val textColor = + color.takeOrElse { LocalTextStyle.current.color.takeOrElse { LocalContentColor.current } } - if (emojiList.isEmpty()) { - Text( - text = text, - color = textColor, - textAlign = textAlign, - fontWeight = fontWeight, - fontSize = fontSize, - maxLines = maxLines, - overflow = overflow, - modifier = modifier, - ) - } else { - val currentStyle = LocalTextStyle.current - val style = - remember(currentStyle) { - currentStyle - .merge( - TextStyle( - color = textColor, - textAlign = textAlign, - fontWeight = fontWeight, - fontSize = fontSize, - ), - ) - .toSpanStyle() - } + if (emojiList.isEmpty()) { + Text( + text = text, + color = textColor, + textAlign = textAlign, + fontWeight = fontWeight, + fontSize = fontSize, + maxLines = maxLines, + overflow = overflow, + modifier = modifier, + ) + } else { + val currentStyle = LocalTextStyle.current + val style = + remember(currentStyle) { + currentStyle + .merge( + TextStyle( + color = textColor, + textAlign = textAlign, + fontWeight = fontWeight, + fontSize = fontSize, + ), + ) + .toSpanStyle() + } - InLineIconRenderer(emojiList, style, fontSize, maxLines, overflow, modifier) - } + InLineIconRenderer(emojiList, style, fontSize, maxLines, overflow, modifier) + } } @Composable fun CreateClickableTextWithEmoji( - clickablePart: String, - maxLines: Int = Int.MAX_VALUE, - tags: ImmutableListOfLists?, - style: TextStyle, - onClick: (Int) -> Unit, + clickablePart: String, + maxLines: Int = Int.MAX_VALUE, + tags: ImmutableListOfLists?, + style: TextStyle, + onClick: (Int) -> Unit, ) { - var emojiList by - remember(clickablePart) { mutableStateOf>(persistentListOf()) } + var emojiList by + remember(clickablePart) { mutableStateOf>(persistentListOf()) } - LaunchedEffect(key1 = clickablePart) { - launch(Dispatchers.Default) { - val emojis = - tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } - ?: emptyMap() + LaunchedEffect(key1 = clickablePart) { + launch(Dispatchers.Default) { + val emojis = + tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } + ?: emptyMap() - if (emojis.isNotEmpty()) { - val newEmojiList = assembleAnnotatedList(clickablePart, emojis) - if (newEmojiList.isNotEmpty()) { - emojiList = newEmojiList.toImmutableList() + if (emojis.isNotEmpty()) { + val newEmojiList = assembleAnnotatedList(clickablePart, emojis) + if (newEmojiList.isNotEmpty()) { + emojiList = newEmojiList.toImmutableList() + } + } } - } } - } - if (emojiList.isEmpty()) { - ClickableText( - text = AnnotatedString(clickablePart), - style = style, - maxLines = maxLines, - onClick = onClick, - ) - } else { - ClickableInLineIconRenderer(emojiList, maxLines, style.toSpanStyle()) { onClick(it) } - } + if (emojiList.isEmpty()) { + ClickableText( + text = AnnotatedString(clickablePart), + style = style, + maxLines = maxLines, + onClick = onClick, + ) + } else { + ClickableInLineIconRenderer(emojiList, maxLines, style.toSpanStyle()) { onClick(it) } + } } @Immutable data class DoubleEmojiList( - val part1: ImmutableList, - val part2: ImmutableList, + val part1: ImmutableList, + val part2: ImmutableList, ) @Composable fun CreateClickableTextWithEmoji( - clickablePart: String, - suffix: String?, - maxLines: Int = Int.MAX_VALUE, - overrideColor: Color? = null, - fontWeight: FontWeight = FontWeight.Normal, - route: String, - nav: (String) -> Unit, - tags: ImmutableListOfLists?, + clickablePart: String, + suffix: String?, + maxLines: Int = Int.MAX_VALUE, + overrideColor: Color? = null, + fontWeight: FontWeight = FontWeight.Normal, + route: String, + nav: (String) -> Unit, + tags: ImmutableListOfLists?, ) { - var emojiLists by remember(clickablePart) { mutableStateOf(null) } + var emojiLists by remember(clickablePart) { mutableStateOf(null) } - LaunchedEffect(key1 = clickablePart) { - launch(Dispatchers.Default) { - val emojis = - tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } - ?: emptyMap() + LaunchedEffect(key1 = clickablePart) { + launch(Dispatchers.Default) { + val emojis = + tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } + ?: emptyMap() - if (emojis.isNotEmpty()) { - val newEmojiList1 = assembleAnnotatedList(clickablePart, emojis) - val newEmojiList2 = - suffix?.let { assembleAnnotatedList(it, emojis) } ?: emptyList() + if (emojis.isNotEmpty()) { + val newEmojiList1 = assembleAnnotatedList(clickablePart, emojis) + val newEmojiList2 = + suffix?.let { assembleAnnotatedList(it, emojis) } ?: emptyList() - if (newEmojiList1.isNotEmpty() || newEmojiList2.isNotEmpty()) { - emojiLists = - DoubleEmojiList(newEmojiList1.toImmutableList(), newEmojiList2.toImmutableList()) + if (newEmojiList1.isNotEmpty() || newEmojiList2.isNotEmpty()) { + emojiLists = + DoubleEmojiList(newEmojiList1.toImmutableList(), newEmojiList2.toImmutableList()) + } + } } - } - } - } - - if (emojiLists == null) { - CreateClickableText(clickablePart, suffix, maxLines, overrideColor, fontWeight, route, nav) - } else { - ClickableInLineIconRenderer( - emojiLists!!.part1, - maxLines, - LocalTextStyle.current - .copy(color = overrideColor ?: MaterialTheme.colorScheme.primary, fontWeight = fontWeight) - .toSpanStyle(), - ) { - nav(route) } - InLineIconRenderer( - emojiLists!!.part2, - LocalTextStyle.current - .copy( - color = overrideColor ?: MaterialTheme.colorScheme.onBackground, - fontWeight = fontWeight, + if (emojiLists == null) { + CreateClickableText(clickablePart, suffix, maxLines, overrideColor, fontWeight, route, nav) + } else { + ClickableInLineIconRenderer( + emojiLists!!.part1, + maxLines, + LocalTextStyle.current + .copy(color = overrideColor ?: MaterialTheme.colorScheme.primary, fontWeight = fontWeight) + .toSpanStyle(), + ) { + nav(route) + } + + InLineIconRenderer( + emojiLists!!.part2, + LocalTextStyle.current + .copy( + color = overrideColor ?: MaterialTheme.colorScheme.onBackground, + fontWeight = fontWeight, + ) + .toSpanStyle(), + maxLines = maxLines, ) - .toSpanStyle(), - maxLines = maxLines, - ) - } + } } suspend fun assembleAnnotatedList( - text: String, - emojis: Map, + text: String, + emojis: Map, ): ImmutableList { - return Nip30CustomEmoji() - .buildArray(text) - .map { - val url = emojis[it] - if (url != null) { - ImageUrlType(url) - } else { - TextType(it) - } - } - .toImmutableList() + return Nip30CustomEmoji() + .buildArray(text) + .map { + val url = emojis[it] + if (url != null) { + ImageUrlType(url) + } else { + TextType(it) + } + } + .toImmutableList() } @Immutable open class Renderable() @@ -585,143 +585,145 @@ suspend fun assembleAnnotatedList( @Composable fun ClickableInLineIconRenderer( - wordsInOrder: ImmutableList, - maxLines: Int = Int.MAX_VALUE, - style: SpanStyle, - onClick: (Int) -> Unit, + wordsInOrder: ImmutableList, + maxLines: Int = Int.MAX_VALUE, + style: SpanStyle, + onClick: (Int) -> Unit, ) { - val placeholderSize = - remember(style) { - if (style.fontSize == TextUnit.Unspecified) { - 22.sp - } else { - style.fontSize.times(1.1f) - } - } - - val inlineContent = - wordsInOrder - .mapIndexedNotNull { idx, value -> - if (value is ImageUrlType) { - Pair( - "inlineContent$idx", - InlineTextContent( - Placeholder( - width = placeholderSize, - height = placeholderSize, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center, - ), - ) { - AsyncImage( - model = value.url, - contentDescription = null, - modifier = Modifier.fillMaxSize().padding(1.dp), - ) - }, - ) - } else { - null + val placeholderSize = + remember(style) { + if (style.fontSize == TextUnit.Unspecified) { + 22.sp + } else { + style.fontSize.times(1.1f) + } } - } - .associate { it.first to it.second } - val annotatedText = buildAnnotatedString { - wordsInOrder.forEachIndexed { idx, value -> - withStyle( - style, - ) { - if (value is TextType) { - append(value.text) - } else if (value is ImageUrlType) { - appendInlineContent("inlineContent$idx", "[icon]") + val inlineContent = + wordsInOrder + .mapIndexedNotNull { idx, value -> + if (value is ImageUrlType) { + Pair( + "inlineContent$idx", + InlineTextContent( + Placeholder( + width = placeholderSize, + height = placeholderSize, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + ) { + AsyncImage( + model = value.url, + contentDescription = null, + modifier = Modifier.fillMaxSize().padding(1.dp), + ) + }, + ) + } else { + null + } + } + .associate { it.first to it.second } + + val annotatedText = + buildAnnotatedString { + wordsInOrder.forEachIndexed { idx, value -> + withStyle( + style, + ) { + if (value is TextType) { + append(value.text) + } else if (value is ImageUrlType) { + appendInlineContent("inlineContent$idx", "[icon]") + } + } + } } - } - } - } - val layoutResult = remember { mutableStateOf(null) } - val pressIndicator = - Modifier.pointerInput(onClick) { - detectTapGestures { pos -> - layoutResult.value?.let { layoutResult -> onClick(layoutResult.getOffsetForPosition(pos)) } - } - } + val layoutResult = remember { mutableStateOf(null) } + val pressIndicator = + Modifier.pointerInput(onClick) { + detectTapGestures { pos -> + layoutResult.value?.let { layoutResult -> onClick(layoutResult.getOffsetForPosition(pos)) } + } + } - BasicText( - text = annotatedText, - modifier = pressIndicator, - inlineContent = inlineContent, - maxLines = maxLines, - onTextLayout = { layoutResult.value = it }, - ) + BasicText( + text = annotatedText, + modifier = pressIndicator, + inlineContent = inlineContent, + maxLines = maxLines, + onTextLayout = { layoutResult.value = it }, + ) } @Composable fun InLineIconRenderer( - wordsInOrder: ImmutableList, - style: SpanStyle, - fontSize: TextUnit = TextUnit.Unspecified, - maxLines: Int = Int.MAX_VALUE, - overflow: TextOverflow = TextOverflow.Clip, - modifier: Modifier = Modifier, + wordsInOrder: ImmutableList, + style: SpanStyle, + fontSize: TextUnit = TextUnit.Unspecified, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, + modifier: Modifier = Modifier, ) { - val placeholderSize = - remember(fontSize) { - if (fontSize == TextUnit.Unspecified) { - 22.sp - } else { - fontSize.times(1.1f) - } - } - - val inlineContent = - wordsInOrder - .mapIndexedNotNull { idx, value -> - if (value is ImageUrlType) { - Pair( - "inlineContent$idx", - InlineTextContent( - Placeholder( - width = placeholderSize, - height = placeholderSize, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center, - ), - ) { - AsyncImage( - model = value.url, - contentDescription = null, - modifier = Modifier.fillMaxSize().padding(horizontal = 0.dp), - ) - }, - ) - } else { - null + val placeholderSize = + remember(fontSize) { + if (fontSize == TextUnit.Unspecified) { + 22.sp + } else { + fontSize.times(1.1f) + } } - } - .associate { it.first to it.second } - val annotatedText = remember { - buildAnnotatedString { - wordsInOrder.forEachIndexed { idx, value -> - withStyle( - style, - ) { - if (value is TextType) { - append(value.text) - } else if (value is ImageUrlType) { - appendInlineContent("inlineContent$idx", "[icon]") - } + val inlineContent = + wordsInOrder + .mapIndexedNotNull { idx, value -> + if (value is ImageUrlType) { + Pair( + "inlineContent$idx", + InlineTextContent( + Placeholder( + width = placeholderSize, + height = placeholderSize, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + ) { + AsyncImage( + model = value.url, + contentDescription = null, + modifier = Modifier.fillMaxSize().padding(horizontal = 0.dp), + ) + }, + ) + } else { + null + } + } + .associate { it.first to it.second } + + val annotatedText = + remember { + buildAnnotatedString { + wordsInOrder.forEachIndexed { idx, value -> + withStyle( + style, + ) { + if (value is TextType) { + append(value.text) + } else if (value is ImageUrlType) { + appendInlineContent("inlineContent$idx", "[icon]") + } + } + } + } } - } - } - } - Text( - text = annotatedText, - inlineContent = inlineContent, - fontSize = fontSize, - maxLines = maxLines, - overflow = overflow, - modifier = modifier, - ) + Text( + text = annotatedText, + inlineContent = inlineContent, + fontSize = fontSize, + maxLines = maxLines, + overflow = overflow, + modifier = modifier, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt index 353f539eb..30f6f5b8b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt @@ -30,21 +30,21 @@ import androidx.compose.ui.text.AnnotatedString @Composable fun ClickableUrl( - urlText: String, - url: String, + urlText: String, + url: String, ) { - val uri = LocalUriHandler.current + val uri = LocalUriHandler.current - val text = remember(urlText) { AnnotatedString(urlText) } + val text = remember(urlText) { AnnotatedString(urlText) } - ClickableText( - text = text, - onClick = { - runCatching { - val doubleCheckedUrl = if (url.contains("://")) url else "https://$url" - uri.openUri(doubleCheckedUrl) - } - }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - ) + ClickableText( + text = text, + onClick = { + runCatching { + val doubleCheckedUrl = if (url.contains("://")) url else "https://$url" + uri.openUri(doubleCheckedUrl) + } + }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt index fe4bbcb8e..38db30210 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt @@ -32,19 +32,19 @@ import com.vitorpamplona.amethyst.model.User @Composable fun ClickableUserTag( - user: User, - nav: (String) -> Unit, + user: User, + nav: (String) -> Unit, ) { - val route = remember { "User/${user.pubkeyHex}" } + val route = remember { "User/${user.pubkeyHex}" } - val innerUserState by user.live().metadata.observeAsState() + val innerUserState by user.live().metadata.observeAsState() - val userName = - remember(innerUserState) { AnnotatedString("@${innerUserState?.user?.toBestDisplayName()}") } + val userName = + remember(innerUserState) { AnnotatedString("@${innerUserState?.user?.toBestDisplayName()}") } - ClickableText( - text = userName, - onClick = { nav(route) }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - ) + ClickableText( + text = userName, + onClick = { nav(route) }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt index 89d121e95..9e59b88ba 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt @@ -43,43 +43,43 @@ import kotlinx.coroutines.launch @Composable fun MayBeWithdrawal(lnurlWord: String) { - var lnWithdrawal by remember { mutableStateOf(null) } + var lnWithdrawal by remember { mutableStateOf(null) } - LaunchedEffect(key1 = lnurlWord) { - launch(Dispatchers.IO) { lnWithdrawal = LnWithdrawalUtil.findWithdrawal(lnurlWord) } - } - - Crossfade(targetState = lnWithdrawal) { - if (it != null) { - ClickableWithdrawal(withdrawalString = it) - } else { - Text( - text = lnurlWord, - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - ) + LaunchedEffect(key1 = lnurlWord) { + launch(Dispatchers.IO) { lnWithdrawal = LnWithdrawalUtil.findWithdrawal(lnurlWord) } + } + + Crossfade(targetState = lnWithdrawal) { + if (it != null) { + ClickableWithdrawal(withdrawalString = it) + } else { + Text( + text = lnurlWord, + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) + } } - } } @Composable fun ClickableWithdrawal(withdrawalString: String) { - val context = LocalContext.current + val context = LocalContext.current - val withdraw = remember(withdrawalString) { AnnotatedString("$withdrawalString ") } + val withdraw = remember(withdrawalString) { AnnotatedString("$withdrawalString ") } - var showErrorMessageDialog by remember { mutableStateOf(null) } + var showErrorMessageDialog by remember { mutableStateOf(null) } - if (showErrorMessageDialog != null) { - ErrorMessageDialog( - title = context.getString(R.string.error_dialog_pay_withdraw_error), - textContent = showErrorMessageDialog ?: "", - onDismiss = { showErrorMessageDialog = null }, + if (showErrorMessageDialog != null) { + ErrorMessageDialog( + title = context.getString(R.string.error_dialog_pay_withdraw_error), + textContent = showErrorMessageDialog ?: "", + onDismiss = { showErrorMessageDialog = null }, + ) + } + + ClickableText( + text = withdraw, + onClick = { payViaIntent(withdrawalString, context) { showErrorMessageDialog = it } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), ) - } - - ClickableText( - text = withdraw, - onClick = { payViaIntent(withdrawalString, context) { showErrorMessageDialog = it } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt index 3a5922547..8128f54b7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt @@ -56,92 +56,92 @@ const val SHORTEN_AFTER_LINES = 10 @Composable fun ExpandableRichTextViewer( - content: String, - canPreview: Boolean, - modifier: Modifier, - tags: ImmutableListOfLists, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + content: String, + canPreview: Boolean, + modifier: Modifier, + tags: ImmutableListOfLists, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var showFullText by remember { mutableStateOf(false) } + var showFullText by remember { mutableStateOf(false) } - val whereToCut = - remember(content) { - // Cuts the text in the first space or new line after SHORT_TEXT_LENGTH characters - val firstSpaceAfterCut = - content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it } - val firstNewLineAfterCut = - content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it } + val whereToCut = + remember(content) { + // Cuts the text in the first space or new line after SHORT_TEXT_LENGTH characters + val firstSpaceAfterCut = + content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it } + val firstNewLineAfterCut = + content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it } - // or after SHORTEN_AFTER_LINES lines - val numberOfLines = content.count { it == '\n' } + // or after SHORTEN_AFTER_LINES lines + val numberOfLines = content.count { it == '\n' } - var charactersInLines = minOf(firstSpaceAfterCut, firstNewLineAfterCut) + var charactersInLines = minOf(firstSpaceAfterCut, firstNewLineAfterCut) - if (numberOfLines > SHORTEN_AFTER_LINES) { - val shortContent = content.lines().take(SHORTEN_AFTER_LINES) - charactersInLines = 0 - for (line in shortContent) { - // +1 because new line character is omitted from .lines - charactersInLines += (line.length + 1) + if (numberOfLines > SHORTEN_AFTER_LINES) { + val shortContent = content.lines().take(SHORTEN_AFTER_LINES) + charactersInLines = 0 + for (line in shortContent) { + // +1 because new line character is omitted from .lines + charactersInLines += (line.length + 1) + } + } + + minOf(firstSpaceAfterCut, firstNewLineAfterCut, charactersInLines) } - } - minOf(firstSpaceAfterCut, firstNewLineAfterCut, charactersInLines) - } - - val text by - remember(content) { - derivedStateOf { - if (showFullText) { - content - } else { - content.take(whereToCut) + val text by + remember(content) { + derivedStateOf { + if (showFullText) { + content + } else { + content.take(whereToCut) + } + } } - } - } - Box { - Crossfade(text, label = "ExpandableRichTextViewer") { - RichTextViewer( - it, - canPreview, - modifier.align(Alignment.TopStart), - tags, - backgroundColor, - accountViewModel, - nav, - ) - } + Box { + Crossfade(text, label = "ExpandableRichTextViewer") { + RichTextViewer( + it, + canPreview, + modifier.align(Alignment.TopStart), + tags, + backgroundColor, + accountViewModel, + nav, + ) + } - if (content.length > whereToCut && !showFullText) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = - Modifier.align(Alignment.BottomCenter) - .fillMaxWidth() - .background(getGradient(backgroundColor)), - ) { - ShowMoreButton { showFullText = !showFullText } - } + if (content.length > whereToCut && !showFullText) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = + Modifier.align(Alignment.BottomCenter) + .fillMaxWidth() + .background(getGradient(backgroundColor)), + ) { + ShowMoreButton { showFullText = !showFullText } + } + } } - } } @Composable fun ShowMoreButton(onClick: () -> Unit) { - Button( - modifier = Modifier.padding(top = 10.dp), - onClick = onClick, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondaryButtonBackground, - ), - contentPadding = ButtonPadding, - ) { - Text(text = stringResource(R.string.show_more), color = Color.White) - } + Button( + modifier = Modifier.padding(top = 10.dp), + onClick = onClick, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryButtonBackground, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(R.string.show_more), color = Color.White) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/GenericLoadable.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/GenericLoadable.kt index 2bce3d281..4e9a06161 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/GenericLoadable.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/GenericLoadable.kt @@ -24,11 +24,11 @@ import androidx.compose.runtime.Immutable @Immutable sealed class GenericLoadable { - @Immutable class Loading : GenericLoadable() + @Immutable class Loading : GenericLoadable() - @Immutable class Loaded(val loaded: T) : GenericLoadable() + @Immutable class Loaded(val loaded: T) : GenericLoadable() - @Immutable class Empty : GenericLoadable() + @Immutable class Empty : GenericLoadable() - @Immutable class Error(val errorMessage: String) : GenericLoadable() + @Immutable class Error(val errorMessage: String) : GenericLoadable() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt index 331525773..3f1213cee 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt @@ -58,123 +58,123 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.encoders.LnInvoiceUtil -import java.text.NumberFormat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.text.NumberFormat @Stable data class InvoiceAmount(val invoice: String, val amount: String?) @Composable fun LoadValueFromInvoice( - lnbcWord: String, - inner: @Composable (invoiceAmount: InvoiceAmount?) -> Unit, + lnbcWord: String, + inner: @Composable (invoiceAmount: InvoiceAmount?) -> Unit, ) { - var lnInvoice by remember { mutableStateOf(null) } + var lnInvoice by remember { mutableStateOf(null) } - LaunchedEffect(key1 = lnbcWord) { - launch(Dispatchers.IO) { - val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord) - if (myInvoice != null) { - val myInvoiceAmount = - try { - NumberFormat.getInstance().format(LnInvoiceUtil.getAmountInSats(myInvoice)) - } catch (e: Exception) { - e.printStackTrace() - null - } + LaunchedEffect(key1 = lnbcWord) { + launch(Dispatchers.IO) { + val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord) + if (myInvoice != null) { + val myInvoiceAmount = + try { + NumberFormat.getInstance().format(LnInvoiceUtil.getAmountInSats(myInvoice)) + } catch (e: Exception) { + e.printStackTrace() + null + } - lnInvoice = InvoiceAmount(myInvoice, myInvoiceAmount) - } + lnInvoice = InvoiceAmount(myInvoice, myInvoiceAmount) + } + } } - } - inner(lnInvoice) + inner(lnInvoice) } @Composable fun MayBeInvoicePreview(lnbcWord: String) { - LoadValueFromInvoice(lnbcWord = lnbcWord) { invoiceAmount -> - Crossfade(targetState = invoiceAmount, label = "MayBeInvoicePreview") { - if (it != null) { - InvoicePreview(it.invoice, it.amount) - } else { - Text( - text = lnbcWord, - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - ) - } + LoadValueFromInvoice(lnbcWord = lnbcWord) { invoiceAmount -> + Crossfade(targetState = invoiceAmount, label = "MayBeInvoicePreview") { + if (it != null) { + InvoicePreview(it.invoice, it.amount) + } else { + Text( + text = lnbcWord, + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) + } + } } - } } @Composable fun InvoicePreview( - lnInvoice: String, - amount: String?, + lnInvoice: String, + amount: String?, ) { - val context = LocalContext.current + val context = LocalContext.current - var showErrorMessageDialog by remember { mutableStateOf(null) } + var showErrorMessageDialog by remember { mutableStateOf(null) } - if (showErrorMessageDialog != null) { - ErrorMessageDialog( - title = context.getString(R.string.error_dialog_pay_invoice_error), - textContent = showErrorMessageDialog ?: "", - onDismiss = { showErrorMessageDialog = null }, - ) - } - - Column( - modifier = - Modifier.fillMaxWidth() - .padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp) - .clip(shape = QuoteBorder) - .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder), - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), - ) { - Icon( - painter = painterResource(R.drawable.lightning), - null, - modifier = Size20Modifier, - tint = Color.Unspecified, + if (showErrorMessageDialog != null) { + ErrorMessageDialog( + title = context.getString(R.string.error_dialog_pay_invoice_error), + textContent = showErrorMessageDialog ?: "", + onDismiss = { showErrorMessageDialog = null }, ) - - Text( - text = stringResource(R.string.lightning_invoice), - fontSize = 20.sp, - fontWeight = FontWeight.W500, - modifier = Modifier.padding(start = 10.dp), - ) - } - - Divider() - - amount?.let { - Text( - text = "$it ${stringResource(id = R.string.sats)}", - fontSize = 25.sp, - fontWeight = FontWeight.W500, - modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), - ) - } - - Button( - modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), - onClick = { payViaIntent(lnInvoice, context) { showErrorMessageDialog = it } }, - shape = QuoteBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text(text = stringResource(R.string.pay), color = Color.White, fontSize = 20.sp) - } } - } + + Column( + modifier = + Modifier.fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp) + .clip(shape = QuoteBorder) + .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(20.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + ) { + Icon( + painter = painterResource(R.drawable.lightning), + null, + modifier = Size20Modifier, + tint = Color.Unspecified, + ) + + Text( + text = stringResource(R.string.lightning_invoice), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) + } + + Divider() + + amount?.let { + Text( + text = "$it ${stringResource(id = R.string.sats)}", + fontSize = 25.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + ) + } + + Button( + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + onClick = { payViaIntent(lnInvoice, context) { showErrorMessageDialog = it } }, + shape = QuoteBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(text = stringResource(R.string.pay), color = Color.White, fontSize = 20.sp) + } + } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt index 7aeb9d459..786aa0cd8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt @@ -65,167 +65,167 @@ import kotlinx.coroutines.launch @Composable fun InvoiceRequestCard( - lud16: String, - toUserPubKeyHex: String, - account: Account, - titleText: String? = null, - buttonText: String? = null, - onSuccess: (String) -> Unit, - onClose: () -> Unit, - onError: (String, String) -> Unit, + lud16: String, + toUserPubKeyHex: String, + account: Account, + titleText: String? = null, + buttonText: String? = null, + onSuccess: (String) -> Unit, + onClose: () -> Unit, + onError: (String, String) -> Unit, ) { - Column( - modifier = - Modifier.fillMaxWidth() - .padding(start = 30.dp, end = 30.dp) - .clip(shape = QuoteBorder) - .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder), - ) { Column( - modifier = Modifier.fillMaxWidth().padding(30.dp), + modifier = + Modifier.fillMaxWidth() + .padding(start = 30.dp, end = 30.dp) + .clip(shape = QuoteBorder) + .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder), ) { - InvoiceRequest( - lud16, - toUserPubKeyHex, - account, - titleText, - buttonText, - onSuccess, - onClose, - onError, - ) + Column( + modifier = Modifier.fillMaxWidth().padding(30.dp), + ) { + InvoiceRequest( + lud16, + toUserPubKeyHex, + account, + titleText, + buttonText, + onSuccess, + onClose, + onError, + ) + } } - } } @Composable fun InvoiceRequest( - lud16: String, - toUserPubKeyHex: String, - account: Account, - titleText: String? = null, - buttonText: String? = null, - onSuccess: (String) -> Unit, - onClose: () -> Unit, - onError: (String, String) -> Unit, + lud16: String, + toUserPubKeyHex: String, + account: Account, + titleText: String? = null, + buttonText: String? = null, + onSuccess: (String) -> Unit, + onClose: () -> Unit, + onError: (String, String) -> Unit, ) { - val context = LocalContext.current - val scope = rememberCoroutineScope() + val context = LocalContext.current + val scope = rememberCoroutineScope() - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), - ) { - Icon( - painter = painterResource(R.drawable.lightning), - null, - modifier = Size20Modifier, - tint = Color.Unspecified, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + ) { + Icon( + painter = painterResource(R.drawable.lightning), + null, + modifier = Size20Modifier, + tint = Color.Unspecified, + ) - Text( - text = titleText ?: stringResource(R.string.lightning_tips), - fontSize = 20.sp, - fontWeight = FontWeight.W500, - modifier = Modifier.padding(start = 10.dp), - ) - } + Text( + text = titleText ?: stringResource(R.string.lightning_tips), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) + } - Divider() + Divider() - var message by remember { mutableStateOf("") } - var amount by remember { mutableStateOf(1000L) } + var message by remember { mutableStateOf("") } + var amount by remember { mutableStateOf(1000L) } - OutlinedTextField( - label = { Text(text = stringResource(R.string.note_to_receiver)) }, - modifier = Modifier.fillMaxWidth(), - value = message, - onValueChange = { message = it }, - placeholder = { - Text( - text = stringResource(R.string.thank_you_so_much), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - singleLine = true, - ) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.amount_in_sats)) }, - modifier = Modifier.fillMaxWidth(), - value = amount.toString(), - onValueChange = { - runCatching { - if (it.isEmpty()) { - amount = 0 - } else { - amount = it.toLong() - } - } - }, - placeholder = { - Text( - text = "1000", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - keyboardType = KeyboardType.Number, - ), - singleLine = true, - ) - - Button( - modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), - onClick = { - scope.launch(Dispatchers.IO) { - if (account.defaultZapType == LnZapEvent.ZapType.NONZAP) { - LightningAddressResolver() - .lnAddressInvoice( - lud16, - amount * 1000, - message, - null, - onSuccess = onSuccess, - onError = onError, - onProgress = {}, - context = context, + OutlinedTextField( + label = { Text(text = stringResource(R.string.note_to_receiver)) }, + modifier = Modifier.fillMaxWidth(), + value = message, + onValueChange = { message = it }, + placeholder = { + Text( + text = stringResource(R.string.thank_you_so_much), + color = MaterialTheme.colorScheme.placeholderText, ) - } else { - account.createZapRequestFor(toUserPubKeyHex, message, account.defaultZapType) { - zapRequest, - -> - LocalCache.justConsume(zapRequest, null) - LightningAddressResolver() - .lnAddressInvoice( - lud16, - amount * 1000, - message, - zapRequest.toJson(), - onSuccess = onSuccess, - onError = onError, - onProgress = {}, - context = context, - ) - } - } - } - }, - shape = QuoteBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text( - text = buttonText ?: stringResource(R.string.send_sats), - color = Color.White, - fontSize = 20.sp, + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + singleLine = true, ) - } + + OutlinedTextField( + label = { Text(text = stringResource(R.string.amount_in_sats)) }, + modifier = Modifier.fillMaxWidth(), + value = amount.toString(), + onValueChange = { + runCatching { + if (it.isEmpty()) { + amount = 0 + } else { + amount = it.toLong() + } + } + }, + placeholder = { + Text( + text = "1000", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number, + ), + singleLine = true, + ) + + Button( + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + onClick = { + scope.launch(Dispatchers.IO) { + if (account.defaultZapType == LnZapEvent.ZapType.NONZAP) { + LightningAddressResolver() + .lnAddressInvoice( + lud16, + amount * 1000, + message, + null, + onSuccess = onSuccess, + onError = onError, + onProgress = {}, + context = context, + ) + } else { + account.createZapRequestFor(toUserPubKeyHex, message, account.defaultZapType) { + zapRequest, + -> + LocalCache.justConsume(zapRequest, null) + LightningAddressResolver() + .lnAddressInvoice( + lud16, + amount * 1000, + message, + zapRequest.toJson(), + onSuccess = onSuccess, + onError = onError, + onProgress = {}, + context = context, + ) + } + } + } + }, + shape = QuoteBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text( + text = buttonText ?: stringResource(R.string.send_sats), + color = Color.White, + fontSize = 20.sp, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt index c7464b15b..dc8b0b5ab 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt @@ -36,61 +36,61 @@ import kotlinx.collections.immutable.persistentListOf @Composable fun LoadUrlPreview( - url: String, - urlText: String, - accountViewModel: AccountViewModel, + url: String, + urlText: String, + accountViewModel: AccountViewModel, ) { - val automaticallyShowUrlPreview = remember { accountViewModel.settings.showUrlPreview.value } + val automaticallyShowUrlPreview = remember { accountViewModel.settings.showUrlPreview.value } - if (!automaticallyShowUrlPreview) { - ClickableUrl(urlText, url) - } else { - var urlPreviewState by - remember(url) { - mutableStateOf( - UrlCachedPreviewer.cache.get(url) ?: UrlPreviewState.Loading, - ) - } - - // Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are - // created). - if (urlPreviewState == UrlPreviewState.Loading) { - LaunchedEffect(url) { accountViewModel.urlPreview(url) { urlPreviewState = it } } - } - - Crossfade( - targetState = urlPreviewState, - animationSpec = tween(durationMillis = 100), - label = "UrlPreview", - ) { state -> - when (state) { - is UrlPreviewState.Loaded -> { - if (state.previewInfo.mimeType.type == "image") { - Box(modifier = HalfVertPadding) { - ZoomableContentView( - ZoomableUrlImage(url), - persistentListOf(), - roundedCorner = true, - accountViewModel, - ) + if (!automaticallyShowUrlPreview) { + ClickableUrl(urlText, url) + } else { + var urlPreviewState by + remember(url) { + mutableStateOf( + UrlCachedPreviewer.cache.get(url) ?: UrlPreviewState.Loading, + ) } - } else if (state.previewInfo.mimeType.type == "video") { - Box(modifier = HalfVertPadding) { - ZoomableContentView( - ZoomableUrlVideo(url), - persistentListOf(), - roundedCorner = true, - accountViewModel, - ) + + // Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are + // created). + if (urlPreviewState == UrlPreviewState.Loading) { + LaunchedEffect(url) { accountViewModel.urlPreview(url) { urlPreviewState = it } } + } + + Crossfade( + targetState = urlPreviewState, + animationSpec = tween(durationMillis = 100), + label = "UrlPreview", + ) { state -> + when (state) { + is UrlPreviewState.Loaded -> { + if (state.previewInfo.mimeType.type == "image") { + Box(modifier = HalfVertPadding) { + ZoomableContentView( + ZoomableUrlImage(url), + persistentListOf(), + roundedCorner = true, + accountViewModel, + ) + } + } else if (state.previewInfo.mimeType.type == "video") { + Box(modifier = HalfVertPadding) { + ZoomableContentView( + ZoomableUrlVideo(url), + persistentListOf(), + roundedCorner = true, + accountViewModel, + ) + } + } else { + UrlPreviewCard(url, state.previewInfo) + } + } + else -> { + ClickableUrl(urlText, url) + } } - } else { - UrlPreviewCard(url, state.previewInfo) - } } - else -> { - ClickableUrl(urlText, url) - } - } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MarkdownParser.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MarkdownParser.kt index 9e5e507b2..eabab7c90 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MarkdownParser.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MarkdownParser.kt @@ -29,156 +29,156 @@ import com.vitorpamplona.quartz.encoders.Nip19 import com.vitorpamplona.quartz.events.ImmutableListOfLists class MarkdownParser { - private fun getDisplayNameAndNIP19FromTag( - tag: String, - tags: ImmutableListOfLists, - ): Pair? { - val matcher = tagIndex.matcher(tag) - val (index, suffix) = - try { - matcher.find() - Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "") - } catch (e: Exception) { - Log.w("Tag Parser", "Couldn't link tag $tag", e) - Pair(null, null) - } - - if (index != null && index >= 0 && index < tags.lists.size) { - val tag = tags.lists[index] - - if (tag.size > 1) { - if (tag[0] == "p") { - LocalCache.checkGetOrCreateUser(tag[1])?.let { - return Pair(it.toBestDisplayName(), it.pubkeyNpub()) - } - } else if (tag[0] == "e" || tag[0] == "a") { - LocalCache.checkGetOrCreateNote(tag[1])?.let { - return Pair(it.idDisplayNote(), it.toNEvent()) - } - } - } - } - - return null - } - - private fun getDisplayNameFromNip19(nip19: Nip19.Return): Pair? { - if (nip19.type == Nip19.Type.USER) { - LocalCache.users[nip19.hex]?.let { - return Pair(it.toBestDisplayName(), it.pubkeyNpub()) - } - } else if (nip19.type == Nip19.Type.NOTE) { - LocalCache.notes[nip19.hex]?.let { - return Pair(it.idDisplayNote(), it.toNEvent()) - } - } else if (nip19.type == Nip19.Type.ADDRESS) { - LocalCache.addressables[nip19.hex]?.let { - return Pair(it.idDisplayNote(), it.toNEvent()) - } - } else if (nip19.type == Nip19.Type.EVENT) { - LocalCache.notes[nip19.hex]?.let { - return Pair(it.idDisplayNote(), it.toNEvent()) - } - } - - return null - } - - fun returnNIP19References( - content: String, - tags: ImmutableListOfLists?, - ): List { - checkNotInMainThread() - - val listOfReferences = mutableListOf() - content.split('\n').forEach { paragraph -> - paragraph.split(' ').forEach { word: String -> - if (startsWithNIP19Scheme(word)) { - val parsedNip19 = Nip19.uriToRoute(word) - parsedNip19?.let { listOfReferences.add(it) } - } - } - } - - tags?.lists?.forEach { - if (it[0] == "p" && it.size > 1) { - listOfReferences.add(Nip19.Return(Nip19.Type.USER, it[1], null, null, null, "")) - } else if (it[0] == "e" && it.size > 1) { - listOfReferences.add(Nip19.Return(Nip19.Type.NOTE, it[1], null, null, null, "")) - } else if (it[0] == "a" && it.size > 1) { - listOfReferences.add(Nip19.Return(Nip19.Type.ADDRESS, it[1], null, null, null, "")) - } - } - - return listOfReferences - } - - fun returnMarkdownWithSpecialContent( - content: String, - tags: ImmutableListOfLists?, - ): String { - var returnContent = "" - content.split('\n').forEach { paragraph -> - paragraph.split(' ').forEach { word: String -> - if (isValidURL(word)) { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(word) - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - returnContent += "![]($word) " - } else { - returnContent += "[$word]($word) " - } - } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { - returnContent += "[$word](mailto:$word) " - } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { - returnContent += "[$word](tel:$word) " - } else if (startsWithNIP19Scheme(word)) { - val parsedNip19 = Nip19.uriToRoute(word) - returnContent += - if (parsedNip19 !== null) { - val pair = getDisplayNameFromNip19(parsedNip19) - if (pair != null) { - val (displayName, nip19) = pair - "[$displayName](nostr:$nip19) " - } else { - "$word " - } - } else { - "$word " - } - } else if (word.startsWith("#")) { - if (tagIndex.matcher(word).matches() && tags != null) { - val pair = getDisplayNameAndNIP19FromTag(word, tags) - if (pair != null) { - returnContent += "[${pair.first}](nostr:${pair.second}) " - } else { - returnContent += "$word " - } - } else if (hashTagsPattern.matcher(word).matches()) { - val hashtagMatcher = hashTagsPattern.matcher(word) - - val (myTag, mySuffix) = - try { - hashtagMatcher.find() - Pair(hashtagMatcher.group(1), hashtagMatcher.group(2)) - } catch (e: Exception) { - Log.e("Hashtag Parser", "Couldn't link hashtag $word", e) + private fun getDisplayNameAndNIP19FromTag( + tag: String, + tags: ImmutableListOfLists, + ): Pair? { + val matcher = tagIndex.matcher(tag) + val (index, suffix) = + try { + matcher.find() + Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "") + } catch (e: Exception) { + Log.w("Tag Parser", "Couldn't link tag $tag", e) Pair(null, null) - } - - if (myTag != null) { - returnContent += "[#$myTag](nostr:Hashtag?id=$myTag)$mySuffix " - } else { - returnContent += "$word " } - } else { - returnContent += "$word " - } - } else { - returnContent += "$word " + + if (index != null && index >= 0 && index < tags.lists.size) { + val tag = tags.lists[index] + + if (tag.size > 1) { + if (tag[0] == "p") { + LocalCache.checkGetOrCreateUser(tag[1])?.let { + return Pair(it.toBestDisplayName(), it.pubkeyNpub()) + } + } else if (tag[0] == "e" || tag[0] == "a") { + LocalCache.checkGetOrCreateNote(tag[1])?.let { + return Pair(it.idDisplayNote(), it.toNEvent()) + } + } + } } - } - returnContent += "\n" + + return null + } + + private fun getDisplayNameFromNip19(nip19: Nip19.Return): Pair? { + if (nip19.type == Nip19.Type.USER) { + LocalCache.users[nip19.hex]?.let { + return Pair(it.toBestDisplayName(), it.pubkeyNpub()) + } + } else if (nip19.type == Nip19.Type.NOTE) { + LocalCache.notes[nip19.hex]?.let { + return Pair(it.idDisplayNote(), it.toNEvent()) + } + } else if (nip19.type == Nip19.Type.ADDRESS) { + LocalCache.addressables[nip19.hex]?.let { + return Pair(it.idDisplayNote(), it.toNEvent()) + } + } else if (nip19.type == Nip19.Type.EVENT) { + LocalCache.notes[nip19.hex]?.let { + return Pair(it.idDisplayNote(), it.toNEvent()) + } + } + + return null + } + + fun returnNIP19References( + content: String, + tags: ImmutableListOfLists?, + ): List { + checkNotInMainThread() + + val listOfReferences = mutableListOf() + content.split('\n').forEach { paragraph -> + paragraph.split(' ').forEach { word: String -> + if (startsWithNIP19Scheme(word)) { + val parsedNip19 = Nip19.uriToRoute(word) + parsedNip19?.let { listOfReferences.add(it) } + } + } + } + + tags?.lists?.forEach { + if (it[0] == "p" && it.size > 1) { + listOfReferences.add(Nip19.Return(Nip19.Type.USER, it[1], null, null, null, "")) + } else if (it[0] == "e" && it.size > 1) { + listOfReferences.add(Nip19.Return(Nip19.Type.NOTE, it[1], null, null, null, "")) + } else if (it[0] == "a" && it.size > 1) { + listOfReferences.add(Nip19.Return(Nip19.Type.ADDRESS, it[1], null, null, null, "")) + } + } + + return listOfReferences + } + + fun returnMarkdownWithSpecialContent( + content: String, + tags: ImmutableListOfLists?, + ): String { + var returnContent = "" + content.split('\n').forEach { paragraph -> + paragraph.split(' ').forEach { word: String -> + if (isValidURL(word)) { + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(word) + if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { + returnContent += "![]($word) " + } else { + returnContent += "[$word]($word) " + } + } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { + returnContent += "[$word](mailto:$word) " + } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { + returnContent += "[$word](tel:$word) " + } else if (startsWithNIP19Scheme(word)) { + val parsedNip19 = Nip19.uriToRoute(word) + returnContent += + if (parsedNip19 !== null) { + val pair = getDisplayNameFromNip19(parsedNip19) + if (pair != null) { + val (displayName, nip19) = pair + "[$displayName](nostr:$nip19) " + } else { + "$word " + } + } else { + "$word " + } + } else if (word.startsWith("#")) { + if (tagIndex.matcher(word).matches() && tags != null) { + val pair = getDisplayNameAndNIP19FromTag(word, tags) + if (pair != null) { + returnContent += "[${pair.first}](nostr:${pair.second}) " + } else { + returnContent += "$word " + } + } else if (hashTagsPattern.matcher(word).matches()) { + val hashtagMatcher = hashTagsPattern.matcher(word) + + val (myTag, mySuffix) = + try { + hashtagMatcher.find() + Pair(hashtagMatcher.group(1), hashtagMatcher.group(2)) + } catch (e: Exception) { + Log.e("Hashtag Parser", "Couldn't link hashtag $word", e) + Pair(null, null) + } + + if (myTag != null) { + returnContent += "[#$myTag](nostr:Hashtag?id=$myTag)$mySuffix " + } else { + returnContent += "$word " + } + } else { + returnContent += "$word " + } + } else { + returnContent += "$word " + } + } + returnContent += "\n" + } + return returnContent } - return returnContent - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt index e44f98914..0e9d5377c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt @@ -38,125 +38,125 @@ import java.io.FileOutputStream import java.util.UUID class MediaCompressor { - suspend fun compress( - uri: Uri, - contentType: String?, - applicationContext: Context, - onReady: (Uri, String?, Long?) -> Unit, - onError: (String) -> Unit, - ) { - checkNotInMainThread() - - if (contentType?.startsWith("video", true) == true) { - VideoCompressor.start( - // => This is required - context = applicationContext, - // => Source can be provided as content uris - uris = listOf(uri), - isStreamable = false, - // THIS STORAGE - // sharedStorageConfiguration = SharedStorageConfiguration( - // saveAt = SaveLocation.movies, // => default is movies - // videoName = "compressed_video" // => required name - // ), - // OR AND NOT BOTH - appSpecificStorageConfiguration = AppSpecificStorageConfiguration(), - configureWith = - Configuration( - quality = VideoQuality.LOW, - // => required name - videoNames = listOf(UUID.randomUUID().toString()), - ), - listener = - object : CompressionListener { - override fun onProgress( - index: Int, - percent: Float, - ) {} - - override fun onStart(index: Int) { - // Compression start - } - - override fun onSuccess( - index: Int, - size: Long, - path: String?, - ) { - if (path != null) { - onReady(Uri.fromFile(File(path)), contentType, size) - } else { - onError("Compression Returned null") - } - } - - override fun onFailure( - index: Int, - failureMessage: String, - ) { - // keeps going with original video - onReady(uri, contentType, null) - } - - override fun onCancelled(index: Int) { - onError("Compression Cancelled") - } - }, - ) - } else if ( - contentType?.startsWith("image", true) == true && - !contentType.contains("gif") && - !contentType.contains("svg") + suspend fun compress( + uri: Uri, + contentType: String?, + applicationContext: Context, + onReady: (Uri, String?, Long?) -> Unit, + onError: (String) -> Unit, ) { - try { - val compressedImageFile = - Compressor.compress(applicationContext, from(uri, contentType, applicationContext)) { - default(width = 640, format = Bitmap.CompressFormat.JPEG) - } - onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length()) - } catch (e: Exception) { - e.printStackTrace() - onReady(uri, contentType, null) - } - } else { - onReady(uri, contentType, null) - } - } + checkNotInMainThread() - fun from( - uri: Uri?, - contentType: String?, - context: Context, - ): File { - val extension = - contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" + if (contentType?.startsWith("video", true) == true) { + VideoCompressor.start( + // => This is required + context = applicationContext, + // => Source can be provided as content uris + uris = listOf(uri), + isStreamable = false, + // THIS STORAGE + // sharedStorageConfiguration = SharedStorageConfiguration( + // saveAt = SaveLocation.movies, // => default is movies + // videoName = "compressed_video" // => required name + // ), + // OR AND NOT BOTH + appSpecificStorageConfiguration = AppSpecificStorageConfiguration(), + configureWith = + Configuration( + quality = VideoQuality.LOW, + // => required name + videoNames = listOf(UUID.randomUUID().toString()), + ), + listener = + object : CompressionListener { + override fun onProgress( + index: Int, + percent: Float, + ) {} - val inputStream = context.contentResolver.openInputStream(uri!!) - val fileName: String = UUID.randomUUID().toString() + ".$extension" - val splitName: Array = splitFileName(fileName) - val tempFile = File.createTempFile(splitName[0], splitName[1]) - inputStream?.use { input -> - FileOutputStream(tempFile).use { output -> - val buffer = ByteArray(1024 * 50) - var read: Int = input.read(buffer) - while (read != -1) { - output.write(buffer, 0, read) - read = input.read(buffer) + override fun onStart(index: Int) { + // Compression start + } + + override fun onSuccess( + index: Int, + size: Long, + path: String?, + ) { + if (path != null) { + onReady(Uri.fromFile(File(path)), contentType, size) + } else { + onError("Compression Returned null") + } + } + + override fun onFailure( + index: Int, + failureMessage: String, + ) { + // keeps going with original video + onReady(uri, contentType, null) + } + + override fun onCancelled(index: Int) { + onError("Compression Cancelled") + } + }, + ) + } else if ( + contentType?.startsWith("image", true) == true && + !contentType.contains("gif") && + !contentType.contains("svg") + ) { + try { + val compressedImageFile = + Compressor.compress(applicationContext, from(uri, contentType, applicationContext)) { + default(width = 640, format = Bitmap.CompressFormat.JPEG) + } + onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length()) + } catch (e: Exception) { + e.printStackTrace() + onReady(uri, contentType, null) + } + } else { + onReady(uri, contentType, null) } - } } - return tempFile - } + fun from( + uri: Uri?, + contentType: String?, + context: Context, + ): File { + val extension = + contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" - private fun splitFileName(fileName: String): Array { - var name = fileName - var extension = "" - val i = fileName.lastIndexOf(".") - if (i != -1) { - name = fileName.substring(0, i) - extension = fileName.substring(i) + val inputStream = context.contentResolver.openInputStream(uri!!) + val fileName: String = UUID.randomUUID().toString() + ".$extension" + val splitName: Array = splitFileName(fileName) + val tempFile = File.createTempFile(splitName[0], splitName[1]) + inputStream?.use { input -> + FileOutputStream(tempFile).use { output -> + val buffer = ByteArray(1024 * 50) + var read: Int = input.read(buffer) + while (read != -1) { + output.write(buffer, 0, read) + read = input.read(buffer) + } + } + } + + return tempFile + } + + private fun splitFileName(fileName: String): Array { + var name = fileName + var extension = "" + val i = fileName.lastIndexOf(".") + if (i != -1) { + name = fileName.substring(0, i) + extension = fileName.substring(i) + } + return arrayOf(name, extension) } - return arrayOf(name, extension) - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 2fbe773aa..a22826de7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -104,700 +104,702 @@ import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.uriToRoute import com.vitorpamplona.quartz.encoders.Nip19 import com.vitorpamplona.quartz.events.ImmutableListOfLists +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.net.MalformedURLException import java.net.URISyntaxException import java.net.URL import java.util.regex.Pattern -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg") val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8") val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)") val hashTagsPattern: Pattern = - Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE) + Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE) fun removeQueryParamsForExtensionComparison(fullUrl: String): String { - return if (fullUrl.contains("?")) { - fullUrl.split("?")[0].lowercase() - } else if (fullUrl.contains("#")) { - fullUrl.split("#")[0].lowercase() - } else { - fullUrl.lowercase() - } + return if (fullUrl.contains("?")) { + fullUrl.split("?")[0].lowercase() + } else if (fullUrl.contains("#")) { + fullUrl.split("#")[0].lowercase() + } else { + fullUrl.lowercase() + } } fun isImageOrVideoUrl(url: String): Boolean { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) - return imageExtensions.any { removedParamsFromUrl.endsWith(it) } || - videoExtensions.any { removedParamsFromUrl.endsWith(it) } + return imageExtensions.any { removedParamsFromUrl.endsWith(it) } || + videoExtensions.any { removedParamsFromUrl.endsWith(it) } } fun isValidURL(url: String?): Boolean { - return try { - URL(url).toURI() - true - } catch (e: MalformedURLException) { - false - } catch (e: URISyntaxException) { - false - } + return try { + URL(url).toURI() + true + } catch (e: MalformedURLException) { + false + } catch (e: URISyntaxException) { + false + } } fun isMarkdown(content: String): Boolean { - return content.startsWith("> ") || - content.startsWith("# ") || - content.contains("##") || - content.contains("__") || - content.contains("```") || - content.contains("](") + return content.startsWith("> ") || + content.startsWith("# ") || + content.contains("##") || + content.contains("__") || + content.contains("```") || + content.contains("](") } @Composable fun RichTextViewer( - content: String, - canPreview: Boolean, - modifier: Modifier, - tags: ImmutableListOfLists, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + content: String, + canPreview: Boolean, + modifier: Modifier, + tags: ImmutableListOfLists, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Column(modifier = modifier) { - if (remember(content) { isMarkdown(content) }) { - RenderContentAsMarkdown(content, tags, accountViewModel, nav) - } else { - RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, nav) + Column(modifier = modifier) { + if (remember(content) { isMarkdown(content) }) { + RenderContentAsMarkdown(content, tags, accountViewModel, nav) + } else { + RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, nav) + } } - } } @OptIn(ExperimentalLayoutApi::class) @Composable private fun RenderRegular( - content: String, - tags: ImmutableListOfLists, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + content: String, + tags: ImmutableListOfLists, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val state by remember(content) { mutableStateOf(CachedRichTextParser.parseText(content, tags)) } + val state by remember(content) { mutableStateOf(CachedRichTextParser.parseText(content, tags)) } - val currentTextStyle = LocalTextStyle.current - val currentTextColor = LocalContentColor.current + val currentTextStyle = LocalTextStyle.current + val currentTextColor = LocalContentColor.current - val textStyle = - remember(currentTextStyle, currentTextColor) { - currentTextStyle.copy( - lineHeight = 1.4.em, - color = currentTextStyle.color.takeOrElse { currentTextColor }, - ) - } - - val spaceWidth = measureSpaceWidth(textStyle) - - Column { - if (canPreview) { - // FlowRow doesn't work well with paragraphs. So we need to split them - state.paragraphs.forEach { paragraph -> - val direction = - if (paragraph.isRTL) { - LayoutDirection.Rtl - } else { - LayoutDirection.Ltr - } - - CompositionLocalProvider(LocalLayoutDirection provides direction) { - FlowRow( - modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), - horizontalArrangement = Arrangement.spacedBy(spaceWidth), - ) { - paragraph.words.forEach { word -> - RenderWordWithPreview( - word, - state, - backgroundColor, - textStyle, - accountViewModel, - nav, - ) - } - } + val textStyle = + remember(currentTextStyle, currentTextColor) { + currentTextStyle.copy( + lineHeight = 1.4.em, + color = currentTextStyle.color.takeOrElse { currentTextColor }, + ) } - } - } else { - // FlowRow doesn't work well with paragraphs. So we need to split them - state.paragraphs.forEach { paragraph -> - val direction = - if (paragraph.isRTL) { - LayoutDirection.Rtl - } else { - LayoutDirection.Ltr - } - CompositionLocalProvider(LocalLayoutDirection provides direction) { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(spaceWidth), - modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), - ) { - paragraph.words.forEach { word -> - RenderWordWithoutPreview( - word, - state, - backgroundColor, - textStyle, - accountViewModel, - nav, - ) + val spaceWidth = measureSpaceWidth(textStyle) + + Column { + if (canPreview) { + // FlowRow doesn't work well with paragraphs. So we need to split them + state.paragraphs.forEach { paragraph -> + val direction = + if (paragraph.isRTL) { + LayoutDirection.Rtl + } else { + LayoutDirection.Ltr + } + + CompositionLocalProvider(LocalLayoutDirection provides direction) { + FlowRow( + modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + ) { + paragraph.words.forEach { word -> + RenderWordWithPreview( + word, + state, + backgroundColor, + textStyle, + accountViewModel, + nav, + ) + } + } + } + } + } else { + // FlowRow doesn't work well with paragraphs. So we need to split them + state.paragraphs.forEach { paragraph -> + val direction = + if (paragraph.isRTL) { + LayoutDirection.Rtl + } else { + LayoutDirection.Ltr + } + + CompositionLocalProvider(LocalLayoutDirection provides direction) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), + ) { + paragraph.words.forEach { word -> + RenderWordWithoutPreview( + word, + state, + backgroundColor, + textStyle, + accountViewModel, + nav, + ) + } + } + } } - } } - } } - } } @Composable fun measureSpaceWidth(textStyle: TextStyle): Dp { - val fontFamilyResolver = LocalFontFamilyResolver.current - val density = LocalDensity.current - val layoutDirection = LocalLayoutDirection.current + val fontFamilyResolver = LocalFontFamilyResolver.current + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current - return remember(fontFamilyResolver, density, layoutDirection, textStyle) { - val widthPx = - TextMeasurer(fontFamilyResolver, density, layoutDirection, 1) - .measure(" ", textStyle) - .size - .width - with(density) { widthPx.toDp() } - } + return remember(fontFamilyResolver, density, layoutDirection, textStyle) { + val widthPx = + TextMeasurer(fontFamilyResolver, density, layoutDirection, 1) + .measure(" ", textStyle) + .size + .width + with(density) { widthPx.toDp() } + } } @Composable private fun RenderWordWithoutPreview( - word: Segment, - state: RichTextViewerState, - backgroundColor: MutableState, - style: TextStyle, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + word: Segment, + state: RichTextViewerState, + backgroundColor: MutableState, + style: TextStyle, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - when (word) { - // Don't preview Images - is ImageSegment -> ClickableUrl(word.segmentText, word.segmentText) - is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText) - is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) - // Don't offer to pay invoices - is InvoiceSegment -> NormalWord(word.segmentText, style) - // Don't offer to withdraw - is WithdrawSegment -> NormalWord(word.segmentText, style) - is CashuSegment -> NormalWord(word.segmentText, style) - is EmailSegment -> ClickableEmail(word.segmentText) - is PhoneSegment -> ClickablePhone(word.segmentText) - is BechSegment -> BechLink(word.segmentText, false, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, nav) - is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) - is HashIndexEventSegment -> TagLink(word, false, backgroundColor, accountViewModel, nav) - is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) - is RegularTextSegment -> NormalWord(word.segmentText, style) - } + when (word) { + // Don't preview Images + is ImageSegment -> ClickableUrl(word.segmentText, word.segmentText) + is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText) + is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) + // Don't offer to pay invoices + is InvoiceSegment -> NormalWord(word.segmentText, style) + // Don't offer to withdraw + is WithdrawSegment -> NormalWord(word.segmentText, style) + is CashuSegment -> NormalWord(word.segmentText, style) + is EmailSegment -> ClickableEmail(word.segmentText) + is PhoneSegment -> ClickablePhone(word.segmentText) + is BechSegment -> BechLink(word.segmentText, false, backgroundColor, accountViewModel, nav) + is HashTagSegment -> HashTag(word, nav) + is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) + is HashIndexEventSegment -> TagLink(word, false, backgroundColor, accountViewModel, nav) + is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) + is RegularTextSegment -> NormalWord(word.segmentText, style) + } } @Composable private fun RenderWordWithPreview( - word: Segment, - state: RichTextViewerState, - backgroundColor: MutableState, - style: TextStyle, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + word: Segment, + state: RichTextViewerState, + backgroundColor: MutableState, + style: TextStyle, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - when (word) { - is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) - is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel) - is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) - is InvoiceSegment -> MayBeInvoicePreview(word.segmentText) - is WithdrawSegment -> MayBeWithdrawal(word.segmentText) - is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) - is EmailSegment -> ClickableEmail(word.segmentText) - is PhoneSegment -> ClickablePhone(word.segmentText) - is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, nav) - is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) - is HashIndexEventSegment -> TagLink(word, true, backgroundColor, accountViewModel, nav) - is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) - is RegularTextSegment -> NormalWord(word.segmentText, style) - } + when (word) { + is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) + is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel) + is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) + is InvoiceSegment -> MayBeInvoicePreview(word.segmentText) + is WithdrawSegment -> MayBeWithdrawal(word.segmentText) + is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) + is EmailSegment -> ClickableEmail(word.segmentText) + is PhoneSegment -> ClickablePhone(word.segmentText) + is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) + is HashTagSegment -> HashTag(word, nav) + is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) + is HashIndexEventSegment -> TagLink(word, true, backgroundColor, accountViewModel, nav) + is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) + is RegularTextSegment -> NormalWord(word.segmentText, style) + } } @Composable private fun ZoomableContentView( - word: String, - state: RichTextViewerState, - accountViewModel: AccountViewModel, + word: String, + state: RichTextViewerState, + accountViewModel: AccountViewModel, ) { - state.imagesForPager[word]?.let { - Box(modifier = HalfVertPadding) { - ZoomableContentView(it, state.imageList, roundedCorner = true, accountViewModel) + state.imagesForPager[word]?.let { + Box(modifier = HalfVertPadding) { + ZoomableContentView(it, state.imageList, roundedCorner = true, accountViewModel) + } } - } } @Composable private fun NormalWord( - word: String, - style: TextStyle, + word: String, + style: TextStyle, ) { - BasicText( - text = word, - style = style, - ) + BasicText( + text = word, + style = style, + ) } @Composable private fun NoProtocolUrlRenderer(word: SchemelessUrlSegment) { - RenderUrl(word) + RenderUrl(word) } @Composable private fun RenderUrl(segment: SchemelessUrlSegment) { - ClickableUrl(segment.url, "https://${segment.url}") - segment.extras?.let { it1 -> Text(it1) } + ClickableUrl(segment.url, "https://${segment.url}") + segment.extras?.let { it1 -> Text(it1) } } @Composable fun RenderCustomEmoji( - word: String, - state: RichTextViewerState, + word: String, + state: RichTextViewerState, ) { - CreateTextWithEmoji( - text = word, - emojis = state.customEmoji, - ) + CreateTextWithEmoji( + text = word, + emojis = state.customEmoji, + ) } val markdownParseOptions = - MarkdownParseOptions( - autolink = true, - isImage = { url -> isImageOrVideoUrl(url) }, - ) + MarkdownParseOptions( + autolink = true, + isImage = { url -> isImageOrVideoUrl(url) }, + ) @Composable private fun RenderContentAsMarkdown( - content: String, - tags: ImmutableListOfLists?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + content: String, + tags: ImmutableListOfLists?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val uri = LocalUriHandler.current - val onClick = remember { - { link: String -> - val route = uriToRoute(link) - if (route != null) { - nav(route) - } else { - runCatching { uri.openUri(link) } - } - Unit - } - } + val uri = LocalUriHandler.current + val onClick = + remember { + { link: String -> + val route = uriToRoute(link) + if (route != null) { + nav(route) + } else { + runCatching { uri.openUri(link) } + } + Unit + } + } - ProvideTextStyle(MarkdownTextStyle) { - Material3RichText(style = MaterialTheme.colorScheme.markdownStyle) { - RefreshableContent(content, tags, accountViewModel) { - Markdown( - content = it, - markdownParseOptions = markdownParseOptions, - onLinkClicked = onClick, - onMediaCompose = { title, destination -> - ZoomableContentView( - content = - remember(destination) { - RichTextParser().parseMediaUrl(destination) ?: ZoomableUrlImage(url = destination) - }, - roundedCorner = true, - accountViewModel = accountViewModel, - ) - }, - ) - } + ProvideTextStyle(MarkdownTextStyle) { + Material3RichText(style = MaterialTheme.colorScheme.markdownStyle) { + RefreshableContent(content, tags, accountViewModel) { + Markdown( + content = it, + markdownParseOptions = markdownParseOptions, + onLinkClicked = onClick, + onMediaCompose = { title, destination -> + ZoomableContentView( + content = + remember(destination) { + RichTextParser().parseMediaUrl(destination) ?: ZoomableUrlImage(url = destination) + }, + roundedCorner = true, + accountViewModel = accountViewModel, + ) + }, + ) + } + } } - } } @Composable private fun RefreshableContent( - content: String, - tags: ImmutableListOfLists?, - accountViewModel: AccountViewModel, - onCompose: @Composable (String) -> Unit, + content: String, + tags: ImmutableListOfLists?, + accountViewModel: AccountViewModel, + onCompose: @Composable (String) -> Unit, ) { - var markdownWithSpecialContent by remember(content) { mutableStateOf(content) } + var markdownWithSpecialContent by remember(content) { mutableStateOf(content) } - ObserverAllNIP19References(content, tags, accountViewModel) { - accountViewModel.returnMarkdownWithSpecialContent(content, tags) { - newMarkdownWithSpecialContent, - -> - if (markdownWithSpecialContent != newMarkdownWithSpecialContent) { - markdownWithSpecialContent = newMarkdownWithSpecialContent - } + ObserverAllNIP19References(content, tags, accountViewModel) { + accountViewModel.returnMarkdownWithSpecialContent(content, tags) { + newMarkdownWithSpecialContent, + -> + if (markdownWithSpecialContent != newMarkdownWithSpecialContent) { + markdownWithSpecialContent = newMarkdownWithSpecialContent + } + } } - } - markdownWithSpecialContent?.let { onCompose(it) } + markdownWithSpecialContent?.let { onCompose(it) } } @Composable fun ObserverAllNIP19References( - content: String, - tags: ImmutableListOfLists?, - accountViewModel: AccountViewModel, - onRefresh: () -> Unit, + content: String, + tags: ImmutableListOfLists?, + accountViewModel: AccountViewModel, + onRefresh: () -> Unit, ) { - var nip19References by remember(content) { mutableStateOf>(emptyList()) } + var nip19References by remember(content) { mutableStateOf>(emptyList()) } - LaunchedEffect(key1 = content) { - accountViewModel.returnNIP19References(content, tags) { - nip19References = it - onRefresh() + LaunchedEffect(key1 = content) { + accountViewModel.returnNIP19References(content, tags) { + nip19References = it + onRefresh() + } } - } - nip19References.forEach { ObserveNIP19(it, accountViewModel, onRefresh) } + nip19References.forEach { ObserveNIP19(it, accountViewModel, onRefresh) } } @Composable fun ObserveNIP19( - it: Nip19.Return, - accountViewModel: AccountViewModel, - onRefresh: () -> Unit, + it: Nip19.Return, + accountViewModel: AccountViewModel, + onRefresh: () -> Unit, ) { - if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) { - ObserveNIP19Event(it, accountViewModel, onRefresh) - } else if (it.type == Nip19.Type.USER) { - ObserveNIP19User(it, accountViewModel, onRefresh) - } + if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) { + ObserveNIP19Event(it, accountViewModel, onRefresh) + } else if (it.type == Nip19.Type.USER) { + ObserveNIP19User(it, accountViewModel, onRefresh) + } } @Composable private fun ObserveNIP19Event( - it: Nip19.Return, - accountViewModel: AccountViewModel, - onRefresh: () -> Unit, + it: Nip19.Return, + accountViewModel: AccountViewModel, + onRefresh: () -> Unit, ) { - var baseNote by remember(it) { mutableStateOf(accountViewModel.getNoteIfExists(it.hex)) } + var baseNote by remember(it) { mutableStateOf(accountViewModel.getNoteIfExists(it.hex)) } - if (baseNote == null) { - LaunchedEffect(key1 = it.hex) { - if ( - it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS - ) { - accountViewModel.checkGetOrCreateNote(it.hex) { note -> - launch(Dispatchers.Main) { baseNote = note } + if (baseNote == null) { + LaunchedEffect(key1 = it.hex) { + if ( + it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS + ) { + accountViewModel.checkGetOrCreateNote(it.hex) { note -> + launch(Dispatchers.Main) { baseNote = note } + } + } } - } } - } - baseNote?.let { note -> ObserveNote(note, onRefresh) } + baseNote?.let { note -> ObserveNote(note, onRefresh) } } @Composable fun ObserveNote( - note: Note, - onRefresh: () -> Unit, + note: Note, + onRefresh: () -> Unit, ) { - val loadedNoteId by note.live().metadata.observeAsState() + val loadedNoteId by note.live().metadata.observeAsState() - LaunchedEffect(key1 = loadedNoteId) { - if (loadedNoteId != null) { - onRefresh() + LaunchedEffect(key1 = loadedNoteId) { + if (loadedNoteId != null) { + onRefresh() + } } - } } @Composable private fun ObserveNIP19User( - it: Nip19.Return, - accountViewModel: AccountViewModel, - onRefresh: () -> Unit, + it: Nip19.Return, + accountViewModel: AccountViewModel, + onRefresh: () -> Unit, ) { - var baseUser by remember(it) { mutableStateOf(accountViewModel.getUserIfExists(it.hex)) } + var baseUser by remember(it) { mutableStateOf(accountViewModel.getUserIfExists(it.hex)) } - if (baseUser == null) { - LaunchedEffect(key1 = it.hex) { - if (it.type == Nip19.Type.USER) { - accountViewModel.checkGetOrCreateUser(it.hex)?.let { user -> - launch(Dispatchers.Main) { baseUser = user } + if (baseUser == null) { + LaunchedEffect(key1 = it.hex) { + if (it.type == Nip19.Type.USER) { + accountViewModel.checkGetOrCreateUser(it.hex)?.let { user -> + launch(Dispatchers.Main) { baseUser = user } + } + } } - } } - } - baseUser?.let { user -> ObserveUser(user, onRefresh) } + baseUser?.let { user -> ObserveUser(user, onRefresh) } } @Composable private fun ObserveUser( - user: User, - onRefresh: () -> Unit, + user: User, + onRefresh: () -> Unit, ) { - val loadedUserMetaId by user.live().metadata.observeAsState() + val loadedUserMetaId by user.live().metadata.observeAsState() - LaunchedEffect(key1 = loadedUserMetaId) { - if (loadedUserMetaId != null) { - onRefresh() + LaunchedEffect(key1 = loadedUserMetaId) { + if (loadedUserMetaId != null) { + onRefresh() + } } - } } @Composable fun BechLink( - word: String, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + word: String, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var loadedLink by remember { mutableStateOf(null) } + var loadedLink by remember { mutableStateOf(null) } - if (loadedLink == null) { - LaunchedEffect(key1 = word) { accountViewModel.parseNIP19(word) { loadedLink = it } } - } - - if (canPreview && loadedLink?.baseNote != null) { - Row { - DisplayFullNote( - loadedLink?.baseNote!!, - accountViewModel, - backgroundColor, - nav, - loadedLink!!, - ) + if (loadedLink == null) { + LaunchedEffect(key1 = word) { accountViewModel.parseNIP19(word) { loadedLink = it } } } - } else if (loadedLink?.nip19 != null) { - Row { ClickableRoute(loadedLink?.nip19!!, accountViewModel, nav) } - } else { - val text = - remember(word) { - if (word.length > 16) { - word.replaceRange(8, word.length - 8, ":") - } else { - word - } - } - Text(text = text, maxLines = 1) - } + if (canPreview && loadedLink?.baseNote != null) { + Row { + DisplayFullNote( + loadedLink?.baseNote!!, + accountViewModel, + backgroundColor, + nav, + loadedLink!!, + ) + } + } else if (loadedLink?.nip19 != null) { + Row { ClickableRoute(loadedLink?.nip19!!, accountViewModel, nav) } + } else { + val text = + remember(word) { + if (word.length > 16) { + word.replaceRange(8, word.length - 8, ":") + } else { + word + } + } + + Text(text = text, maxLines = 1) + } } @Composable private fun DisplayFullNote( - it: Note, - accountViewModel: AccountViewModel, - backgroundColor: MutableState, - nav: (String) -> Unit, - loadedLink: LoadedBechLink, + it: Note, + accountViewModel: AccountViewModel, + backgroundColor: MutableState, + nav: (String) -> Unit, + loadedLink: LoadedBechLink, ) { - NoteCompose( - baseNote = it, - accountViewModel = accountViewModel, - modifier = MaterialTheme.colorScheme.replyModifier, - parentBackgroundColor = backgroundColor, - isQuotedNote = true, - nav = nav, - ) - - val extraChars = remember(loadedLink) { loadedLink.nip19.additionalChars.ifBlank { null } } - - extraChars?.let { - Text( - it, + NoteCompose( + baseNote = it, + accountViewModel = accountViewModel, + modifier = MaterialTheme.colorScheme.replyModifier, + parentBackgroundColor = backgroundColor, + isQuotedNote = true, + nav = nav, ) - } + + val extraChars = remember(loadedLink) { loadedLink.nip19.additionalChars.ifBlank { null } } + + extraChars?.let { + Text( + it, + ) + } } @Composable fun HashTag( - word: HashTagSegment, - nav: (String) -> Unit, + word: HashTagSegment, + nav: (String) -> Unit, ) { - RenderHashtag(word, nav) + RenderHashtag(word, nav) } @Composable private fun RenderHashtag( - segment: HashTagSegment, - nav: (String) -> Unit, + segment: HashTagSegment, + nav: (String) -> Unit, ) { - val primary = MaterialTheme.colorScheme.primary - val background = MaterialTheme.colorScheme.onBackground - val hashtagIcon: HashtagIcon? = - remember(segment.hashtag) { checkForHashtagWithIcon(segment.hashtag, primary) } + val primary = MaterialTheme.colorScheme.primary + val background = MaterialTheme.colorScheme.onBackground + val hashtagIcon: HashtagIcon? = + remember(segment.hashtag) { checkForHashtagWithIcon(segment.hashtag, primary) } - val regularText = remember { SpanStyle(color = background) } - val clickableTextStyle = remember { SpanStyle(color = primary) } + val regularText = remember { SpanStyle(color = background) } + val clickableTextStyle = remember { SpanStyle(color = primary) } - val annotatedTermsString = remember { - buildAnnotatedString { - withStyle(clickableTextStyle) { - pushStringAnnotation("routeToHashtag", "") - append("#${segment.hashtag}") - } + val annotatedTermsString = + remember { + buildAnnotatedString { + withStyle(clickableTextStyle) { + pushStringAnnotation("routeToHashtag", "") + append("#${segment.hashtag}") + } - if (hashtagIcon != null) { - withStyle(clickableTextStyle) { - pushStringAnnotation("routeToHashtag", "") - appendInlineContent("inlineContent", "[icon]") + if (hashtagIcon != null) { + withStyle(clickableTextStyle) { + pushStringAnnotation("routeToHashtag", "") + appendInlineContent("inlineContent", "[icon]") + } + } + + segment.extras?.ifBlank { "" }?.let { withStyle(regularText) { append(it) } } + } } - } - segment.extras?.ifBlank { "" }?.let { withStyle(regularText) { append(it) } } - } - } + val inlineContent = + if (hashtagIcon != null) { + mapOf("inlineContent" to InlineIcon(hashtagIcon)) + } else { + emptyMap() + } - val inlineContent = - if (hashtagIcon != null) { - mapOf("inlineContent" to InlineIcon(hashtagIcon)) - } else { - emptyMap() - } + val pressIndicator = remember { Modifier.clickable { nav("Hashtag/${segment.hashtag}") } } - val pressIndicator = remember { Modifier.clickable { nav("Hashtag/${segment.hashtag}") } } - - Text( - text = annotatedTermsString, - modifier = pressIndicator, - inlineContent = inlineContent, - ) + Text( + text = annotatedTermsString, + modifier = pressIndicator, + inlineContent = inlineContent, + ) } @Composable private fun InlineIcon(hashtagIcon: HashtagIcon) = - InlineTextContent( - Placeholder( - width = Font17SP, - height = Font17SP, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center, - ), - ) { - Icon( - painter = painterResource(hashtagIcon.icon), - contentDescription = hashtagIcon.description, - tint = hashtagIcon.color, - modifier = hashtagIcon.modifier, - ) - } + InlineTextContent( + Placeholder( + width = Font17SP, + height = Font17SP, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + ) { + Icon( + painter = painterResource(hashtagIcon.icon), + contentDescription = hashtagIcon.description, + tint = hashtagIcon.color, + modifier = hashtagIcon.modifier, + ) + } @Composable fun TagLink( - word: HashIndexUserSegment, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + word: HashIndexUserSegment, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LoadUser(baseUserHex = word.hex, accountViewModel) { - if (it == null) { - Text(text = word.segmentText) - } else { - Row { DisplayUserFromTag(it, word.extras, nav) } + LoadUser(baseUserHex = word.hex, accountViewModel) { + if (it == null) { + Text(text = word.segmentText) + } else { + Row { DisplayUserFromTag(it, word.extras, nav) } + } } - } } @Composable fun LoadNote( - baseNoteHex: String, - accountViewModel: AccountViewModel, - content: @Composable (Note?) -> Unit, + baseNoteHex: String, + accountViewModel: AccountViewModel, + content: @Composable (Note?) -> Unit, ) { - var note by - remember(baseNoteHex) { mutableStateOf(accountViewModel.getNoteIfExists(baseNoteHex)) } + var note by + remember(baseNoteHex) { mutableStateOf(accountViewModel.getNoteIfExists(baseNoteHex)) } - if (note == null) { - LaunchedEffect(key1 = baseNoteHex) { - accountViewModel.checkGetOrCreateNote(baseNoteHex) { note = it } + if (note == null) { + LaunchedEffect(key1 = baseNoteHex) { + accountViewModel.checkGetOrCreateNote(baseNoteHex) { note = it } + } } - } - content(note) + content(note) } @Composable fun TagLink( - word: HashIndexEventSegment, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + word: HashIndexEventSegment, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LoadNote(baseNoteHex = word.hex, accountViewModel) { - if (it == null) { - Text(text = remember { word.segmentText.toShortenHex() }) - } else { - Row { - DisplayNoteFromTag( - it, - word.extras, - canPreview, - accountViewModel, - backgroundColor, - nav, - ) - } + LoadNote(baseNoteHex = word.hex, accountViewModel) { + if (it == null) { + Text(text = remember { word.segmentText.toShortenHex() }) + } else { + Row { + DisplayNoteFromTag( + it, + word.extras, + canPreview, + accountViewModel, + backgroundColor, + nav, + ) + } + } } - } } @Composable private fun DisplayNoteFromTag( - baseNote: Note, - addedChars: String?, - canPreview: Boolean, - accountViewModel: AccountViewModel, - backgroundColor: MutableState, - nav: (String) -> Unit, + baseNote: Note, + addedChars: String?, + canPreview: Boolean, + accountViewModel: AccountViewModel, + backgroundColor: MutableState, + nav: (String) -> Unit, ) { - if (canPreview) { - NoteCompose( - baseNote = baseNote, - accountViewModel = accountViewModel, - modifier = MaterialTheme.colorScheme.innerPostModifier, - parentBackgroundColor = backgroundColor, - isQuotedNote = true, - nav = nav, - ) - } else { - ClickableNoteTag(baseNote, nav) - } + if (canPreview) { + NoteCompose( + baseNote = baseNote, + accountViewModel = accountViewModel, + modifier = MaterialTheme.colorScheme.innerPostModifier, + parentBackgroundColor = backgroundColor, + isQuotedNote = true, + nav = nav, + ) + } else { + ClickableNoteTag(baseNote, nav) + } - addedChars?.ifBlank { null }?.let { Text(text = it) } + addedChars?.ifBlank { null }?.let { Text(text = it) } } @Composable private fun DisplayUserFromTag( - baseUser: User, - addedChars: String?, - nav: (String) -> Unit, + baseUser: User, + addedChars: String?, + nav: (String) -> Unit, ) { - val route = remember { "User/${baseUser.pubkeyHex}" } - val hex = remember { baseUser.pubkeyDisplayHex() } + val route = remember { "User/${baseUser.pubkeyHex}" } + val hex = remember { baseUser.pubkeyDisplayHex() } - val meta by baseUser.live().userMetadataInfo.observeAsState(baseUser.info) + val meta by baseUser.live().userMetadataInfo.observeAsState(baseUser.info) - Crossfade(targetState = meta) { - Row { - val displayName = remember(it) { it?.bestDisplayName() ?: it?.bestUsername() ?: hex } - CreateClickableTextWithEmoji( - clickablePart = displayName, - suffix = addedChars, - maxLines = 1, - route = route, - nav = nav, - tags = it?.tags, - ) + Crossfade(targetState = meta) { + Row { + val displayName = remember(it) { it?.bestDisplayName() ?: it?.bestUsername() ?: hex } + CreateClickableTextWithEmoji( + clickablePart = displayName, + suffix = addedChars, + maxLines = 1, + route = route, + nav = nav, + tags = it?.tags, + ) + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt index e20fffe69..4cca27d38 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt @@ -32,60 +32,61 @@ import coil.request.ImageRequest import coil.request.Options import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.utils.Robohash -import java.nio.charset.Charset import okio.Buffer +import java.nio.charset.Charset @Stable class HashImageFetcher( - private val context: Context, - private val isLightTheme: Boolean, - private val data: Uri, + private val context: Context, + private val isLightTheme: Boolean, + private val data: Uri, ) : Fetcher { - override suspend fun fetch(): SourceResult { - checkNotInMainThread() - val source = - try { - val buffer = Buffer() - buffer.writeString( - Robohash.assemble(data.toString(), isLightTheme), - Charset.defaultCharset(), + override suspend fun fetch(): SourceResult { + checkNotInMainThread() + val source = + try { + val buffer = Buffer() + buffer.writeString( + Robohash.assemble(data.toString(), isLightTheme), + Charset.defaultCharset(), + ) + buffer + } finally { + } + + return SourceResult( + source = ImageSource(source, context), + mimeType = "image/svg+xml", + dataSource = DataSource.MEMORY, ) - buffer - } finally {} - - return SourceResult( - source = ImageSource(source, context), - mimeType = "image/svg+xml", - dataSource = DataSource.MEMORY, - ) - } - - object Factory : Fetcher.Factory { - override fun create( - data: Uri, - options: Options, - imageLoader: ImageLoader, - ): Fetcher { - return HashImageFetcher( - options.context, - options.parameters.value("isLightTheme") ?: true, - data, - ) } - } + + object Factory : Fetcher.Factory { + override fun create( + data: Uri, + options: Options, + imageLoader: ImageLoader, + ): Fetcher { + return HashImageFetcher( + options.context, + options.parameters.value("isLightTheme") ?: true, + data, + ) + } + } } object RobohashImageRequest { - fun build( - context: Context, - message: String, - isLightTheme: Boolean, - ): ImageRequest { - return ImageRequest.Builder(context) - .data(message) - .fetcherFactory(HashImageFetcher.Factory) - .setParameter("isLightTheme", isLightTheme) - .addHeader("Cache-Control", "max-age=31536000") - .build() - } + fun build( + context: Context, + message: String, + isLightTheme: Boolean, + ): ImageRequest { + return ImageRequest.Builder(context) + .data(message) + .fetcherFactory(HashImageFetcher.Factory) + .setParameter("isLightTheme", isLightTheme) + .addHeader("Cache-Control", "max-age=31536000") + .build() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt index 79be719c4..d119568cc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt @@ -55,141 +55,141 @@ import java.util.Base64 @Composable fun RobohashAsyncImage( - robot: String, - modifier: Modifier = Modifier, - contentDescription: String? = null, - transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = - AsyncImagePainter.DefaultTransform, - onState: ((AsyncImagePainter.State) -> Unit)? = null, - alignment: Alignment = Alignment.Center, - contentScale: ContentScale = ContentScale.Fit, - alpha: Float = DefaultAlpha, - colorFilter: ColorFilter? = null, - filterQuality: FilterQuality = DrawScope.DefaultFilterQuality, + robot: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = + AsyncImagePainter.DefaultTransform, + onState: ((AsyncImagePainter.State) -> Unit)? = null, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + filterQuality: FilterQuality = DrawScope.DefaultFilterQuality, ) { - val context = LocalContext.current - val isLightTheme = MaterialTheme.colorScheme.isLight + val context = LocalContext.current + val isLightTheme = MaterialTheme.colorScheme.isLight - val imageRequest = remember(robot) { RobohashImageRequest.build(context, robot, isLightTheme) } + val imageRequest = remember(robot) { RobohashImageRequest.build(context, robot, isLightTheme) } - AsyncImage( - model = imageRequest, - contentDescription = contentDescription, - modifier = modifier, - transform = transform, - onState = onState, - alignment = alignment, - contentScale = contentScale, - alpha = alpha, - colorFilter = colorFilter, - filterQuality = filterQuality, - ) -} - -@Composable -fun RobohashFallbackAsyncImage( - robot: String, - model: String?, - contentDescription: String?, - modifier: Modifier = Modifier, - alignment: Alignment = Alignment.Center, - contentScale: ContentScale = ContentScale.Fit, - alpha: Float = DefaultAlpha, - colorFilter: ColorFilter? = null, - filterQuality: FilterQuality = DrawScope.DefaultFilterQuality, - loadProfilePicture: Boolean, -) { - val context = LocalContext.current - val isLightTheme = MaterialTheme.colorScheme.isLight - val painter = - rememberAsyncImagePainter( - model = RobohashImageRequest.build(context, robot, isLightTheme), - ) - - if (model != null && loadProfilePicture) { - val isBase64 by remember { derivedStateOf { model.startsWith("data:image/jpeg;base64,") } } - - if (isBase64) { - val base64Painter = - rememberAsyncImagePainter( - model = Base64Requester.imageRequest(context, model), - ) - - Image( - painter = base64Painter, - contentDescription = null, - modifier = modifier, - alignment = alignment, - contentScale = contentScale, - colorFilter = colorFilter, - ) - } else { - AsyncImage( - model = model, + AsyncImage( + model = imageRequest, contentDescription = contentDescription, modifier = modifier, - placeholder = painter, - fallback = painter, - error = painter, + transform = transform, + onState = onState, alignment = alignment, contentScale = contentScale, alpha = alpha, colorFilter = colorFilter, filterQuality = filterQuality, - ) - } - } else { - Image( - painter = painter, - contentDescription = contentDescription, - modifier = modifier, - alignment = alignment, - contentScale = contentScale, - colorFilter = colorFilter, ) - } +} + +@Composable +fun RobohashFallbackAsyncImage( + robot: String, + model: String?, + contentDescription: String?, + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + filterQuality: FilterQuality = DrawScope.DefaultFilterQuality, + loadProfilePicture: Boolean, +) { + val context = LocalContext.current + val isLightTheme = MaterialTheme.colorScheme.isLight + val painter = + rememberAsyncImagePainter( + model = RobohashImageRequest.build(context, robot, isLightTheme), + ) + + if (model != null && loadProfilePicture) { + val isBase64 by remember { derivedStateOf { model.startsWith("data:image/jpeg;base64,") } } + + if (isBase64) { + val base64Painter = + rememberAsyncImagePainter( + model = Base64Requester.imageRequest(context, model), + ) + + Image( + painter = base64Painter, + contentDescription = null, + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + colorFilter = colorFilter, + ) + } else { + AsyncImage( + model = model, + contentDescription = contentDescription, + modifier = modifier, + placeholder = painter, + fallback = painter, + error = painter, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality, + ) + } + } else { + Image( + painter = painter, + contentDescription = contentDescription, + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + colorFilter = colorFilter, + ) + } } object Base64Requester { - fun imageRequest( - context: Context, - message: String, - ): ImageRequest { - return ImageRequest.Builder(context).data(message).fetcherFactory(Base64Fetcher.Factory).build() - } + fun imageRequest( + context: Context, + message: String, + ): ImageRequest { + return ImageRequest.Builder(context).data(message).fetcherFactory(Base64Fetcher.Factory).build() + } } @Stable class Base64Fetcher( - private val options: Options, - private val data: Uri, + private val options: Options, + private val data: Uri, ) : Fetcher { - override suspend fun fetch(): FetchResult { - checkNotInMainThread() + override suspend fun fetch(): FetchResult { + checkNotInMainThread() - val base64String = data.toString().removePrefix("data:image/jpeg;base64,") + val base64String = data.toString().removePrefix("data:image/jpeg;base64,") - val byteArray = Base64.getDecoder().decode(base64String) - val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) + val byteArray = Base64.getDecoder().decode(base64String) + val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) - if (bitmap == null) { - throw Exception("Unable to load base64 $base64String") + if (bitmap == null) { + throw Exception("Unable to load base64 $base64String") + } + + return DrawableResult( + drawable = bitmap.toDrawable(options.context.resources), + isSampled = false, + dataSource = DataSource.MEMORY, + ) } - return DrawableResult( - drawable = bitmap.toDrawable(options.context.resources), - isSampled = false, - dataSource = DataSource.MEMORY, - ) - } - - object Factory : Fetcher.Factory { - override fun create( - data: Uri, - options: Options, - imageLoader: ImageLoader, - ): Fetcher { - return Base64Fetcher(options, data) + object Factory : Fetcher.Factory { + override fun create( + data: Uri, + options: Options, + imageLoader: ImageLoader, + ): Fetcher { + return Base64Fetcher(options, data) + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt index c7d130df6..e7bf72a08 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt @@ -46,42 +46,42 @@ import com.vitorpamplona.amethyst.ui.theme.Size24dp @Composable fun SelectTextDialog( - text: String, - onDismiss: () -> Unit, + text: String, + onDismiss: () -> Unit, ) { - val screenHeight = LocalConfiguration.current.screenHeightDp.dp - val maxHeight = - if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT) { - screenHeight * 0.6f - } else { - screenHeight * 0.9f - } + val screenHeight = LocalConfiguration.current.screenHeightDp.dp + val maxHeight = + if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT) { + screenHeight * 0.6f + } else { + screenHeight * 0.9f + } - Dialog( - onDismissRequest = onDismiss, - ) { - Card { - Column( - modifier = Modifier.heightIn(Size24dp, maxHeight), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End, - ) { - IconButton( - onClick = onDismiss, - ) { - ArrowBackIcon() - } - Text(text = stringResource(R.string.select_text_dialog_top)) + Dialog( + onDismissRequest = onDismiss, + ) { + Card { + Column( + modifier = Modifier.heightIn(Size24dp, maxHeight), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + ) { + IconButton( + onClick = onDismiss, + ) { + ArrowBackIcon() + } + Text(text = stringResource(R.string.select_text_dialog_top)) + } + Divider() + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + Row(modifier = Modifier.padding(16.dp)) { SelectionContainer { Text(text) } } + } + } } - Divider() - Column( - modifier = Modifier.verticalScroll(rememberScrollState()), - ) { - Row(modifier = Modifier.padding(16.dp)) { SelectionContainer { Text(text) } } - } - } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index cd6c432d4..008cb5792 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt @@ -61,106 +61,106 @@ import com.vitorpamplona.quartz.events.EventInterface @Composable fun SensitivityWarning( - note: Note, - accountViewModel: AccountViewModel, - content: @Composable () -> Unit, + note: Note, + accountViewModel: AccountViewModel, + content: @Composable () -> Unit, ) { - note.event?.let { SensitivityWarning(it, accountViewModel, content) } + note.event?.let { SensitivityWarning(it, accountViewModel, content) } } @Composable fun SensitivityWarning( - event: EventInterface, - accountViewModel: AccountViewModel, - content: @Composable () -> Unit, + event: EventInterface, + accountViewModel: AccountViewModel, + content: @Composable () -> Unit, ) { - val hasSensitiveContent = remember(event) { event.isSensitive() ?: false } + val hasSensitiveContent = remember(event) { event.isSensitive() ?: false } - if (hasSensitiveContent) { - SensitivityWarning(accountViewModel, content) - } else { - content() - } -} - -@Composable -fun SensitivityWarning( - accountViewModel: AccountViewModel, - content: @Composable () -> Unit, -) { - val accountState by accountViewModel.accountLiveData.observeAsState() - - var showContentWarningNote by - remember(accountState) { mutableStateOf(accountState?.account?.showSensitiveContent != true) } - - Crossfade(targetState = showContentWarningNote) { - if (it) { - ContentWarningNote { showContentWarningNote = false } + if (hasSensitiveContent) { + SensitivityWarning(accountViewModel, content) } else { - content() + content() + } +} + +@Composable +fun SensitivityWarning( + accountViewModel: AccountViewModel, + content: @Composable () -> Unit, +) { + val accountState by accountViewModel.accountLiveData.observeAsState() + + var showContentWarningNote by + remember(accountState) { mutableStateOf(accountState?.account?.showSensitiveContent != true) } + + Crossfade(targetState = showContentWarningNote) { + if (it) { + ContentWarningNote { showContentWarningNote = false } + } else { + content() + } } - } } @Composable fun ContentWarningNote(onDismiss: () -> Unit) { - Column { - Row(modifier = Modifier.padding(horizontal = 12.dp)) { - Column(modifier = Modifier.padding(start = 10.dp)) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - Box( - Modifier.height(80.dp).width(90.dp), - ) { - Icon( - imageVector = Icons.Default.Visibility, - contentDescription = stringResource(R.string.content_warning), - modifier = Modifier.size(70.dp).align(Alignment.BottomStart), - tint = MaterialTheme.colorScheme.onBackground, - ) - Icon( - imageVector = Icons.Rounded.Warning, - contentDescription = stringResource(R.string.content_warning), - modifier = Modifier.size(30.dp).align(Alignment.TopEnd), - tint = MaterialTheme.colorScheme.onBackground, - ) - } - } + Column { + Row(modifier = Modifier.padding(horizontal = 12.dp)) { + Column(modifier = Modifier.padding(start = 10.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + Box( + Modifier.height(80.dp).width(90.dp), + ) { + Icon( + imageVector = Icons.Default.Visibility, + contentDescription = stringResource(R.string.content_warning), + modifier = Modifier.size(70.dp).align(Alignment.BottomStart), + tint = MaterialTheme.colorScheme.onBackground, + ) + Icon( + imageVector = Icons.Rounded.Warning, + contentDescription = stringResource(R.string.content_warning), + modifier = Modifier.size(30.dp).align(Alignment.TopEnd), + tint = MaterialTheme.colorScheme.onBackground, + ) + } + } - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - Text( - text = stringResource(R.string.content_warning), - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - ) - } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + Text( + text = stringResource(R.string.content_warning), + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) + } - Row { - Text( - text = stringResource(R.string.content_warning_explanation), - color = Color.Gray, - modifier = Modifier.padding(top = 10.dp), - textAlign = TextAlign.Center, - ) - } + Row { + Text( + text = stringResource(R.string.content_warning_explanation), + color = Color.Gray, + modifier = Modifier.padding(top = 10.dp), + textAlign = TextAlign.Center, + ) + } - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - Button( - modifier = Modifier.padding(top = 10.dp), - onClick = onDismiss, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - contentPadding = ButtonPadding, - ) { - Text( - text = stringResource(R.string.show_anyway), - color = Color.White, - ) - } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + Button( + modifier = Modifier.padding(top = 10.dp), + onClick = onDismiss, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text( + text = stringResource(R.string.show_anyway), + color = Color.White, + ) + } + } + } } - } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt index 6932e7e4d..41d19c1af 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt @@ -49,66 +49,66 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @OptIn(ExperimentalFoundationApi::class) @Composable fun SlidingCarousel( - pagerState: PagerState, - modifier: Modifier = Modifier, - itemContent: @Composable (index: Int) -> Unit, + pagerState: PagerState, + modifier: Modifier = Modifier, + itemContent: @Composable (index: Int) -> Unit, ) { - val isDragged by pagerState.interactionSource.collectIsDraggedAsState() + val isDragged by pagerState.interactionSource.collectIsDraggedAsState() - Box( - modifier = modifier.fillMaxWidth(), - ) { - HorizontalPager(state = pagerState) { page -> itemContent(page) } - - // you can remove the surface in case you don't want - // the transparent bacground - Surface( - modifier = Modifier.padding(bottom = 8.dp).align(Alignment.BottomCenter), - shape = CircleShape, - color = Color.Black.copy(alpha = 0.5f), + Box( + modifier = modifier.fillMaxWidth(), ) { - DotsIndicator( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), - totalDots = pagerState.pageCount, - selectedIndex = if (isDragged) pagerState.currentPage else pagerState.targetPage, - dotSize = 8.dp, - ) + HorizontalPager(state = pagerState) { page -> itemContent(page) } + + // you can remove the surface in case you don't want + // the transparent bacground + Surface( + modifier = Modifier.padding(bottom = 8.dp).align(Alignment.BottomCenter), + shape = CircleShape, + color = Color.Black.copy(alpha = 0.5f), + ) { + DotsIndicator( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + totalDots = pagerState.pageCount, + selectedIndex = if (isDragged) pagerState.currentPage else pagerState.targetPage, + dotSize = 8.dp, + ) + } } - } } @Composable fun DotsIndicator( - modifier: Modifier = Modifier, - totalDots: Int, - selectedIndex: Int, - selectedColor: Color = MaterialTheme.colorScheme.primary, - unSelectedColor: Color = MaterialTheme.colorScheme.placeholderText, - dotSize: Dp, + modifier: Modifier = Modifier, + totalDots: Int, + selectedIndex: Int, + selectedColor: Color = MaterialTheme.colorScheme.primary, + unSelectedColor: Color = MaterialTheme.colorScheme.placeholderText, + dotSize: Dp, ) { - LazyRow( - modifier = modifier.wrapContentWidth().wrapContentHeight(), - ) { - items(totalDots) { index -> - IndicatorDot( - color = if (index == selectedIndex) selectedColor else unSelectedColor, - size = dotSize, - ) + LazyRow( + modifier = modifier.wrapContentWidth().wrapContentHeight(), + ) { + items(totalDots) { index -> + IndicatorDot( + color = if (index == selectedIndex) selectedColor else unSelectedColor, + size = dotSize, + ) - if (index != totalDots - 1) { - Spacer(modifier = Modifier.padding(horizontal = 2.dp)) - } + if (index != totalDots - 1) { + Spacer(modifier = Modifier.padding(horizontal = 2.dp)) + } + } } - } } @Composable fun IndicatorDot( - modifier: Modifier = Modifier, - size: Dp, - color: Color, + modifier: Modifier = Modifier, + size: Dp, + color: Color, ) { - Box( - modifier = modifier.size(size).clip(CircleShape).background(color), - ) + Box( + modifier = modifier.size(size).clip(CircleShape).background(color), + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SplitItem.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SplitItem.kt index 59ae669d8..569e0c6b7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SplitItem.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SplitItem.kt @@ -26,100 +26,100 @@ import androidx.compose.runtime.setValue import kotlin.math.abs class SplitItem(val key: T) { - var percentage by mutableStateOf(0f) + var percentage by mutableStateOf(0f) } class Split() { - var items: List> by mutableStateOf(emptyList()) + var items: List> by mutableStateOf(emptyList()) - fun addItem(key: T): Int { - val wasEqualSplit = isEqualSplit() - val newItem = SplitItem(key) - items = items.plus(newItem) + fun addItem(key: T): Int { + val wasEqualSplit = isEqualSplit() + val newItem = SplitItem(key) + items = items.plus(newItem) - if (wasEqualSplit) { - forceEqualSplit() - } else { - updatePercentage(items.lastIndex, equalSplit()) - } - - return items.lastIndex - } - - fun equalSplit() = 1f / items.size - - fun isEqualSplit(): Boolean { - val expectedPercentage = equalSplit() - return items.all { (it.percentage - expectedPercentage) < 0.01 } - } - - fun forceEqualSplit() { - val correctPercentage = equalSplit() - items.forEach { it.percentage = correctPercentage } - } - - fun updatePercentage( - index: Int, - percentage: Float, - ) { - if (items.isEmpty()) return - - val splitItem = items.getOrNull(index) ?: return - - if (items.size == 1) { - splitItem.percentage = 1f - } else { - splitItem.percentage = percentage - - println("Update ${items[index].key} to $percentage") - - val othersMustShare = 1.0f - splitItem.percentage - - val othersHave = - items.sumOf { if (it == splitItem) 0.0 else it.percentage.toDouble() }.toFloat() - - if (abs(othersHave - othersMustShare) < 0.01) return // nothing to do - - println("Others Must Share $othersMustShare but have $othersHave") - - bottomUpAdjustment(othersMustShare, othersHave, index) - } - } - - private fun bottomUpAdjustment( - othersMustShare: Float, - othersHave: Float, - exceptForIndex: Int, - ) { - var needToRemove = othersHave - othersMustShare - if (needToRemove > 0) { - for (i in items.indices.reversed()) { - if (i == exceptForIndex) continue // do not update the current item - - if (needToRemove < items[i].percentage) { - val oldValue = items[i].percentage - items[i].percentage -= needToRemove - needToRemove = 0f - println( - "- Updating ${items[i].key} from $oldValue to ${items[i].percentage - needToRemove}. $needToRemove left", - ) + if (wasEqualSplit) { + forceEqualSplit() } else { - val oldValue = items[i].percentage - needToRemove -= items[i].percentage - items[i].percentage = 0f - println("- Updating ${items[i].key} from $oldValue to ${0}. $needToRemove left") + updatePercentage(items.lastIndex, equalSplit()) } - if (needToRemove < 0.01) { - break - } - } - } else if (needToRemove < 0) { - if (items.lastIndex == exceptForIndex) { - items[items.lastIndex - 1].percentage += -needToRemove - } else { - items.last().percentage += -needToRemove - } + return items.lastIndex + } + + fun equalSplit() = 1f / items.size + + fun isEqualSplit(): Boolean { + val expectedPercentage = equalSplit() + return items.all { (it.percentage - expectedPercentage) < 0.01 } + } + + fun forceEqualSplit() { + val correctPercentage = equalSplit() + items.forEach { it.percentage = correctPercentage } + } + + fun updatePercentage( + index: Int, + percentage: Float, + ) { + if (items.isEmpty()) return + + val splitItem = items.getOrNull(index) ?: return + + if (items.size == 1) { + splitItem.percentage = 1f + } else { + splitItem.percentage = percentage + + println("Update ${items[index].key} to $percentage") + + val othersMustShare = 1.0f - splitItem.percentage + + val othersHave = + items.sumOf { if (it == splitItem) 0.0 else it.percentage.toDouble() }.toFloat() + + if (abs(othersHave - othersMustShare) < 0.01) return // nothing to do + + println("Others Must Share $othersMustShare but have $othersHave") + + bottomUpAdjustment(othersMustShare, othersHave, index) + } + } + + private fun bottomUpAdjustment( + othersMustShare: Float, + othersHave: Float, + exceptForIndex: Int, + ) { + var needToRemove = othersHave - othersMustShare + if (needToRemove > 0) { + for (i in items.indices.reversed()) { + if (i == exceptForIndex) continue // do not update the current item + + if (needToRemove < items[i].percentage) { + val oldValue = items[i].percentage + items[i].percentage -= needToRemove + needToRemove = 0f + println( + "- Updating ${items[i].key} from $oldValue to ${items[i].percentage - needToRemove}. $needToRemove left", + ) + } else { + val oldValue = items[i].percentage + needToRemove -= items[i].percentage + items[i].percentage = 0f + println("- Updating ${items[i].key} from $oldValue to ${0}. $needToRemove left") + } + + if (needToRemove < 0.01) { + break + } + } + } else if (needToRemove < 0) { + if (items.lastIndex == exceptForIndex) { + items[items.lastIndex - 1].percentage += -needToRemove + } else { + items.last().percentage += -needToRemove + } + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt index 5fe5a2cb6..d95aab102 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt @@ -59,145 +59,145 @@ import kotlinx.collections.immutable.ImmutableList @Composable fun TextSpinner( - label: String?, - placeholder: String, - options: ImmutableList, - onSelect: (Int) -> Unit, - modifier: Modifier = Modifier, + label: String?, + placeholder: String, + options: ImmutableList, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, ) { - TextSpinner( - placeholder, - options, - onSelect, - modifier, - ) { currentOption, modifier -> - OutlinedTextField( - value = currentOption, - onValueChange = {}, - readOnly = true, - label = { label?.let { Text(it) } }, - modifier = modifier, - ) - } + TextSpinner( + placeholder, + options, + onSelect, + modifier, + ) { currentOption, modifier -> + OutlinedTextField( + value = currentOption, + onValueChange = {}, + readOnly = true, + label = { label?.let { Text(it) } }, + modifier = modifier, + ) + } } @Composable fun TextSpinner( - placeholder: String, - options: ImmutableList, - onSelect: (Int) -> Unit, - modifier: Modifier = Modifier, - mainElement: @Composable (currentOption: String, modifier: Modifier) -> Unit, + placeholder: String, + options: ImmutableList, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, + mainElement: @Composable (currentOption: String, modifier: Modifier) -> Unit, ) { - val focusRequester = remember { FocusRequester() } - val interactionSource = remember { MutableInteractionSource() } - var optionsShowing by remember { mutableStateOf(false) } - var currentText by remember { mutableStateOf(placeholder) } + val focusRequester = remember { FocusRequester() } + val interactionSource = remember { MutableInteractionSource() } + var optionsShowing by remember { mutableStateOf(false) } + var currentText by remember { mutableStateOf(placeholder) } - Box( - modifier = modifier, - contentAlignment = Alignment.Center, - ) { - mainElement( - currentText, - remember { Modifier.fillMaxWidth().focusRequester(focusRequester) }, - ) Box( - modifier = - Modifier.matchParentSize().clickable( - interactionSource = interactionSource, - indication = null, - ) { - optionsShowing = true - focusRequester.requestFocus() - }, - ) - } - - if (optionsShowing) { - options.isNotEmpty().also { - SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) { - currentText = options[it].title - optionsShowing = false - onSelect(it) - } + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + mainElement( + currentText, + remember { Modifier.fillMaxWidth().focusRequester(focusRequester) }, + ) + Box( + modifier = + Modifier.matchParentSize().clickable( + interactionSource = interactionSource, + indication = null, + ) { + optionsShowing = true + focusRequester.requestFocus() + }, + ) + } + + if (optionsShowing) { + options.isNotEmpty().also { + SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) { + currentText = options[it].title + optionsShowing = false + onSelect(it) + } + } } - } } @Composable fun SpinnerSelectionDialog( - title: String? = null, - options: ImmutableList, - onDismiss: () -> Unit, - onSelect: (Int) -> Unit, + title: String? = null, + options: ImmutableList, + onDismiss: () -> Unit, + onSelect: (Int) -> Unit, ) { - SpinnerSelectionDialog( - title = title, - options = options, - onSelect = onSelect, - onDismiss = onDismiss, - ) { item -> - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth(), - ) { - Text(text = item.title, color = MaterialTheme.colorScheme.onSurface) + SpinnerSelectionDialog( + title = title, + options = options, + onSelect = onSelect, + onDismiss = onDismiss, + ) { item -> + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = item.title, color = MaterialTheme.colorScheme.onSurface) + } + item.explainer?.let { + Spacer(modifier = Modifier.height(5.dp)) + Row( + horizontalArrangement = Arrangement.Start, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = it, color = Color.Gray, fontSize = Font14SP) + } + } } - item.explainer?.let { - Spacer(modifier = Modifier.height(5.dp)) - Row( - horizontalArrangement = Arrangement.Start, - modifier = Modifier.fillMaxWidth(), - ) { - Text(text = it, color = Color.Gray, fontSize = Font14SP) - } - } - } } @Composable fun SpinnerSelectionDialog( - title: String? = null, - options: ImmutableList, - onSelect: (Int) -> Unit, - onDismiss: () -> Unit, - onRenderItem: @Composable (T) -> Unit, + title: String? = null, + options: ImmutableList, + onSelect: (Int) -> Unit, + onDismiss: () -> Unit, + onRenderItem: @Composable (T) -> Unit, ) { - Dialog(onDismissRequest = onDismiss) { - Surface( - border = BorderStroke(0.25.dp, Color.LightGray), - shape = RoundedCornerShape(5.dp), - ) { - LazyColumn { - title?.let { - item { - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp, 16.dp), - horizontalArrangement = Arrangement.Center, - ) { - Text( - text = title, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - ) + Dialog(onDismissRequest = onDismiss) { + Surface( + border = BorderStroke(0.25.dp, Color.LightGray), + shape = RoundedCornerShape(5.dp), + ) { + LazyColumn { + title?.let { + item { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp, 16.dp), + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = title, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + ) + } + Divider(color = Color.LightGray, thickness = DividerThickness) + } + } + itemsIndexed(options) { index, item -> + Row( + modifier = Modifier.fillMaxWidth().clickable { onSelect(index) }.padding(16.dp, 16.dp), + ) { + Column { onRenderItem(item) } + } + if (index < options.lastIndex) { + Divider(color = Color.LightGray, thickness = DividerThickness) + } + } } - Divider(color = Color.LightGray, thickness = DividerThickness) - } } - itemsIndexed(options) { index, item -> - Row( - modifier = Modifier.fillMaxWidth().clickable { onSelect(index) }.padding(16.dp, 16.dp), - ) { - Column { onRenderItem(item) } - } - if (index < options.lastIndex) { - Divider(color = Color.LightGray, thickness = DividerThickness) - } - } - } } - } } @Immutable data class TitleExplainer(val title: String, val explainer: String? = null) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt index ffc8e5f70..e06228df8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt @@ -24,8 +24,8 @@ import androidx.compose.runtime.Immutable @Immutable data class TranslationConfig( - val result: String?, - val sourceLang: String?, - val targetLang: String?, - val showOriginal: Boolean, + val result: String?, + val sourceLang: String?, + val targetLang: String?, + val showOriginal: Boolean, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt index f9dfbf093..0e05b63d3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt @@ -43,50 +43,50 @@ import com.vitorpamplona.amethyst.ui.theme.innerPostModifier @Composable fun UrlPreviewCard( - url: String, - previewInfo: UrlInfoItem, + url: String, + previewInfo: UrlInfoItem, ) { - val uri = LocalUriHandler.current + val uri = LocalUriHandler.current - Column( - modifier = - MaterialTheme.colorScheme.innerPostModifier.clickable { runCatching { uri.openUri(url) } }, - ) { - AsyncImage( - model = previewInfo.imageUrlFullPath, - contentDescription = stringResource(R.string.preview_card_image_for, previewInfo.url), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) + Column( + modifier = + MaterialTheme.colorScheme.innerPostModifier.clickable { runCatching { uri.openUri(url) } }, + ) { + AsyncImage( + model = previewInfo.imageUrlFullPath, + contentDescription = stringResource(R.string.preview_card_image_for, previewInfo.url), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) - Spacer(modifier = StdVertSpacer) + Spacer(modifier = StdVertSpacer) - Text( - text = previewInfo.verifiedUrl?.host ?: previewInfo.url, - style = MaterialTheme.typography.bodySmall, - modifier = MaxWidthWithHorzPadding, - color = Color.Gray, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Text( + text = previewInfo.verifiedUrl?.host ?: previewInfo.url, + style = MaterialTheme.typography.bodySmall, + modifier = MaxWidthWithHorzPadding, + color = Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) - Text( - text = previewInfo.title, - style = MaterialTheme.typography.bodyMedium, - modifier = MaxWidthWithHorzPadding, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Text( + text = previewInfo.title, + style = MaterialTheme.typography.bodyMedium, + modifier = MaxWidthWithHorzPadding, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) - Text( - text = previewInfo.description, - style = MaterialTheme.typography.bodySmall, - modifier = MaxWidthWithHorzPadding, - color = Color.Gray, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) + Text( + text = previewInfo.description, + style = MaterialTheme.typography.bodySmall, + modifier = MaxWidthWithHorzPadding, + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) - Spacer(modifier = DoubleVertSpacer) - } + Spacer(modifier = DoubleVertSpacer) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt index bb5f668e3..a37b3e116 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt @@ -25,11 +25,11 @@ import com.vitorpamplona.amethyst.service.previews.UrlInfoItem @Immutable sealed class UrlPreviewState { - @Immutable object Loading : UrlPreviewState() + @Immutable object Loading : UrlPreviewState() - @Immutable class Loaded(val previewInfo: UrlInfoItem) : UrlPreviewState() + @Immutable class Loaded(val previewInfo: UrlInfoItem) : UrlPreviewState() - @Immutable object Empty : UrlPreviewState() + @Immutable object Empty : UrlPreviewState() - @Immutable class Error(val errorMessage: String) : UrlPreviewState() + @Immutable class Error(val errorMessage: String) : UrlPreviewState() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index dc6af9ee5..ddd58eede 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -105,8 +105,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size50Modifier import com.vitorpamplona.amethyst.ui.theme.Size75dp import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize import com.vitorpamplona.amethyst.ui.theme.imageModifier -import java.util.UUID -import kotlin.math.abs import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -114,442 +112,446 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch +import java.util.UUID +import kotlin.math.abs public val DEFAULT_MUTED_SETTING = mutableStateOf(true) @Composable fun LoadThumbAndThenVideoView( - videoUri: String, - title: String? = null, - thumbUri: String, - authorName: String? = null, - roundedCorner: Boolean, - nostrUriCallback: String? = null, - accountViewModel: AccountViewModel, - onDialog: ((Boolean) -> Unit)? = null, + videoUri: String, + title: String? = null, + thumbUri: String, + authorName: String? = null, + roundedCorner: Boolean, + nostrUriCallback: String? = null, + accountViewModel: AccountViewModel, + onDialog: ((Boolean) -> Unit)? = null, ) { - var loadingFinished by remember { mutableStateOf>(Pair(false, null)) } - val context = LocalContext.current + var loadingFinished by remember { mutableStateOf>(Pair(false, null)) } + val context = LocalContext.current - LaunchedEffect(Unit) { - accountViewModel.loadThumb( - context, - thumbUri, - onReady = { - if (it != null) { - loadingFinished = Pair(true, it) - } else { - loadingFinished = Pair(true, null) - } - }, - onError = { loadingFinished = Pair(true, null) }, - ) - } - - if (loadingFinished.first) { - if (loadingFinished.second != null) { - VideoView( - videoUri = videoUri, - title = title, - thumb = VideoThumb(loadingFinished.second), - roundedCorner = roundedCorner, - artworkUri = thumbUri, - authorName = authorName, - nostrUriCallback = nostrUriCallback, - accountViewModel = accountViewModel, - onDialog = onDialog, - ) - } else { - VideoView( - videoUri = videoUri, - title = title, - thumb = null, - roundedCorner = roundedCorner, - artworkUri = thumbUri, - authorName = authorName, - nostrUriCallback = nostrUriCallback, - accountViewModel = accountViewModel, - onDialog = onDialog, - ) + LaunchedEffect(Unit) { + accountViewModel.loadThumb( + context, + thumbUri, + onReady = { + if (it != null) { + loadingFinished = Pair(true, it) + } else { + loadingFinished = Pair(true, null) + } + }, + onError = { loadingFinished = Pair(true, null) }, + ) + } + + if (loadingFinished.first) { + if (loadingFinished.second != null) { + VideoView( + videoUri = videoUri, + title = title, + thumb = VideoThumb(loadingFinished.second), + roundedCorner = roundedCorner, + artworkUri = thumbUri, + authorName = authorName, + nostrUriCallback = nostrUriCallback, + accountViewModel = accountViewModel, + onDialog = onDialog, + ) + } else { + VideoView( + videoUri = videoUri, + title = title, + thumb = null, + roundedCorner = roundedCorner, + artworkUri = thumbUri, + authorName = authorName, + nostrUriCallback = nostrUriCallback, + accountViewModel = accountViewModel, + onDialog = onDialog, + ) + } } - } } @Composable fun VideoView( - videoUri: String, - title: String? = null, - thumb: VideoThumb? = null, - roundedCorner: Boolean, - topPaddingForControllers: Dp = Dp.Unspecified, - waveform: ImmutableList? = null, - artworkUri: String? = null, - authorName: String? = null, - dimensions: String? = null, - blurhash: String? = null, - nostrUriCallback: String? = null, - onDialog: ((Boolean) -> Unit)? = null, - onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, - accountViewModel: AccountViewModel, - alwaysShowVideo: Boolean = false, + videoUri: String, + title: String? = null, + thumb: VideoThumb? = null, + roundedCorner: Boolean, + topPaddingForControllers: Dp = Dp.Unspecified, + waveform: ImmutableList? = null, + artworkUri: String? = null, + authorName: String? = null, + dimensions: String? = null, + blurhash: String? = null, + nostrUriCallback: String? = null, + onDialog: ((Boolean) -> Unit)? = null, + onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, + accountViewModel: AccountViewModel, + alwaysShowVideo: Boolean = false, ) { - val defaultToStart by remember(videoUri) { mutableStateOf(DEFAULT_MUTED_SETTING.value) } + val defaultToStart by remember(videoUri) { mutableStateOf(DEFAULT_MUTED_SETTING.value) } - val automaticallyStartPlayback = remember { - mutableStateOf( - if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback.value, - ) - } - - if (blurhash == null) { - val ratio = aspectRatio(dimensions) - val modifier = - if (ratio != null && roundedCorner && automaticallyStartPlayback.value) { - Modifier.aspectRatio(ratio) - } else { - Modifier - } - - Box(modifier) { - if (!automaticallyStartPlayback.value) { - ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback) - } else { - VideoViewInner( - videoUri = videoUri, - defaultToStart = defaultToStart, - title = title, - thumb = thumb, - roundedCorner = roundedCorner, - topPaddingForControllers = topPaddingForControllers, - waveform = waveform, - artworkUri = artworkUri, - authorName = authorName, - dimensions = dimensions, - blurhash = blurhash, - nostrUriCallback = nostrUriCallback, - automaticallyStartPlayback = automaticallyStartPlayback, - onControllerVisibilityChanged = onControllerVisibilityChanged, - onDialog = onDialog, - ) - } - } - } else { - val ratio = aspectRatio(dimensions) - val modifier = - if (ratio != null && roundedCorner) { - Modifier.aspectRatio(ratio) - } else { - Modifier - } - - Box(modifier, contentAlignment = Alignment.Center) { - if (!automaticallyStartPlayback.value) { - DisplayBlurHash( - blurhash, - null, - ContentScale.Crop, - MaterialTheme.colorScheme.imageModifier, - ) - IconButton( - modifier = Modifier.size(Size75dp), - onClick = { automaticallyStartPlayback.value = true }, - ) { - DownloadForOfflineIcon(Size75dp, Color.White) + val automaticallyStartPlayback = + remember { + mutableStateOf( + if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback.value, + ) + } + + if (blurhash == null) { + val ratio = aspectRatio(dimensions) + val modifier = + if (ratio != null && roundedCorner && automaticallyStartPlayback.value) { + Modifier.aspectRatio(ratio) + } else { + Modifier + } + + Box(modifier) { + if (!automaticallyStartPlayback.value) { + ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback) + } else { + VideoViewInner( + videoUri = videoUri, + defaultToStart = defaultToStart, + title = title, + thumb = thumb, + roundedCorner = roundedCorner, + topPaddingForControllers = topPaddingForControllers, + waveform = waveform, + artworkUri = artworkUri, + authorName = authorName, + dimensions = dimensions, + blurhash = blurhash, + nostrUriCallback = nostrUriCallback, + automaticallyStartPlayback = automaticallyStartPlayback, + onControllerVisibilityChanged = onControllerVisibilityChanged, + onDialog = onDialog, + ) + } + } + } else { + val ratio = aspectRatio(dimensions) + val modifier = + if (ratio != null && roundedCorner) { + Modifier.aspectRatio(ratio) + } else { + Modifier + } + + Box(modifier, contentAlignment = Alignment.Center) { + if (!automaticallyStartPlayback.value) { + DisplayBlurHash( + blurhash, + null, + ContentScale.Crop, + MaterialTheme.colorScheme.imageModifier, + ) + IconButton( + modifier = Modifier.size(Size75dp), + onClick = { automaticallyStartPlayback.value = true }, + ) { + DownloadForOfflineIcon(Size75dp, Color.White) + } + } else { + VideoViewInner( + videoUri = videoUri, + defaultToStart = defaultToStart, + title = title, + thumb = thumb, + roundedCorner = roundedCorner, + topPaddingForControllers = topPaddingForControllers, + waveform = waveform, + artworkUri = artworkUri, + authorName = authorName, + dimensions = dimensions, + blurhash = blurhash, + nostrUriCallback = nostrUriCallback, + automaticallyStartPlayback = automaticallyStartPlayback, + onControllerVisibilityChanged = onControllerVisibilityChanged, + onDialog = onDialog, + ) + } } - } else { - VideoViewInner( - videoUri = videoUri, - defaultToStart = defaultToStart, - title = title, - thumb = thumb, - roundedCorner = roundedCorner, - topPaddingForControllers = topPaddingForControllers, - waveform = waveform, - artworkUri = artworkUri, - authorName = authorName, - dimensions = dimensions, - blurhash = blurhash, - nostrUriCallback = nostrUriCallback, - automaticallyStartPlayback = automaticallyStartPlayback, - onControllerVisibilityChanged = onControllerVisibilityChanged, - onDialog = onDialog, - ) - } } - } } @Composable @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) fun VideoViewInner( - videoUri: String, - defaultToStart: Boolean = false, - title: String? = null, - thumb: VideoThumb? = null, - roundedCorner: Boolean, - topPaddingForControllers: Dp = Dp.Unspecified, - waveform: ImmutableList? = null, - artworkUri: String? = null, - authorName: String? = null, - dimensions: String? = null, - blurhash: String? = null, - nostrUriCallback: String? = null, - automaticallyStartPlayback: State, - onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, - onDialog: ((Boolean) -> Unit)? = null, + videoUri: String, + defaultToStart: Boolean = false, + title: String? = null, + thumb: VideoThumb? = null, + roundedCorner: Boolean, + topPaddingForControllers: Dp = Dp.Unspecified, + waveform: ImmutableList? = null, + artworkUri: String? = null, + authorName: String? = null, + dimensions: String? = null, + blurhash: String? = null, + nostrUriCallback: String? = null, + automaticallyStartPlayback: State, + onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, + onDialog: ((Boolean) -> Unit)? = null, ) { - VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen -> - GetMediaItem(videoUri, title, artworkUri, authorName) { mediaItem -> - GetVideoController( - mediaItem = mediaItem, - videoUri = videoUri, - defaultToStart = defaultToStart, - nostrUriCallback = nostrUriCallback, - ) { controller, keepPlaying -> - RenderVideoPlayer( - controller = controller, - thumbData = thumb, - roundedCorner = roundedCorner, - dimensions = dimensions, - blurhash = blurhash, - topPaddingForControllers = topPaddingForControllers, - waveform = waveform, - keepPlaying = keepPlaying, - automaticallyStartPlayback = automaticallyStartPlayback, - activeOnScreen = activeOnScreen, - modifier = modifier, - onControllerVisibilityChanged = onControllerVisibilityChanged, - onDialog = onDialog, - ) - } + VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen -> + GetMediaItem(videoUri, title, artworkUri, authorName) { mediaItem -> + GetVideoController( + mediaItem = mediaItem, + videoUri = videoUri, + defaultToStart = defaultToStart, + nostrUriCallback = nostrUriCallback, + ) { controller, keepPlaying -> + RenderVideoPlayer( + controller = controller, + thumbData = thumb, + roundedCorner = roundedCorner, + dimensions = dimensions, + blurhash = blurhash, + topPaddingForControllers = topPaddingForControllers, + waveform = waveform, + keepPlaying = keepPlaying, + automaticallyStartPlayback = automaticallyStartPlayback, + activeOnScreen = activeOnScreen, + modifier = modifier, + onControllerVisibilityChanged = onControllerVisibilityChanged, + onDialog = onDialog, + ) + } + } } - } } @Composable fun GetMediaItem( - videoUri: String, - title: String?, - artworkUri: String?, - authorName: String?, - inner: @Composable (State) -> Unit, + videoUri: String, + title: String?, + artworkUri: String?, + authorName: String?, + inner: @Composable (State) -> Unit, ) { - val mediaItem = - produceState( - initialValue = null, - key1 = videoUri, - ) { - this.value = - MediaItem.Builder() - .setMediaId(videoUri) - .setUri(videoUri) - .setMediaMetadata( - MediaMetadata.Builder() - .setArtist(authorName?.ifBlank { null }) - .setTitle(title?.ifBlank { null } ?: videoUri) - .setArtworkUri( - try { - if (artworkUri != null) { - Uri.parse(artworkUri) - } else { - null - } - } catch (e: Exception) { - null - }, - ) - .build(), - ) - .build() - } + val mediaItem = + produceState( + initialValue = null, + key1 = videoUri, + ) { + this.value = + MediaItem.Builder() + .setMediaId(videoUri) + .setUri(videoUri) + .setMediaMetadata( + MediaMetadata.Builder() + .setArtist(authorName?.ifBlank { null }) + .setTitle(title?.ifBlank { null } ?: videoUri) + .setArtworkUri( + try { + if (artworkUri != null) { + Uri.parse(artworkUri) + } else { + null + } + } catch (e: Exception) { + null + }, + ) + .build(), + ) + .build() + } - mediaItem.value?.let { - val myState = remember(videoUri) { mutableStateOf(it) } - inner(myState) - } + mediaItem.value?.let { + val myState = remember(videoUri) { mutableStateOf(it) } + inner(myState) + } } @Immutable sealed class MediaControllerState { - @Immutable object NotStarted : MediaControllerState() + @Immutable object NotStarted : MediaControllerState() - @Immutable object Loading : MediaControllerState() + @Immutable object Loading : MediaControllerState() - @Stable class Loaded(val instance: MediaController) : MediaControllerState() + @Stable class Loaded(val instance: MediaController) : MediaControllerState() } @Composable @OptIn(UnstableApi::class) fun GetVideoController( - mediaItem: State, - videoUri: String, - defaultToStart: Boolean = false, - nostrUriCallback: String? = null, - inner: @Composable (controller: MediaController, keepPlaying: MutableState) -> Unit, + mediaItem: State, + videoUri: String, + defaultToStart: Boolean = false, + nostrUriCallback: String? = null, + inner: @Composable (controller: MediaController, keepPlaying: MutableState) -> Unit, ) { - val context = LocalContext.current + val context = LocalContext.current - val controller = - remember(videoUri) { - val globalMutex = keepPlayingMutex - mutableStateOf( - if (videoUri == globalMutex?.currentMediaItem?.mediaId) { - MediaControllerState.Loaded(globalMutex) - } else { - MediaControllerState.NotStarted - }, - ) - } - - val keepPlaying = - remember(videoUri) { - mutableStateOf( - keepPlayingMutex != null && controller.value == keepPlayingMutex, - ) - } - - val uid = remember(videoUri) { UUID.randomUUID().toString() } - - val scope = rememberCoroutineScope() - - // Prepares a VideoPlayer from the foreground service. - DisposableEffect(key1 = videoUri) { - // If it is not null, the user might have come back from a playing video, like clicking on - // the notification of the video player. - if (controller.value == MediaControllerState.NotStarted) { - controller.value = MediaControllerState.Loading - - scope.launch(Dispatchers.IO) { - Log.d("PlaybackService", "Preparing Video $videoUri ") - PlaybackClientController.prepareController( - uid, - videoUri, - nostrUriCallback, - context, - ) { - scope.launch(Dispatchers.Main) { - // REQUIRED TO BE RUN IN THE MAIN THREAD - - val newState = MediaControllerState.Loaded(it) - - if (!it.isPlaying) { - if (keepPlayingMutex?.isPlaying == true) { - // There is a video playing, start this one on mute. - newState.instance.volume = 0f - } else { - // There is no other video playing. Use the default mute state to - // decide if sound is on or not. - newState.instance.volume = if (defaultToStart) 0f else 1f - } - } - - newState.instance.setMediaItem(mediaItem.value) - newState.instance.prepare() - - controller.value = newState - } + val controller = + remember(videoUri) { + val globalMutex = keepPlayingMutex + mutableStateOf( + if (videoUri == globalMutex?.currentMediaItem?.mediaId) { + MediaControllerState.Loaded(globalMutex) + } else { + MediaControllerState.NotStarted + }, + ) } - } - } else if (controller.value is MediaControllerState.Loaded) { - (controller.value as? MediaControllerState.Loaded)?.instance?.let { - scope.launch(Dispatchers.Main) { - if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) { - if (it.isPlaying) { - // There is a video playing, start this one on mute. - it.volume = 0f - } else { - // There is no other video playing. Use the default mute state to - // decide if sound is on or not. - it.volume = if (defaultToStart) 0f else 1f - } - it.setMediaItem(mediaItem.value) - it.prepare() - } + val keepPlaying = + remember(videoUri) { + mutableStateOf( + keepPlayingMutex != null && controller.value == keepPlayingMutex, + ) } - } - } - onDispose { - GlobalScope.launch(Dispatchers.Main) { - if (!keepPlaying.value) { - // Stops and releases the media. - (controller.value as? MediaControllerState.Loaded)?.instance?.let { - it.stop() - it.release() - Log.d("PlaybackService", "Releasing Video $videoUri ") - controller.value = MediaControllerState.NotStarted - } - } - } - } - } + val uid = remember(videoUri) { UUID.randomUUID().toString() } - // User pauses and resumes the app. What to do with videos? - val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(key1 = lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - // if the controller is null, restarts the controller with a new one - // if the controller is not null, just continue playing what the controller was playing - scope.launch(Dispatchers.IO) { - if (controller.value == MediaControllerState.NotStarted) { + val scope = rememberCoroutineScope() + + // Prepares a VideoPlayer from the foreground service. + DisposableEffect(key1 = videoUri) { + // If it is not null, the user might have come back from a playing video, like clicking on + // the notification of the video player. + if (controller.value == MediaControllerState.NotStarted) { controller.value = MediaControllerState.Loading - Log.d("PlaybackService", "Preparing Video from Resume $videoUri ") + scope.launch(Dispatchers.IO) { + Log.d("PlaybackService", "Preparing Video $videoUri ") + PlaybackClientController.prepareController( + uid, + videoUri, + nostrUriCallback, + context, + ) { + scope.launch(Dispatchers.Main) { + // REQUIRED TO BE RUN IN THE MAIN THREAD - PlaybackClientController.prepareController( - uid, - videoUri, - nostrUriCallback, - context, - ) { - scope.launch(Dispatchers.Main) { - // REQUIRED TO BE RUN IN THE MAIN THREAD + val newState = MediaControllerState.Loaded(it) - val newState = MediaControllerState.Loaded(it) + if (!it.isPlaying) { + if (keepPlayingMutex?.isPlaying == true) { + // There is a video playing, start this one on mute. + newState.instance.volume = 0f + } else { + // There is no other video playing. Use the default mute state to + // decide if sound is on or not. + newState.instance.volume = if (defaultToStart) 0f else 1f + } + } - // checks again to make sure no other thread has created a controller. - if (!it.isPlaying) { - if (keepPlayingMutex?.isPlaying == true) { - // There is a video playing, start this one on mute. - newState.instance.volume = 0f - } else { - // There is no other video playing. Use the default mute state to - // decide if sound is on or not. - newState.instance.volume = if (defaultToStart) 0f else 1f - } + newState.instance.setMediaItem(mediaItem.value) + newState.instance.prepare() + + controller.value = newState + } } - - newState.instance.setMediaItem(mediaItem.value) - newState.instance.prepare() - - controller.value = newState - } } - } - } - } - if (event == Lifecycle.Event.ON_PAUSE) { - GlobalScope.launch(Dispatchers.Main) { - if (!keepPlaying.value) { - // Stops and releases the media. + } else if (controller.value is MediaControllerState.Loaded) { (controller.value as? MediaControllerState.Loaded)?.instance?.let { - Log.d("PlaybackService", "Releasing Video from Pause $videoUri ") - it.stop() - it.release() - controller.value = MediaControllerState.NotStarted + scope.launch(Dispatchers.Main) { + if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) { + if (it.isPlaying) { + // There is a video playing, start this one on mute. + it.volume = 0f + } else { + // There is no other video playing. Use the default mute state to + // decide if sound is on or not. + it.volume = if (defaultToStart) 0f else 1f + } + + it.setMediaItem(mediaItem.value) + it.prepare() + } + } + } + } + + onDispose { + GlobalScope.launch(Dispatchers.Main) { + if (!keepPlaying.value) { + // Stops and releases the media. + (controller.value as? MediaControllerState.Loaded)?.instance?.let { + it.stop() + it.release() + Log.d("PlaybackService", "Releasing Video $videoUri ") + controller.value = MediaControllerState.NotStarted + } + } } - } } - } } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + // User pauses and resumes the app. What to do with videos? + val lifeCycleOwner = LocalLifecycleOwner.current + DisposableEffect(key1 = lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + // if the controller is null, restarts the controller with a new one + // if the controller is not null, just continue playing what the controller was playing + scope.launch(Dispatchers.IO) { + if (controller.value == MediaControllerState.NotStarted) { + controller.value = MediaControllerState.Loading - (controller.value as? MediaControllerState.Loaded)?.let { inner(it.instance, keepPlaying) } + Log.d("PlaybackService", "Preparing Video from Resume $videoUri ") + + PlaybackClientController.prepareController( + uid, + videoUri, + nostrUriCallback, + context, + ) { + scope.launch(Dispatchers.Main) { + // REQUIRED TO BE RUN IN THE MAIN THREAD + + val newState = MediaControllerState.Loaded(it) + + // checks again to make sure no other thread has created a controller. + if (!it.isPlaying) { + if (keepPlayingMutex?.isPlaying == true) { + // There is a video playing, start this one on mute. + newState.instance.volume = 0f + } else { + // There is no other video playing. Use the default mute state to + // decide if sound is on or not. + newState.instance.volume = if (defaultToStart) 0f else 1f + } + } + + newState.instance.setMediaItem(mediaItem.value) + newState.instance.prepare() + + controller.value = newState + } + } + } + } + } + if (event == Lifecycle.Event.ON_PAUSE) { + GlobalScope.launch(Dispatchers.Main) { + if (!keepPlaying.value) { + // Stops and releases the media. + (controller.value as? MediaControllerState.Loaded)?.instance?.let { + Log.d("PlaybackService", "Releasing Video from Pause $videoUri ") + it.stop() + it.release() + controller.value = MediaControllerState.NotStarted + } + } + } + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } + + (controller.value as? MediaControllerState.Loaded)?.let { inner(it.instance, keepPlaying) } } // background playing mutex. @@ -560,7 +562,7 @@ val trackingVideos = mutableListOf() @Stable class VisibilityData() { - var distanceToCenter: Float? = null + var distanceToCenter: Float? = null } /** @@ -569,461 +571,465 @@ class VisibilityData() { */ @Composable fun VideoPlayerActiveMutex( - videoUri: String, - inner: @Composable (Modifier, MutableState) -> Unit, + videoUri: String, + inner: @Composable (Modifier, MutableState) -> Unit, ) { - val myCache = remember(videoUri) { VisibilityData() } + val myCache = remember(videoUri) { VisibilityData() } - // Is the current video the closest to the center? - val active = remember(videoUri) { mutableStateOf(false) } + // Is the current video the closest to the center? + val active = remember(videoUri) { mutableStateOf(false) } - // Keep track of all available videos. - DisposableEffect(key1 = videoUri) { - trackingVideos.add(myCache) - onDispose { trackingVideos.remove(myCache) } - } - - val myModifier = - remember(videoUri) { - Modifier.fillMaxWidth().defaultMinSize(minHeight = 70.dp).onVisiblePositionChanges { - distanceToCenter -> - myCache.distanceToCenter = distanceToCenter - - if (distanceToCenter != null) { - // finds out of the current video is the closest to the center. - var newActive = true - for (video in trackingVideos) { - val videoPos = video.distanceToCenter - if (videoPos != null && videoPos < distanceToCenter) { - newActive = false - break - } - } - - // marks the current video active - if (active.value != newActive) { - active.value = newActive - } - } else { - // got out of screen, marks video as inactive - if (active.value) { - active.value = false - } - } - } + // Keep track of all available videos. + DisposableEffect(key1 = videoUri) { + trackingVideos.add(myCache) + onDispose { trackingVideos.remove(myCache) } } - inner(myModifier, active) + val myModifier = + remember(videoUri) { + Modifier.fillMaxWidth().defaultMinSize(minHeight = 70.dp).onVisiblePositionChanges { + distanceToCenter -> + myCache.distanceToCenter = distanceToCenter + + if (distanceToCenter != null) { + // finds out of the current video is the closest to the center. + var newActive = true + for (video in trackingVideos) { + val videoPos = video.distanceToCenter + if (videoPos != null && videoPos < distanceToCenter) { + newActive = false + break + } + } + + // marks the current video active + if (active.value != newActive) { + active.value = newActive + } + } else { + // got out of screen, marks video as inactive + if (active.value) { + active.value = false + } + } + } + } + + inner(myModifier, active) } @Stable data class VideoThumb( - val thumb: Drawable?, + val thumb: Drawable?, ) @Composable @OptIn(UnstableApi::class) private fun RenderVideoPlayer( - controller: MediaController, - thumbData: VideoThumb?, - roundedCorner: Boolean, - dimensions: String? = null, - blurhash: String? = null, - topPaddingForControllers: Dp = Dp.Unspecified, - waveform: ImmutableList? = null, - keepPlaying: MutableState, - automaticallyStartPlayback: State, - activeOnScreen: MutableState, - modifier: Modifier, - onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, - onDialog: ((Boolean) -> Unit)?, + controller: MediaController, + thumbData: VideoThumb?, + roundedCorner: Boolean, + dimensions: String? = null, + blurhash: String? = null, + topPaddingForControllers: Dp = Dp.Unspecified, + waveform: ImmutableList? = null, + keepPlaying: MutableState, + automaticallyStartPlayback: State, + activeOnScreen: MutableState, + modifier: Modifier, + onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, + onDialog: ((Boolean) -> Unit)?, ) { - ControlWhenPlayerIsActive(controller, keepPlaying, automaticallyStartPlayback, activeOnScreen) + ControlWhenPlayerIsActive(controller, keepPlaying, automaticallyStartPlayback, activeOnScreen) - val controllerVisible = remember(controller) { mutableStateOf(false) } + val controllerVisible = remember(controller) { mutableStateOf(false) } - val videoPlaybackHeight = remember { mutableStateOf(Dp.Unspecified) } + val videoPlaybackHeight = remember { mutableStateOf(Dp.Unspecified) } - val localDensity = LocalDensity.current + val localDensity = LocalDensity.current - BoxWithConstraints( - modifier = - Modifier.onGloballyPositioned { coordinates -> - videoPlaybackHeight.value = with(localDensity) { coordinates.size.height.toDp() } - }, - ) { - val borders = MaterialTheme.colorScheme.imageModifier + BoxWithConstraints( + modifier = + Modifier.onGloballyPositioned { coordinates -> + videoPlaybackHeight.value = with(localDensity) { coordinates.size.height.toDp() } + }, + ) { + val borders = MaterialTheme.colorScheme.imageModifier - val myModifier = remember { - if (roundedCorner) { - modifier.then( - borders.defaultMinSize(minHeight = 75.dp).align(Alignment.Center), - ) - } else { - modifier.fillMaxWidth().defaultMinSize(minHeight = 75.dp).align(Alignment.Center) - } - } - - val factory = - remember(controller) { - { context: Context -> - PlayerView(context).apply { - player = controller - layoutParams = - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) - setBackgroundColor(Color.Transparent.toArgb()) - setShutterBackgroundColor(Color.Transparent.toArgb()) - controllerAutoShow = false - thumbData?.thumb?.let { defaultArtwork = it } - hideController() - resizeMode = - if (maxHeight.isFinite) { - AspectRatioFrameLayout.RESIZE_MODE_FIT - } else { - AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH - } - onDialog?.let { innerOnDialog -> - setFullscreenButtonClickListener { - controller.pause() - innerOnDialog(it) - } + val myModifier = + remember { + if (roundedCorner) { + modifier.then( + borders.defaultMinSize(minHeight = 75.dp).align(Alignment.Center), + ) + } else { + modifier.fillMaxWidth().defaultMinSize(minHeight = 75.dp).align(Alignment.Center) + } } - setControllerVisibilityListener( - PlayerView.ControllerVisibilityListener { visible -> - controllerVisible.value = visible == View.VISIBLE - onControllerVisibilityChanged?.let { callback -> callback(visible == View.VISIBLE) } - }, + + val factory = + remember(controller) { + { context: Context -> + PlayerView(context).apply { + player = controller + layoutParams = + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + setBackgroundColor(Color.Transparent.toArgb()) + setShutterBackgroundColor(Color.Transparent.toArgb()) + controllerAutoShow = false + thumbData?.thumb?.let { defaultArtwork = it } + hideController() + resizeMode = + if (maxHeight.isFinite) { + AspectRatioFrameLayout.RESIZE_MODE_FIT + } else { + AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH + } + onDialog?.let { innerOnDialog -> + setFullscreenButtonClickListener { + controller.pause() + innerOnDialog(it) + } + } + setControllerVisibilityListener( + PlayerView.ControllerVisibilityListener { visible -> + controllerVisible.value = visible == View.VISIBLE + onControllerVisibilityChanged?.let { callback -> callback(visible == View.VISIBLE) } + }, + ) + } + } + } + + val ratio = remember { aspectRatio(dimensions) } + + if (ratio != null) { + DisplayBlurHash( + blurhash, + null, + ContentScale.Crop, + myModifier.aspectRatio(ratio), ) - } } - } - val ratio = remember { aspectRatio(dimensions) } + AndroidView( + modifier = myModifier, + factory = factory, + ) - if (ratio != null) { - DisplayBlurHash( - blurhash, - null, - ContentScale.Crop, - myModifier.aspectRatio(ratio), - ) - } + waveform?.let { Waveform(it, controller, remember { Modifier.align(Alignment.Center) }) } - AndroidView( - modifier = myModifier, - factory = factory, - ) + val startingMuteState = remember(controller) { controller.volume < 0.001 } - waveform?.let { Waveform(it, controller, remember { Modifier.align(Alignment.Center) }) } + val topPadding = + remember { + derivedStateOf { + if (topPaddingForControllers.isSpecified && videoPlaybackHeight.value.value > 0) { + val space = (abs(this.maxHeight.value - videoPlaybackHeight.value.value) / 2).dp + if (space > topPaddingForControllers) { + Size0dp + } else { + topPaddingForControllers - space + } + } else { + Size0dp + } + } + } - val startingMuteState = remember(controller) { controller.volume < 0.001 } + MuteButton( + controllerVisible, + startingMuteState, + topPadding, + ) { mute: Boolean -> + // makes the new setting the default for new creations. + DEFAULT_MUTED_SETTING.value = mute - val topPadding = remember { - derivedStateOf { - if (topPaddingForControllers.isSpecified && videoPlaybackHeight.value.value > 0) { - val space = (abs(this.maxHeight.value - videoPlaybackHeight.value.value) / 2).dp - if (space > topPaddingForControllers) { - Size0dp - } else { - topPaddingForControllers - space - } - } else { - Size0dp + // if the user unmutes a video and it's not the current playing, switches to that one. + if (!mute && keepPlayingMutex != null && keepPlayingMutex != controller) { + keepPlayingMutex?.stop() + keepPlayingMutex?.release() + keepPlayingMutex = null + } + + controller.volume = if (mute) 0f else 1f } - } - } - MuteButton( - controllerVisible, - startingMuteState, - topPadding, - ) { mute: Boolean -> - // makes the new setting the default for new creations. - DEFAULT_MUTED_SETTING.value = mute + KeepPlayingButton( + keepPlaying, + controllerVisible, + topPadding, + Modifier.align(Alignment.TopEnd), + ) { newKeepPlaying: Boolean -> + // If something else is playing and the user marks this video to keep playing, stops the other + // one. + if (newKeepPlaying) { + if (keepPlayingMutex != null && keepPlayingMutex != controller) { + keepPlayingMutex?.stop() + keepPlayingMutex?.release() + } + keepPlayingMutex = controller + } else { + if (keepPlayingMutex == controller) { + keepPlayingMutex = null + } + } - // if the user unmutes a video and it's not the current playing, switches to that one. - if (!mute && keepPlayingMutex != null && keepPlayingMutex != controller) { - keepPlayingMutex?.stop() - keepPlayingMutex?.release() - keepPlayingMutex = null - } - - controller.volume = if (mute) 0f else 1f - } - - KeepPlayingButton( - keepPlaying, - controllerVisible, - topPadding, - Modifier.align(Alignment.TopEnd), - ) { newKeepPlaying: Boolean -> - // If something else is playing and the user marks this video to keep playing, stops the other - // one. - if (newKeepPlaying) { - if (keepPlayingMutex != null && keepPlayingMutex != controller) { - keepPlayingMutex?.stop() - keepPlayingMutex?.release() + keepPlaying.value = newKeepPlaying } - keepPlayingMutex = controller - } else { - if (keepPlayingMutex == controller) { - keepPlayingMutex = null - } - } - - keepPlaying.value = newKeepPlaying } - } } private fun pollCurrentDuration(controller: MediaController) = - flow { - while (controller.currentPosition <= controller.duration) { - emit(controller.currentPosition / controller.duration.toFloat()) - delay(100) - } + flow { + while (controller.currentPosition <= controller.duration) { + emit(controller.currentPosition / controller.duration.toFloat()) + delay(100) + } } - .conflate() + .conflate() @Composable fun Waveform( - waveform: ImmutableList, - controller: MediaController, - modifier: Modifier, + waveform: ImmutableList, + controller: MediaController, + modifier: Modifier, ) { - val waveformProgress = remember { mutableStateOf(0F) } + val waveformProgress = remember { mutableStateOf(0F) } - DrawWaveform(waveform, waveformProgress, modifier) + DrawWaveform(waveform, waveformProgress, modifier) - val restartFlow = remember { mutableIntStateOf(0) } + val restartFlow = remember { mutableIntStateOf(0) } - // Keeps the screen on while playing and viewing videos. - DisposableEffect(key1 = controller) { - val listener = - object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - // doesn't consider the mutex because the screen can turn off if the video - // being played in the mutex is not visible. - if (isPlaying) { - restartFlow.value += 1 - } - } - } + // Keeps the screen on while playing and viewing videos. + DisposableEffect(key1 = controller) { + val listener = + object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + // doesn't consider the mutex because the screen can turn off if the video + // being played in the mutex is not visible. + if (isPlaying) { + restartFlow.value += 1 + } + } + } - controller.addListener(listener) - onDispose { controller.removeListener(listener) } - } + controller.addListener(listener) + onDispose { controller.removeListener(listener) } + } - LaunchedEffect(key1 = restartFlow.value) { - pollCurrentDuration(controller).collect { value -> waveformProgress.value = value } - } + LaunchedEffect(key1 = restartFlow.value) { + pollCurrentDuration(controller).collect { value -> waveformProgress.value = value } + } } @Composable fun DrawWaveform( - waveform: ImmutableList, - waveformProgress: MutableState, - modifier: Modifier, + waveform: ImmutableList, + waveformProgress: MutableState, + modifier: Modifier, ) { - AudioWaveformReadOnly( - modifier = modifier.padding(start = 10.dp, end = 10.dp), - amplitudes = waveform, - progress = waveformProgress.value, - progressBrush = - Brush.infiniteLinearGradient( - colors = listOf(Color(0xff2598cf), Color(0xff652d80)), - animation = tween(durationMillis = 6000, easing = LinearEasing), - width = 128F, - ), - onProgressChange = { waveformProgress.value = it }, - ) + AudioWaveformReadOnly( + modifier = modifier.padding(start = 10.dp, end = 10.dp), + amplitudes = waveform, + progress = waveformProgress.value, + progressBrush = + Brush.infiniteLinearGradient( + colors = listOf(Color(0xff2598cf), Color(0xff652d80)), + animation = tween(durationMillis = 6000, easing = LinearEasing), + width = 128F, + ), + onProgressChange = { waveformProgress.value = it }, + ) } @Composable fun ControlWhenPlayerIsActive( - controller: Player, - keepPlaying: MutableState, - automaticallyStartPlayback: State, - activeOnScreen: MutableState, + controller: Player, + keepPlaying: MutableState, + automaticallyStartPlayback: State, + activeOnScreen: MutableState, ) { - LaunchedEffect(key1 = activeOnScreen.value) { - // active means being fully visible - if (activeOnScreen.value) { - // should auto start video from settings? - if (!automaticallyStartPlayback.value) { - if (controller.isPlaying) { - // if it is visible, it's playing but it wasn't supposed to start automatically. - controller.pause() - } - } else if (!controller.isPlaying) { - // if it is visible, was supposed to start automatically, but it's not + LaunchedEffect(key1 = activeOnScreen.value) { + // active means being fully visible + if (activeOnScreen.value) { + // should auto start video from settings? + if (!automaticallyStartPlayback.value) { + if (controller.isPlaying) { + // if it is visible, it's playing but it wasn't supposed to start automatically. + controller.pause() + } + } else if (!controller.isPlaying) { + // if it is visible, was supposed to start automatically, but it's not - // If something else is playing, play on mute. - if (keepPlayingMutex != null && keepPlayingMutex != controller) { - controller.volume = 0f + // If something else is playing, play on mute. + if (keepPlayingMutex != null && keepPlayingMutex != controller) { + controller.volume = 0f + } + controller.play() + } + } else { + // Pauses the video when it becomes invisible. + // Destroys the video later when it Disposes the element + // meanwhile if the user comes back, the position in the track is saved. + if (!keepPlaying.value) { + controller.pause() + } } - controller.play() - } - } else { - // Pauses the video when it becomes invisible. - // Destroys the video later when it Disposes the element - // meanwhile if the user comes back, the position in the track is saved. - if (!keepPlaying.value) { - controller.pause() - } } - } - val view = LocalView.current + val view = LocalView.current - // Keeps the screen on while playing and viewing videos. - DisposableEffect(key1 = controller, key2 = view) { - val listener = - object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - // doesn't consider the mutex because the screen can turn off if the video - // being played in the mutex is not visible. - view.keepScreenOn = isPlaying + // Keeps the screen on while playing and viewing videos. + DisposableEffect(key1 = controller, key2 = view) { + val listener = + object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + // doesn't consider the mutex because the screen can turn off if the video + // being played in the mutex is not visible. + view.keepScreenOn = isPlaying + } + } + + controller.addListener(listener) + onDispose { + view.keepScreenOn = false + controller.removeListener(listener) } - } - - controller.addListener(listener) - onDispose { - view.keepScreenOn = false - controller.removeListener(listener) } - } } -fun Modifier.onVisiblePositionChanges(onVisiblePosition: (Float?) -> Unit): Modifier = composed { - val view = LocalView.current +fun Modifier.onVisiblePositionChanges(onVisiblePosition: (Float?) -> Unit): Modifier = + composed { + val view = LocalView.current - onGloballyPositioned { coordinates -> - onVisiblePosition(coordinates.getDistanceToVertCenterIfVisible(view)) - } -} + onGloballyPositioned { coordinates -> + onVisiblePosition(coordinates.getDistanceToVertCenterIfVisible(view)) + } + } fun LayoutCoordinates.getDistanceToVertCenterIfVisible(view: View): Float? { - if (!isAttached) return null - // Window relative bounds of our compose root view that are visible on the screen - val globalRootRect = Rect() - if (!view.getGlobalVisibleRect(globalRootRect)) { - // we aren't visible at all. + if (!isAttached) return null + // Window relative bounds of our compose root view that are visible on the screen + val globalRootRect = Rect() + if (!view.getGlobalVisibleRect(globalRootRect)) { + // we aren't visible at all. + return null + } + + val bounds = boundsInWindow() + + if (bounds.isEmpty) return null + + // Make sure we are completely in bounds. + if ( + bounds.top >= globalRootRect.top && + bounds.left >= globalRootRect.left && + bounds.right <= globalRootRect.right && + bounds.bottom <= globalRootRect.bottom + ) { + return abs( + ((bounds.top + bounds.bottom) / 2) - ((globalRootRect.top + globalRootRect.bottom) / 2), + ) + } + return null - } - - val bounds = boundsInWindow() - - if (bounds.isEmpty) return null - - // Make sure we are completely in bounds. - if ( - bounds.top >= globalRootRect.top && - bounds.left >= globalRootRect.left && - bounds.right <= globalRootRect.right && - bounds.bottom <= globalRootRect.bottom - ) { - return abs( - ((bounds.top + bounds.bottom) / 2) - ((globalRootRect.top + globalRootRect.bottom) / 2), - ) - } - - return null } @Composable private fun MuteButton( - controllerVisible: MutableState, - startingMuteState: Boolean, - topPadding: State, - toggle: (Boolean) -> Unit, + controllerVisible: MutableState, + startingMuteState: Boolean, + topPadding: State, + toggle: (Boolean) -> Unit, ) { - val holdOn = remember { - mutableStateOf( - true, - ) - } - - LaunchedEffect(key1 = controllerVisible) { - launch(Dispatchers.Default) { - delay(2000) - holdOn.value = false - } - } - - val mutedInstance = remember(startingMuteState) { mutableStateOf(startingMuteState) } - - AnimatedVisibility( - visible = holdOn.value || controllerVisible.value, - modifier = Modifier.padding(top = topPadding.value), - enter = remember { fadeIn() }, - exit = remember { fadeOut() }, - ) { - Box(modifier = VolumeBottomIconSize) { - Box( - Modifier.clip(CircleShape) - .fillMaxSize(0.6f) - .align(Alignment.Center) - .background(MaterialTheme.colorScheme.background), - ) - - IconButton( - onClick = { - mutedInstance.value = !mutedInstance.value - toggle(mutedInstance.value) - }, - modifier = Size50Modifier, - ) { - if (mutedInstance.value) { - MutedIcon() - } else { - MuteIcon() + val holdOn = + remember { + mutableStateOf( + true, + ) + } + + LaunchedEffect(key1 = controllerVisible) { + launch(Dispatchers.Default) { + delay(2000) + holdOn.value = false + } + } + + val mutedInstance = remember(startingMuteState) { mutableStateOf(startingMuteState) } + + AnimatedVisibility( + visible = holdOn.value || controllerVisible.value, + modifier = Modifier.padding(top = topPadding.value), + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + Box(modifier = VolumeBottomIconSize) { + Box( + Modifier.clip(CircleShape) + .fillMaxSize(0.6f) + .align(Alignment.Center) + .background(MaterialTheme.colorScheme.background), + ) + + IconButton( + onClick = { + mutedInstance.value = !mutedInstance.value + toggle(mutedInstance.value) + }, + modifier = Size50Modifier, + ) { + if (mutedInstance.value) { + MutedIcon() + } else { + MuteIcon() + } + } } - } } - } } @Composable private fun KeepPlayingButton( - keepPlayingStart: MutableState, - controllerVisible: MutableState, - topPadding: State, - alignment: Modifier, - toggle: (Boolean) -> Unit, + keepPlayingStart: MutableState, + controllerVisible: MutableState, + topPadding: State, + alignment: Modifier, + toggle: (Boolean) -> Unit, ) { - val keepPlaying = remember(keepPlayingStart.value) { mutableStateOf(keepPlayingStart.value) } + val keepPlaying = remember(keepPlayingStart.value) { mutableStateOf(keepPlayingStart.value) } - AnimatedVisibility( - visible = controllerVisible.value, - modifier = alignment.padding(top = topPadding.value), - enter = remember { fadeIn() }, - exit = remember { fadeOut() }, - ) { - Box(modifier = PinBottomIconSize) { - Box( - Modifier.clip(CircleShape) - .fillMaxSize(0.6f) - .align(Alignment.Center) - .background(MaterialTheme.colorScheme.background), - ) + AnimatedVisibility( + visible = controllerVisible.value, + modifier = alignment.padding(top = topPadding.value), + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + Box(modifier = PinBottomIconSize) { + Box( + Modifier.clip(CircleShape) + .fillMaxSize(0.6f) + .align(Alignment.Center) + .background(MaterialTheme.colorScheme.background), + ) - IconButton( - onClick = { - keepPlaying.value = !keepPlaying.value - toggle(keepPlaying.value) - }, - modifier = Size50Modifier, - ) { - if (keepPlaying.value) { - LyricsIcon(Size22Modifier, MaterialTheme.colorScheme.onBackground) - } else { - LyricsOffIcon(Size22Modifier, MaterialTheme.colorScheme.onBackground) + IconButton( + onClick = { + keepPlaying.value = !keepPlaying.value + toggle(keepPlaying.value) + }, + modifier = Size50Modifier, + ) { + if (keepPlaying.value) { + LyricsIcon(Size22Modifier, MaterialTheme.colorScheme.onBackground) + } else { + LyricsOffIcon(Size22Modifier, MaterialTheme.colorScheme.onBackground) + } + } } - } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt index 74a884bb1..c2b5c5114 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt @@ -47,68 +47,68 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable fun ZapRaiserRequest( - titleText: String? = null, - newPostViewModel: NewPostViewModel, + titleText: String? = null, + newPostViewModel: NewPostViewModel, ) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + Column( + modifier = Modifier.fillMaxWidth(), ) { - Icon( - painter = painterResource(R.drawable.lightning), - null, - modifier = Size20Modifier, - tint = Color.Unspecified, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + ) { + Icon( + painter = painterResource(R.drawable.lightning), + null, + modifier = Size20Modifier, + tint = Color.Unspecified, + ) - Text( - text = titleText ?: stringResource(R.string.zapraiser), - fontSize = 20.sp, - fontWeight = FontWeight.W500, - modifier = Modifier.padding(start = 10.dp), - ) - } - - Divider() - - Text( - text = stringResource(R.string.zapraiser_explainer), - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(vertical = 10.dp), - ) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.zapraiser_target_amount_in_sats)) }, - modifier = Modifier.fillMaxWidth(), - value = - if (newPostViewModel.zapRaiserAmount != null) { - newPostViewModel.zapRaiserAmount.toString() - } else { - "" - }, - onValueChange = { - runCatching { - if (it.isEmpty()) { - newPostViewModel.zapRaiserAmount = null - } else { - newPostViewModel.zapRaiserAmount = it.toLongOrNull() - } + Text( + text = titleText ?: stringResource(R.string.zapraiser), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) } - }, - placeholder = { + + Divider() + Text( - text = "1000", - color = MaterialTheme.colorScheme.placeholderText, + text = stringResource(R.string.zapraiser_explainer), + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(vertical = 10.dp), ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - keyboardType = KeyboardType.Number, - ), - singleLine = true, - ) - } + + OutlinedTextField( + label = { Text(text = stringResource(R.string.zapraiser_target_amount_in_sats)) }, + modifier = Modifier.fillMaxWidth(), + value = + if (newPostViewModel.zapRaiserAmount != null) { + newPostViewModel.zapRaiserAmount.toString() + } else { + "" + }, + onValueChange = { + runCatching { + if (it.isEmpty()) { + newPostViewModel.zapRaiserAmount = null + } else { + newPostViewModel.zapRaiserAmount = it.toLongOrNull() + } + } + }, + placeholder = { + Text( + text = "1000", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number, + ), + singleLine = true, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index d37aa8411..b002f1854 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -129,7 +129,6 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.toHexKey -import java.io.File import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers @@ -137,188 +136,191 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable +import java.io.File @Immutable abstract class ZoomableContent( - val description: String? = null, - val dim: String? = null, + val description: String? = null, + val dim: String? = null, ) @Immutable abstract class ZoomableUrlContent( - val url: String, - description: String? = null, - val hash: String? = null, - dim: String? = null, - val uri: String? = null, + val url: String, + description: String? = null, + val hash: String? = null, + dim: String? = null, + val uri: String? = null, ) : ZoomableContent(description, dim) @Immutable class ZoomableUrlImage( - url: String, - description: String? = null, - hash: String? = null, - val blurhash: String? = null, - dim: String? = null, - uri: String? = null, + url: String, + description: String? = null, + hash: String? = null, + val blurhash: String? = null, + dim: String? = null, + uri: String? = null, ) : ZoomableUrlContent(url, description, hash, dim, uri) @Immutable class ZoomableUrlVideo( - url: String, - description: String? = null, - hash: String? = null, - dim: String? = null, - uri: String? = null, - val artworkUri: String? = null, - val authorName: String? = null, - val blurhash: String? = null, + url: String, + description: String? = null, + hash: String? = null, + dim: String? = null, + uri: String? = null, + val artworkUri: String? = null, + val authorName: String? = null, + val blurhash: String? = null, ) : ZoomableUrlContent(url, description, hash, dim, uri) @Immutable abstract class ZoomablePreloadedContent( - val localFile: File?, - description: String? = null, - val mimeType: String? = null, - val isVerified: Boolean? = null, - dim: String? = null, - val uri: String, + val localFile: File?, + description: String? = null, + val mimeType: String? = null, + val isVerified: Boolean? = null, + dim: String? = null, + val uri: String, ) : ZoomableContent(description, dim) @Immutable class ZoomableLocalImage( - localFile: File?, - mimeType: String? = null, - description: String? = null, - val blurhash: String? = null, - dim: String? = null, - isVerified: Boolean? = null, - uri: String, + localFile: File?, + mimeType: String? = null, + description: String? = null, + val blurhash: String? = null, + dim: String? = null, + isVerified: Boolean? = null, + uri: String, ) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri) @Immutable class ZoomableLocalVideo( - localFile: File?, - mimeType: String? = null, - description: String? = null, - dim: String? = null, - isVerified: Boolean? = null, - uri: String, - val artworkUri: String? = null, - val authorName: String? = null, + localFile: File?, + mimeType: String? = null, + description: String? = null, + dim: String? = null, + isVerified: Boolean? = null, + uri: String, + val artworkUri: String? = null, + val authorName: String? = null, ) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri) fun figureOutMimeType(fullUrl: String): ZoomableContent { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) - val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } - val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) + val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } + val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } - return if (isImage) { - ZoomableUrlImage(fullUrl) - } else if (isVideo) { - ZoomableUrlVideo(fullUrl) - } else { - ZoomableUrlImage(fullUrl) - } + return if (isImage) { + ZoomableUrlImage(fullUrl) + } else if (isVideo) { + ZoomableUrlVideo(fullUrl) + } else { + ZoomableUrlImage(fullUrl) + } } @Composable @OptIn(ExperimentalFoundationApi::class) fun ZoomableContentView( - content: ZoomableContent, - images: ImmutableList = listOf(content).toImmutableList(), - roundedCorner: Boolean, - accountViewModel: AccountViewModel, + content: ZoomableContent, + images: ImmutableList = listOf(content).toImmutableList(), + roundedCorner: Boolean, + accountViewModel: AccountViewModel, ) { - // store the dialog open or close state - var dialogOpen by remember { mutableStateOf(false) } + // store the dialog open or close state + var dialogOpen by remember { mutableStateOf(false) } - // store the dialog open or close state - val shareOpen = remember { mutableStateOf(false) } + // store the dialog open or close state + val shareOpen = remember { mutableStateOf(false) } - if (shareOpen.value) { - ShareImageAction(shareOpen, content) { shareOpen.value = false } - } - - var mainImageModifier = - if (roundedCorner) { - MaterialTheme.colorScheme.imageModifier - } else { - Modifier.fillMaxWidth() + if (shareOpen.value) { + ShareImageAction(shareOpen, content) { shareOpen.value = false } } - if (content is ZoomableUrlContent) { - mainImageModifier = - mainImageModifier.combinedClickable( - onClick = { dialogOpen = true }, - onLongClick = { shareOpen.value = true }, - ) - } else if (content is ZoomablePreloadedContent) { - mainImageModifier = - mainImageModifier.combinedClickable( - onClick = { dialogOpen = true }, - onLongClick = { shareOpen.value = true }, - ) - } else { - mainImageModifier = mainImageModifier.clickable { dialogOpen = true } - } + var mainImageModifier = + if (roundedCorner) { + MaterialTheme.colorScheme.imageModifier + } else { + Modifier.fillMaxWidth() + } - when (content) { - is ZoomableUrlImage -> - UrlImageView(content, mainImageModifier, accountViewModel = accountViewModel) - is ZoomableUrlVideo -> - VideoView( - videoUri = content.url, - title = content.description, - artworkUri = content.artworkUri, - authorName = content.authorName, - dimensions = content.dim, - blurhash = content.blurhash, - roundedCorner = roundedCorner, - nostrUriCallback = content.uri, - onDialog = { dialogOpen = true }, - accountViewModel = accountViewModel, - ) - is ZoomableLocalImage -> - LocalImageView(content, mainImageModifier, accountViewModel = accountViewModel) - is ZoomableLocalVideo -> - content.localFile?.let { - VideoView( - videoUri = it.toUri().toString(), - title = content.description, - artworkUri = content.artworkUri, - authorName = content.authorName, - roundedCorner = roundedCorner, - nostrUriCallback = content.uri, - onDialog = { dialogOpen = true }, - accountViewModel = accountViewModel, - ) - } - } + if (content is ZoomableUrlContent) { + mainImageModifier = + mainImageModifier.combinedClickable( + onClick = { dialogOpen = true }, + onLongClick = { shareOpen.value = true }, + ) + } else if (content is ZoomablePreloadedContent) { + mainImageModifier = + mainImageModifier.combinedClickable( + onClick = { dialogOpen = true }, + onLongClick = { shareOpen.value = true }, + ) + } else { + mainImageModifier = mainImageModifier.clickable { dialogOpen = true } + } - if (dialogOpen) { - ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false }, accountViewModel) - } + when (content) { + is ZoomableUrlImage -> + UrlImageView(content, mainImageModifier, accountViewModel = accountViewModel) + is ZoomableUrlVideo -> + VideoView( + videoUri = content.url, + title = content.description, + artworkUri = content.artworkUri, + authorName = content.authorName, + dimensions = content.dim, + blurhash = content.blurhash, + roundedCorner = roundedCorner, + nostrUriCallback = content.uri, + onDialog = { dialogOpen = true }, + accountViewModel = accountViewModel, + ) + is ZoomableLocalImage -> + LocalImageView(content, mainImageModifier, accountViewModel = accountViewModel) + is ZoomableLocalVideo -> + content.localFile?.let { + VideoView( + videoUri = it.toUri().toString(), + title = content.description, + artworkUri = content.artworkUri, + authorName = content.authorName, + roundedCorner = roundedCorner, + nostrUriCallback = content.uri, + onDialog = { dialogOpen = true }, + accountViewModel = accountViewModel, + ) + } + } + + if (dialogOpen) { + ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false }, accountViewModel) + } } @Composable private fun LocalImageView( - content: ZoomableLocalImage, - mainImageModifier: Modifier, - topPaddingForControllers: Dp = Dp.Unspecified, - accountViewModel: AccountViewModel, - alwayShowImage: Boolean = false, + content: ZoomableLocalImage, + mainImageModifier: Modifier, + topPaddingForControllers: Dp = Dp.Unspecified, + accountViewModel: AccountViewModel, + alwayShowImage: Boolean = false, ) { - if (content.localFile != null && content.localFile.exists()) { - BoxWithConstraints(contentAlignment = Alignment.Center) { - val showImage = remember { - mutableStateOf( - if (alwayShowImage) true else accountViewModel.settings.showImages.value, - ) - } + if (content.localFile != null && content.localFile.exists()) { + BoxWithConstraints(contentAlignment = Alignment.Center) { + val showImage = + remember { + mutableStateOf( + if (alwayShowImage) true else accountViewModel.settings.showImages.value, + ) + } - val myModifier = remember { - mainImageModifier.widthIn(max = maxWidth).heightIn(max = maxHeight) + val myModifier = + remember { + mainImageModifier.widthIn(max = maxWidth).heightIn(max = maxHeight) /* .run { aspectRatio(content.dim)?.let { ratio -> @@ -326,62 +328,65 @@ private fun LocalImageView( } ?: this } */ - } + } - val contentScale = remember { - if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth - } + val contentScale = + remember { + if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth + } - val verifierModifier = - if (topPaddingForControllers.isSpecified) { - Modifier.padding(top = topPaddingForControllers).align(Alignment.TopEnd) - } else { - Modifier.align(Alignment.TopEnd) + val verifierModifier = + if (topPaddingForControllers.isSpecified) { + Modifier.padding(top = topPaddingForControllers).align(Alignment.TopEnd) + } else { + Modifier.align(Alignment.TopEnd) + } + + val painterState = remember { mutableStateOf(null) } + + if (showImage.value) { + AsyncImage( + model = content.localFile, + contentDescription = content.description, + contentScale = contentScale, + modifier = myModifier, + onState = { painterState.value = it }, + ) + } + + AddedImageFeatures( + painterState, + content, + contentScale, + myModifier, + verifierModifier, + showImage, + ) } - - val painterState = remember { mutableStateOf(null) } - - if (showImage.value) { - AsyncImage( - model = content.localFile, - contentDescription = content.description, - contentScale = contentScale, - modifier = myModifier, - onState = { painterState.value = it }, - ) - } - - AddedImageFeatures( - painterState, - content, - contentScale, - myModifier, - verifierModifier, - showImage, - ) + } else { + BlankNote() } - } else { - BlankNote() - } } @Composable private fun UrlImageView( - content: ZoomableUrlImage, - mainImageModifier: Modifier, - topPaddingForControllers: Dp = Dp.Unspecified, - accountViewModel: AccountViewModel, - alwayShowImage: Boolean = false, + content: ZoomableUrlImage, + mainImageModifier: Modifier, + topPaddingForControllers: Dp = Dp.Unspecified, + accountViewModel: AccountViewModel, + alwayShowImage: Boolean = false, ) { - BoxWithConstraints(contentAlignment = Alignment.Center) { - val showImage = remember { - mutableStateOf( - if (alwayShowImage) true else accountViewModel.settings.showImages.value, - ) - } + BoxWithConstraints(contentAlignment = Alignment.Center) { + val showImage = + remember { + mutableStateOf( + if (alwayShowImage) true else accountViewModel.settings.showImages.value, + ) + } - val myModifier = remember { - mainImageModifier.widthIn(max = maxWidth).heightIn(max = maxHeight) + val myModifier = + remember { + mainImageModifier.widthIn(max = maxWidth).heightIn(max = maxHeight) /* Is this necessary? It makes images bleed into other pages .run { aspectRatio(content.dim)?.let { ratio -> @@ -389,730 +394,736 @@ private fun UrlImageView( } ?: this } */ + } + + val contentScale = + remember { + if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth + } + + val verifierModifier = + if (topPaddingForControllers.isSpecified) { + Modifier.padding(top = topPaddingForControllers).align(Alignment.TopEnd) + } else { + Modifier.align(Alignment.TopEnd) + } + + val painterState = remember { mutableStateOf(null) } + + if (showImage.value) { + AsyncImage( + model = content.url, + contentDescription = content.description, + contentScale = contentScale, + modifier = myModifier, + onState = { painterState.value = it }, + ) + } + + AddedImageFeatures( + painterState, + content, + contentScale, + myModifier, + verifierModifier, + showImage, + ) } - - val contentScale = remember { - if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth - } - - val verifierModifier = - if (topPaddingForControllers.isSpecified) { - Modifier.padding(top = topPaddingForControllers).align(Alignment.TopEnd) - } else { - Modifier.align(Alignment.TopEnd) - } - - val painterState = remember { mutableStateOf(null) } - - if (showImage.value) { - AsyncImage( - model = content.url, - contentDescription = content.description, - contentScale = contentScale, - modifier = myModifier, - onState = { painterState.value = it }, - ) - } - - AddedImageFeatures( - painterState, - content, - contentScale, - myModifier, - verifierModifier, - showImage, - ) - } } @Composable fun ImageUrlWithDownloadButton( - url: String, - showImage: MutableState, + url: String, + showImage: MutableState, ) { - val uri = LocalUriHandler.current + val uri = LocalUriHandler.current - val primary = MaterialTheme.colorScheme.primary - val background = MaterialTheme.colorScheme.onBackground + val primary = MaterialTheme.colorScheme.primary + val background = MaterialTheme.colorScheme.onBackground - val regularText = remember { SpanStyle(color = background) } - val clickableTextStyle = remember { SpanStyle(color = primary) } + val regularText = remember { SpanStyle(color = background) } + val clickableTextStyle = remember { SpanStyle(color = primary) } - val annotatedTermsString = remember { - buildAnnotatedString { - withStyle(clickableTextStyle) { - pushStringAnnotation("routeToImage", "") - append("$url ") - } + val annotatedTermsString = + remember { + buildAnnotatedString { + withStyle(clickableTextStyle) { + pushStringAnnotation("routeToImage", "") + append("$url ") + } - withStyle(clickableTextStyle) { - pushStringAnnotation("routeToImage", "") - appendInlineContent("inlineContent", "[icon]") - } + withStyle(clickableTextStyle) { + pushStringAnnotation("routeToImage", "") + appendInlineContent("inlineContent", "[icon]") + } - withStyle(regularText) { append(" ") } - } - } + withStyle(regularText) { append(" ") } + } + } - val inlineContent = mapOf("inlineContent" to InlineDownloadIcon(showImage)) + val inlineContent = mapOf("inlineContent" to InlineDownloadIcon(showImage)) - val pressIndicator = remember { Modifier.clickable { runCatching { uri.openUri(url) } } } + val pressIndicator = remember { Modifier.clickable { runCatching { uri.openUri(url) } } } - Text( - text = annotatedTermsString, - modifier = pressIndicator, - inlineContent = inlineContent, - ) + Text( + text = annotatedTermsString, + modifier = pressIndicator, + inlineContent = inlineContent, + ) } @Composable private fun InlineDownloadIcon(showImage: MutableState) = - InlineTextContent( - Placeholder( - width = Font17SP, - height = Font17SP, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center, - ), - ) { - IconButton( - modifier = Modifier.size(Size20dp), - onClick = { showImage.value = true }, + InlineTextContent( + Placeholder( + width = Font17SP, + height = Font17SP, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), ) { - DownloadForOfflineIcon(Size24dp) + IconButton( + modifier = Modifier.size(Size20dp), + onClick = { showImage.value = true }, + ) { + DownloadForOfflineIcon(Size24dp) + } } - } @Composable @OptIn(ExperimentalLayoutApi::class) private fun AddedImageFeatures( - painter: MutableState, - content: ZoomableLocalImage, - contentScale: ContentScale, - myModifier: Modifier, - verifiedModifier: Modifier, - showImage: MutableState, + painter: MutableState, + content: ZoomableLocalImage, + contentScale: ContentScale, + myModifier: Modifier, + verifiedModifier: Modifier, + showImage: MutableState, ) { - val ratio = remember { aspectRatio(content.dim) } + val ratio = remember { aspectRatio(content.dim) } - if (!showImage.value) { - if (content.blurhash != null && ratio != null) { - DisplayBlurHash( - content.blurhash, - content.description, - ContentScale.Crop, - myModifier.aspectRatio(ratio).clickable { showImage.value = true }, - ) - IconButton( - modifier = Modifier.size(Size75dp), - onClick = { showImage.value = true }, - ) { - DownloadForOfflineIcon(Size75dp, Color.White) - } - } else { - ImageUrlWithDownloadButton(content.uri, showImage) - } - } else { - when (painter.value) { - null, - is AsyncImagePainter.State.Loading, -> { - if (content.blurhash != null) { - if (ratio != null) { + if (!showImage.value) { + if (content.blurhash != null && ratio != null) { DisplayBlurHash( - content.blurhash, - content.description, - ContentScale.Crop, - myModifier.aspectRatio(ratio), + content.blurhash, + content.description, + ContentScale.Crop, + myModifier.aspectRatio(ratio).clickable { showImage.value = true }, ) - } else { - DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier) - } + IconButton( + modifier = Modifier.size(Size75dp), + onClick = { showImage.value = true }, + ) { + DownloadForOfflineIcon(Size75dp, Color.White) + } } else { - FlowRow { DisplayUrlWithLoadingSymbol(content) } + ImageUrlWithDownloadButton(content.uri, showImage) } - } - is AsyncImagePainter.State.Error -> { - BlankNote() - } - is AsyncImagePainter.State.Success -> { - if (content.isVerified != null) { - HashVerificationSymbol(content.isVerified, verifiedModifier) + } else { + when (painter.value) { + null, + is AsyncImagePainter.State.Loading, + -> { + if (content.blurhash != null) { + if (ratio != null) { + DisplayBlurHash( + content.blurhash, + content.description, + ContentScale.Crop, + myModifier.aspectRatio(ratio), + ) + } else { + DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier) + } + } else { + FlowRow { DisplayUrlWithLoadingSymbol(content) } + } + } + is AsyncImagePainter.State.Error -> { + BlankNote() + } + is AsyncImagePainter.State.Success -> { + if (content.isVerified != null) { + HashVerificationSymbol(content.isVerified, verifiedModifier) + } + } + else -> {} } - } - else -> {} } - } } @Composable @OptIn(ExperimentalLayoutApi::class) private fun AddedImageFeatures( - painter: MutableState, - content: ZoomableUrlImage, - contentScale: ContentScale, - myModifier: Modifier, - verifiedModifier: Modifier, - showImage: MutableState, + painter: MutableState, + content: ZoomableUrlImage, + contentScale: ContentScale, + myModifier: Modifier, + verifiedModifier: Modifier, + showImage: MutableState, ) { - val ratio = remember { aspectRatio(content.dim) } + val ratio = remember { aspectRatio(content.dim) } - if (!showImage.value) { - if (content.blurhash != null && ratio != null) { - DisplayBlurHash( - content.blurhash, - content.description, - ContentScale.Crop, - myModifier.aspectRatio(ratio).clickable { showImage.value = true }, - ) - IconButton( - modifier = Modifier.size(Size75dp), - onClick = { showImage.value = true }, - ) { - DownloadForOfflineIcon(Size75dp, Color.White) - } - } else { - ImageUrlWithDownloadButton(content.url, showImage) - } - } else { - var verifiedHash by remember { mutableStateOf(null) } - - when (painter.value) { - null, - is AsyncImagePainter.State.Loading, -> { - if (content.blurhash != null) { - if (ratio != null) { + if (!showImage.value) { + if (content.blurhash != null && ratio != null) { DisplayBlurHash( - content.blurhash, - content.description, - ContentScale.Crop, - myModifier.aspectRatio(ratio), + content.blurhash, + content.description, + ContentScale.Crop, + myModifier.aspectRatio(ratio).clickable { showImage.value = true }, ) - } else { - DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier) - } - } else { - FlowRow(Modifier.fillMaxWidth()) { DisplayUrlWithLoadingSymbol(content) } - } - } - is AsyncImagePainter.State.Error -> { - FlowRow(Modifier.fillMaxWidth()) { - ClickableUrl(urlText = "${content.url} ", url = content.url) - } - } - is AsyncImagePainter.State.Success -> { - if (content.hash != null) { - val context = LocalContext.current - LaunchedEffect(key1 = content.url) { - launch(Dispatchers.IO) { - val newVerifiedHash = verifyHash(content, context) - if (newVerifiedHash != verifiedHash) { - verifiedHash = newVerifiedHash - } + IconButton( + modifier = Modifier.size(Size75dp), + onClick = { showImage.value = true }, + ) { + DownloadForOfflineIcon(Size75dp, Color.White) } - } + } else { + ImageUrlWithDownloadButton(content.url, showImage) } + } else { + var verifiedHash by remember { mutableStateOf(null) } - verifiedHash?.let { HashVerificationSymbol(it, verifiedModifier) } - } - else -> {} + when (painter.value) { + null, + is AsyncImagePainter.State.Loading, + -> { + if (content.blurhash != null) { + if (ratio != null) { + DisplayBlurHash( + content.blurhash, + content.description, + ContentScale.Crop, + myModifier.aspectRatio(ratio), + ) + } else { + DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier) + } + } else { + FlowRow(Modifier.fillMaxWidth()) { DisplayUrlWithLoadingSymbol(content) } + } + } + is AsyncImagePainter.State.Error -> { + FlowRow(Modifier.fillMaxWidth()) { + ClickableUrl(urlText = "${content.url} ", url = content.url) + } + } + is AsyncImagePainter.State.Success -> { + if (content.hash != null) { + val context = LocalContext.current + LaunchedEffect(key1 = content.url) { + launch(Dispatchers.IO) { + val newVerifiedHash = verifyHash(content, context) + if (newVerifiedHash != verifiedHash) { + verifiedHash = newVerifiedHash + } + } + } + } + + verifiedHash?.let { HashVerificationSymbol(it, verifiedModifier) } + } + else -> {} + } } - } } fun aspectRatio(dim: String?): Float? { - if (dim == null) return null - if (dim == "0x0") return null + if (dim == null) return null + if (dim == "0x0") return null - val parts = dim.split("x") - if (parts.size != 2) return null + val parts = dim.split("x") + if (parts.size != 2) return null - return try { - val width = parts[0].toFloat() - val height = parts[1].toFloat() + return try { + val width = parts[0].toFloat() + val height = parts[1].toFloat() - if (width < 0.1 || height < 0.1) { - null - } else { - width / height + if (width < 0.1 || height < 0.1) { + null + } else { + width / height + } + } catch (e: Exception) { + null } - } catch (e: Exception) { - null - } } @Composable private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) { - var cnt by remember { mutableStateOf(null) } + var cnt by remember { mutableStateOf(null) } - LaunchedEffect(Unit) { - launch(Dispatchers.IO) { - delay(200) - cnt = content + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + delay(200) + cnt = content + } } - } - cnt?.let { DisplayUrlWithLoadingSymbolWait(it) } + cnt?.let { DisplayUrlWithLoadingSymbolWait(it) } } @Composable private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) { - val uri = LocalUriHandler.current + val uri = LocalUriHandler.current - val primary = MaterialTheme.colorScheme.primary - val background = MaterialTheme.colorScheme.onBackground + val primary = MaterialTheme.colorScheme.primary + val background = MaterialTheme.colorScheme.onBackground - val regularText = remember { SpanStyle(color = background) } - val clickableTextStyle = remember { SpanStyle(color = primary) } + val regularText = remember { SpanStyle(color = background) } + val clickableTextStyle = remember { SpanStyle(color = primary) } - val annotatedTermsString = remember { - buildAnnotatedString { - if (content is ZoomableUrlContent) { - withStyle(clickableTextStyle) { - pushStringAnnotation("routeToImage", "") - append(content.url + " ") + val annotatedTermsString = + remember { + buildAnnotatedString { + if (content is ZoomableUrlContent) { + withStyle(clickableTextStyle) { + pushStringAnnotation("routeToImage", "") + append(content.url + " ") + } + } else { + withStyle(regularText) { append("Loading content...") } + } + + withStyle(clickableTextStyle) { + pushStringAnnotation("routeToImage", "") + appendInlineContent("inlineContent", "[icon]") + } + + withStyle(regularText) { append(" ") } + } } - } else { - withStyle(regularText) { append("Loading content...") } - } - withStyle(clickableTextStyle) { - pushStringAnnotation("routeToImage", "") - appendInlineContent("inlineContent", "[icon]") - } + val inlineContent = mapOf("inlineContent" to InlineLoadingIcon()) - withStyle(regularText) { append(" ") } - } - } + val pressIndicator = + remember { + if (content is ZoomableUrlContent) { + Modifier.clickable { runCatching { uri.openUri(content.url) } } + } else { + Modifier + } + } - val inlineContent = mapOf("inlineContent" to InlineLoadingIcon()) - - val pressIndicator = remember { - if (content is ZoomableUrlContent) { - Modifier.clickable { runCatching { uri.openUri(content.url) } } - } else { - Modifier - } - } - - Text( - text = annotatedTermsString, - modifier = pressIndicator, - inlineContent = inlineContent, - ) + Text( + text = annotatedTermsString, + modifier = pressIndicator, + inlineContent = inlineContent, + ) } @Composable private fun InlineLoadingIcon() = - InlineTextContent( - Placeholder( - width = Font17SP, - height = Font17SP, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center, - ), - ) { - LoadingAnimation() - } + InlineTextContent( + Placeholder( + width = Font17SP, + height = Font17SP, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + ) { + LoadingAnimation() + } @Composable fun DisplayBlurHash( - blurhash: String?, - description: String?, - contentScale: ContentScale, - modifier: Modifier, + blurhash: String?, + description: String?, + contentScale: ContentScale, + modifier: Modifier, ) { - if (blurhash == null) return + if (blurhash == null) return - val context = LocalContext.current - AsyncImage( - model = - remember { - BlurHashRequester.imageRequest( - context, - blurhash, - ) - }, - contentDescription = description, - contentScale = contentScale, - modifier = modifier, - ) + val context = LocalContext.current + AsyncImage( + model = + remember { + BlurHashRequester.imageRequest( + context, + blurhash, + ) + }, + contentDescription = description, + contentScale = contentScale, + modifier = modifier, + ) } @Composable fun ZoomableImageDialog( - imageUrl: ZoomableContent, - allImages: ImmutableList = listOf(imageUrl).toImmutableList(), - onDismiss: () -> Unit, - accountViewModel: AccountViewModel, + imageUrl: ZoomableContent, + allImages: ImmutableList = listOf(imageUrl).toImmutableList(), + onDismiss: () -> Unit, + accountViewModel: AccountViewModel, ) { - val orientation = LocalConfiguration.current.orientation - - Dialog( - onDismissRequest = onDismiss, - properties = - DialogProperties( - usePlatformDefaultWidth = true, - decorFitsSystemWindows = false, - ), - ) { - val view = LocalView.current - val insets = ViewCompat.getRootWindowInsets(view) - val orientation = LocalConfiguration.current.orientation - println("This Log only exists to force orientation listener $orientation") - val activityWindow = getActivityWindow() - val dialogWindow = getDialogWindow() - val parentView = LocalView.current.parent as View - SideEffect { - if (activityWindow != null && dialogWindow != null) { - val attributes = WindowManager.LayoutParams() - attributes.copyFrom(activityWindow.attributes) - attributes.type = dialogWindow.attributes.type - dialogWindow.attributes = attributes - parentView.layoutParams = - FrameLayout.LayoutParams(activityWindow.decorView.width, activityWindow.decorView.height) - view.layoutParams = - FrameLayout.LayoutParams(activityWindow.decorView.width, activityWindow.decorView.height) - } - } + Dialog( + onDismissRequest = onDismiss, + properties = + DialogProperties( + usePlatformDefaultWidth = true, + decorFitsSystemWindows = false, + ), + ) { + val view = LocalView.current + val insets = ViewCompat.getRootWindowInsets(view) - DisposableEffect(key1 = Unit) { - if (Build.VERSION.SDK_INT >= 30) { - view.windowInsetsController?.hide( - android.view.WindowInsets.Type.systemBars(), - ) - } + val orientation = LocalConfiguration.current.orientation + println("This Log only exists to force orientation listener $orientation") - onDispose { - if (Build.VERSION.SDK_INT >= 30) { - view.windowInsetsController?.show( - android.view.WindowInsets.Type.systemBars(), - ) + val activityWindow = getActivityWindow() + val dialogWindow = getDialogWindow() + val parentView = LocalView.current.parent as View + SideEffect { + if (activityWindow != null && dialogWindow != null) { + val attributes = WindowManager.LayoutParams() + attributes.copyFrom(activityWindow.attributes) + attributes.type = dialogWindow.attributes.type + dialogWindow.attributes = attributes + parentView.layoutParams = + FrameLayout.LayoutParams(activityWindow.decorView.width, activityWindow.decorView.height) + view.layoutParams = + FrameLayout.LayoutParams(activityWindow.decorView.width, activityWindow.decorView.height) + } } - } - } - Surface(modifier = Modifier.fillMaxSize()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { - DialogContent(allImages, imageUrl, onDismiss, accountViewModel) - } + DisposableEffect(key1 = Unit) { + if (Build.VERSION.SDK_INT >= 30) { + view.windowInsetsController?.hide( + android.view.WindowInsets.Type.systemBars(), + ) + } + + onDispose { + if (Build.VERSION.SDK_INT >= 30) { + view.windowInsetsController?.show( + android.view.WindowInsets.Type.systemBars(), + ) + } + } + } + + Surface(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + DialogContent(allImages, imageUrl, onDismiss, accountViewModel) + } + } } - } } @Composable @OptIn(ExperimentalFoundationApi::class) private fun DialogContent( - allImages: ImmutableList, - imageUrl: ZoomableContent, - onDismiss: () -> Unit, - accountViewModel: AccountViewModel, + allImages: ImmutableList, + imageUrl: ZoomableContent, + onDismiss: () -> Unit, + accountViewModel: AccountViewModel, ) { - val pagerState: PagerState = rememberPagerState { allImages.size } - val controllerVisible = remember { mutableStateOf(false) } - val holdOn = remember { mutableStateOf(true) } + val pagerState: PagerState = rememberPagerState { allImages.size } + val controllerVisible = remember { mutableStateOf(false) } + val holdOn = remember { mutableStateOf(true) } - LaunchedEffect(key1 = pagerState, key2 = imageUrl) { - launch { - val page = allImages.indexOf(imageUrl) - if (page > -1) { - pagerState.scrollToPage(page) - } - } - launch(Dispatchers.Default) { - delay(2000) - holdOn.value = false - } - } - - if (allImages.size > 1) { - SlidingCarousel( - pagerState = pagerState, - ) { index -> - RenderImageOrVideo( - content = allImages[index], - roundedCorner = false, - topPaddingForControllers = Size55dp, - onControllerVisibilityChanged = { controllerVisible.value = it }, - onToggleControllerVisibility = { controllerVisible.value = !controllerVisible.value }, - accountViewModel = accountViewModel, - ) - } - } else { - RenderImageOrVideo( - content = imageUrl, - roundedCorner = false, - topPaddingForControllers = Size55dp, - onControllerVisibilityChanged = { controllerVisible.value = it }, - onToggleControllerVisibility = { controllerVisible.value = !controllerVisible.value }, - accountViewModel = accountViewModel, - ) - } - - AnimatedVisibility( - visible = holdOn.value || controllerVisible.value, - enter = remember { fadeIn() }, - exit = remember { fadeOut() }, - ) { - Row( - modifier = Modifier.padding(10.dp).statusBarsPadding().systemBarsPadding().fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CloseButton(onPress = onDismiss) - - val myContent = allImages[pagerState.currentPage] - if (myContent is ZoomableUrlContent) { - Row { - CopyToClipboard(content = myContent) - Spacer(modifier = StdHorzSpacer) - SaveToGallery(url = myContent.url) + LaunchedEffect(key1 = pagerState, key2 = imageUrl) { + launch { + val page = allImages.indexOf(imageUrl) + if (page > -1) { + pagerState.scrollToPage(page) + } + } + launch(Dispatchers.Default) { + delay(2000) + holdOn.value = false + } + } + + if (allImages.size > 1) { + SlidingCarousel( + pagerState = pagerState, + ) { index -> + RenderImageOrVideo( + content = allImages[index], + roundedCorner = false, + topPaddingForControllers = Size55dp, + onControllerVisibilityChanged = { controllerVisible.value = it }, + onToggleControllerVisibility = { controllerVisible.value = !controllerVisible.value }, + accountViewModel = accountViewModel, + ) + } + } else { + RenderImageOrVideo( + content = imageUrl, + roundedCorner = false, + topPaddingForControllers = Size55dp, + onControllerVisibilityChanged = { controllerVisible.value = it }, + onToggleControllerVisibility = { controllerVisible.value = !controllerVisible.value }, + accountViewModel = accountViewModel, + ) + } + + AnimatedVisibility( + visible = holdOn.value || controllerVisible.value, + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + Row( + modifier = Modifier.padding(10.dp).statusBarsPadding().systemBarsPadding().fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton(onPress = onDismiss) + + val myContent = allImages[pagerState.currentPage] + if (myContent is ZoomableUrlContent) { + Row { + CopyToClipboard(content = myContent) + Spacer(modifier = StdHorzSpacer) + SaveToGallery(url = myContent.url) + } + } else if (myContent is ZoomableLocalImage && myContent.localFile != null) { + SaveToGallery( + localFile = myContent.localFile, + mimeType = myContent.mimeType, + ) + } } - } else if (myContent is ZoomableLocalImage && myContent.localFile != null) { - SaveToGallery( - localFile = myContent.localFile, - mimeType = myContent.mimeType, - ) - } } - } } @Composable @OptIn(ExperimentalFoundationApi::class) fun InlineCarrousel( - allImages: ImmutableList, - imageUrl: String, + allImages: ImmutableList, + imageUrl: String, ) { - val pagerState: PagerState = rememberPagerState { allImages.size } + val pagerState: PagerState = rememberPagerState { allImages.size } - LaunchedEffect(key1 = pagerState, key2 = imageUrl) { - launch { - val page = allImages.indexOf(imageUrl) - if (page > -1) { - pagerState.scrollToPage(page) - } + LaunchedEffect(key1 = pagerState, key2 = imageUrl) { + launch { + val page = allImages.indexOf(imageUrl) + if (page > -1) { + pagerState.scrollToPage(page) + } + } } - } - if (allImages.size > 1) { - SlidingCarousel( - pagerState = pagerState, - ) { index -> - AsyncImage( - model = allImages[index], - contentDescription = null, - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) + if (allImages.size > 1) { + SlidingCarousel( + pagerState = pagerState, + ) { index -> + AsyncImage( + model = allImages[index], + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } + } else { + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) } - } else { - AsyncImage( - model = imageUrl, - contentDescription = null, - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) - } } @Composable private fun CopyToClipboard(content: ZoomableContent) { - val popupExpanded = remember { mutableStateOf(false) } + val popupExpanded = remember { mutableStateOf(false) } - OutlinedButton( - modifier = Modifier.padding(horizontal = Size5dp), - onClick = { popupExpanded.value = true }, - ) { - Icon( - imageVector = Icons.Default.Share, - modifier = Size20Modifier, - contentDescription = stringResource(R.string.copy_url_to_clipboard), - ) + OutlinedButton( + modifier = Modifier.padding(horizontal = Size5dp), + onClick = { popupExpanded.value = true }, + ) { + Icon( + imageVector = Icons.Default.Share, + modifier = Size20Modifier, + contentDescription = stringResource(R.string.copy_url_to_clipboard), + ) - ShareImageAction(popupExpanded, content) { popupExpanded.value = false } - } + ShareImageAction(popupExpanded, content) { popupExpanded.value = false } + } } @Composable private fun ShareImageAction( - popupExpanded: MutableState, - content: ZoomableContent, - onDismiss: () -> Unit, + popupExpanded: MutableState, + content: ZoomableContent, + onDismiss: () -> Unit, ) { - DropdownMenu( - expanded = popupExpanded.value, - onDismissRequest = onDismiss, - ) { - val clipboardManager = LocalClipboardManager.current + DropdownMenu( + expanded = popupExpanded.value, + onDismissRequest = onDismiss, + ) { + val clipboardManager = LocalClipboardManager.current - if (content is ZoomableUrlContent) { - DropdownMenuItem( - text = { Text(stringResource(R.string.copy_url_to_clipboard)) }, - onClick = { - clipboardManager.setText(AnnotatedString(content.url)) - onDismiss() - }, - ) - if (content.uri != null) { - DropdownMenuItem( - text = { Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) }, - onClick = { - clipboardManager.setText(AnnotatedString(content.uri)) - onDismiss() - }, - ) - } - } + if (content is ZoomableUrlContent) { + DropdownMenuItem( + text = { Text(stringResource(R.string.copy_url_to_clipboard)) }, + onClick = { + clipboardManager.setText(AnnotatedString(content.url)) + onDismiss() + }, + ) + if (content.uri != null) { + DropdownMenuItem( + text = { Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) }, + onClick = { + clipboardManager.setText(AnnotatedString(content.uri)) + onDismiss() + }, + ) + } + } - if (content is ZoomablePreloadedContent) { - DropdownMenuItem( - text = { Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) }, - onClick = { - clipboardManager.setText(AnnotatedString(content.uri)) - onDismiss() - }, - ) + if (content is ZoomablePreloadedContent) { + DropdownMenuItem( + text = { Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) }, + onClick = { + clipboardManager.setText(AnnotatedString(content.uri)) + onDismiss() + }, + ) + } } - } } @Composable private fun RenderImageOrVideo( - content: ZoomableContent, - roundedCorner: Boolean, - topPaddingForControllers: Dp = Dp.Unspecified, - onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, - onToggleControllerVisibility: (() -> Unit)? = null, - accountViewModel: AccountViewModel, + content: ZoomableContent, + roundedCorner: Boolean, + topPaddingForControllers: Dp = Dp.Unspecified, + onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, + onToggleControllerVisibility: (() -> Unit)? = null, + accountViewModel: AccountViewModel, ) { - val automaticallyStartPlayback = remember { mutableStateOf(true) } + val automaticallyStartPlayback = remember { mutableStateOf(true) } - if (content is ZoomableUrlImage) { - val mainModifier = - Modifier.fillMaxSize() - .zoomable( - rememberZoomState(), - onTap = { - if (onToggleControllerVisibility != null) { - onToggleControllerVisibility() + if (content is ZoomableUrlImage) { + val mainModifier = + Modifier.fillMaxSize() + .zoomable( + rememberZoomState(), + onTap = { + if (onToggleControllerVisibility != null) { + onToggleControllerVisibility() + } + }, + ) + + UrlImageView( + content = content, + mainImageModifier = mainModifier, + topPaddingForControllers = topPaddingForControllers, + accountViewModel, + alwayShowImage = true, + ) + } else if (content is ZoomableUrlVideo) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { + VideoViewInner( + videoUri = content.url, + title = content.description, + artworkUri = content.artworkUri, + authorName = content.authorName, + roundedCorner = roundedCorner, + topPaddingForControllers = topPaddingForControllers, + automaticallyStartPlayback = automaticallyStartPlayback, + onControllerVisibilityChanged = onControllerVisibilityChanged, + ) + } + } else if (content is ZoomableLocalImage) { + val mainModifier = + Modifier.fillMaxSize() + .zoomable( + rememberZoomState(), + onTap = { + if (onToggleControllerVisibility != null) { + onToggleControllerVisibility() + } + }, + ) + + LocalImageView( + content = content, + mainImageModifier = mainModifier, + topPaddingForControllers = topPaddingForControllers, + accountViewModel, + alwayShowImage = true, + ) + } else if (content is ZoomableLocalVideo) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { + content.localFile?.let { + VideoViewInner( + videoUri = it.toUri().toString(), + title = content.description, + artworkUri = content.artworkUri, + authorName = content.authorName, + roundedCorner = roundedCorner, + topPaddingForControllers = topPaddingForControllers, + automaticallyStartPlayback = automaticallyStartPlayback, + onControllerVisibilityChanged = onControllerVisibilityChanged, + ) } - }, - ) - - UrlImageView( - content = content, - mainImageModifier = mainModifier, - topPaddingForControllers = topPaddingForControllers, - accountViewModel, - alwayShowImage = true, - ) - } else if (content is ZoomableUrlVideo) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { - VideoViewInner( - videoUri = content.url, - title = content.description, - artworkUri = content.artworkUri, - authorName = content.authorName, - roundedCorner = roundedCorner, - topPaddingForControllers = topPaddingForControllers, - automaticallyStartPlayback = automaticallyStartPlayback, - onControllerVisibilityChanged = onControllerVisibilityChanged, - ) + } } - } else if (content is ZoomableLocalImage) { - val mainModifier = - Modifier.fillMaxSize() - .zoomable( - rememberZoomState(), - onTap = { - if (onToggleControllerVisibility != null) { - onToggleControllerVisibility() - } - }, - ) - - LocalImageView( - content = content, - mainImageModifier = mainModifier, - topPaddingForControllers = topPaddingForControllers, - accountViewModel, - alwayShowImage = true, - ) - } else if (content is ZoomableLocalVideo) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { - content.localFile?.let { - VideoViewInner( - videoUri = it.toUri().toString(), - title = content.description, - artworkUri = content.artworkUri, - authorName = content.authorName, - roundedCorner = roundedCorner, - topPaddingForControllers = topPaddingForControllers, - automaticallyStartPlayback = automaticallyStartPlayback, - onControllerVisibilityChanged = onControllerVisibilityChanged, - ) - } - } - } } @OptIn(ExperimentalCoilApi::class) private fun verifyHash( - content: ZoomableUrlContent, - context: Context, + content: ZoomableUrlContent, + context: Context, ): Boolean? { - if (content.hash == null) return null + if (content.hash == null) return null - context.imageLoader.diskCache?.get(content.url)?.use { snapshot -> - val hash = CryptoUtils.sha256(snapshot.data.toFile().readBytes()).toHexKey() + context.imageLoader.diskCache?.get(content.url)?.use { snapshot -> + val hash = CryptoUtils.sha256(snapshot.data.toFile().readBytes()).toHexKey() - Log.d("Image Hash Verification", "$hash == ${content.hash}") + Log.d("Image Hash Verification", "$hash == ${content.hash}") - return hash == content.hash - } + return hash == content.hash + } - return null + return null } @Composable private fun HashVerificationSymbol( - verifiedHash: Boolean, - modifier: Modifier, + verifiedHash: Boolean, + modifier: Modifier, ) { - val localContext = LocalContext.current + val localContext = LocalContext.current - val openDialogMsg = remember { mutableStateOf(null) } + val openDialogMsg = remember { mutableStateOf(null) } - openDialogMsg.value?.let { - InformationDialog( - title = localContext.getString(R.string.hash_verification_info_title), - textContent = it, + openDialogMsg.value?.let { + InformationDialog( + title = localContext.getString(R.string.hash_verification_info_title), + textContent = it, + ) { + openDialogMsg.value = null + } + } + + Box( + modifier.width(40.dp).height(40.dp).padding(10.dp), ) { - openDialogMsg.value = null + if (verifiedHash) { + IconButton( + onClick = { + openDialogMsg.value = localContext.getString(R.string.hash_verification_passed) + }, + ) { + HashCheckIcon(Size30dp) + } + } else { + IconButton( + onClick = { + openDialogMsg.value = localContext.getString(R.string.hash_verification_failed) + }, + ) { + HashCheckFailedIcon(Size30dp) + } + } } - } - - Box( - modifier.width(40.dp).height(40.dp).padding(10.dp), - ) { - if (verifiedHash) { - IconButton( - onClick = { - openDialogMsg.value = localContext.getString(R.string.hash_verification_passed) - }, - ) { - HashCheckIcon(Size30dp) - } - } else { - IconButton( - onClick = { - openDialogMsg.value = localContext.getString(R.string.hash_verification_failed) - }, - ) { - HashCheckFailedIcon(Size30dp) - } - } - } } // Window utils @@ -1122,17 +1133,17 @@ fun getDialogWindow(): Window? = (LocalView.current.parent as? DialogWindowProvi @Composable fun getActivityWindow(): Window? = LocalView.current.context.getActivityWindow() private tailrec fun Context.getActivityWindow(): Window? = - when (this) { - is Activity -> window - is ContextWrapper -> baseContext.getActivityWindow() - else -> null - } + when (this) { + is Activity -> window + is ContextWrapper -> baseContext.getActivityWindow() + else -> null + } @Composable fun getActivity(): Activity? = LocalView.current.context.getActivity() private tailrec fun Context.getActivity(): Activity? = - when (this) { - is Activity -> this - is ContextWrapper -> baseContext.getActivity() - else -> null - } + when (this) { + is Activity -> this + is ContextWrapper -> baseContext.getActivity() + else -> null + } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt index 76c841a00..0af660d01 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt @@ -25,27 +25,27 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note class BookmarkPrivateFeedFilter(val account: Account) : FeedFilter() { - override fun feedKey(): String { - return account.userProfile().latestBookmarkList?.id ?: "" - } + override fun feedKey(): String { + return account.userProfile().latestBookmarkList?.id ?: "" + } - override fun feed(): List { - val bookmarks = account.userProfile().latestBookmarkList + override fun feed(): List { + val bookmarks = account.userProfile().latestBookmarkList - if (!account.isWriteable()) return emptyList() + if (!account.isWriteable()) return emptyList() - val privateTags = bookmarks?.cachedPrivateTags() ?: return emptyList() + val privateTags = bookmarks?.cachedPrivateTags() ?: return emptyList() - val notes = - bookmarks.filterEvents(privateTags).mapNotNull { LocalCache.checkGetOrCreateNote(it) } + val notes = + bookmarks.filterEvents(privateTags).mapNotNull { LocalCache.checkGetOrCreateNote(it) } - val addresses = - bookmarks.filterAddresses(privateTags).map { LocalCache.getOrCreateAddressableNote(it) } + val addresses = + bookmarks.filterAddresses(privateTags).map { LocalCache.getOrCreateAddressableNote(it) } - return notes - .plus(addresses) - .toSet() - .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .reversed() - } + return notes + .plus(addresses) + .toSet() + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + .reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt index 339a2adfe..d6ea8cad1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt @@ -25,22 +25,22 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note class BookmarkPublicFeedFilter(val account: Account) : FeedFilter() { - override fun feedKey(): String { - return account.userProfile().latestBookmarkList?.id ?: "" - } + override fun feedKey(): String { + return account.userProfile().latestBookmarkList?.id ?: "" + } - override fun feed(): List { - val bookmarks = account.userProfile().latestBookmarkList + override fun feed(): List { + val bookmarks = account.userProfile().latestBookmarkList - val notes = - bookmarks?.taggedEvents()?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList() - val addresses = - bookmarks?.taggedAddresses()?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList() + val notes = + bookmarks?.taggedEvents()?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList() + val addresses = + bookmarks?.taggedAddresses()?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList() - return notes - .plus(addresses) - .toSet() - .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .reversed() - } + return notes + .plus(addresses) + .toSet() + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + .reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt index 263a64564..6a5ad0238 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt @@ -25,25 +25,25 @@ import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Note class ChannelFeedFilter(val channel: Channel, val account: Account) : AdditiveFeedFilter() { - override fun feedKey(): String { - return channel.idHex - } + override fun feedKey(): String { + return channel.idHex + } - // returns the last Note of each user. - override fun feed(): List { - return channel.notes.values - .filter { account.isAcceptable(it) } - .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .reversed() - } + // returns the last Note of each user. + override fun feed(): List { + return channel.notes.values + .filter { account.isAcceptable(it) } + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + .reversed() + } - override fun applyFilter(collection: Set): Set { - return collection - .filter { channel.notes.containsKey(it.idHex) && account.isAcceptable(it) } - .toSet() - } + override fun applyFilter(collection: Set): Set { + return collection + .filter { channel.notes.containsKey(it.idHex) && account.isAcceptable(it) } + .toSet() + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt index ad1c39204..6776068f9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt @@ -25,28 +25,28 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.events.ChatroomKey class ChatroomFeedFilter(val withUser: ChatroomKey, val account: Account) : - AdditiveFeedFilter() { - // returns the last Note of each user. - override fun feedKey(): String { - return withUser.hashCode().toString() - } + AdditiveFeedFilter() { + // returns the last Note of each user. + override fun feedKey(): String { + return withUser.hashCode().toString() + } - override fun feed(): List { - val messages = account.userProfile().privateChatrooms[withUser] ?: return emptyList() + override fun feed(): List { + val messages = account.userProfile().privateChatrooms[withUser] ?: return emptyList() - return messages.roomMessages - .filter { account.isAcceptable(it) } - .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .reversed() - } + return messages.roomMessages + .filter { account.isAcceptable(it) } + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + .reversed() + } - override fun applyFilter(collection: Set): Set { - val messages = account.userProfile().privateChatrooms[withUser] ?: return emptySet() + override fun applyFilter(collection: Set): Set { + val messages = account.userProfile().privateChatrooms[withUser] ?: return emptySet() - return collection.filter { it in messages.roomMessages && account.isAcceptable(it) }.toSet() - } + return collection.filter { it in messages.roomMessages && account.isAcceptable(it) }.toSet() + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt index 6157d0011..72a58bfb6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt @@ -29,172 +29,174 @@ import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.ChatroomKeyable class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + } - // returns the last Note of each user. - override fun feed(): List { - val me = account.userProfile() - val followingKeySet = account.followingKeySet() + // returns the last Note of each user. + override fun feed(): List { + val me = account.userProfile() + val followingKeySet = account.followingKeySet() - val knownChatrooms = - me.privateChatrooms.filter { - (it.value.senderIntersects(followingKeySet) || me.hasSentMessagesTo(it.key)) && - !account.isAllHidden(it.key.users) - } + val knownChatrooms = + me.privateChatrooms.filter { + (it.value.senderIntersects(followingKeySet) || me.hasSentMessagesTo(it.key)) && + !account.isAllHidden(it.key.users) + } - val privateMessages = - knownChatrooms.mapNotNull { it -> - it.value.roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).lastOrNull { - it.event != null - } - } + val privateMessages = + knownChatrooms.mapNotNull { it -> + it.value.roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).lastOrNull { + it.event != null + } + } - val publicChannels = - account - .selectedChatsFollowList() - .mapNotNull { LocalCache.getChannelIfExists(it) } - .mapNotNull { it -> - it.notes.values - .filter { account.isAcceptable(it) && it.event != null } + val publicChannels = + account + .selectedChatsFollowList() + .mapNotNull { LocalCache.getChannelIfExists(it) } + .mapNotNull { it -> + it.notes.values + .filter { account.isAcceptable(it) && it.event != null } + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + .lastOrNull() + } + + return (privateMessages + publicChannels) .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .lastOrNull() + .reversed() + } + + override fun updateListWith( + oldList: List, + newItems: Set, + ): List { + val me = account.userProfile() + + // Gets the latest message by channel from the new items. + val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account) + + // Gets the latest message by room from the new items. + val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account) + + if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty()) { + return oldList } - return (privateMessages + publicChannels) - .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .reversed() - } + var myNewList = oldList - override fun updateListWith( - oldList: List, - newItems: Set, - ): List { - val me = account.userProfile() - - // Gets the latest message by channel from the new items. - val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account) - - // Gets the latest message by room from the new items. - val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account) - - if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty()) { - return oldList - } - - var myNewList = oldList - - newRelevantPublicMessages.forEach { newNotePair -> - var hasUpdated = false - oldList.forEach { oldNote -> - if (newNotePair.key == oldNote.channelHex()) { - hasUpdated = true - if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) { - myNewList = myNewList.updated(oldNote, newNotePair.value) - } - } - } - if (!hasUpdated) { - myNewList = myNewList.plus(newNotePair.value) - } - } - - newRelevantPrivateMessages.forEach { newNotePair -> - var hasUpdated = false - oldList.forEach { oldNote -> - val oldRoom = (oldNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex) - - if (newNotePair.key == oldRoom) { - hasUpdated = true - if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) { - myNewList = myNewList.updated(oldNote, newNotePair.value) - } - } - } - if (!hasUpdated) { - myNewList = myNewList.plus(newNotePair.value) - } - } - - return sort(myNewList.toSet()).take(1000) - } - - override fun applyFilter(newItems: Set): Set { - // Gets the latest message by channel from the new items. - val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account) - - // Gets the latest message by room from the new items. - val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account) - - return if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty()) { - emptySet() - } else { - (newRelevantPrivateMessages.values + newRelevantPublicMessages.values).toSet() - } - } - - private fun filterRelevantPublicMessages( - newItems: Set, - account: Account, - ): MutableMap { - val followingChannels = - account.userProfile().latestContactList?.taggedEvents()?.toSet() ?: emptySet() - val newRelevantPublicMessages = mutableMapOf() - newItems - .filter { it.event is ChannelMessageEvent } - .forEach { newNote -> - newNote.channelHex()?.let { channelHex -> - if (channelHex in followingChannels && account.isAcceptable(newNote)) { - val lastNote = newRelevantPublicMessages.get(channelHex) - if (lastNote != null) { - if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) { - newRelevantPublicMessages.put(channelHex, newNote) - } - } else { - newRelevantPublicMessages.put(channelHex, newNote) + newRelevantPublicMessages.forEach { newNotePair -> + var hasUpdated = false + oldList.forEach { oldNote -> + if (newNotePair.key == oldNote.channelHex()) { + hasUpdated = true + if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) { + myNewList = myNewList.updated(oldNote, newNotePair.value) + } + } } - } - } - } - return newRelevantPublicMessages - } - - private fun filterRelevantPrivateMessages( - newItems: Set, - account: Account, - ): MutableMap { - val me = account.userProfile() - val followingKeySet = account.followingKeySet() - - val newRelevantPrivateMessages = mutableMapOf() - newItems - .filter { it.event is ChatroomKeyable } - .forEach { newNote -> - val roomKey = (newNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex) - val room = account.userProfile().privateChatrooms[roomKey] - - if (roomKey != null && room != null) { - if ( - (newNote.author?.pubkeyHex == me.pubkeyHex || - room.senderIntersects(followingKeySet) || - me.hasSentMessagesTo(roomKey)) && !account.isAllHidden(roomKey.users) - ) { - val lastNote = newRelevantPrivateMessages.get(roomKey) - if (lastNote != null) { - if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) { - newRelevantPrivateMessages.put(roomKey, newNote) - } - } else { - newRelevantPrivateMessages.put(roomKey, newNote) + if (!hasUpdated) { + myNewList = myNewList.plus(newNotePair.value) } - } } - } - return newRelevantPrivateMessages - } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + newRelevantPrivateMessages.forEach { newNotePair -> + var hasUpdated = false + oldList.forEach { oldNote -> + val oldRoom = (oldNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex) + + if (newNotePair.key == oldRoom) { + hasUpdated = true + if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) { + myNewList = myNewList.updated(oldNote, newNotePair.value) + } + } + } + if (!hasUpdated) { + myNewList = myNewList.plus(newNotePair.value) + } + } + + return sort(myNewList.toSet()).take(1000) + } + + override fun applyFilter(newItems: Set): Set { + // Gets the latest message by channel from the new items. + val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account) + + // Gets the latest message by room from the new items. + val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account) + + return if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty()) { + emptySet() + } else { + (newRelevantPrivateMessages.values + newRelevantPublicMessages.values).toSet() + } + } + + private fun filterRelevantPublicMessages( + newItems: Set, + account: Account, + ): MutableMap { + val followingChannels = + account.userProfile().latestContactList?.taggedEvents()?.toSet() ?: emptySet() + val newRelevantPublicMessages = mutableMapOf() + newItems + .filter { it.event is ChannelMessageEvent } + .forEach { newNote -> + newNote.channelHex()?.let { channelHex -> + if (channelHex in followingChannels && account.isAcceptable(newNote)) { + val lastNote = newRelevantPublicMessages.get(channelHex) + if (lastNote != null) { + if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) { + newRelevantPublicMessages.put(channelHex, newNote) + } + } else { + newRelevantPublicMessages.put(channelHex, newNote) + } + } + } + } + return newRelevantPublicMessages + } + + private fun filterRelevantPrivateMessages( + newItems: Set, + account: Account, + ): MutableMap { + val me = account.userProfile() + val followingKeySet = account.followingKeySet() + + val newRelevantPrivateMessages = mutableMapOf() + newItems + .filter { it.event is ChatroomKeyable } + .forEach { newNote -> + val roomKey = (newNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex) + val room = account.userProfile().privateChatrooms[roomKey] + + if (roomKey != null && room != null) { + if ( + ( + newNote.author?.pubkeyHex == me.pubkeyHex || + room.senderIntersects(followingKeySet) || + me.hasSentMessagesTo(roomKey) + ) && !account.isAllHidden(roomKey.users) + ) { + val lastNote = newRelevantPrivateMessages.get(roomKey) + if (lastNote != null) { + if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) { + newRelevantPrivateMessages.put(roomKey, newNote) + } + } else { + newRelevantPrivateMessages.put(roomKey, newNote) + } + } + } + } + return newRelevantPrivateMessages + } + + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt index 44ffb6804..a09f841fa 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt @@ -28,114 +28,116 @@ import com.vitorpamplona.quartz.events.ChatroomKeyable import com.vitorpamplona.quartz.events.PrivateDmEvent class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex - } - - // returns the last Note of each user. - override fun feed(): List { - val me = account.userProfile() - val followingKeySet = account.followingKeySet() - - val newChatrooms = - me.privateChatrooms.filter { - !it.value.senderIntersects(followingKeySet) && - !me.hasSentMessagesTo(it.key) && - !account.isAllHidden(it.key.users) - } - - val privateMessages = - newChatrooms.mapNotNull { it -> - it.value.roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).lastOrNull { - it.event != null - } - } - - return privateMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } - - override fun updateListWith( - oldList: List, - newItems: Set, - ): List { - val me = account.userProfile() - - // Gets the latest message by room from the new items. - val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account) - - if (newRelevantPrivateMessages.isEmpty()) { - return oldList + override fun feedKey(): String { + return account.userProfile().pubkeyHex } - var myNewList = oldList + // returns the last Note of each user. + override fun feed(): List { + val me = account.userProfile() + val followingKeySet = account.followingKeySet() - newRelevantPrivateMessages.forEach { newNotePair -> - var hasUpdated = false - oldList.forEach { oldNote -> - val oldRoom = (oldNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex) - - if (newNotePair.key == oldRoom) { - hasUpdated = true - if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) { - myNewList = myNewList.updated(oldNote, newNotePair.value) - } - } - } - if (!hasUpdated) { - myNewList = myNewList.plus(newNotePair.value) - } - } - - return sort(myNewList.toSet()).take(1000) - } - - override fun applyFilter(newItems: Set): Set { - // Gets the latest message by room from the new items. - val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account) - - return if (newRelevantPrivateMessages.isEmpty()) { - emptySet() - } else { - newRelevantPrivateMessages.values.toSet() - } - } - - private fun filterRelevantPrivateMessages( - newItems: Set, - account: Account, - ): MutableMap { - val me = account.userProfile() - val followingKeySet = account.followingKeySet() - - val newRelevantPrivateMessages = mutableMapOf() - newItems - .filter { it.event is PrivateDmEvent } - .forEach { newNote -> - val roomKey = (newNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex) - val room = account.userProfile().privateChatrooms[roomKey] - - if ( - roomKey != null && - room != null && - (newNote.author?.pubkeyHex != me.pubkeyHex && - room.senderIntersects(followingKeySet) && - !me.hasSentMessagesTo(roomKey)) && - !account.isAllHidden(roomKey.users) - ) { - val lastNote = newRelevantPrivateMessages.get(roomKey) - if (lastNote != null) { - if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) { - newRelevantPrivateMessages.put(roomKey, newNote) + val newChatrooms = + me.privateChatrooms.filter { + !it.value.senderIntersects(followingKeySet) && + !me.hasSentMessagesTo(it.key) && + !account.isAllHidden(it.key.users) } - } else { - newRelevantPrivateMessages.put(roomKey, newNote) - } - } - } - return newRelevantPrivateMessages - } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + val privateMessages = + newChatrooms.mapNotNull { it -> + it.value.roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).lastOrNull { + it.event != null + } + } + + return privateMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } + + override fun updateListWith( + oldList: List, + newItems: Set, + ): List { + val me = account.userProfile() + + // Gets the latest message by room from the new items. + val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account) + + if (newRelevantPrivateMessages.isEmpty()) { + return oldList + } + + var myNewList = oldList + + newRelevantPrivateMessages.forEach { newNotePair -> + var hasUpdated = false + oldList.forEach { oldNote -> + val oldRoom = (oldNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex) + + if (newNotePair.key == oldRoom) { + hasUpdated = true + if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) { + myNewList = myNewList.updated(oldNote, newNotePair.value) + } + } + } + if (!hasUpdated) { + myNewList = myNewList.plus(newNotePair.value) + } + } + + return sort(myNewList.toSet()).take(1000) + } + + override fun applyFilter(newItems: Set): Set { + // Gets the latest message by room from the new items. + val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account) + + return if (newRelevantPrivateMessages.isEmpty()) { + emptySet() + } else { + newRelevantPrivateMessages.values.toSet() + } + } + + private fun filterRelevantPrivateMessages( + newItems: Set, + account: Account, + ): MutableMap { + val me = account.userProfile() + val followingKeySet = account.followingKeySet() + + val newRelevantPrivateMessages = mutableMapOf() + newItems + .filter { it.event is PrivateDmEvent } + .forEach { newNote -> + val roomKey = (newNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex) + val room = account.userProfile().privateChatrooms[roomKey] + + if ( + roomKey != null && + room != null && + ( + newNote.author?.pubkeyHex != me.pubkeyHex && + room.senderIntersects(followingKeySet) && + !me.hasSentMessagesTo(roomKey) + ) && + !account.isAllHidden(roomKey.users) + ) { + val lastNote = newRelevantPrivateMessages.get(roomKey) + if (lastNote != null) { + if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) { + newRelevantPrivateMessages.put(roomKey, newNote) + } + } else { + newRelevantPrivateMessages.put(roomKey, newNote) + } + } + } + return newRelevantPrivateMessages + } + + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt index af0ec5737..9cfa7bf8c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt @@ -27,45 +27,45 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent class CommunityFeedFilter(val note: AddressableNote, val account: Account) : - AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + note.idHex - } + AdditiveFeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + note.idHex + } - override fun feed(): List { - return sort(innerApplyFilter(LocalCache.notes.values)) - } + override fun feed(): List { + return sort(innerApplyFilter(LocalCache.notes.values)) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - private fun innerApplyFilter(collection: Collection): Set { - val myUnapprovedPosts = - collection - .asSequence() - .filter { it.event is CommunityPostApprovalEvent } // Only Approvals - .filter { - it.author?.pubkeyHex == account.userProfile().pubkeyHex - } // made by the logged in user - .filter { it.event?.isTaggedAddressableNote(note.idHex) == true } // for this community - .filter { it.isNewThread() } // check if it is a new thread - .toSet() + private fun innerApplyFilter(collection: Collection): Set { + val myUnapprovedPosts = + collection + .asSequence() + .filter { it.event is CommunityPostApprovalEvent } // Only Approvals + .filter { + it.author?.pubkeyHex == account.userProfile().pubkeyHex + } // made by the logged in user + .filter { it.event?.isTaggedAddressableNote(note.idHex) == true } // for this community + .filter { it.isNewThread() } // check if it is a new thread + .toSet() - val approvedPosts = - collection - .asSequence() - .filter { it.event is CommunityPostApprovalEvent } // Only Approvals - .filter { it.event?.isTaggedAddressableNote(note.idHex) == true } // Of the given community - .mapNotNull { it.replyTo } - .flatten() // get approved posts - .filter { it.isNewThread() } // check if it is a new thread - .toSet() + val approvedPosts = + collection + .asSequence() + .filter { it.event is CommunityPostApprovalEvent } // Only Approvals + .filter { it.event?.isTaggedAddressableNote(note.idHex) == true } // Of the given community + .mapNotNull { it.replyTo } + .flatten() // get approved posts + .filter { it.isNewThread() } // check if it is a new thread + .toSet() - return myUnapprovedPosts + approvedPosts - } + return myUnapprovedPosts + approvedPosts + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt index 25bd17a6c..5c902730b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt @@ -32,81 +32,81 @@ import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.utils.TimeUtils open class DiscoverChatFeedFilter(val account: Account) : AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList.value - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList.value + } - override fun showHiddenKey(): Boolean { - return account.defaultDiscoveryFollowList.value == - PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.defaultDiscoveryFollowList.value == - MuteListEvent.blockListFor(account.userProfile().pubkeyHex) - } + override fun showHiddenKey(): Boolean { + return account.defaultDiscoveryFollowList.value == + PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + account.defaultDiscoveryFollowList.value == + MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + } - override fun feed(): List { - val allChannelNotes = - LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) } + override fun feed(): List { + val allChannelNotes = + LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) } - val notes = innerApplyFilter(allChannelNotes) + val notes = innerApplyFilter(allChannelNotes) - return sort(notes) - } + return sort(notes) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - protected open fun innerApplyFilter(collection: Collection): Set { - val now = TimeUtils.now() - val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS - val isHiddenList = showHiddenKey() + protected open fun innerApplyFilter(collection: Collection): Set { + val now = TimeUtils.now() + val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS + val isHiddenList = showHiddenKey() - val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet() - val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet() - val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet() + val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet() + val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet() + val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet() - val createEvents = collection.filter { it.event is ChannelCreateEvent } - val anyOtherChannelEvent = - collection - .asSequence() - .filter { it.event is IsInPublicChatChannel } - .mapNotNull { (it.event as? IsInPublicChatChannel)?.channel() } - .mapNotNull { LocalCache.checkGetOrCreateNote(it) } - .toSet() + val createEvents = collection.filter { it.event is ChannelCreateEvent } + val anyOtherChannelEvent = + collection + .asSequence() + .filter { it.event is IsInPublicChatChannel } + .mapNotNull { (it.event as? IsInPublicChatChannel)?.channel() } + .mapNotNull { LocalCache.checkGetOrCreateNote(it) } + .toSet() - val activities = - (createEvents + anyOtherChannelEvent) - .asSequence() - // .filter { it.event is ChannelCreateEvent } // Event heads might not be loaded yet. - .filter { - isGlobal || - it.author?.pubkeyHex in followingKeySet || - it.event?.isTaggedHashes(followingTagSet) == true || - it.event?.isTaggedGeoHashes(followingGeohashSet) == true - } - .filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true } - .filter { (it.createdAt() ?: 0) <= now } - .toSet() + val activities = + (createEvents + anyOtherChannelEvent) + .asSequence() + // .filter { it.event is ChannelCreateEvent } // Event heads might not be loaded yet. + .filter { + isGlobal || + it.author?.pubkeyHex in followingKeySet || + it.event?.isTaggedHashes(followingTagSet) == true || + it.event?.isTaggedGeoHashes(followingGeohashSet) == true + } + .filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true } + .filter { (it.createdAt() ?: 0) <= now } + .toSet() - return activities - } + return activities + } - override fun sort(collection: Set): List { - val followingKeySet = - account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users + override fun sort(collection: Set): List { + val followingKeySet = + account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users - val counter = ParticipantListBuilder() - val participantCounts = - collection.associate { it to counter.countFollowsThatParticipateOn(it, followingKeySet) } + val counter = ParticipantListBuilder() + val participantCounts = + collection.associate { it to counter.countFollowsThatParticipateOn(it, followingKeySet) } - return collection - .sortedWith( - compareBy( - { participantCounts[it] }, - { it.createdAt() }, - { it.idHex }, - ), - ) - .reversed() - } + return collection + .sortedWith( + compareBy( + { participantCounts[it] }, + { it.createdAt() }, + { it.idHex }, + ), + ) + .reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt index 1a3193dc5..ff6065c8c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt @@ -32,85 +32,85 @@ import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.utils.TimeUtils open class DiscoverCommunityFeedFilter(val account: Account) : AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList.value - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList.value + } - override fun showHiddenKey(): Boolean { - return account.defaultDiscoveryFollowList.value == - PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.defaultDiscoveryFollowList.value == - MuteListEvent.blockListFor(account.userProfile().pubkeyHex) - } + override fun showHiddenKey(): Boolean { + return account.defaultDiscoveryFollowList.value == + PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + account.defaultDiscoveryFollowList.value == + MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + } - override fun feed(): List { - val allNotes = LocalCache.addressables.values + override fun feed(): List { + val allNotes = LocalCache.addressables.values - val notes = innerApplyFilter(allNotes) + val notes = innerApplyFilter(allNotes) - return sort(notes) - } + return sort(notes) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - protected open fun innerApplyFilter(collection: Collection): Set { - val now = TimeUtils.now() - val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS - val isHiddenList = showHiddenKey() + protected open fun innerApplyFilter(collection: Collection): Set { + val now = TimeUtils.now() + val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS + val isHiddenList = showHiddenKey() - val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet() - val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet() - val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet() + val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet() + val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet() + val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet() - val createEvents = collection.filter { it.event is CommunityDefinitionEvent } - val anyOtherCommunityEvent = - collection - .asSequence() - .filter { it.event is CommunityPostApprovalEvent } - .mapNotNull { (it.event as? CommunityPostApprovalEvent)?.communities() } - .flatten() - .map { LocalCache.getOrCreateAddressableNote(it) } - .toSet() + val createEvents = collection.filter { it.event is CommunityDefinitionEvent } + val anyOtherCommunityEvent = + collection + .asSequence() + .filter { it.event is CommunityPostApprovalEvent } + .mapNotNull { (it.event as? CommunityPostApprovalEvent)?.communities() } + .flatten() + .map { LocalCache.getOrCreateAddressableNote(it) } + .toSet() - val activities = - (createEvents + anyOtherCommunityEvent) - .asSequence() - .filter { it.event is CommunityDefinitionEvent } - .filter { - isGlobal || - it.author?.pubkeyHex in followingKeySet || - it.event?.isTaggedHashes(followingTagSet) == true || - it.event?.isTaggedGeoHashes(followingGeohashSet) == true - } - .filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true } - .filter { (it.createdAt() ?: 0) <= now } - .toSet() + val activities = + (createEvents + anyOtherCommunityEvent) + .asSequence() + .filter { it.event is CommunityDefinitionEvent } + .filter { + isGlobal || + it.author?.pubkeyHex in followingKeySet || + it.event?.isTaggedHashes(followingTagSet) == true || + it.event?.isTaggedGeoHashes(followingGeohashSet) == true + } + .filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true } + .filter { (it.createdAt() ?: 0) <= now } + .toSet() - return activities - } + return activities + } - override fun sort(collection: Set): List { - val followingKeySet = - account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users + override fun sort(collection: Set): List { + val followingKeySet = + account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users - val counter = ParticipantListBuilder() - val participantCounts = - collection.associate { it to counter.countFollowsThatParticipateOn(it, followingKeySet) } + val counter = ParticipantListBuilder() + val participantCounts = + collection.associate { it to counter.countFollowsThatParticipateOn(it, followingKeySet) } - val allParticipants = - collection.associate { it to counter.countFollowsThatParticipateOn(it, null) } + val allParticipants = + collection.associate { it to counter.countFollowsThatParticipateOn(it, null) } - return collection - .sortedWith( - compareBy( - { participantCounts[it] }, - { allParticipants[it] }, - { it.createdAt() }, - { it.idHex }, - ), - ) - .reversed() - } + return collection + .sortedWith( + compareBy( + { participantCounts[it] }, + { allParticipants[it] }, + { it.createdAt() }, + { it.idHex }, + ), + ) + .reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt index 1ad077b70..825878aa2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt @@ -34,95 +34,95 @@ import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.utils.TimeUtils open class DiscoverLiveFeedFilter( - val account: Account, + val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + followList() - } - - open fun followList(): String { - return account.defaultDiscoveryFollowList.value - } - - override fun showHiddenKey(): Boolean { - return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) - } - - override fun feed(): List { - val allChannelNotes = - LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) } - val allMessageNotes = LocalCache.channels.values.map { it.notes.values }.flatten() - - val notes = innerApplyFilter(allChannelNotes + allMessageNotes) - - return sort(notes) - } - - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } - - protected open fun innerApplyFilter(collection: Collection): Set { - val now = TimeUtils.now() - val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS - val isHiddenList = showHiddenKey() - - val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet() - val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet() - val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet() - - val activities = - collection - .asSequence() - .filter { it.event is LiveActivitiesEvent } - .filter { - isGlobal || - (it.event as LiveActivitiesEvent).participantsIntersect(followingKeySet) || - it.event?.isTaggedHashes( - followingTagSet, - ) == true || - it.event?.isTaggedGeoHashes( - followingGeohashSet, - ) == true - } - .filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true } - .filter { (it.createdAt() ?: 0) <= now } - .toSet() - - return activities - } - - override fun sort(collection: Set): List { - val followingKeySet = - account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users - - val counter = ParticipantListBuilder() - val participantCounts = - collection.associate { it to counter.countFollowsThatParticipateOn(it, followingKeySet) } - - val allParticipants = - collection.associate { it to counter.countFollowsThatParticipateOn(it, null) } - - return collection - .sortedWith( - compareBy( - { convertStatusToOrder((it.event as? LiveActivitiesEvent)?.status()) }, - { participantCounts[it] }, - { allParticipants[it] }, - { (it.event as? LiveActivitiesEvent)?.starts() ?: it.createdAt() }, - { it.idHex }, - ), - ) - .reversed() - } - - fun convertStatusToOrder(status: String?): Int { - return when (status) { - STATUS_LIVE -> 2 - STATUS_PLANNED -> 1 - STATUS_ENDED -> 0 - else -> 0 + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + followList() + } + + open fun followList(): String { + return account.defaultDiscoveryFollowList.value + } + + override fun showHiddenKey(): Boolean { + return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + } + + override fun feed(): List { + val allChannelNotes = + LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) } + val allMessageNotes = LocalCache.channels.values.map { it.notes.values }.flatten() + + val notes = innerApplyFilter(allChannelNotes + allMessageNotes) + + return sort(notes) + } + + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } + + protected open fun innerApplyFilter(collection: Collection): Set { + val now = TimeUtils.now() + val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS + val isHiddenList = showHiddenKey() + + val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet() + val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet() + val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet() + + val activities = + collection + .asSequence() + .filter { it.event is LiveActivitiesEvent } + .filter { + isGlobal || + (it.event as LiveActivitiesEvent).participantsIntersect(followingKeySet) || + it.event?.isTaggedHashes( + followingTagSet, + ) == true || + it.event?.isTaggedGeoHashes( + followingGeohashSet, + ) == true + } + .filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true } + .filter { (it.createdAt() ?: 0) <= now } + .toSet() + + return activities + } + + override fun sort(collection: Set): List { + val followingKeySet = + account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users + + val counter = ParticipantListBuilder() + val participantCounts = + collection.associate { it to counter.countFollowsThatParticipateOn(it, followingKeySet) } + + val allParticipants = + collection.associate { it to counter.countFollowsThatParticipateOn(it, null) } + + return collection + .sortedWith( + compareBy( + { convertStatusToOrder((it.event as? LiveActivitiesEvent)?.status()) }, + { participantCounts[it] }, + { allParticipants[it] }, + { (it.event as? LiveActivitiesEvent)?.starts() ?: it.createdAt() }, + { it.idHex }, + ), + ) + .reversed() + } + + fun convertStatusToOrder(status: String?): Int { + return when (status) { + STATUS_LIVE -> 2 + STATUS_PLANNED -> 1 + STATUS_ENDED -> 0 + else -> 0 + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveNowFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveNowFeedFilter.kt index 996d130a8..ebbcc996d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveNowFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveNowFeedFilter.kt @@ -29,27 +29,27 @@ import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE class DiscoverLiveNowFeedFilter( - account: Account, + account: Account, ) : DiscoverLiveFeedFilter(account) { - override fun followList(): String { - // uses follows by default, but other lists if they were selected in the top bar - val currentList = super.followList() - return if (currentList == GLOBAL_FOLLOWS) { - KIND3_FOLLOWS - } else { - currentList + override fun followList(): String { + // uses follows by default, but other lists if they were selected in the top bar + val currentList = super.followList() + return if (currentList == GLOBAL_FOLLOWS) { + KIND3_FOLLOWS + } else { + currentList + } } - } - override fun innerApplyFilter(collection: Collection): Set { - val allItems = super.innerApplyFilter(collection) + override fun innerApplyFilter(collection: Collection): Set { + val allItems = super.innerApplyFilter(collection) - val onlineOnly = - allItems.filter { - val noteEvent = it.event as? LiveActivitiesEvent - noteEvent?.status() == STATUS_LIVE && OnlineChecker.isOnline(noteEvent.streaming()) - } + val onlineOnly = + allItems.filter { + val noteEvent = it.event as? LiveActivitiesEvent + noteEvent?.status() == STATUS_LIVE && OnlineChecker.isOnline(noteEvent.streaming()) + } - return onlineOnly.toSet() - } + return onlineOnly.toSet() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt index fd8604ac6..267a07e97 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt @@ -30,66 +30,66 @@ import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.utils.TimeUtils open class DiscoverMarketplaceFeedFilter( - val account: Account, + val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + followList() - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + followList() + } - open fun followList(): String { - return account.defaultDiscoveryFollowList.value - } + open fun followList(): String { + return account.defaultDiscoveryFollowList.value + } - override fun showHiddenKey(): Boolean { - return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) - } + override fun showHiddenKey(): Boolean { + return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + } - override fun feed(): List { - val classifieds = - LocalCache.addressables.filter { it.value.event is ClassifiedsEvent }.map { it.value } + override fun feed(): List { + val classifieds = + LocalCache.addressables.filter { it.value.event is ClassifiedsEvent }.map { it.value } - val notes = innerApplyFilter(classifieds) + val notes = innerApplyFilter(classifieds) - return sort(notes) - } + return sort(notes) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - protected open fun innerApplyFilter(collection: Collection): Set { - val now = TimeUtils.now() - val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS - val isHiddenList = showHiddenKey() + protected open fun innerApplyFilter(collection: Collection): Set { + val now = TimeUtils.now() + val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS + val isHiddenList = showHiddenKey() - val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet() - val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet() - val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet() + val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet() + val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet() + val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet() - val activities = - collection - .asSequence() - .filter { - it.event is ClassifiedsEvent && - it.event?.hasTagWithContent("image") == true && - it.event?.hasTagWithContent("price") == true && - it.event?.hasTagWithContent("title") == true - } - .filter { - isGlobal || - it.author?.pubkeyHex in followingKeySet || - it.event?.isTaggedHashes(followingTagSet) == true || - it.event?.isTaggedGeoHashes(followingGeohashSet) == true - } - .filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true } - .filter { (it.createdAt() ?: 0) <= now } - .toSet() + val activities = + collection + .asSequence() + .filter { + it.event is ClassifiedsEvent && + it.event?.hasTagWithContent("image") == true && + it.event?.hasTagWithContent("price") == true && + it.event?.hasTagWithContent("title") == true + } + .filter { + isGlobal || + it.author?.pubkeyHex in followingKeySet || + it.event?.isTaggedHashes(followingTagSet) == true || + it.event?.isTaggedGeoHashes(followingGeohashSet) == true + } + .filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true } + .filter { (it.createdAt() ?: 0) <= now } + .toSet() - return activities - } + return activities + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt index 14a39cd3e..a247c4f42 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt @@ -25,49 +25,49 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread import kotlin.time.measureTimedValue abstract class FeedFilter { - fun loadTop(): List { - checkNotInMainThread() + fun loadTop(): List { + checkNotInMainThread() - val (feed, elapsed) = measureTimedValue { feed() } + val (feed, elapsed) = measureTimedValue { feed() } - Log.d("Time", "${this.javaClass.simpleName} Full Feed in $elapsed with ${feed.size} objects") - return feed.take(limit()) - } + Log.d("Time", "${this.javaClass.simpleName} Full Feed in $elapsed with ${feed.size} objects") + return feed.take(limit()) + } - open fun limit() = 1000 + open fun limit() = 1000 - /** Returns a string that serves as the key to invalidate the list if it changes. */ - abstract fun feedKey(): String + /** Returns a string that serves as the key to invalidate the list if it changes. */ + abstract fun feedKey(): String - open fun showHiddenKey(): Boolean = false + open fun showHiddenKey(): Boolean = false - abstract fun feed(): List + abstract fun feed(): List } abstract class AdditiveFeedFilter : FeedFilter() { - abstract fun applyFilter(collection: Set): Set + abstract fun applyFilter(collection: Set): Set - abstract fun sort(collection: Set): List + abstract fun sort(collection: Set): List - open fun updateListWith( - oldList: List, - newItems: Set, - ): List { - checkNotInMainThread() + open fun updateListWith( + oldList: List, + newItems: Set, + ): List { + checkNotInMainThread() - val (feed, elapsed) = - measureTimedValue { - val newItemsToBeAdded = applyFilter(newItems) - if (newItemsToBeAdded.isNotEmpty()) { - val newList = oldList.toSet() + newItemsToBeAdded - sort(newList).take(limit()) - } else { - oldList - } - } + val (feed, elapsed) = + measureTimedValue { + val newItemsToBeAdded = applyFilter(newItems) + if (newItemsToBeAdded.isNotEmpty()) { + val newList = oldList.toSet() + newItemsToBeAdded + sort(newList).take(limit()) + } else { + oldList + } + } - // Log.d("Time", "${this.javaClass.simpleName} Additive Feed in $elapsed with ${feed.size} - // objects") - return feed - } + // Log.d("Time", "${this.javaClass.simpleName} Additive Feed in $elapsed with ${feed.size} + // objects") + return feed + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt index a7bbf8423..479242948 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt @@ -31,36 +31,38 @@ import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.TextNoteEvent class GeoHashFeedFilter(val tag: String, val account: Account) : AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + tag - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + tag + } - override fun feed(): List { - return sort(innerApplyFilter(LocalCache.notes.values)) - } + override fun feed(): List { + return sort(innerApplyFilter(LocalCache.notes.values)) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - private fun innerApplyFilter(collection: Collection): Set { - val myTag = tag ?: return emptySet() + private fun innerApplyFilter(collection: Collection): Set { + val myTag = tag ?: return emptySet() - return collection - .asSequence() - .filter { - (it.event is TextNoteEvent || - it.event is LongTextNoteEvent || - it.event is ChannelMessageEvent || - it.event is PrivateDmEvent || - it.event is PollNoteEvent || - it.event is AudioHeaderEvent) && it.event?.isTaggedGeoHash(myTag) == true - } - .filter { account.isAcceptable(it) } - .toSet() - } + return collection + .asSequence() + .filter { + ( + it.event is TextNoteEvent || + it.event is LongTextNoteEvent || + it.event is ChannelMessageEvent || + it.event is PrivateDmEvent || + it.event is PollNoteEvent || + it.event is AudioHeaderEvent + ) && it.event?.isTaggedGeoHash(myTag) == true + } + .filter { account.isAcceptable(it) } + .toSet() + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt index 0e17e7fec..f0be67cb9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt @@ -31,36 +31,38 @@ import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.TextNoteEvent class HashtagFeedFilter(val tag: String, val account: Account) : AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + tag - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + tag + } - override fun feed(): List { - return sort(innerApplyFilter(LocalCache.notes.values)) - } + override fun feed(): List { + return sort(innerApplyFilter(LocalCache.notes.values)) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - private fun innerApplyFilter(collection: Collection): Set { - val myTag = tag ?: return emptySet() + private fun innerApplyFilter(collection: Collection): Set { + val myTag = tag ?: return emptySet() - return collection - .asSequence() - .filter { - (it.event is TextNoteEvent || - it.event is LongTextNoteEvent || - it.event is ChannelMessageEvent || - it.event is PrivateDmEvent || - it.event is PollNoteEvent || - it.event is AudioHeaderEvent) && it.event?.isTaggedHash(myTag) == true - } - .filter { account.isAcceptable(it) } - .toSet() - } + return collection + .asSequence() + .filter { + ( + it.event is TextNoteEvent || + it.event is LongTextNoteEvent || + it.event is ChannelMessageEvent || + it.event is PrivateDmEvent || + it.event is PollNoteEvent || + it.event is AudioHeaderEvent + ) && it.event?.isTaggedHash(myTag) == true + } + .filter { account.isAcceptable(it) } + .toSet() + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt index 1eb86041f..ed6f508df 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt @@ -25,43 +25,43 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User class HiddenAccountsFeedFilter(val account: Account) : FeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + } - override fun showHiddenKey(): Boolean { - return true - } + override fun showHiddenKey(): Boolean { + return true + } - override fun feed(): List { - return account.flowHiddenUsers.value.hiddenUsers.map { LocalCache.getOrCreateUser(it) } - } + override fun feed(): List { + return account.flowHiddenUsers.value.hiddenUsers.map { LocalCache.getOrCreateUser(it) } + } } class HiddenWordsFeedFilter(val account: Account) : FeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + } - override fun showHiddenKey(): Boolean { - return true - } + override fun showHiddenKey(): Boolean { + return true + } - override fun feed(): List { - return account.flowHiddenUsers.value.hiddenWords.toList() - } + override fun feed(): List { + return account.flowHiddenUsers.value.hiddenWords.toList() + } } class SpammerAccountsFeedFilter(val account: Account) : FeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + } - override fun showHiddenKey(): Boolean { - return true - } + override fun showHiddenKey(): Boolean { + return true + } - override fun feed(): List { - return (account.transientHiddenUsers).map { LocalCache.getOrCreateUser(it) } - } + override fun feed(): List { + return (account.transientHiddenUsers).map { LocalCache.getOrCreateUser(it) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt index 361589cc9..6cc46044e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt @@ -33,56 +33,60 @@ import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.utils.TimeUtils class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList.value - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList.value + } - override fun showHiddenKey(): Boolean { - return account.defaultHomeFollowList.value == - PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.defaultHomeFollowList.value == - MuteListEvent.blockListFor(account.userProfile().pubkeyHex) - } + override fun showHiddenKey(): Boolean { + return account.defaultHomeFollowList.value == + PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + account.defaultHomeFollowList.value == + MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + } - override fun feed(): List { - return sort(innerApplyFilter(LocalCache.notes.values)) - } + override fun feed(): List { + return sort(innerApplyFilter(LocalCache.notes.values)) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - private fun innerApplyFilter(collection: Collection): Set { - val isGlobal = account.defaultHomeFollowList.value == GLOBAL_FOLLOWS - val isHiddenList = showHiddenKey() + private fun innerApplyFilter(collection: Collection): Set { + val isGlobal = account.defaultHomeFollowList.value == GLOBAL_FOLLOWS + val isHiddenList = showHiddenKey() - val followingKeySet = account.liveHomeFollowLists.value?.users ?: emptySet() - val followingTagSet = account.liveHomeFollowLists.value?.hashtags ?: emptySet() - val followingGeohashSet = account.liveHomeFollowLists.value?.geotags ?: emptySet() + val followingKeySet = account.liveHomeFollowLists.value?.users ?: emptySet() + val followingTagSet = account.liveHomeFollowLists.value?.hashtags ?: emptySet() + val followingGeohashSet = account.liveHomeFollowLists.value?.geotags ?: emptySet() - val now = TimeUtils.now() + val now = TimeUtils.now() - return collection - .asSequence() - .filter { - (it.event is TextNoteEvent || - it.event is PollNoteEvent || - it.event is ChannelMessageEvent || - it.event is LiveActivitiesChatMessageEvent) && - (isGlobal || - it.author?.pubkeyHex in followingKeySet || - it.event?.isTaggedHashes(followingTagSet) ?: false || - it.event?.isTaggedGeoHashes(followingGeohashSet) ?: false) && - // && account.isAcceptable(it) // This filter follows only. No need to check if - // acceptable - (isHiddenList || it.author?.let { !account.isHidden(it) } ?: true) && - ((it.event?.createdAt() ?: 0) < now) && - !it.isNewThread() - } - .toSet() - } + return collection + .asSequence() + .filter { + ( + it.event is TextNoteEvent || + it.event is PollNoteEvent || + it.event is ChannelMessageEvent || + it.event is LiveActivitiesChatMessageEvent + ) && + ( + isGlobal || + it.author?.pubkeyHex in followingKeySet || + it.event?.isTaggedHashes(followingTagSet) ?: false || + it.event?.isTaggedGeoHashes(followingGeohashSet) ?: false + ) && + // && account.isAcceptable(it) // This filter follows only. No need to check if + // acceptable + (isHiddenList || it.author?.let { !account.isHidden(it) } ?: true) && + ((it.event?.createdAt() ?: 0) < now) && + !it.isNewThread() + } + .toSet() + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt index 19bc2330b..fe83e7364 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt @@ -38,80 +38,91 @@ import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.utils.TimeUtils class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList.value - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList.value + } - override fun showHiddenKey(): Boolean { - return account.defaultHomeFollowList.value == - PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.defaultHomeFollowList.value == - MuteListEvent.blockListFor(account.userProfile().pubkeyHex) - } + override fun showHiddenKey(): Boolean { + return account.defaultHomeFollowList.value == + PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + account.defaultHomeFollowList.value == + MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + } - override fun feed(): List { - val notes = innerApplyFilter(LocalCache.notes.values, true) - val longFormNotes = innerApplyFilter(LocalCache.addressables.values, false) + override fun feed(): List { + val notes = innerApplyFilter(LocalCache.notes.values, true) + val longFormNotes = innerApplyFilter(LocalCache.addressables.values, false) - return sort(notes + longFormNotes) - } + return sort(notes + longFormNotes) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection, false) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection, false) + } - private fun innerApplyFilter( - collection: Collection, - ignoreAddressables: Boolean, - ): Set { - val isGlobal = account.defaultHomeFollowList.value == GLOBAL_FOLLOWS - val gRelays = account.activeGlobalRelays() - val isHiddenList = showHiddenKey() + private fun innerApplyFilter( + collection: Collection, + ignoreAddressables: Boolean, + ): Set { + val isGlobal = account.defaultHomeFollowList.value == GLOBAL_FOLLOWS + val gRelays = account.activeGlobalRelays() + val isHiddenList = showHiddenKey() - val followingKeySet = account.liveHomeFollowLists.value?.users ?: emptySet() - val followingTagSet = account.liveHomeFollowLists.value?.hashtags ?: emptySet() - val followingGeohashSet = account.liveHomeFollowLists.value?.geotags ?: emptySet() - val followingCommunities = account.liveHomeFollowLists.value?.communities ?: emptySet() + val followingKeySet = account.liveHomeFollowLists.value?.users ?: emptySet() + val followingTagSet = account.liveHomeFollowLists.value?.hashtags ?: emptySet() + val followingGeohashSet = account.liveHomeFollowLists.value?.geotags ?: emptySet() + val followingCommunities = account.liveHomeFollowLists.value?.communities ?: emptySet() - val oneMinuteInTheFuture = TimeUtils.now() + (1 * 60) // one minute in the future. - val oneHr = 60 * 60 + val oneMinuteInTheFuture = TimeUtils.now() + (1 * 60) // one minute in the future. + val oneHr = 60 * 60 - return collection - .asSequence() - .filter { it -> - val noteEvent = it.event - val isGlobalRelay = it.relays.any { gRelays.contains(it.url) } - (noteEvent is TextNoteEvent || - noteEvent is ClassifiedsEvent || - noteEvent is RepostEvent || - noteEvent is GenericRepostEvent || - noteEvent is LongTextNoteEvent || - noteEvent is PollNoteEvent || - noteEvent is HighlightEvent || - noteEvent is AudioTrackEvent || - noteEvent is AudioHeaderEvent) && - (!ignoreAddressables || noteEvent.kind() < 10000) && - ((isGlobal && isGlobalRelay) || - it.author?.pubkeyHex in followingKeySet || - noteEvent.isTaggedHashes(followingTagSet) || - noteEvent.isTaggedGeoHashes(followingGeohashSet) || - noteEvent.isTaggedAddressableNotes(followingCommunities)) && - // && account.isAcceptable(it) // This filter follows only. No need to check if - // acceptable - (isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true) && - ((it.event?.createdAt() ?: 0) < oneMinuteInTheFuture) && - it.isNewThread() && - ((noteEvent !is RepostEvent && noteEvent !is GenericRepostEvent) || // not a repost - (it.replyTo?.lastOrNull()?.author?.pubkeyHex !in followingKeySet || - (noteEvent.createdAt() > - (it.replyTo?.lastOrNull()?.createdAt() - ?: 0) + oneHr)) // or a repost of by a non-follower's post (likely not seen yet) - ) - } - .toSet() - } + return collection + .asSequence() + .filter { it -> + val noteEvent = it.event + val isGlobalRelay = it.relays.any { gRelays.contains(it.url) } + ( + noteEvent is TextNoteEvent || + noteEvent is ClassifiedsEvent || + noteEvent is RepostEvent || + noteEvent is GenericRepostEvent || + noteEvent is LongTextNoteEvent || + noteEvent is PollNoteEvent || + noteEvent is HighlightEvent || + noteEvent is AudioTrackEvent || + noteEvent is AudioHeaderEvent + ) && + (!ignoreAddressables || noteEvent.kind() < 10000) && + ( + (isGlobal && isGlobalRelay) || + it.author?.pubkeyHex in followingKeySet || + noteEvent.isTaggedHashes(followingTagSet) || + noteEvent.isTaggedGeoHashes(followingGeohashSet) || + noteEvent.isTaggedAddressableNotes(followingCommunities) + ) && + // && account.isAcceptable(it) // This filter follows only. No need to check if + // acceptable + (isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true) && + ((it.event?.createdAt() ?: 0) < oneMinuteInTheFuture) && + it.isNewThread() && + ( + (noteEvent !is RepostEvent && noteEvent !is GenericRepostEvent) || // not a repost + ( + it.replyTo?.lastOrNull()?.author?.pubkeyHex !in followingKeySet || + ( + noteEvent.createdAt() > + ( + it.replyTo?.lastOrNull()?.createdAt() + ?: 0 + ) + oneHr + ) + ) // or a repost of by a non-follower's post (likely not seen yet) + ) + } + .toSet() + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt index 79caad47f..451cf33de 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt @@ -40,81 +40,83 @@ import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.RepostEvent class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + account.defaultNotificationFollowList.value - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + account.defaultNotificationFollowList.value + } - override fun showHiddenKey(): Boolean { - return account.defaultNotificationFollowList.value == - PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.defaultNotificationFollowList.value == - MuteListEvent.blockListFor(account.userProfile().pubkeyHex) - } + override fun showHiddenKey(): Boolean { + return account.defaultNotificationFollowList.value == + PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + account.defaultNotificationFollowList.value == + MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + } - override fun feed(): List { - return sort(innerApplyFilter(LocalCache.notes.values)) - } + override fun feed(): List { + return sort(innerApplyFilter(LocalCache.notes.values)) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - private fun innerApplyFilter(collection: Collection): Set { - val isGlobal = account.defaultNotificationFollowList.value == GLOBAL_FOLLOWS - val isHiddenList = showHiddenKey() + private fun innerApplyFilter(collection: Collection): Set { + val isGlobal = account.defaultNotificationFollowList.value == GLOBAL_FOLLOWS + val isHiddenList = showHiddenKey() - val followingKeySet = account.liveNotificationFollowLists.value?.users ?: emptySet() + val followingKeySet = account.liveNotificationFollowLists.value?.users ?: emptySet() - val loggedInUser = account.userProfile() - val loggedInUserHex = loggedInUser.pubkeyHex + val loggedInUser = account.userProfile() + val loggedInUserHex = loggedInUser.pubkeyHex - return collection - .filter { - it.event !is ChannelCreateEvent && - it.event !is ChannelMetadataEvent && - it.event !is LnZapRequestEvent && - it.event !is BadgeDefinitionEvent && - it.event !is BadgeProfilesEvent && - it.event !is GiftWrapEvent && - (it.event is LnZapEvent || it.author !== loggedInUser) && - (isGlobal || it.author?.pubkeyHex in followingKeySet) && - it.event?.isTaggedUser(loggedInUserHex) ?: false && - (isHiddenList || it.author == null || !account.isHidden(it.author!!.pubkeyHex)) && - tagsAnEventByUser(it, loggedInUserHex) - } - .toSet() - } + return collection + .filter { + it.event !is ChannelCreateEvent && + it.event !is ChannelMetadataEvent && + it.event !is LnZapRequestEvent && + it.event !is BadgeDefinitionEvent && + it.event !is BadgeProfilesEvent && + it.event !is GiftWrapEvent && + (it.event is LnZapEvent || it.author !== loggedInUser) && + (isGlobal || it.author?.pubkeyHex in followingKeySet) && + it.event?.isTaggedUser(loggedInUserHex) ?: false && + (isHiddenList || it.author == null || !account.isHidden(it.author!!.pubkeyHex)) && + tagsAnEventByUser(it, loggedInUserHex) + } + .toSet() + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } - fun tagsAnEventByUser( - note: Note, - authorHex: HexKey, - ): Boolean { - val event = note.event + fun tagsAnEventByUser( + note: Note, + authorHex: HexKey, + ): Boolean { + val event = note.event - if (event is BaseTextNoteEvent) { - val isAuthoredPostCited = - event.findCitations().any { - LocalCache.notes[it]?.author?.pubkeyHex == authorHex || - LocalCache.addressables[it]?.author?.pubkeyHex == authorHex + if (event is BaseTextNoteEvent) { + val isAuthoredPostCited = + event.findCitations().any { + LocalCache.notes[it]?.author?.pubkeyHex == authorHex || + LocalCache.addressables[it]?.author?.pubkeyHex == authorHex + } + + return isAuthoredPostCited || + ( + event.citedUsers().contains(authorHex) || + note.replyTo?.any { it.author?.pubkeyHex == authorHex } == true + ) } - return isAuthoredPostCited || - (event.citedUsers().contains(authorHex) || - note.replyTo?.any { it.author?.pubkeyHex == authorHex } == true) - } + if (event is ReactionEvent) { + return note.replyTo?.lastOrNull()?.author?.pubkeyHex == authorHex + } - if (event is ReactionEvent) { - return note.replyTo?.lastOrNull()?.author?.pubkeyHex == authorHex - } + if (event is RepostEvent || event is GenericRepostEvent) { + return note.replyTo?.lastOrNull()?.author?.pubkeyHex == authorHex + } - if (event is RepostEvent || event is GenericRepostEvent) { - return note.replyTo?.lastOrNull()?.author?.pubkeyHex == authorHex + return true } - - return true - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt index a5a836493..ff5a663ab 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt @@ -28,31 +28,31 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class ThreadFeedFilter(val account: Account, val noteId: String) : FeedFilter() { - override fun feedKey(): String { - return noteId - } + override fun feedKey(): String { + return noteId + } - override fun feed(): List { - val cachedSignatures: MutableMap = mutableMapOf() - val followingKeySet = account.liveKind3Follows.value.users - val eventsToWatch = ThreadAssembler().findThreadFor(noteId) - val eventsInHex = eventsToWatch.map { it.idHex }.toSet() - val now = TimeUtils.now() + override fun feed(): List { + val cachedSignatures: MutableMap = mutableMapOf() + val followingKeySet = account.liveKind3Follows.value.users + val eventsToWatch = ThreadAssembler().findThreadFor(noteId) + val eventsInHex = eventsToWatch.map { it.idHex }.toSet() + val now = TimeUtils.now() - // Currently orders by date of each event, descending, at each level of the reply stack - val order = - compareByDescending { - it - .replyLevelSignature( - eventsInHex, - cachedSignatures, - account.userProfile(), - followingKeySet, - now, - ) - .signature - } + // Currently orders by date of each event, descending, at each level of the reply stack + val order = + compareByDescending { + it + .replyLevelSignature( + eventsInHex, + cachedSignatures, + account.userProfile(), + followingKeySet, + now, + ) + .signature + } - return eventsToWatch.sortedWith(order) - } + return eventsToWatch.sortedWith(order) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt index fdc333d63..2bf886287 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt @@ -26,39 +26,39 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.events.AppRecommendationEvent class UserProfileAppRecommendationsFeedFilter(val user: User) : AdditiveFeedFilter() { - override fun feedKey(): String { - return user.pubkeyHex - } + override fun feedKey(): String { + return user.pubkeyHex + } - override fun feed(): List { - return sort(innerApplyFilter(LocalCache.addressables.values)) - } + override fun feed(): List { + return sort(innerApplyFilter(LocalCache.addressables.values)) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - private fun innerApplyFilter(collection: Collection): Set { - val recommendations = - collection - .asSequence() - .filter { it.event is AppRecommendationEvent } - .mapNotNull { - val noteEvent = it.event as? AppRecommendationEvent - if (noteEvent != null && noteEvent.pubKey == user.pubkeyHex) { - noteEvent.recommendations() - } else { - null - } - } - .flatten() - .map { LocalCache.getOrCreateAddressableNote(it) } - .toSet() + private fun innerApplyFilter(collection: Collection): Set { + val recommendations = + collection + .asSequence() + .filter { it.event is AppRecommendationEvent } + .mapNotNull { + val noteEvent = it.event as? AppRecommendationEvent + if (noteEvent != null && noteEvent.pubKey == user.pubkeyHex) { + noteEvent.recommendations() + } else { + null + } + } + .flatten() + .map { LocalCache.getOrCreateAddressableNote(it) } + .toSet() - return recommendations - } + return recommendations + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt index 3497e9c01..8004bc44a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt @@ -26,28 +26,28 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User class UserProfileBookmarksFeedFilter(val user: User, val account: Account) : FeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + user.pubkeyHex - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + user.pubkeyHex + } - override fun feed(): List { - val notes = - user.latestBookmarkList - ?.taggedEvents() - ?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } - ?.toSet() - ?: emptySet() + override fun feed(): List { + val notes = + user.latestBookmarkList + ?.taggedEvents() + ?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } + ?.toSet() + ?: emptySet() - val addresses = - user.latestBookmarkList - ?.taggedAddresses() - ?.map { LocalCache.getOrCreateAddressableNote(it) } - ?.toSet() - ?: emptySet() + val addresses = + user.latestBookmarkList + ?.taggedAddresses() + ?.map { LocalCache.getOrCreateAddressableNote(it) } + ?.toSet() + ?: emptySet() - return (notes + addresses) - .filter { account.isAcceptable(it) } - .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .reversed() - } + return (notes + addresses) + .filter { account.isAcceptable(it) } + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + .reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt index 66eadc2ce..bea08fe34 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt @@ -30,36 +30,38 @@ import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent class UserProfileConversationsFeedFilter(val user: User, val account: Account) : - AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + user.pubkeyHex - } + AdditiveFeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + user.pubkeyHex + } - override fun feed(): List { - return sort(innerApplyFilter(LocalCache.notes.values)) - } + override fun feed(): List { + return sort(innerApplyFilter(LocalCache.notes.values)) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - private fun innerApplyFilter(collection: Collection): Set { - return collection - .filter { - it.author == user && - (it.event is TextNoteEvent || - it.event is PollNoteEvent || - it.event is ChannelMessageEvent || - it.event is LiveActivitiesChatMessageEvent) && - !it.isNewThread() && - account.isAcceptable(it) == true - } - .toSet() - } + private fun innerApplyFilter(collection: Collection): Set { + return collection + .filter { + it.author == user && + ( + it.event is TextNoteEvent || + it.event is PollNoteEvent || + it.event is ChannelMessageEvent || + it.event is LiveActivitiesChatMessageEvent + ) && + !it.isNewThread() && + account.isAcceptable(it) == true + } + .toSet() + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } - override fun limit() = 200 + override fun limit() = 200 } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt index 6f289a7e0..72e8b3994 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt @@ -25,13 +25,13 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User class UserProfileFollowersFeedFilter(val user: User, val account: Account) : FeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + user.pubkeyHex - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + user.pubkeyHex + } - override fun feed(): List { - return LocalCache.users.values.filter { it.isFollowing(user) && !account.isHidden(it) } - } + override fun feed(): List { + return LocalCache.users.values.filter { it.isFollowing(user) && !account.isHidden(it) } + } - override fun limit() = 400 + override fun limit() = 400 } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt index d845449bd..2d6472ace 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt @@ -26,27 +26,27 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.events.ContactListEvent class UserProfileFollowsFeedFilter(val user: User, val account: Account) : FeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + user.pubkeyHex - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + user.pubkeyHex + } - val cache: MutableMap> = mutableMapOf() + val cache: MutableMap> = mutableMapOf() - override fun feed(): List { - val contactList = user.latestContactList ?: return emptyList() + override fun feed(): List { + val contactList = user.latestContactList ?: return emptyList() - val previousList = cache[contactList] - if (previousList != null) return previousList + val previousList = cache[contactList] + if (previousList != null) return previousList - cache[contactList] = - user.latestContactList - ?.unverifiedFollowKeySet() - ?.mapNotNull { LocalCache.checkGetOrCreateUser(it) } - ?.toSet() - ?.filter { !account.isHidden(it) } - ?.reversed() - ?: emptyList() + cache[contactList] = + user.latestContactList + ?.unverifiedFollowKeySet() + ?.mapNotNull { LocalCache.checkGetOrCreateUser(it) } + ?.toSet() + ?.filter { !account.isHidden(it) } + ?.reversed() + ?: emptyList() - return cache[contactList] ?: emptyList() - } + return cache[contactList] ?: emptyList() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt index 8b5e80460..1ff82dff1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt @@ -35,44 +35,46 @@ import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.TextNoteEvent class UserProfileNewThreadFeedFilter(val user: User, val account: Account) : - AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + user.pubkeyHex - } + AdditiveFeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + user.pubkeyHex + } - override fun feed(): List { - val notes = innerApplyFilter(LocalCache.notes.values) - val longFormNotes = innerApplyFilter(LocalCache.addressables.values) + override fun feed(): List { + val notes = innerApplyFilter(LocalCache.notes.values) + val longFormNotes = innerApplyFilter(LocalCache.addressables.values) - return sort(notes + longFormNotes) - } + return sort(notes + longFormNotes) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - private fun innerApplyFilter(collection: Collection): Set { - return collection - .filter { - it.author == user && - (it.event is TextNoteEvent || - it.event is ClassifiedsEvent || - it.event is RepostEvent || - it.event is GenericRepostEvent || - it.event is LongTextNoteEvent || - it.event is PollNoteEvent || - it.event is HighlightEvent || - it.event is AudioTrackEvent || - it.event is AudioHeaderEvent) && - it.isNewThread() && - account.isAcceptable(it) == true - } - .toSet() - } + private fun innerApplyFilter(collection: Collection): Set { + return collection + .filter { + it.author == user && + ( + it.event is TextNoteEvent || + it.event is ClassifiedsEvent || + it.event is RepostEvent || + it.event is GenericRepostEvent || + it.event is LongTextNoteEvent || + it.event is PollNoteEvent || + it.event is HighlightEvent || + it.event is AudioTrackEvent || + it.event is AudioHeaderEvent + ) && + it.isNewThread() && + account.isAcceptable(it) == true + } + .toSet() + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } - override fun limit() = 200 + override fun limit() = 200 } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt index b3543a3e0..ee703b87e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt @@ -25,27 +25,27 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.events.ReportEvent class UserProfileReportsFeedFilter(val user: User) : AdditiveFeedFilter() { - override fun feedKey(): String { - return user.pubkeyHex - } + override fun feedKey(): String { + return user.pubkeyHex + } - override fun feed(): List { - return sort(innerApplyFilter(user.reports.values.flatten())) - } + override fun feed(): List { + return sort(innerApplyFilter(user.reports.values.flatten())) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - private fun innerApplyFilter(collection: Collection): Set { - return collection - .filter { it.event is ReportEvent && it.event?.isTaggedUser(user.pubkeyHex) == true } - .toSet() - } + private fun innerApplyFilter(collection: Collection): Set { + return collection + .filter { it.event is ReportEvent && it.event?.isTaggedUser(user.pubkeyHex) == true } + .toSet() + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } - override fun limit() = 400 + override fun limit() = 400 } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt index 8c4be5d56..cbc459f6a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt @@ -25,13 +25,13 @@ import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse import com.vitorpamplona.quartz.events.zaps.UserZaps class UserProfileZapsFeedFilter(val user: User) : FeedFilter() { - override fun feedKey(): String { - return user.pubkeyHex - } + override fun feedKey(): String { + return user.pubkeyHex + } - override fun feed(): List { - return UserZaps.forProfileFeed(user.zaps) - } + override fun feed(): List { + return UserZaps.forProfileFeed(user.zaps) + } - override fun limit() = 400 + override fun limit() = 400 } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt index d3cc1c868..321c3417f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt @@ -31,58 +31,58 @@ import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.utils.TimeUtils class VideoFeedFilter(val account: Account) : AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + account.defaultStoriesFollowList.value - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + account.defaultStoriesFollowList.value + } - override fun showHiddenKey(): Boolean { - return account.defaultStoriesFollowList.value == - PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.defaultStoriesFollowList.value == - MuteListEvent.blockListFor(account.userProfile().pubkeyHex) - } + override fun showHiddenKey(): Boolean { + return account.defaultStoriesFollowList.value == + PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + account.defaultStoriesFollowList.value == + MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + } - override fun feed(): List { - val notes = innerApplyFilter(LocalCache.notes.values) + override fun feed(): List { + val notes = innerApplyFilter(LocalCache.notes.values) - return sort(notes) - } + return sort(notes) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - private fun innerApplyFilter(collection: Collection): Set { - val now = TimeUtils.now() - val isGlobal = account.defaultStoriesFollowList.value == GLOBAL_FOLLOWS - val isHiddenList = - account.defaultStoriesFollowList.value == - PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.defaultStoriesFollowList.value == - MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + private fun innerApplyFilter(collection: Collection): Set { + val now = TimeUtils.now() + val isGlobal = account.defaultStoriesFollowList.value == GLOBAL_FOLLOWS + val isHiddenList = + account.defaultStoriesFollowList.value == + PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + account.defaultStoriesFollowList.value == + MuteListEvent.blockListFor(account.userProfile().pubkeyHex) - val followingKeySet = account.liveStoriesFollowLists.value?.users ?: emptySet() - val followingTagSet = account.liveStoriesFollowLists.value?.hashtags ?: emptySet() - val followingGeohashSet = account.liveStoriesFollowLists.value?.geotags ?: emptySet() + val followingKeySet = account.liveStoriesFollowLists.value?.users ?: emptySet() + val followingTagSet = account.liveStoriesFollowLists.value?.hashtags ?: emptySet() + val followingGeohashSet = account.liveStoriesFollowLists.value?.geotags ?: emptySet() - return collection - .asSequence() - .filter { - (it.event is FileHeaderEvent && (it.event as FileHeaderEvent).hasUrl()) || - it.event is FileStorageHeaderEvent - } - .filter { - isGlobal || - it.author?.pubkeyHex in followingKeySet || - (it.event?.isTaggedHashes(followingTagSet) ?: false) || - (it.event?.isTaggedGeoHashes(followingGeohashSet) ?: false) - } - .filter { isHiddenList || account.isAcceptable(it) } - .filter { it.createdAt()!! <= now } - .toSet() - } + return collection + .asSequence() + .filter { + (it.event is FileHeaderEvent && (it.event as FileHeaderEvent).hasUrl()) || + it.event is FileStorageHeaderEvent + } + .filter { + isGlobal || + it.author?.pubkeyHex in followingKeySet || + (it.event?.isTaggedHashes(followingTagSet) ?: false) || + (it.event?.isTaggedGeoHashes(followingGeohashSet) ?: false) + } + .filter { isHiddenList || account.isAcceptable(it) } + .filter { it.createdAt()!! <= now } + .toSet() + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/AddRemoveButtons.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/AddRemoveButtons.kt index 1bb4becdf..02bc66459 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/AddRemoveButtons.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/AddRemoveButtons.kt @@ -40,74 +40,74 @@ import com.vitorpamplona.amethyst.ui.theme.ThemeComparison @Composable @Preview fun AddButtonPreview() { - ThemeComparison( - onDark = { - Row { - Column { - AddButton(isActive = true) {} - AddButton(isActive = false) {} - } + ThemeComparison( + onDark = { + Row { + Column { + AddButton(isActive = true) {} + AddButton(isActive = false) {} + } - Column { - RemoveButton(isActive = true) {} - RemoveButton(isActive = false) {} - } - } - }, - onLight = { - Row { - Column { - AddButton(isActive = true) {} - AddButton(isActive = false) {} - } + Column { + RemoveButton(isActive = true) {} + RemoveButton(isActive = false) {} + } + } + }, + onLight = { + Row { + Column { + AddButton(isActive = true) {} + AddButton(isActive = false) {} + } - Column { - RemoveButton(isActive = true) {} - RemoveButton(isActive = false) {} - } - } - }, - ) + Column { + RemoveButton(isActive = true) {} + RemoveButton(isActive = false) {} + } + } + }, + ) } @Composable fun AddButton( - text: Int = R.string.add, - isActive: Boolean = true, - modifier: Modifier = Modifier.padding(start = 3.dp), - onClick: () -> Unit, + text: Int = R.string.add, + isActive: Boolean = true, + modifier: Modifier = Modifier.padding(start = 3.dp), + onClick: () -> Unit, ) { - Button( - modifier = modifier, - onClick = { - if (isActive) { - onClick() - } - }, - shape = ButtonBorder, - enabled = isActive, - contentPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp), - ) { - Text(text = stringResource(text), color = Color.White, textAlign = TextAlign.Center) - } + Button( + modifier = modifier, + onClick = { + if (isActive) { + onClick() + } + }, + shape = ButtonBorder, + enabled = isActive, + contentPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp), + ) { + Text(text = stringResource(text), color = Color.White, textAlign = TextAlign.Center) + } } @Composable fun RemoveButton( - isActive: Boolean = true, - onClick: () -> Unit, + isActive: Boolean = true, + onClick: () -> Unit, ) { - Button( - modifier = Modifier.padding(start = 3.dp), - onClick = { - if (isActive) { - onClick() - } - }, - shape = ButtonBorder, - enabled = isActive, - contentPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp), - ) { - Text(text = stringResource(R.string.remove), color = Color.White) - } + Button( + modifier = Modifier.padding(start = 3.dp), + onClick = { + if (isActive) { + onClick() + } + }, + shape = ButtonBorder, + enabled = isActive, + contentPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp), + ) { + Text(text = stringResource(R.string.remove), color = Color.White) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayCommunity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayCommunity.kt index b4f82cefc..3db228e20 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayCommunity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayCommunity.kt @@ -37,47 +37,47 @@ import com.vitorpamplona.quartz.events.CommunityDefinitionEvent @Composable fun DisplayFollowingCommunityInPost( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Column(HalfStartPadding) { - Row(verticalAlignment = Alignment.CenterVertically) { DisplayCommunity(baseNote, nav) } - } + Column(HalfStartPadding) { + Row(verticalAlignment = Alignment.CenterVertically) { DisplayCommunity(baseNote, nav) } + } } @Composable private fun DisplayCommunity( - note: Note, - nav: (String) -> Unit, + note: Note, + nav: (String) -> Unit, ) { - val communityTag = - remember(note) { note.event?.getTagOfAddressableKind(CommunityDefinitionEvent.KIND) } ?: return + val communityTag = + remember(note) { note.event?.getTagOfAddressableKind(CommunityDefinitionEvent.KIND) } ?: return - val displayTag = remember(note) { AnnotatedString(getCommunityShortName(communityTag)) } - val route = remember(note) { "Community/${communityTag.toTag()}" } + val displayTag = remember(note) { AnnotatedString(getCommunityShortName(communityTag)) } + val route = remember(note) { "Community/${communityTag.toTag()}" } - ClickableText( - text = displayTag, - onClick = { nav(route) }, - style = - LocalTextStyle.current.copy( - color = - MaterialTheme.colorScheme.primary.copy( - alpha = 0.52f, - ), - ), - maxLines = 1, - ) + ClickableText( + text = displayTag, + onClick = { nav(route) }, + style = + LocalTextStyle.current.copy( + color = + MaterialTheme.colorScheme.primary.copy( + alpha = 0.52f, + ), + ), + maxLines = 1, + ) } private fun getCommunityShortName(communityTag: ATag): String { - val name = - if (communityTag.dTag.length > 10) { - communityTag.dTag.take(10) + "..." - } else { - communityTag.dTag.take(10) - } + val name = + if (communityTag.dTag.length > 10) { + communityTag.dTag.take(10) + "..." + } else { + communityTag.dTag.take(10) + } - return "/n/$name" + return "/n/$name" } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayHashtags.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayHashtags.kt index 08c5737bd..efbcf4801 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayHashtags.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayHashtags.kt @@ -42,51 +42,51 @@ import kotlinx.coroutines.launch @Composable fun DisplayFollowingHashtagsInPost( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = remember { baseNote.event } ?: return + val noteEvent = remember { baseNote.event } ?: return - val userFollowState by accountViewModel.userFollows.observeAsState() - var firstTag by remember { mutableStateOf(null) } + val userFollowState by accountViewModel.userFollows.observeAsState() + var firstTag by remember { mutableStateOf(null) } - LaunchedEffect(key1 = userFollowState) { - launch(Dispatchers.Default) { - val followingTags = userFollowState?.user?.cachedFollowingTagSet() ?: emptySet() - val newFirstTag = noteEvent.firstIsTaggedHashes(followingTags) + LaunchedEffect(key1 = userFollowState) { + launch(Dispatchers.Default) { + val followingTags = userFollowState?.user?.cachedFollowingTagSet() ?: emptySet() + val newFirstTag = noteEvent.firstIsTaggedHashes(followingTags) - if (firstTag != newFirstTag) { - launch(Dispatchers.Main) { firstTag = newFirstTag } - } + if (firstTag != newFirstTag) { + launch(Dispatchers.Main) { firstTag = newFirstTag } + } + } } - } - firstTag?.let { - Column(verticalArrangement = Arrangement.Center) { - Row(verticalAlignment = Alignment.CenterVertically) { DisplayTagList(it, nav) } + firstTag?.let { + Column(verticalArrangement = Arrangement.Center) { + Row(verticalAlignment = Alignment.CenterVertically) { DisplayTagList(it, nav) } + } } - } } @Composable private fun DisplayTagList( - firstTag: String, - nav: (String) -> Unit, + firstTag: String, + nav: (String) -> Unit, ) { - val displayTag = remember(firstTag) { AnnotatedString(" #$firstTag") } - val route = remember(firstTag) { "Hashtag/$firstTag" } + val displayTag = remember(firstTag) { AnnotatedString(" #$firstTag") } + val route = remember(firstTag) { "Hashtag/$firstTag" } - ClickableText( - text = displayTag, - onClick = { nav(route) }, - style = - LocalTextStyle.current.copy( - color = - MaterialTheme.colorScheme.primary.copy( - alpha = 0.52f, - ), - ), - maxLines = 1, - ) + ClickableText( + text = displayTag, + onClick = { nav(route) }, + style = + LocalTextStyle.current.copy( + color = + MaterialTheme.colorScheme.primary.copy( + alpha = 0.52f, + ), + ), + maxLines = 1, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayPoW.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayPoW.kt index bbfc06033..db6f0b1e8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayPoW.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayPoW.kt @@ -33,21 +33,21 @@ import com.vitorpamplona.amethyst.ui.theme.lessImportantLink @Composable @Preview fun DisplayPoWPreview() { - ThemeComparison( - onDark = { DisplayPoW(pow = 24) }, - onLight = { DisplayPoW(pow = 24) }, - ) + ThemeComparison( + onDark = { DisplayPoW(pow = 24) }, + onLight = { DisplayPoW(pow = 24) }, + ) } @Composable fun DisplayPoW(pow: Int) { - val powStr = remember(pow) { "PoW-$pow" } + val powStr = remember(pow) { "PoW-$pow" } - Text( - powStr, - color = MaterialTheme.colorScheme.lessImportantLink, - fontSize = Font14SP, - fontWeight = FontWeight.Bold, - maxLines = 1, - ) + Text( + powStr, + color = MaterialTheme.colorScheme.lessImportantLink, + fontSize = Font14SP, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayReward.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayReward.kt index 105194aa7..f8258f7df 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayReward.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayReward.kt @@ -69,206 +69,206 @@ import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.placeholderText -import java.math.BigDecimal import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.math.BigDecimal @Stable data class Reward(val amount: BigDecimal) @Composable fun DisplayReward( - baseReward: Reward, - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseReward: Reward, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var popupExpanded by remember { mutableStateOf(false) } + var popupExpanded by remember { mutableStateOf(false) } - Column { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { popupExpanded = true }, - ) { - ClickableText( - text = AnnotatedString("#bounty"), - onClick = { nav("Hashtag/bounty") }, - style = - LocalTextStyle.current.copy( - color = - MaterialTheme.colorScheme.primary.copy( - alpha = 0.52f, - ), - ), - ) + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { popupExpanded = true }, + ) { + ClickableText( + text = AnnotatedString("#bounty"), + onClick = { nav("Hashtag/bounty") }, + style = + LocalTextStyle.current.copy( + color = + MaterialTheme.colorScheme.primary.copy( + alpha = 0.52f, + ), + ), + ) - RenderPledgeAmount(baseNote, baseReward, accountViewModel) + RenderPledgeAmount(baseNote, baseReward, accountViewModel) + } + + if (popupExpanded) { + AddBountyAmountDialog(baseNote, accountViewModel) { popupExpanded = false } + } } - - if (popupExpanded) { - AddBountyAmountDialog(baseNote, accountViewModel) { popupExpanded = false } - } - } } @Composable private fun RenderPledgeAmount( - baseNote: Note, - baseReward: Reward, - accountViewModel: AccountViewModel, + baseNote: Note, + baseReward: Reward, + accountViewModel: AccountViewModel, ) { - val repliesState by baseNote.live().replies.observeAsState() - var reward by remember { - mutableStateOf( - showAmount(baseReward.amount), - ) - } - - var hasPledge by remember { - mutableStateOf( - false, - ) - } - - LaunchedEffect(key1 = repliesState) { - launch(Dispatchers.IO) { - repliesState?.note?.pledgedAmountByOthers()?.let { - val newRewardAmount = showAmount(baseReward.amount.add(it)) - if (newRewardAmount != reward) { - reward = newRewardAmount - } - } - val newHasPledge = repliesState?.note?.hasPledgeBy(accountViewModel.userProfile()) == true - if (hasPledge != newHasPledge) { - launch(Dispatchers.Main) { hasPledge = newHasPledge } - } + val repliesState by baseNote.live().replies.observeAsState() + var reward by remember { + mutableStateOf( + showAmount(baseReward.amount), + ) } - } - if (hasPledge) { - ZappedIcon(modifier = Size20Modifier) - } else { - ZapIcon(modifier = Size20Modifier, MaterialTheme.colorScheme.placeholderText) - } + var hasPledge by remember { + mutableStateOf( + false, + ) + } - Text( - text = reward, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - ) + LaunchedEffect(key1 = repliesState) { + launch(Dispatchers.IO) { + repliesState?.note?.pledgedAmountByOthers()?.let { + val newRewardAmount = showAmount(baseReward.amount.add(it)) + if (newRewardAmount != reward) { + reward = newRewardAmount + } + } + val newHasPledge = repliesState?.note?.hasPledgeBy(accountViewModel.userProfile()) == true + if (hasPledge != newHasPledge) { + launch(Dispatchers.Main) { hasPledge = newHasPledge } + } + } + } + + if (hasPledge) { + ZappedIcon(modifier = Size20Modifier) + } else { + ZapIcon(modifier = Size20Modifier, MaterialTheme.colorScheme.placeholderText) + } + + Text( + text = reward, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) } class AddBountyAmountViewModel : ViewModel() { - private var account: Account? = null - private var bounty: Note? = null + private var account: Account? = null + private var bounty: Note? = null - var nextAmount by mutableStateOf(TextFieldValue("")) + var nextAmount by mutableStateOf(TextFieldValue("")) - fun load( - account: Account, - bounty: Note?, - ) { - this.account = account - this.bounty = bounty - } - - fun sendPost() { - val newValue = nextAmount.text.trim().toLongOrNull() - - if (newValue != null) { - account?.sendPost( - message = newValue.toString(), - replyTo = listOfNotNull(bounty), - mentions = listOfNotNull(bounty?.author), - tags = listOf("bounty-added-reward"), - wantsToMarkAsSensitive = false, - replyingTo = null, - root = null, - directMentions = setOf(), - ) - - nextAmount = TextFieldValue("") + fun load( + account: Account, + bounty: Note?, + ) { + this.account = account + this.bounty = bounty } - } - fun cancel() { - nextAmount = TextFieldValue("") - } + fun sendPost() { + val newValue = nextAmount.text.trim().toLongOrNull() - fun hasChanged(): Boolean { - return nextAmount.text.trim().toLongOrNull() != null - } + if (newValue != null) { + account?.sendPost( + message = newValue.toString(), + replyTo = listOfNotNull(bounty), + mentions = listOfNotNull(bounty?.author), + tags = listOf("bounty-added-reward"), + wantsToMarkAsSensitive = false, + replyingTo = null, + root = null, + directMentions = setOf(), + ) + + nextAmount = TextFieldValue("") + } + } + + fun cancel() { + nextAmount = TextFieldValue("") + } + + fun hasChanged(): Boolean { + return nextAmount.text.trim().toLongOrNull() != null + } } @Composable fun AddBountyAmountDialog( - bounty: Note, - accountViewModel: AccountViewModel, - onClose: () -> Unit, + bounty: Note, + accountViewModel: AccountViewModel, + onClose: () -> Unit, ) { - val postViewModel: AddBountyAmountViewModel = viewModel() - postViewModel.load(accountViewModel.account, bounty) - val scope = rememberCoroutineScope() + val postViewModel: AddBountyAmountViewModel = viewModel() + postViewModel.load(accountViewModel.account, bounty) + val scope = rememberCoroutineScope() - Dialog( - onDismissRequest = { onClose() }, - properties = - DialogProperties( - dismissOnClickOutside = false, - usePlatformDefaultWidth = false, - ), - ) { - Surface { - Column( - modifier = Modifier.padding(10.dp).width(IntrinsicSize.Min), - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - CloseButton( - onPress = { - postViewModel.cancel() - onClose() - }, - ) + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + dismissOnClickOutside = false, + usePlatformDefaultWidth = false, + ), + ) { + Surface { + Column( + modifier = Modifier.padding(10.dp).width(IntrinsicSize.Min), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + CloseButton( + onPress = { + postViewModel.cancel() + onClose() + }, + ) - PostButton( - onPost = { - scope.launch(Dispatchers.IO) { - postViewModel.sendPost() - onClose() - } - }, - isActive = postViewModel.hasChanged(), - ) + PostButton( + onPost = { + scope.launch(Dispatchers.IO) { + postViewModel.sendPost() + onClose() + } + }, + isActive = postViewModel.hasChanged(), + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier.padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.pledge_amount_in_sats)) }, + value = postViewModel.nextAmount, + onValueChange = { postViewModel.nextAmount = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Number, + ), + placeholder = { + Text( + text = "10000, 50000, 5000000", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + ) + } + } } - - Spacer(modifier = Modifier.height(10.dp)) - - Row( - modifier = Modifier.padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.pledge_amount_in_sats)) }, - value = postViewModel.nextAmount, - onValueChange = { postViewModel.nextAmount = it }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Number, - ), - placeholder = { - Text( - text = "10000, 50000, 5000000", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - singleLine = true, - ) - } - } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayUncitedHashtags.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayUncitedHashtags.kt index c790c735d..c102b88b6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayUncitedHashtags.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayUncitedHashtags.kt @@ -35,27 +35,27 @@ import kotlinx.collections.immutable.ImmutableList @OptIn(ExperimentalLayoutApi::class) @Composable fun DisplayUncitedHashtags( - hashtags: ImmutableList, - eventContent: String, - nav: (String) -> Unit, + hashtags: ImmutableList, + eventContent: String, + nav: (String) -> Unit, ) { - val unusedHashtags = - remember(eventContent) { hashtags.filter { !eventContent.contains(it, true) } } + val unusedHashtags = + remember(eventContent) { hashtags.filter { !eventContent.contains(it, true) } } - if (unusedHashtags.isNotEmpty()) { - FlowRow( - modifier = HalfTopPadding, - ) { - unusedHashtags.forEach { hashtag -> - ClickableText( - text = remember { AnnotatedString("#$hashtag ") }, - onClick = { nav("Hashtag/$hashtag") }, - style = - LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.lessImportantLink, - ), - ) - } + if (unusedHashtags.isNotEmpty()) { + FlowRow( + modifier = HalfTopPadding, + ) { + unusedHashtags.forEach { hashtag -> + ClickableText( + text = remember { AnnotatedString("#$hashtag ") }, + onClick = { nav("Hashtag/$hashtag") }, + style = + LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.lessImportantLink, + ), + ) + } + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayZapSplits.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayZapSplits.kt index 41258fa0e..68d34b904 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayZapSplits.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayZapSplits.kt @@ -53,50 +53,50 @@ import com.vitorpamplona.quartz.events.EventInterface @OptIn(ExperimentalLayoutApi::class) @Composable fun DisplayZapSplits( - noteEvent: EventInterface, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + noteEvent: EventInterface, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val list = remember(noteEvent) { noteEvent.zapSplitSetup() } - if (list.isEmpty()) return + val list = remember(noteEvent) { noteEvent.zapSplitSetup() } + if (list.isEmpty()) return - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - Modifier.height(20.dp).width(25.dp), - ) { - Icon( - imageVector = Icons.Outlined.Bolt, - contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier.size(20.dp).align(Alignment.CenterStart), - tint = BitcoinOrange, - ) - Icon( - imageVector = Icons.Outlined.ArrowForwardIos, - contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), - tint = BitcoinOrange, - ) - } - - Spacer(modifier = StdHorzSpacer) - - FlowRow { - list.forEach { - if (it.isLnAddress) { - ClickableText( - text = AnnotatedString(it.lnAddressOrPubKeyHex), - onClick = {}, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - ) - } else { - UserPicture( - userHex = it.lnAddressOrPubKeyHex, - size = Size25dp, - accountViewModel = accountViewModel, - nav = nav, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.height(20.dp).width(25.dp), + ) { + Icon( + imageVector = Icons.Outlined.Bolt, + contentDescription = stringResource(id = R.string.zaps), + modifier = Modifier.size(20.dp).align(Alignment.CenterStart), + tint = BitcoinOrange, + ) + Icon( + imageVector = Icons.Outlined.ArrowForwardIos, + contentDescription = stringResource(id = R.string.zaps), + modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), + tint = BitcoinOrange, + ) + } + + Spacer(modifier = StdHorzSpacer) + + FlowRow { + list.forEach { + if (it.isLnAddress) { + ClickableText( + text = AnnotatedString(it.lnAddressOrPubKeyHex), + onClick = {}, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + ) + } else { + UserPicture( + userHex = it.lnAddressOrPubKeyHex, + size = Size25dp, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } } - } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt index 8b6ee8c76..b7c1280c8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt @@ -53,91 +53,91 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Composable @Preview fun ChannelNamePreview() { - Column { - ChatHeaderLayout( - channelPicture = { - Image( - painter = painterResource(R.drawable.github), - contentDescription = stringResource(id = R.string.profile_banner), - contentScale = ContentScale.FillWidth, + Column { + ChatHeaderLayout( + channelPicture = { + Image( + painter = painterResource(R.drawable.github), + contentDescription = stringResource(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + ) + }, + firstRow = { + Text("This is my author", Modifier.weight(1f)) + TimeAgo(TimeUtils.now()) + }, + secondRow = { + Text("This is a message from this person", Modifier.weight(1f)) + NewItemsBubble() + }, + onClick = {}, ) - }, - firstRow = { - Text("This is my author", Modifier.weight(1f)) - TimeAgo(TimeUtils.now()) - }, - secondRow = { - Text("This is a message from this person", Modifier.weight(1f)) - NewItemsBubble() - }, - onClick = {}, - ) - Divider() + Divider() - ListItem( - headlineContent = { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("This is my author", Modifier.weight(1f)) - TimeAgo(TimeUtils.now()) - } - }, - supportingContent = { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("This is a message from this person", Modifier.weight(1f)) - NewItemsBubble() - } - }, - leadingContent = { - Image( - painter = painterResource(R.drawable.github), - contentDescription = stringResource(id = R.string.profile_banner), - contentScale = ContentScale.FillWidth, - modifier = Size55Modifier, + ListItem( + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("This is my author", Modifier.weight(1f)) + TimeAgo(TimeUtils.now()) + } + }, + supportingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("This is a message from this person", Modifier.weight(1f)) + NewItemsBubble() + } + }, + leadingContent = { + Image( + painter = painterResource(R.drawable.github), + contentDescription = stringResource(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = Size55Modifier, + ) + }, ) - }, - ) - } + } } @Composable fun ChatHeaderLayout( - channelPicture: @Composable () -> Unit, - firstRow: @Composable RowScope.() -> Unit, - secondRow: @Composable RowScope.() -> Unit, - onClick: () -> Unit, + channelPicture: @Composable () -> Unit, + firstRow: @Composable RowScope.() -> Unit, + secondRow: @Composable RowScope.() -> Unit, + onClick: () -> Unit, ) { - Column(modifier = remember { Modifier.clickable(onClick = onClick) }) { - Row( - modifier = ChatHeadlineBorders, - verticalAlignment = Alignment.CenterVertically, - ) { - Box(Size55Modifier) { channelPicture() } - - Spacer(modifier = DoubleHorzSpacer) - - Column( - modifier = Modifier.fillMaxWidth(), - ) { + Column(modifier = remember { Modifier.clickable(onClick = onClick) }) { Row( - verticalAlignment = Alignment.CenterVertically, + modifier = ChatHeadlineBorders, + verticalAlignment = Alignment.CenterVertically, ) { - firstRow() + Box(Size55Modifier) { channelPicture() } + + Spacer(modifier = DoubleHorzSpacer) + + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + firstRow() + } + + Spacer(modifier = Height4dpModifier) + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + secondRow() + } + } } - Spacer(modifier = Height4dpModifier) - - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - secondRow() - } - } + Divider( + modifier = StdTopPadding, + thickness = DividerThickness, + ) } - - Divider( - modifier = StdTopPadding, - thickness = DividerThickness, - ) - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt index 30270fa29..94f3a89a9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt @@ -57,94 +57,94 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable @Preview fun LeftPictureLayoutPreview() { - ThemeComparison( - onDark = { LeftPictureLayoutPreviewCard() }, - onLight = { LeftPictureLayoutPreviewCard() }, - ) + ThemeComparison( + onDark = { LeftPictureLayoutPreviewCard() }, + onLight = { LeftPictureLayoutPreviewCard() }, + ) } @Composable fun LeftPictureLayoutPreviewCard() { - LeftPictureLayout( - onImage = { - Image( - painter = painterResource(R.drawable.github), - contentDescription = stringResource(id = R.string.profile_banner), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxSize().clip(QuoteBorder), - ) - }, - onTitleRow = { - Text( - text = "This is my title", - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) + LeftPictureLayout( + onImage = { + Image( + painter = painterResource(R.drawable.github), + contentDescription = stringResource(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxSize().clip(QuoteBorder), + ) + }, + onTitleRow = { + Text( + text = "This is my title", + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) - Spacer(modifier = StdHorzSpacer) - LikeIcon( - iconSizeModifier = Size16Modifier, - grayTint = MaterialTheme.colorScheme.onSurface, - ) - TextCount(12, MaterialTheme.colorScheme.onSurface) - Spacer(modifier = StdHorzSpacer) - ZappedIcon(Size20Modifier) - TextCount(120, MaterialTheme.colorScheme.onSurface) - }, - onDescription = { - Text( - "This is 3-line description, This is 3-line description, This is 3-line description, This is 3-line description", - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - fontSize = 14.sp, - ) - }, - onBottomRow = { Text("This is my Moderator List") }, - ) + Spacer(modifier = StdHorzSpacer) + LikeIcon( + iconSizeModifier = Size16Modifier, + grayTint = MaterialTheme.colorScheme.onSurface, + ) + TextCount(12, MaterialTheme.colorScheme.onSurface) + Spacer(modifier = StdHorzSpacer) + ZappedIcon(Size20Modifier) + TextCount(120, MaterialTheme.colorScheme.onSurface) + }, + onDescription = { + Text( + "This is 3-line description, This is 3-line description, This is 3-line description, This is 3-line description", + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + fontSize = 14.sp, + ) + }, + onBottomRow = { Text("This is my Moderator List") }, + ) } @Composable fun LeftPictureLayout( - onImage: @Composable () -> Unit, - onTitleRow: @Composable RowScope.() -> Unit, - onDescription: @Composable () -> Unit, - onBottomRow: @Composable RowScope.() -> Unit, + onImage: @Composable () -> Unit, + onTitleRow: @Composable RowScope.() -> Unit, + onDescription: @Composable () -> Unit, + onBottomRow: @Composable RowScope.() -> Unit, ) { - Row(Modifier.aspectRatio(ratio = 4f)) { - Column( - modifier = Modifier.fillMaxWidth(0.25f).aspectRatio(ratio = 1f), - ) { - onImage() + Row(Modifier.aspectRatio(ratio = 4f)) { + Column( + modifier = Modifier.fillMaxWidth(0.25f).aspectRatio(ratio = 1f), + ) { + onImage() + } + + Spacer(modifier = DoubleHorzSpacer) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + onTitleRow() + } + + Row( + modifier = Modifier.fillMaxWidth().weight(1f), + ) { + onDescription() + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + onBottomRow() + } + } } - - Spacer(modifier = DoubleHorzSpacer) - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.SpaceBetween, - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - onTitleRow() - } - - Row( - modifier = Modifier.fillMaxWidth().weight(1f), - ) { - onDescription() - } - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - onBottomRow() - } - } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/RepostLayout.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/RepostLayout.kt index f6efd7b05..c0e3abd19 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/RepostLayout.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/RepostLayout.kt @@ -38,31 +38,31 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable @Preview private fun GenericRepostSectionPreview() { - GenericRepostLayout( - baseAuthorPicture = { Text("ab") }, - repostAuthorPicture = { Text("cd") }, - ) + GenericRepostLayout( + baseAuthorPicture = { Text("ab") }, + repostAuthorPicture = { Text("cd") }, + ) } @Composable fun GenericRepostLayout( - baseAuthorPicture: @Composable () -> Unit, - repostAuthorPicture: @Composable () -> Unit, + baseAuthorPicture: @Composable () -> Unit, + repostAuthorPicture: @Composable () -> Unit, ) { - Box(modifier = Size55Modifier) { - Box(remember { Size35Modifier.align(Alignment.TopStart) }) { baseAuthorPicture() } + Box(modifier = Size55Modifier) { + Box(remember { Size35Modifier.align(Alignment.TopStart) }) { baseAuthorPicture() } - Box( - remember { Size18Modifier.align(Alignment.BottomStart).padding(1.dp) }, - ) { - RepostedIcon(modifier = Size18Modifier, MaterialTheme.colorScheme.placeholderText) - } + Box( + remember { Size18Modifier.align(Alignment.BottomStart).padding(1.dp) }, + ) { + RepostedIcon(modifier = Size18Modifier, MaterialTheme.colorScheme.placeholderText) + } - Box( - remember { Size35Modifier.align(Alignment.BottomEnd) }, - contentAlignment = Alignment.BottomEnd, - ) { - repostAuthorPicture() + Box( + remember { Size35Modifier.align(Alignment.BottomEnd) }, + contentAlignment = Alignment.BottomEnd, + ) { + repostAuthorPicture() + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt index 924bb1b9e..402baa618 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt @@ -86,229 +86,230 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun AccountSwitchBottomSheet( - accountViewModel: AccountViewModel, - accountStateViewModel: AccountStateViewModel, + accountViewModel: AccountViewModel, + accountStateViewModel: AccountStateViewModel, ) { - val accounts = LocalPreferences.allSavedAccounts() + val accounts = LocalPreferences.allSavedAccounts() - var popupExpanded by remember { mutableStateOf(false) } - val scrollState = rememberScrollState() + var popupExpanded by remember { mutableStateOf(false) } + val scrollState = rememberScrollState() - Column(modifier = Modifier.verticalScroll(scrollState)) { - Row( - modifier = Modifier.fillMaxWidth().padding(Size10dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(stringResource(R.string.account_switch_select_account), fontWeight = FontWeight.Bold) - } - accounts.forEach { acc -> DisplayAccount(acc, accountViewModel, accountStateViewModel) } - Row( - modifier = Modifier.fillMaxWidth().padding(top = Size10dp, bottom = Size55dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - TextButton(onClick = { popupExpanded = true }) { - Text(stringResource(R.string.account_switch_add_account_btn)) - } - } - } - - if (popupExpanded) { - Dialog( - onDismissRequest = { popupExpanded = false }, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Surface(modifier = Modifier.fillMaxSize()) { - Box { - LoginPage(accountStateViewModel, isFirstLogin = false) - TopAppBar( - title = { - Text(text = stringResource(R.string.account_switch_add_account_dialog_title)) - }, - navigationIcon = { - IconButton(onClick = { popupExpanded = false }) { ArrowBackIcon() } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) + Column(modifier = Modifier.verticalScroll(scrollState)) { + Row( + modifier = Modifier.fillMaxWidth().padding(Size10dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(R.string.account_switch_select_account), fontWeight = FontWeight.Bold) + } + accounts.forEach { acc -> DisplayAccount(acc, accountViewModel, accountStateViewModel) } + Row( + modifier = Modifier.fillMaxWidth().padding(top = Size10dp, bottom = Size55dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = { popupExpanded = true }) { + Text(stringResource(R.string.account_switch_add_account_btn)) + } + } + } + + if (popupExpanded) { + Dialog( + onDismissRequest = { popupExpanded = false }, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface(modifier = Modifier.fillMaxSize()) { + Box { + LoginPage(accountStateViewModel, isFirstLogin = false) + TopAppBar( + title = { + Text(text = stringResource(R.string.account_switch_add_account_dialog_title)) + }, + navigationIcon = { + IconButton(onClick = { popupExpanded = false }) { ArrowBackIcon() } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + } + } } - } } - } } @Composable fun DisplayAccount( - acc: AccountInfo, - accountViewModel: AccountViewModel, - accountStateViewModel: AccountStateViewModel, + acc: AccountInfo, + accountViewModel: AccountViewModel, + accountStateViewModel: AccountStateViewModel, ) { - var baseUser by remember { - mutableStateOf( - LocalCache.getUserIfExists( - decodePublicKey( - acc.npub, - ) - .toHexKey(), - ), - ) - } - - if (baseUser == null) { - LaunchedEffect(key1 = acc.npub) { - launch(Dispatchers.IO) { - baseUser = - try { - LocalCache.getOrCreateUser( - decodePublicKey(acc.npub).toHexKey(), - ) - } catch (e: Exception) { - null - } - } + var baseUser by remember { + mutableStateOf( + LocalCache.getUserIfExists( + decodePublicKey( + acc.npub, + ) + .toHexKey(), + ), + ) } - } - baseUser?.let { - Row( - modifier = - Modifier.fillMaxWidth() - .clickable { accountStateViewModel.switchUser(acc) } - .padding(16.dp, 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically, - ) { + if (baseUser == null) { + LaunchedEffect(key1 = acc.npub) { + launch(Dispatchers.IO) { + baseUser = + try { + LocalCache.getOrCreateUser( + decodePublicKey(acc.npub).toHexKey(), + ) + } catch (e: Exception) { + null + } + } + } + } + + baseUser?.let { Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .clickable { accountStateViewModel.switchUser(acc) } + .padding(16.dp, 16.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Box( - modifier = Modifier.width(55.dp).padding(0.dp), - ) { - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.width(55.dp).padding(0.dp), + ) { + val automaticallyShowProfilePicture = + remember { + accountViewModel.settings.showProfilePictures.value + } + + AccountPicture(it, automaticallyShowProfilePicture) + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { AccountName(acc, it) } + Column(modifier = Modifier.width(32.dp)) { ActiveMarker(acc, accountViewModel) } + } } - AccountPicture(it, automaticallyShowProfilePicture) - } - Spacer(modifier = Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { AccountName(acc, it) } - Column(modifier = Modifier.width(32.dp)) { ActiveMarker(acc, accountViewModel) } + LogoutButton(acc, accountStateViewModel) } - } - - LogoutButton(acc, accountStateViewModel) } - } } @Composable private fun ActiveMarker( - acc: AccountInfo, - accountViewModel: AccountViewModel, + acc: AccountInfo, + accountViewModel: AccountViewModel, ) { - val isCurrentUser by - remember(accountViewModel) { - derivedStateOf { accountViewModel.account.userProfile().pubkeyNpub() == acc.npub } - } + val isCurrentUser by + remember(accountViewModel) { + derivedStateOf { accountViewModel.account.userProfile().pubkeyNpub() == acc.npub } + } - if (isCurrentUser) { - Icon( - imageVector = Icons.Default.RadioButtonChecked, - contentDescription = stringResource(R.string.account_switch_active_account), - tint = MaterialTheme.colorScheme.secondary, - ) - } + if (isCurrentUser) { + Icon( + imageVector = Icons.Default.RadioButtonChecked, + contentDescription = stringResource(R.string.account_switch_active_account), + tint = MaterialTheme.colorScheme.secondary, + ) + } } @Composable private fun AccountPicture( - user: User, - loadProfilePicture: Boolean, + user: User, + loadProfilePicture: Boolean, ) { - val profilePicture by user.live().profilePictureChanges.observeAsState() + val profilePicture by user.live().profilePictureChanges.observeAsState() - RobohashFallbackAsyncImage( - robot = user.pubkeyHex, - model = profilePicture, - contentDescription = stringResource(R.string.profile_image), - modifier = AccountPictureModifier, - loadProfilePicture = loadProfilePicture, - ) + RobohashFallbackAsyncImage( + robot = user.pubkeyHex, + model = profilePicture, + contentDescription = stringResource(R.string.profile_image), + modifier = AccountPictureModifier, + loadProfilePicture = loadProfilePicture, + ) } @Composable private fun AccountName( - acc: AccountInfo, - user: User, + acc: AccountInfo, + user: User, ) { - val displayName by user.live().metadata.map { user.bestDisplayName() }.observeAsState() + val displayName by user.live().metadata.map { user.bestDisplayName() }.observeAsState() - val tags by - user - .live() - .metadata - .map { user.info?.latestMetadata?.tags?.toImmutableListOfLists() } - .observeAsState() + val tags by + user + .live() + .metadata + .map { user.info?.latestMetadata?.tags?.toImmutableListOfLists() } + .observeAsState() - displayName?.let { - CreateTextWithEmoji( - text = it, - tags = tags, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + displayName?.let { + CreateTextWithEmoji( + text = it, + tags = tags, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Text( + text = remember(user) { acc.npub.toShortenHex() }, ) - } - - Text( - text = remember(user) { acc.npub.toShortenHex() }, - ) } @Composable private fun LogoutButton( - acc: AccountInfo, - accountStateViewModel: AccountStateViewModel, + acc: AccountInfo, + accountStateViewModel: AccountStateViewModel, ) { - var logoutDialog by remember { mutableStateOf(false) } - if (logoutDialog) { - AlertDialog( - title = { Text(text = stringResource(R.string.log_out)) }, - text = { Text(text = stringResource(R.string.are_you_sure_you_want_to_log_out)) }, - onDismissRequest = { logoutDialog = false }, - confirmButton = { - TextButton( - onClick = { - logoutDialog = false - accountStateViewModel.logOff(acc) - }, - ) { - Text(text = stringResource(R.string.log_out)) - } - }, - dismissButton = { - TextButton( - onClick = { logoutDialog = false }, - ) { - Text(text = stringResource(R.string.cancel)) - } - }, - ) - } + var logoutDialog by remember { mutableStateOf(false) } + if (logoutDialog) { + AlertDialog( + title = { Text(text = stringResource(R.string.log_out)) }, + text = { Text(text = stringResource(R.string.are_you_sure_you_want_to_log_out)) }, + onDismissRequest = { logoutDialog = false }, + confirmButton = { + TextButton( + onClick = { + logoutDialog = false + accountStateViewModel.logOff(acc) + }, + ) { + Text(text = stringResource(R.string.log_out)) + } + }, + dismissButton = { + TextButton( + onClick = { logoutDialog = false }, + ) { + Text(text = stringResource(R.string.cancel)) + } + }, + ) + } - IconButton( - onClick = { logoutDialog = true }, - ) { - Icon( - imageVector = Icons.Default.Logout, - contentDescription = stringResource(R.string.log_out), - tint = MaterialTheme.colorScheme.onSurface, - ) - } + IconButton( + onClick = { logoutDialog = true }, + ) { + Icon( + imageVector = Icons.Default.Logout, + contentDescription = stringResource(R.string.log_out), + tint = MaterialTheme.colorScheme.onSurface, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt index 43e5df7b8..e2f173a71 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt @@ -62,162 +62,162 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp import kotlinx.collections.immutable.persistentListOf val bottomNavigationItems = - persistentListOf( - Route.Home, - Route.Message, - Route.Video, - Route.Discover, - Route.Notification, - ) + persistentListOf( + Route.Home, + Route.Message, + Route.Video, + Route.Discover, + Route.Notification, + ) enum class Keyboard { - Opened, - Closed, + Opened, + Closed, } fun isKeyboardOpen(view: View): Keyboard { - val rect = Rect() - view.getWindowVisibleDisplayFrame(rect) - val screenHeight = view.rootView.height - val keypadHeight = screenHeight - rect.bottom + val rect = Rect() + view.getWindowVisibleDisplayFrame(rect) + val screenHeight = view.rootView.height + val keypadHeight = screenHeight - rect.bottom - return if (keypadHeight > screenHeight * 0.15) { - Keyboard.Opened - } else { - Keyboard.Closed - } + return if (keypadHeight > screenHeight * 0.15) { + Keyboard.Opened + } else { + Keyboard.Closed + } } @Composable fun keyboardAsState(): State { - val view = LocalView.current + val view = LocalView.current - val keyboardState = remember(view) { mutableStateOf(isKeyboardOpen(view)) } + val keyboardState = remember(view) { mutableStateOf(isKeyboardOpen(view)) } - DisposableEffect(view) { - val onGlobalListener = - ViewTreeObserver.OnGlobalLayoutListener { - val newKeyboardValue = isKeyboardOpen(view) + DisposableEffect(view) { + val onGlobalListener = + ViewTreeObserver.OnGlobalLayoutListener { + val newKeyboardValue = isKeyboardOpen(view) - if (newKeyboardValue != keyboardState.value) { - keyboardState.value = newKeyboardValue - } - } - view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener) + if (newKeyboardValue != keyboardState.value) { + keyboardState.value = newKeyboardValue + } + } + view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener) - onDispose { view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener) } - } + onDispose { view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener) } + } - return keyboardState + return keyboardState } @Composable fun IfKeyboardClosed(inner: @Composable () -> Unit) { - val isKeyboardState by keyboardAsState() - if (isKeyboardState == Keyboard.Closed) { - inner() - } + val isKeyboardState by keyboardAsState() + if (isKeyboardState == Keyboard.Closed) { + inner() + } } @Composable fun AppBottomBar( - accountViewModel: AccountViewModel, - navEntryState: State, - nav: (Route, Boolean) -> Unit, + accountViewModel: AccountViewModel, + navEntryState: State, + nav: (Route, Boolean) -> Unit, ) { - IfKeyboardClosed { RenderBottomMenu(accountViewModel, navEntryState, nav) } + IfKeyboardClosed { RenderBottomMenu(accountViewModel, navEntryState, nav) } } @Composable private fun RenderBottomMenu( - accountViewModel: AccountViewModel, - navEntryState: State, - nav: (Route, Boolean) -> Unit, + accountViewModel: AccountViewModel, + navEntryState: State, + nav: (Route, Boolean) -> Unit, ) { - Column(modifier = BottomTopHeight) { - Divider( - thickness = DividerThickness, - ) - NavigationBar(tonalElevation = Size0dp) { - bottomNavigationItems.forEach { item -> - HasNewItemsIcon(item, accountViewModel, navEntryState, nav) - } + Column(modifier = BottomTopHeight) { + Divider( + thickness = DividerThickness, + ) + NavigationBar(tonalElevation = Size0dp) { + bottomNavigationItems.forEach { item -> + HasNewItemsIcon(item, accountViewModel, navEntryState, nav) + } + } } - } } @Composable private fun RowScope.HasNewItemsIcon( - route: Route, - accountViewModel: AccountViewModel, - navEntryState: State, - nav: (Route, Boolean) -> Unit, + route: Route, + accountViewModel: AccountViewModel, + navEntryState: State, + nav: (Route, Boolean) -> Unit, ) { - val selected by - remember(navEntryState.value) { - derivedStateOf { navEntryState.value?.destination?.route?.substringBefore("?") == route.base } - } + val selected by + remember(navEntryState.value) { + derivedStateOf { navEntryState.value?.destination?.route?.substringBefore("?") == route.base } + } - NavigationBarItem( - icon = { - NotifiableIcon( - selected, - route, - accountViewModel, - ) - }, - selected = selected, - onClick = { nav(route, selected) }, - ) + NavigationBarItem( + icon = { + NotifiableIcon( + selected, + route, + accountViewModel, + ) + }, + selected = selected, + onClick = { nav(route, selected) }, + ) } @Composable private fun NotifiableIcon( - selected: Boolean, - route: Route, - accountViewModel: AccountViewModel, + selected: Boolean, + route: Route, + accountViewModel: AccountViewModel, ) { - Box(route.notifSize) { - Icon( - painter = painterResource(id = route.icon), - contentDescription = null, - modifier = route.iconSize, - tint = if (selected) MaterialTheme.colorScheme.primary else Color.Unspecified, - ) + Box(route.notifSize) { + Icon( + painter = painterResource(id = route.icon), + contentDescription = null, + modifier = route.iconSize, + tint = if (selected) MaterialTheme.colorScheme.primary else Color.Unspecified, + ) - AddNotifIconIfNeeded(route, accountViewModel, Modifier.align(Alignment.TopEnd)) - } + AddNotifIconIfNeeded(route, accountViewModel, Modifier.align(Alignment.TopEnd)) + } } @Composable fun AddNotifIconIfNeeded( - route: Route, - accountViewModel: AccountViewModel, - modifier: Modifier, + route: Route, + accountViewModel: AccountViewModel, + modifier: Modifier, ) { - val flow = accountViewModel.notificationDots.hasNewItems[route] ?: return - val hasNewItems by flow.collectAsStateWithLifecycle() - if (hasNewItems) { - NotificationDotIcon(modifier) - } + val flow = accountViewModel.notificationDots.hasNewItems[route] ?: return + val hasNewItems by flow.collectAsStateWithLifecycle() + if (hasNewItems) { + NotificationDotIcon(modifier) + } } @Composable private fun NotificationDotIcon(modifier: Modifier) { - Box(modifier.size(Size10dp)) { - Box( - modifier = - remember { Size10Modifier.clip(shape = CircleShape) } - .background(MaterialTheme.colorScheme.primary), - contentAlignment = Alignment.TopEnd, - ) { - Text( - "", - color = Color.White, - textAlign = TextAlign.Center, - fontSize = Font12SP, - modifier = remember { Modifier.wrapContentHeight().align(Alignment.TopEnd) }, - ) + Box(modifier.size(Size10dp)) { + Box( + modifier = + remember { Size10Modifier.clip(shape = CircleShape) } + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.TopEnd, + ) { + Text( + "", + color = Color.White, + textAlign = TextAlign.Center, + fontSize = Font12SP, + modifier = remember { Modifier.wrapContentHeight().align(Alignment.TopEnd) }, + ) + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index e04d1f92f..ddc18b822 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -77,336 +77,337 @@ import kotlinx.coroutines.launch @Composable fun AppNavigation( - homeFeedViewModel: NostrHomeFeedViewModel, - repliesFeedViewModel: NostrHomeRepliesFeedViewModel, - knownFeedViewModel: NostrChatroomListKnownFeedViewModel, - newFeedViewModel: NostrChatroomListNewFeedViewModel, - videoFeedViewModel: NostrVideoFeedViewModel, - discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel, - discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, - discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, - discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel, - notifFeedViewModel: NotificationViewModel, - userReactionsStatsModel: UserReactionsViewModel, - navController: NavHostController, - accountViewModel: AccountViewModel, - sharedPreferencesViewModel: SharedPreferencesViewModel, + homeFeedViewModel: NostrHomeFeedViewModel, + repliesFeedViewModel: NostrHomeRepliesFeedViewModel, + knownFeedViewModel: NostrChatroomListKnownFeedViewModel, + newFeedViewModel: NostrChatroomListNewFeedViewModel, + videoFeedViewModel: NostrVideoFeedViewModel, + discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel, + discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, + discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, + discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel, + notifFeedViewModel: NotificationViewModel, + userReactionsStatsModel: UserReactionsViewModel, + navController: NavHostController, + accountViewModel: AccountViewModel, + sharedPreferencesViewModel: SharedPreferencesViewModel, ) { - val scope = rememberCoroutineScope() - val nav = remember { - { route: String -> - scope.launch { - if (getRouteWithArguments(navController) != route) { - navController.navigate(route) - } - } - Unit - } - } - - NavHost( - navController, - startDestination = Route.Home.route, - enterTransition = { fadeIn(animationSpec = tween(200)) }, - exitTransition = { fadeOut(animationSpec = tween(200)) }, - ) { - Route.Home.let { route -> - composable( - route.route, - route.arguments, - content = { it -> - val nip47 = it.arguments?.getString("nip47") - - HomeScreen( - homeFeedViewModel = homeFeedViewModel, - repliesFeedViewModel = repliesFeedViewModel, - accountViewModel = accountViewModel, - nav = nav, - nip47 = nip47, - ) - - if (nip47 != null) { - LaunchedEffect(key1 = Unit) { - launch { - delay(1000) - it.arguments?.remove("nip47") - } + val scope = rememberCoroutineScope() + val nav = + remember { + { route: String -> + scope.launch { + if (getRouteWithArguments(navController) != route) { + navController.navigate(route) + } + } + Unit } - } - }, - ) - } + } - composable( - Route.Message.route, - content = { - ChatroomListScreen( - knownFeedViewModel, - newFeedViewModel, - accountViewModel, - nav, + NavHost( + navController, + startDestination = Route.Home.route, + enterTransition = { fadeIn(animationSpec = tween(200)) }, + exitTransition = { fadeOut(animationSpec = tween(200)) }, + ) { + Route.Home.let { route -> + composable( + route.route, + route.arguments, + content = { it -> + val nip47 = it.arguments?.getString("nip47") + + HomeScreen( + homeFeedViewModel = homeFeedViewModel, + repliesFeedViewModel = repliesFeedViewModel, + accountViewModel = accountViewModel, + nav = nav, + nip47 = nip47, + ) + + if (nip47 != null) { + LaunchedEffect(key1 = Unit) { + launch { + delay(1000) + it.arguments?.remove("nip47") + } + } + } + }, + ) + } + + composable( + Route.Message.route, + content = { + ChatroomListScreen( + knownFeedViewModel, + newFeedViewModel, + accountViewModel, + nav, + ) + }, ) - }, - ) - Route.Video.let { route -> - composable( - route.route, - route.arguments, - content = { - VideoScreen( - videoFeedView = videoFeedViewModel, - accountViewModel = accountViewModel, - nav = nav, - ) - }, - ) - } - - Route.Discover.let { route -> - composable( - route.route, - route.arguments, - content = { - DiscoverScreen( - discoveryMarketplaceFeedViewModel = discoverMarketplaceFeedViewModel, - discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, - discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, - discoveryChatFeedViewModel = discoveryChatFeedViewModel, - accountViewModel = accountViewModel, - nav = nav, - ) - }, - ) - } - - Route.Search.let { route -> - composable( - route.route, - route.arguments, - content = { - SearchScreen( - accountViewModel = accountViewModel, - nav = nav, - ) - }, - ) - } - - Route.Notification.let { route -> - composable( - route.route, - route.arguments, - content = { - NotificationScreen( - notifFeedViewModel = notifFeedViewModel, - userReactionsStatsModel = userReactionsStatsModel, - sharedPreferencesViewModel = sharedPreferencesViewModel, - accountViewModel = accountViewModel, - nav = nav, - ) - }, - ) - } - - composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, nav) }) - composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, nav) }) - - Route.Profile.let { route -> - composable( - route.route, - route.arguments, - content = { - ProfileScreen( - userId = it.arguments?.getString("id"), - accountViewModel = accountViewModel, - nav = nav, - ) - }, - ) - } - - Route.Note.let { route -> - composable( - route.route, - route.arguments, - content = { - ThreadScreen( - noteId = it.arguments?.getString("id"), - accountViewModel = accountViewModel, - nav = nav, - ) - }, - ) - } - - Route.Hashtag.let { route -> - composable( - route.route, - route.arguments, - content = { - HashtagScreen( - tag = it.arguments?.getString("id"), - accountViewModel = accountViewModel, - nav = nav, - ) - }, - ) - } - - Route.Geohash.let { route -> - composable( - route.route, - route.arguments, - content = { - GeoHashScreen( - tag = it.arguments?.getString("id"), - accountViewModel = accountViewModel, - nav = nav, - ) - }, - ) - } - - Route.Community.let { route -> - composable( - route.route, - route.arguments, - content = { - CommunityScreen( - aTagHex = it.arguments?.getString("id"), - accountViewModel = accountViewModel, - nav = nav, - ) - }, - ) - } - - Route.Room.let { route -> - composable( - route.route, - route.arguments, - content = { - ChatroomScreen( - roomId = it.arguments?.getString("id"), - draftMessage = it.arguments?.getString("message"), - accountViewModel = accountViewModel, - nav = nav, - ) - }, - ) - } - - Route.RoomByAuthor.let { route -> - composable( - route.route, - route.arguments, - content = { - ChatroomScreenByAuthor( - authorPubKeyHex = it.arguments?.getString("id"), - accountViewModel = accountViewModel, - nav = nav, - ) - }, - ) - } - - Route.Channel.let { route -> - composable( - route.route, - route.arguments, - content = { - ChannelScreen( - channelId = it.arguments?.getString("id"), - accountViewModel = accountViewModel, - nav = nav, - ) - }, - ) - } - - Route.Event.let { route -> - composable( - route.route, - route.arguments, - content = { - LoadRedirectScreen( - eventId = it.arguments?.getString("id"), - accountViewModel = accountViewModel, - navController = navController, - ) - }, - ) - } - - Route.Settings.let { route -> - composable( - route.route, - route.arguments, - content = { - SettingsScreen( - sharedPreferencesViewModel, - ) - }, - ) - } - } - - val activity = LocalContext.current.getActivity() - var actionableNextPage by remember { - mutableStateOf(uriToRoute(activity.intent?.data?.toString()?.ifBlank { null })) - } - actionableNextPage?.let { - LaunchedEffect(it) { - navController.navigate(it) { - popUpTo(Route.Home.route) - launchSingleTop = true - } - } - actionableNextPage = null - } - - DisposableEffect(activity) { - val consumer = - Consumer { intent -> - val uri = intent?.data?.toString() - val newPage = uriToRoute(uri) - - newPage?.let { route -> - val currentRoute = getRouteWithArguments(navController) - if (!isSameRoute(currentRoute, route)) { - navController.navigate(route) { - popUpTo(Route.Home.route) - launchSingleTop = true - } - } + Route.Video.let { route -> + composable( + route.route, + route.arguments, + content = { + VideoScreen( + videoFeedView = videoFeedViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) } - } - activity.addOnNewIntentListener(consumer) - onDispose { activity.removeOnNewIntentListener(consumer) } - } + + Route.Discover.let { route -> + composable( + route.route, + route.arguments, + content = { + DiscoverScreen( + discoveryMarketplaceFeedViewModel = discoverMarketplaceFeedViewModel, + discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, + discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, + discoveryChatFeedViewModel = discoveryChatFeedViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + + Route.Search.let { route -> + composable( + route.route, + route.arguments, + content = { + SearchScreen( + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + + Route.Notification.let { route -> + composable( + route.route, + route.arguments, + content = { + NotificationScreen( + notifFeedViewModel = notifFeedViewModel, + userReactionsStatsModel = userReactionsStatsModel, + sharedPreferencesViewModel = sharedPreferencesViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + + composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, nav) }) + composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, nav) }) + + Route.Profile.let { route -> + composable( + route.route, + route.arguments, + content = { + ProfileScreen( + userId = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + + Route.Note.let { route -> + composable( + route.route, + route.arguments, + content = { + ThreadScreen( + noteId = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + + Route.Hashtag.let { route -> + composable( + route.route, + route.arguments, + content = { + HashtagScreen( + tag = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + + Route.Geohash.let { route -> + composable( + route.route, + route.arguments, + content = { + GeoHashScreen( + tag = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + + Route.Community.let { route -> + composable( + route.route, + route.arguments, + content = { + CommunityScreen( + aTagHex = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + + Route.Room.let { route -> + composable( + route.route, + route.arguments, + content = { + ChatroomScreen( + roomId = it.arguments?.getString("id"), + draftMessage = it.arguments?.getString("message"), + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + + Route.RoomByAuthor.let { route -> + composable( + route.route, + route.arguments, + content = { + ChatroomScreenByAuthor( + authorPubKeyHex = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + + Route.Channel.let { route -> + composable( + route.route, + route.arguments, + content = { + ChannelScreen( + channelId = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + + Route.Event.let { route -> + composable( + route.route, + route.arguments, + content = { + LoadRedirectScreen( + eventId = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + navController = navController, + ) + }, + ) + } + + Route.Settings.let { route -> + composable( + route.route, + route.arguments, + content = { + SettingsScreen( + sharedPreferencesViewModel, + ) + }, + ) + } + } + + val activity = LocalContext.current.getActivity() + var actionableNextPage by remember { + mutableStateOf(uriToRoute(activity.intent?.data?.toString()?.ifBlank { null })) + } + actionableNextPage?.let { + LaunchedEffect(it) { + navController.navigate(it) { + popUpTo(Route.Home.route) + launchSingleTop = true + } + } + actionableNextPage = null + } + + DisposableEffect(activity) { + val consumer = + Consumer { intent -> + val uri = intent?.data?.toString() + val newPage = uriToRoute(uri) + + newPage?.let { route -> + val currentRoute = getRouteWithArguments(navController) + if (!isSameRoute(currentRoute, route)) { + navController.navigate(route) { + popUpTo(Route.Home.route) + launchSingleTop = true + } + } + } + } + activity.addOnNewIntentListener(consumer) + onDispose { activity.removeOnNewIntentListener(consumer) } + } } fun Context.getActivity(): MainActivity { - if (this is MainActivity) return this - return if (this is ContextWrapper) baseContext.getActivity() else getActivity() + if (this is MainActivity) return this + return if (this is ContextWrapper) baseContext.getActivity() else getActivity() } private fun isSameRoute( - currentRoute: String?, - newRoute: String, + currentRoute: String?, + newRoute: String, ): Boolean { - if (currentRoute == null) return false + if (currentRoute == null) return false - if (currentRoute == newRoute) { - return true - } - - if (newRoute.startsWith("Event/") && currentRoute.contains("/")) { - if (newRoute.split("/")[1] == currentRoute.split("/")[1]) { - return true + if (currentRoute == newRoute) { + return true } - } - return false + if (newRoute.startsWith("Event/") && currentRoute.contains("/")) { + if (newRoute.split("/")[1] == currentRoute.split("/")[1]) { + return true + } + } + + return false } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index b9c3efa1f..29b080d6f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -152,926 +152,927 @@ import kotlinx.coroutines.launch @Composable fun AppTopBar( - followLists: FollowListViewModel, - navEntryState: State, - drawerState: DrawerState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - navPopBack: () -> Unit, + followLists: FollowListViewModel, + navEntryState: State, + drawerState: DrawerState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + navPopBack: () -> Unit, ) { - val currentRoute by - remember(navEntryState.value) { - derivedStateOf { navEntryState.value?.destination?.route?.substringBefore("?") } - } + val currentRoute by + remember(navEntryState.value) { + derivedStateOf { navEntryState.value?.destination?.route?.substringBefore("?") } + } - val id by - remember(navEntryState.value) { - derivedStateOf { navEntryState.value?.arguments?.getString("id") } - } + val id by + remember(navEntryState.value) { + derivedStateOf { navEntryState.value?.arguments?.getString("id") } + } - RenderTopRouteBar(currentRoute, id, followLists, drawerState, accountViewModel, nav, navPopBack) + RenderTopRouteBar(currentRoute, id, followLists, drawerState, accountViewModel, nav, navPopBack) } @Composable private fun RenderTopRouteBar( - currentRoute: String?, - id: String?, - followLists: FollowListViewModel, - drawerState: DrawerState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - navPopBack: () -> Unit, + currentRoute: String?, + id: String?, + followLists: FollowListViewModel, + drawerState: DrawerState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + navPopBack: () -> Unit, ) { - when (currentRoute) { - Route.Home.base -> HomeTopBar(followLists, drawerState, accountViewModel, nav) - Route.Video.base -> StoriesTopBar(followLists, drawerState, accountViewModel, nav) - Route.Discover.base -> DiscoveryTopBar(followLists, drawerState, accountViewModel, nav) - Route.Notification.base -> NotificationTopBar(followLists, drawerState, accountViewModel, nav) - Route.Settings.base -> - TopBarWithBackButton(stringResource(id = R.string.application_preferences), navPopBack) - else -> { - if (id != null) { - when (currentRoute) { - Route.Channel.base -> ChannelTopBar(id, accountViewModel, nav, navPopBack) - Route.RoomByAuthor.base -> RoomByAuthorTopBar(id, accountViewModel, nav, navPopBack) - Route.Room.base -> RoomTopBar(id, accountViewModel, nav, navPopBack) - Route.Community.base -> CommunityTopBar(id, accountViewModel, nav, navPopBack) - Route.Hashtag.base -> HashTagTopBar(id, accountViewModel, navPopBack) - Route.Geohash.base -> GeoHashTopBar(id, accountViewModel, navPopBack) - Route.Note.base -> ThreadTopBar(id, accountViewModel, navPopBack) - else -> MainTopBar(drawerState, accountViewModel, nav) + when (currentRoute) { + Route.Home.base -> HomeTopBar(followLists, drawerState, accountViewModel, nav) + Route.Video.base -> StoriesTopBar(followLists, drawerState, accountViewModel, nav) + Route.Discover.base -> DiscoveryTopBar(followLists, drawerState, accountViewModel, nav) + Route.Notification.base -> NotificationTopBar(followLists, drawerState, accountViewModel, nav) + Route.Settings.base -> + TopBarWithBackButton(stringResource(id = R.string.application_preferences), navPopBack) + else -> { + if (id != null) { + when (currentRoute) { + Route.Channel.base -> ChannelTopBar(id, accountViewModel, nav, navPopBack) + Route.RoomByAuthor.base -> RoomByAuthorTopBar(id, accountViewModel, nav, navPopBack) + Route.Room.base -> RoomTopBar(id, accountViewModel, nav, navPopBack) + Route.Community.base -> CommunityTopBar(id, accountViewModel, nav, navPopBack) + Route.Hashtag.base -> HashTagTopBar(id, accountViewModel, navPopBack) + Route.Geohash.base -> GeoHashTopBar(id, accountViewModel, navPopBack) + Route.Note.base -> ThreadTopBar(id, accountViewModel, navPopBack) + else -> MainTopBar(drawerState, accountViewModel, nav) + } + } else { + MainTopBar(drawerState, accountViewModel, nav) + } } - } else { - MainTopBar(drawerState, accountViewModel, nav) - } } - } } @Composable private fun ThreadTopBar( - id: String, - accountViewModel: AccountViewModel, - navPopBack: () -> Unit, + id: String, + accountViewModel: AccountViewModel, + navPopBack: () -> Unit, ) { - FlexibleTopBarWithBackButton( - title = { Text(stringResource(id = R.string.thread_title)) }, - popBack = navPopBack, - ) + FlexibleTopBarWithBackButton( + title = { Text(stringResource(id = R.string.thread_title)) }, + popBack = navPopBack, + ) } @Composable private fun GeoHashTopBar( - tag: String, - accountViewModel: AccountViewModel, - navPopBack: () -> Unit, + tag: String, + accountViewModel: AccountViewModel, + navPopBack: () -> Unit, ) { - FlexibleTopBarWithBackButton( - title = { - DislayGeoTagHeader(tag, remember { Modifier.weight(1f) }) - GeoHashActionOptions(tag, accountViewModel) - }, - popBack = navPopBack, - ) + FlexibleTopBarWithBackButton( + title = { + DislayGeoTagHeader(tag, remember { Modifier.weight(1f) }) + GeoHashActionOptions(tag, accountViewModel) + }, + popBack = navPopBack, + ) } @Composable private fun HashTagTopBar( - tag: String, - accountViewModel: AccountViewModel, - navPopBack: () -> Unit, + tag: String, + accountViewModel: AccountViewModel, + navPopBack: () -> Unit, ) { - FlexibleTopBarWithBackButton( - title = { - Text( - remember(tag) { "#$tag" }, - modifier = Modifier.weight(1f), - ) + FlexibleTopBarWithBackButton( + title = { + Text( + remember(tag) { "#$tag" }, + modifier = Modifier.weight(1f), + ) - HashtagActionOptions(tag, accountViewModel) - }, - popBack = navPopBack, - ) + HashtagActionOptions(tag, accountViewModel) + }, + popBack = navPopBack, + ) } @Composable private fun CommunityTopBar( - id: String, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - navPopBack: () -> Unit, + id: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + navPopBack: () -> Unit, ) { - LoadAddressableNote(aTagHex = id, accountViewModel) { baseNote -> - if (baseNote != null) { - FlexibleTopBarWithBackButton( - title = { ShortCommunityHeader(baseNote, accountViewModel, nav) }, - extendableRow = { - Column(Modifier.verticalScroll(rememberScrollState())) { - LongCommunityHeader(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav) - } - }, - popBack = navPopBack, - ) - } else { - Spacer(BottomTopHeight) + LoadAddressableNote(aTagHex = id, accountViewModel) { baseNote -> + if (baseNote != null) { + FlexibleTopBarWithBackButton( + title = { ShortCommunityHeader(baseNote, accountViewModel, nav) }, + extendableRow = { + Column(Modifier.verticalScroll(rememberScrollState())) { + LongCommunityHeader(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav) + } + }, + popBack = navPopBack, + ) + } else { + Spacer(BottomTopHeight) + } } - } } @Composable private fun RoomTopBar( - id: String, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - navPopBack: () -> Unit, + id: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + navPopBack: () -> Unit, ) { - LoadRoom(roomId = id, accountViewModel) { room -> - if (room != null) { - RenderRoomTopBar(room, accountViewModel, nav, navPopBack) - } else { - Spacer(BottomTopHeight) + LoadRoom(roomId = id, accountViewModel) { room -> + if (room != null) { + RenderRoomTopBar(room, accountViewModel, nav, navPopBack) + } else { + Spacer(BottomTopHeight) + } } - } } @Composable private fun RoomByAuthorTopBar( - id: String, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - navPopBack: () -> Unit, + id: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + navPopBack: () -> Unit, ) { - LoadRoomByAuthor(authorPubKeyHex = id, accountViewModel) { room -> - if (room != null) { - RenderRoomTopBar(room, accountViewModel, nav, navPopBack) - } else { - Spacer(BottomTopHeight) + LoadRoomByAuthor(authorPubKeyHex = id, accountViewModel) { room -> + if (room != null) { + RenderRoomTopBar(room, accountViewModel, nav, navPopBack) + } else { + Spacer(BottomTopHeight) + } } - } } @Composable private fun RenderRoomTopBar( - room: ChatroomKey, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - navPopBack: () -> Unit, + room: ChatroomKey, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + navPopBack: () -> Unit, ) { - if (room.users.size == 1) { - FlexibleTopBarWithBackButton( - title = { - LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser -> - if (baseUser != null) { - ClickableUserPicture( - baseUser = baseUser, - accountViewModel = accountViewModel, - size = Size34dp, - ) + if (room.users.size == 1) { + FlexibleTopBarWithBackButton( + title = { + LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser -> + if (baseUser != null) { + ClickableUserPicture( + baseUser = baseUser, + accountViewModel = accountViewModel, + size = Size34dp, + ) - Spacer(modifier = DoubleHorzSpacer) + Spacer(modifier = DoubleHorzSpacer) - UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Normal) - } - } - }, - extendableRow = { - LoadUser(baseUserHex = room.users.first(), accountViewModel) { - if (it != null) { - UserCompose( - baseUser = it, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - }, - popBack = navPopBack, - ) - } else { - FlexibleTopBarWithBackButton( - title = { - NonClickableUserPictures( - users = room.users, - accountViewModel = accountViewModel, - size = Size34dp, + UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Normal) + } + } + }, + extendableRow = { + LoadUser(baseUserHex = room.users.first(), accountViewModel) { + if (it != null) { + UserCompose( + baseUser = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + }, + popBack = navPopBack, ) + } else { + FlexibleTopBarWithBackButton( + title = { + NonClickableUserPictures( + users = room.users, + accountViewModel = accountViewModel, + size = Size34dp, + ) - RoomNameOnlyDisplay( - room, - Modifier.padding(start = 10.dp).weight(1f), - fontWeight = FontWeight.Normal, - accountViewModel.userProfile(), + RoomNameOnlyDisplay( + room, + Modifier.padding(start = 10.dp).weight(1f), + fontWeight = FontWeight.Normal, + accountViewModel.userProfile(), + ) + }, + extendableRow = { + LongRoomHeader(room = room, accountViewModel = accountViewModel, nav = nav) + }, + popBack = navPopBack, ) - }, - extendableRow = { - LongRoomHeader(room = room, accountViewModel = accountViewModel, nav = nav) - }, - popBack = navPopBack, - ) - } + } } @Composable private fun ChannelTopBar( - id: String, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - navPopBack: () -> Unit, + id: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + navPopBack: () -> Unit, ) { - LoadChannel(baseChannelHex = id, accountViewModel) { baseChannel -> - FlexibleTopBarWithBackButton( - title = { - ShortChannelHeader( - baseChannel = baseChannel, - accountViewModel = accountViewModel, - nav = nav, - showFlag = true, + LoadChannel(baseChannelHex = id, accountViewModel) { baseChannel -> + FlexibleTopBarWithBackButton( + title = { + ShortChannelHeader( + baseChannel = baseChannel, + accountViewModel = accountViewModel, + nav = nav, + showFlag = true, + ) + }, + extendableRow = { + LongChannelHeader(baseChannel = baseChannel, accountViewModel = accountViewModel, nav = nav) + }, + popBack = navPopBack, ) - }, - extendableRow = { - LongChannelHeader(baseChannel = baseChannel, accountViewModel = accountViewModel, nav = nav) - }, - popBack = navPopBack, - ) - } + } } @Composable fun NoTopBar() {} @Composable fun StoriesTopBar( - followLists: FollowListViewModel, - drawerState: DrawerState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + followLists: FollowListViewModel, + drawerState: DrawerState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> - val list by accountViewModel.account.defaultStoriesFollowList.collectAsStateWithLifecycle() + GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> + val list by accountViewModel.account.defaultStoriesFollowList.collectAsStateWithLifecycle() - FollowListWithRoutes( - followListsModel = followLists, - listName = list, - ) { listName -> - accountViewModel.account.changeDefaultStoriesFollowList(listName.code) + FollowListWithRoutes( + followListsModel = followLists, + listName = list, + ) { listName -> + accountViewModel.account.changeDefaultStoriesFollowList(listName.code) + } } - } } @Composable fun HomeTopBar( - followLists: FollowListViewModel, - drawerState: DrawerState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + followLists: FollowListViewModel, + drawerState: DrawerState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> - val list by accountViewModel.account.defaultHomeFollowList.collectAsStateWithLifecycle() + GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> + val list by accountViewModel.account.defaultHomeFollowList.collectAsStateWithLifecycle() - FollowListWithRoutes( - followListsModel = followLists, - listName = list, - ) { listName -> - if (listName.type == CodeNameType.ROUTE) { - nav(listName.code) - } else { - accountViewModel.account.changeDefaultHomeFollowList(listName.code) - } + FollowListWithRoutes( + followListsModel = followLists, + listName = list, + ) { listName -> + if (listName.type == CodeNameType.ROUTE) { + nav(listName.code) + } else { + accountViewModel.account.changeDefaultHomeFollowList(listName.code) + } + } } - } } @Composable fun NotificationTopBar( - followLists: FollowListViewModel, - drawerState: DrawerState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + followLists: FollowListViewModel, + drawerState: DrawerState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> - val list by accountViewModel.account.defaultNotificationFollowList.collectAsStateWithLifecycle() + GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> + val list by accountViewModel.account.defaultNotificationFollowList.collectAsStateWithLifecycle() - FollowListWithoutRoutes( - followListsModel = followLists, - listName = list, - ) { listName -> - accountViewModel.account.changeDefaultNotificationFollowList(listName.code) + FollowListWithoutRoutes( + followListsModel = followLists, + listName = list, + ) { listName -> + accountViewModel.account.changeDefaultNotificationFollowList(listName.code) + } } - } } @Composable fun DiscoveryTopBar( - followLists: FollowListViewModel, - drawerState: DrawerState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + followLists: FollowListViewModel, + drawerState: DrawerState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> - val list by accountViewModel.account.defaultDiscoveryFollowList.collectAsStateWithLifecycle() + GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> + val list by accountViewModel.account.defaultDiscoveryFollowList.collectAsStateWithLifecycle() - FollowListWithoutRoutes( - followListsModel = followLists, - listName = list, - ) { listName -> - accountViewModel.account.changeDefaultDiscoveryFollowList(listName.code) + FollowListWithoutRoutes( + followListsModel = followLists, + listName = list, + ) { listName -> + accountViewModel.account.changeDefaultDiscoveryFollowList(listName.code) + } } - } } @Composable fun MainTopBar( - drawerState: DrawerState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + drawerState: DrawerState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - GenericMainTopBar(drawerState, accountViewModel, nav) { AmethystClickableIcon() } + GenericMainTopBar(drawerState, accountViewModel, nav) { AmethystClickableIcon() } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun GenericMainTopBar( - drawerState: DrawerState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - content: @Composable (AccountViewModel) -> Unit, + drawerState: DrawerState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + content: @Composable (AccountViewModel) -> Unit, ) { - Column(modifier = BottomTopHeight) { - TopAppBar( - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - title = { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Box(Modifier) { - Column( - modifier = Modifier.fillMaxWidth().fillMaxHeight(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - content(accountViewModel) - } - } - } - }, - navigationIcon = { - val coroutineScope = rememberCoroutineScope() - LoggedInUserPictureDrawer(accountViewModel) { coroutineScope.launch { drawerState.open() } } - }, - actions = { SearchButton { nav(Route.Search.route) } }, - ) - Divider(thickness = DividerThickness) - } + Column(modifier = BottomTopHeight) { + TopAppBar( + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + title = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box(Modifier) { + Column( + modifier = Modifier.fillMaxWidth().fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + content(accountViewModel) + } + } + } + }, + navigationIcon = { + val coroutineScope = rememberCoroutineScope() + LoggedInUserPictureDrawer(accountViewModel) { coroutineScope.launch { drawerState.open() } } + }, + actions = { SearchButton { nav(Route.Search.route) } }, + ) + Divider(thickness = DividerThickness) + } } @Composable private fun SearchButton(onClick: () -> Unit) { - IconButton( - onClick = onClick, - ) { - SearchIcon(modifier = Size22Modifier, Color.Unspecified) - } + IconButton( + onClick = onClick, + ) { + SearchIcon(modifier = Size22Modifier, Color.Unspecified) + } } @Composable private fun LoggedInUserPictureDrawer( - accountViewModel: AccountViewModel, - onClick: () -> Unit, + accountViewModel: AccountViewModel, + onClick: () -> Unit, ) { - val profilePicture by - accountViewModel.account.userProfile().live().profilePictureChanges.observeAsState() - val pubkeyHex = remember { accountViewModel.userProfile().pubkeyHex } + val profilePicture by + accountViewModel.account.userProfile().live().profilePictureChanges.observeAsState() + val pubkeyHex = remember { accountViewModel.userProfile().pubkeyHex } - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } + val automaticallyShowProfilePicture = + remember { + accountViewModel.settings.showProfilePictures.value + } - IconButton( - onClick = onClick, - ) { - RobohashFallbackAsyncImage( - robot = pubkeyHex, - model = profilePicture, - contentDescription = stringResource(id = R.string.profile_image), - modifier = HeaderPictureModifier, - contentScale = ContentScale.Crop, - loadProfilePicture = automaticallyShowProfilePicture, - ) - } + IconButton( + onClick = onClick, + ) { + RobohashFallbackAsyncImage( + robot = pubkeyHex, + model = profilePicture, + contentDescription = stringResource(id = R.string.profile_image), + modifier = HeaderPictureModifier, + contentScale = ContentScale.Crop, + loadProfilePicture = automaticallyShowProfilePicture, + ) + } } @Composable fun FollowListWithRoutes( - followListsModel: FollowListViewModel, - listName: String, - onChange: (CodeName) -> Unit, + followListsModel: FollowListViewModel, + listName: String, + onChange: (CodeName) -> Unit, ) { - val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() + val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() - SimpleTextSpinner( - placeholderCode = listName, - options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) }, - ) + SimpleTextSpinner( + placeholderCode = listName, + options = allLists, + onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) }, + ) } @Composable fun FollowListWithoutRoutes( - followListsModel: FollowListViewModel, - listName: String, - onChange: (CodeName) -> Unit, + followListsModel: FollowListViewModel, + listName: String, + onChange: (CodeName) -> Unit, ) { - val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle() + val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle() - SimpleTextSpinner( - placeholderCode = listName, - options = allLists, - onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) }, - ) + SimpleTextSpinner( + placeholderCode = listName, + options = allLists, + onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) }, + ) } enum class CodeNameType { - HARDCODED, - PEOPLE_LIST, - ROUTE, + HARDCODED, + PEOPLE_LIST, + ROUTE, } abstract class Name { - abstract fun name(): String + abstract fun name(): String - open fun name(context: Context) = name() + open fun name(context: Context) = name() } class GeoHashName(val geoHashTag: String) : Name() { - override fun name() = "/g/$geoHashTag" + override fun name() = "/g/$geoHashTag" } class HashtagName(val hashTag: String) : Name() { - override fun name() = "#$hashTag" + override fun name() = "#$hashTag" } class ResourceName(val resourceId: Int) : Name() { - override fun name() = " $resourceId " // Space to make sure it goes first + override fun name() = " $resourceId " // Space to make sure it goes first - override fun name(context: Context) = context.getString(resourceId) + override fun name(context: Context) = context.getString(resourceId) } class PeopleListName(val note: AddressableNote) : Name() { - override fun name() = (note.event as? PeopleListEvent)?.nameOrTitle() ?: note.dTag() ?: "" + override fun name() = (note.event as? PeopleListEvent)?.nameOrTitle() ?: note.dTag() ?: "" } class CommunityName(val note: AddressableNote) : Name() { - override fun name() = "/n/${(note.dTag() ?: "")}" + override fun name() = "/n/${(note.dTag() ?: "")}" } @Immutable data class CodeName(val code: String, val name: Name, val type: CodeNameType) @Stable class FollowListViewModel(val account: Account) : ViewModel() { - val kind3Follow = - CodeName(KIND3_FOLLOWS, ResourceName(R.string.follow_list_kind3follows), CodeNameType.HARDCODED) - val globalFollow = - CodeName(GLOBAL_FOLLOWS, ResourceName(R.string.follow_list_global), CodeNameType.HARDCODED) - val muteListFollow = - CodeName( - MuteListEvent.blockListFor(account.userProfile().pubkeyHex), - ResourceName(R.string.follow_list_mute_list), - CodeNameType.HARDCODED, - ) + val kind3Follow = + CodeName(KIND3_FOLLOWS, ResourceName(R.string.follow_list_kind3follows), CodeNameType.HARDCODED) + val globalFollow = + CodeName(GLOBAL_FOLLOWS, ResourceName(R.string.follow_list_global), CodeNameType.HARDCODED) + val muteListFollow = + CodeName( + MuteListEvent.blockListFor(account.userProfile().pubkeyHex), + ResourceName(R.string.follow_list_mute_list), + CodeNameType.HARDCODED, + ) - private var _kind3GlobalPeopleRoutes = - MutableStateFlow>(emptyList().toPersistentList()) - val kind3GlobalPeopleRoutes = _kind3GlobalPeopleRoutes.asStateFlow() + private var _kind3GlobalPeopleRoutes = + MutableStateFlow>(emptyList().toPersistentList()) + val kind3GlobalPeopleRoutes = _kind3GlobalPeopleRoutes.asStateFlow() - private var _kind3GlobalPeople = - MutableStateFlow>(emptyList().toPersistentList()) - val kind3GlobalPeople = _kind3GlobalPeople.asStateFlow() + private var _kind3GlobalPeople = + MutableStateFlow>(emptyList().toPersistentList()) + val kind3GlobalPeople = _kind3GlobalPeople.asStateFlow() - fun refresh() { - viewModelScope.launch(Dispatchers.Default) { refreshFollows() } - } - - private suspend fun refreshFollows() { - checkNotInMainThread() - - val newFollowLists = - LocalCache.addressables - .mapNotNull { - val event = (it.value.event as? PeopleListEvent) - // Has to have an list - if ( - event != null && - event.pubKey == account.userProfile().pubkeyHex && - (event.tags.size > 1 || event.content.length > 50) - ) { - CodeName(event.address().toTag(), PeopleListName(it.value), CodeNameType.PEOPLE_LIST) - } else { - null - } - } - .sortedBy { it.name.name() } - - val communities = - account.userProfile().cachedFollowingCommunitiesSet().mapNotNull { - LocalCache.checkGetOrCreateAddressableNote(it)?.let { communityNote -> - CodeName( - "Community/${communityNote.idHex}", - CommunityName(communityNote), - CodeNameType.ROUTE, - ) - } - } - - val hashtags = - account.userProfile().cachedFollowingTagSet().map { - CodeName("Hashtag/$it", HashtagName(it), CodeNameType.ROUTE) - } - - val geotags = - account.userProfile().cachedFollowingGeohashSet().map { - CodeName("Geohash/$it", GeoHashName(it), CodeNameType.ROUTE) - } - - val routeList = (communities + hashtags + geotags).sortedBy { it.name.name() } - - val kind3GlobalPeopleRouteList = - listOf(listOf(kind3Follow, globalFollow), newFollowLists, routeList, listOf(muteListFollow)) - .flatten() - .toImmutableList() - - if (!equalImmutableLists(_kind3GlobalPeopleRoutes.value, kind3GlobalPeopleRouteList)) { - _kind3GlobalPeopleRoutes.emit(kind3GlobalPeopleRouteList) + fun refresh() { + viewModelScope.launch(Dispatchers.Default) { refreshFollows() } } - val kind3GlobalPeopleList = - listOf(listOf(kind3Follow, globalFollow), newFollowLists, listOf(muteListFollow)) - .flatten() - .toImmutableList() + private suspend fun refreshFollows() { + checkNotInMainThread() - if (!equalImmutableLists(_kind3GlobalPeople.value, kind3GlobalPeopleList)) { - _kind3GlobalPeople.emit(kind3GlobalPeopleList) - } - } + val newFollowLists = + LocalCache.addressables + .mapNotNull { + val event = (it.value.event as? PeopleListEvent) + // Has to have an list + if ( + event != null && + event.pubKey == account.userProfile().pubkeyHex && + (event.tags.size > 1 || event.content.length > 50) + ) { + CodeName(event.address().toTag(), PeopleListName(it.value), CodeNameType.PEOPLE_LIST) + } else { + null + } + } + .sortedBy { it.name.name() } - var collectorJob: Job? = null - - init { - Log.d("Init", "App Top Bar") - refresh() - collectorJob = - viewModelScope.launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { newNotes -> - checkNotInMainThread() - if ( - newNotes.any { - it.event?.pubKey() == account.userProfile().pubkeyHex && - (it.event is PeopleListEvent || - it.event is MuteListEvent || - it.event is ContactListEvent) + val communities = + account.userProfile().cachedFollowingCommunitiesSet().mapNotNull { + LocalCache.checkGetOrCreateAddressableNote(it)?.let { communityNote -> + CodeName( + "Community/${communityNote.idHex}", + CommunityName(communityNote), + CodeNameType.ROUTE, + ) + } } - ) { - refresh() - } + + val hashtags = + account.userProfile().cachedFollowingTagSet().map { + CodeName("Hashtag/$it", HashtagName(it), CodeNameType.ROUTE) + } + + val geotags = + account.userProfile().cachedFollowingGeohashSet().map { + CodeName("Geohash/$it", GeoHashName(it), CodeNameType.ROUTE) + } + + val routeList = (communities + hashtags + geotags).sortedBy { it.name.name() } + + val kind3GlobalPeopleRouteList = + listOf(listOf(kind3Follow, globalFollow), newFollowLists, routeList, listOf(muteListFollow)) + .flatten() + .toImmutableList() + + if (!equalImmutableLists(_kind3GlobalPeopleRoutes.value, kind3GlobalPeopleRouteList)) { + _kind3GlobalPeopleRoutes.emit(kind3GlobalPeopleRouteList) } - } - } - override fun onCleared() { - collectorJob?.cancel() - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - super.onCleared() - } + val kind3GlobalPeopleList = + listOf(listOf(kind3Follow, globalFollow), newFollowLists, listOf(muteListFollow)) + .flatten() + .toImmutableList() - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): FollowListViewModel { - return FollowListViewModel(account) as FollowListViewModel + if (!equalImmutableLists(_kind3GlobalPeople.value, kind3GlobalPeopleList)) { + _kind3GlobalPeople.emit(kind3GlobalPeopleList) + } + } + + var collectorJob: Job? = null + + init { + Log.d("Init", "App Top Bar") + refresh() + collectorJob = + viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + checkNotInMainThread() + if ( + newNotes.any { + it.event?.pubKey() == account.userProfile().pubkeyHex && + ( + it.event is PeopleListEvent || + it.event is MuteListEvent || + it.event is ContactListEvent + ) + } + ) { + refresh() + } + } + } + } + + override fun onCleared() { + collectorJob?.cancel() + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + super.onCleared() + } + + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): FollowListViewModel { + return FollowListViewModel(account) as FollowListViewModel + } } - } } @Composable fun SimpleTextSpinner( - placeholderCode: String, - options: ImmutableList, - onSelect: (Int) -> Unit, - modifier: Modifier = Modifier, + placeholderCode: String, + options: ImmutableList, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, ) { - val interactionSource = remember { MutableInteractionSource() } - var optionsShowing by remember { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + var optionsShowing by remember { mutableStateOf(false) } - val context = LocalContext.current - val selectAnOption = - stringResource( - id = R.string.select_an_option, - ) + val context = LocalContext.current + val selectAnOption = + stringResource( + id = R.string.select_an_option, + ) - var currentText by - remember(placeholderCode, options) { - mutableStateOf( - options.firstOrNull { it.code == placeholderCode }?.name?.name(context) ?: selectAnOption, - ) - } + var currentText by + remember(placeholderCode, options) { + mutableStateOf( + options.firstOrNull { it.code == placeholderCode }?.name?.name(context) ?: selectAnOption, + ) + } - Box( - modifier = modifier, - contentAlignment = Alignment.Center, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Spacer(modifier = Size20Modifier) - Text(currentText) - Icon( - imageVector = Icons.Default.ExpandMore, - null, - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.placeholderText, - ) - } Box( - modifier = - Modifier.matchParentSize().clickable( - interactionSource = interactionSource, - indication = null, - ) { - optionsShowing = true - }, - ) - } - - if (optionsShowing) { - options.isNotEmpty().also { - SpinnerSelectionDialog( - options = options, - onDismiss = { optionsShowing = false }, - onSelect = { - currentText = options[it].name.name(context) - optionsShowing = false - onSelect(it) - }, - ) { - RenderOption(it.name) - } + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(modifier = Size20Modifier) + Text(currentText) + Icon( + imageVector = Icons.Default.ExpandMore, + null, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + Box( + modifier = + Modifier.matchParentSize().clickable( + interactionSource = interactionSource, + indication = null, + ) { + optionsShowing = true + }, + ) + } + + if (optionsShowing) { + options.isNotEmpty().also { + SpinnerSelectionDialog( + options = options, + onDismiss = { optionsShowing = false }, + onSelect = { + currentText = options[it].name.name(context) + optionsShowing = false + onSelect(it) + }, + ) { + RenderOption(it.name) + } + } } - } } @Composable fun RenderOption(option: Name) { - when (option) { - is GeoHashName -> { - val geohash = runCatching { option.geoHashTag.toGeoHash() }.getOrNull() - if (geohash != null) { - LoadCityName(geohash) { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth(), - ) { - Text(text = "/g/$it", color = MaterialTheme.colorScheme.onSurface) - } + when (option) { + is GeoHashName -> { + val geohash = runCatching { option.geoHashTag.toGeoHash() }.getOrNull() + if (geohash != null) { + LoadCityName(geohash) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = "/g/$it", color = MaterialTheme.colorScheme.onSurface) + } + } + } } - } - } - is HashtagName -> { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth(), - ) { - Text(text = option.name(), color = MaterialTheme.colorScheme.onSurface) - } - } - is ResourceName -> { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(id = option.resourceId), - color = MaterialTheme.colorScheme.onSurface, - ) - } - } - is PeopleListName -> { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth(), - ) { - Text(text = option.name(), color = MaterialTheme.colorScheme.onSurface) - } - } - is CommunityName -> { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth(), - ) { - val name by - option.note - .live() - .metadata - .map { "/n/" + ((it.note as? AddressableNote)?.dTag() ?: "") } - .observeAsState() + is HashtagName -> { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = option.name(), color = MaterialTheme.colorScheme.onSurface) + } + } + is ResourceName -> { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(id = option.resourceId), + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + is PeopleListName -> { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = option.name(), color = MaterialTheme.colorScheme.onSurface) + } + } + is CommunityName -> { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + val name by + option.note + .live() + .metadata + .map { "/n/" + ((it.note as? AddressableNote)?.dTag() ?: "") } + .observeAsState() - Text(text = name ?: "", color = MaterialTheme.colorScheme.onSurface) - } + Text(text = name ?: "", color = MaterialTheme.colorScheme.onSurface) + } + } } - } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun TopBarWithBackButton( - caption: String, - popBack: () -> Unit, + caption: String, + popBack: () -> Unit, ) { - Column(modifier = BottomTopHeight) { - TopAppBar( - title = { Text(caption) }, - navigationIcon = { - IconButton( - onClick = popBack, - modifier = Modifier, - ) { - ArrowBackIcon() - } - }, - actions = {}, - ) - Divider(thickness = DividerThickness) - } + Column(modifier = BottomTopHeight) { + TopAppBar( + title = { Text(caption) }, + navigationIcon = { + IconButton( + onClick = popBack, + modifier = Modifier, + ) { + ArrowBackIcon() + } + }, + actions = {}, + ) + Divider(thickness = DividerThickness) + } } @Composable fun FlexibleTopBarWithBackButton( - title: @Composable RowScope.() -> Unit, - extendableRow: (@Composable () -> Unit)? = null, - popBack: () -> Unit, + title: @Composable RowScope.() -> Unit, + extendableRow: (@Composable () -> Unit)? = null, + popBack: () -> Unit, ) { - Column { - MyExtensibleTopAppBar( - title = title, - extendableRow = extendableRow, - navigationIcon = { IconButton(onClick = popBack) { ArrowBackIcon() } }, - actions = {}, - ) - Spacer(modifier = HalfVertSpacer) - Divider(thickness = DividerThickness) - } + Column { + MyExtensibleTopAppBar( + title = title, + extendableRow = extendableRow, + navigationIcon = { IconButton(onClick = popBack) { ArrowBackIcon() } }, + actions = {}, + ) + Spacer(modifier = HalfVertSpacer) + Divider(thickness = DividerThickness) + } } @Composable fun AmethystClickableIcon() { - val context = LocalContext.current + val context = LocalContext.current - IconButton( - onClick = { debugState(context) }, - ) { - AmethystIcon(Size40dp) - } + IconButton( + onClick = { debugState(context) }, + ) { + AmethystIcon(Size40dp) + } } fun debugState(context: Context) { - Client.allSubscriptions() - .map { - "$it ${ + Client.allSubscriptions() + .map { + "$it ${ Client.getSubscriptionFilters(it) .joinToString { it.filter.toJson() } }" - } - .forEach { Log.d("STATE DUMP", it) } + } + .forEach { Log.d("STATE DUMP", it) } - NostrAccountDataSource.printCounter() - NostrChannelDataSource.printCounter() - NostrChatroomDataSource.printCounter() - NostrChatroomListDataSource.printCounter() - NostrCommunityDataSource.printCounter() - NostrDiscoveryDataSource.printCounter() - NostrHashtagDataSource.printCounter() - NostrGeohashDataSource.printCounter() - NostrHomeDataSource.printCounter() - NostrSearchEventOrUserDataSource.printCounter() - NostrSingleChannelDataSource.printCounter() - NostrSingleEventDataSource.printCounter() - NostrSingleUserDataSource.printCounter() - NostrThreadDataSource.printCounter() - NostrUserProfileDataSource.printCounter() - NostrVideoDataSource.printCounter() + NostrAccountDataSource.printCounter() + NostrChannelDataSource.printCounter() + NostrChatroomDataSource.printCounter() + NostrChatroomListDataSource.printCounter() + NostrCommunityDataSource.printCounter() + NostrDiscoveryDataSource.printCounter() + NostrHashtagDataSource.printCounter() + NostrGeohashDataSource.printCounter() + NostrHomeDataSource.printCounter() + NostrSearchEventOrUserDataSource.printCounter() + NostrSingleChannelDataSource.printCounter() + NostrSingleEventDataSource.printCounter() + NostrSingleUserDataSource.printCounter() + NostrThreadDataSource.printCounter() + NostrUserProfileDataSource.printCounter() + NostrVideoDataSource.printCounter() - val totalMemoryKb = Runtime.getRuntime().totalMemory() / (1024 * 1024) - val freeMemoryKb = Runtime.getRuntime().freeMemory() / (1024 * 1024) + val totalMemoryKb = Runtime.getRuntime().totalMemory() / (1024 * 1024) + val freeMemoryKb = Runtime.getRuntime().freeMemory() / (1024 * 1024) - val jvmHeapAllocatedKb = totalMemoryKb - freeMemoryKb + val jvmHeapAllocatedKb = totalMemoryKb - freeMemoryKb - Log.d("STATE DUMP", "Total Heap Allocated: " + jvmHeapAllocatedKb + " MB") + Log.d("STATE DUMP", "Total Heap Allocated: " + jvmHeapAllocatedKb + " MB") - val nativeHeap = Debug.getNativeHeapAllocatedSize() / (1024 * 1024) + val nativeHeap = Debug.getNativeHeapAllocatedSize() / (1024 * 1024) - Log.d("STATE DUMP", "Total Native Heap Allocated: " + nativeHeap + " MB") + Log.d("STATE DUMP", "Total Native Heap Allocated: " + nativeHeap + " MB") - Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays()) + Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays()) - val imageLoader = Coil.imageLoader(context) - Log.d( - "STATE DUMP", - "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB", - ) - Log.d( - "STATE DUMP", - "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB", - ) + val imageLoader = Coil.imageLoader(context) + Log.d( + "STATE DUMP", + "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB", + ) + Log.d( + "STATE DUMP", + "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB", + ) - Log.d( - "STATE DUMP", - "Notes: " + - LocalCache.notes.filter { it.value.liveSet != null }.size + - " / " + - LocalCache.notes.filter { it.value.event != null }.size + - " / " + - LocalCache.notes.size, - ) - Log.d( - "STATE DUMP", - "Addressables: " + - LocalCache.addressables.filter { it.value.liveSet != null }.size + - " / " + - LocalCache.addressables.filter { it.value.event != null }.size + - " / " + - LocalCache.addressables.size, - ) - Log.d( - "STATE DUMP", - "Users: " + - LocalCache.users.filter { it.value.liveSet != null }.size + - " / " + - LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + - " / " + - LocalCache.users.size, - ) + Log.d( + "STATE DUMP", + "Notes: " + + LocalCache.notes.filter { it.value.liveSet != null }.size + + " / " + + LocalCache.notes.filter { it.value.event != null }.size + + " / " + + LocalCache.notes.size, + ) + Log.d( + "STATE DUMP", + "Addressables: " + + LocalCache.addressables.filter { it.value.liveSet != null }.size + + " / " + + LocalCache.addressables.filter { it.value.event != null }.size + + " / " + + LocalCache.addressables.size, + ) + Log.d( + "STATE DUMP", + "Users: " + + LocalCache.users.filter { it.value.liveSet != null }.size + + " / " + + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + + " / " + + LocalCache.users.size, + ) - Log.d( - "STATE DUMP", - "Memory used by Events: " + - LocalCache.notes.values.sumOf { it.event?.countMemory() ?: 0 } / (1024 * 1024) + - " MB", - ) + Log.d( + "STATE DUMP", + "Memory used by Events: " + + LocalCache.notes.values.sumOf { it.event?.countMemory() ?: 0 } / (1024 * 1024) + + " MB", + ) - LocalCache.notes.values - .groupBy { it.event?.kind() } - .forEach { Log.d("STATE DUMP", "Kind ${it.key}: \t${it.value.size} elements ") } - LocalCache.addressables.values - .groupBy { it.event?.kind() } - .forEach { Log.d("STATE DUMP", "Kind ${it.key}: \t${it.value.size} elements ") } + LocalCache.notes.values + .groupBy { it.event?.kind() } + .forEach { Log.d("STATE DUMP", "Kind ${it.key}: \t${it.value.size} elements ") } + LocalCache.addressables.values + .groupBy { it.event?.kind() } + .forEach { Log.d("STATE DUMP", "Kind ${it.key}: \t${it.value.size} elements ") } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun MyExtensibleTopAppBar( - title: @Composable RowScope.() -> Unit, - extendableRow: (@Composable () -> Unit)? = null, - modifier: Modifier = Modifier, - navigationIcon: @Composable (() -> Unit)? = null, - actions: @Composable RowScope.() -> Unit = {}, + title: @Composable RowScope.() -> Unit, + extendableRow: (@Composable () -> Unit)? = null, + modifier: Modifier = Modifier, + navigationIcon: @Composable (() -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {}, ) { - val expanded = remember { mutableStateOf(false) } + val expanded = remember { mutableStateOf(false) } - Column( - Modifier.clickable { expanded.value = !expanded.value }, - ) { - Row(modifier = BottomTopHeight) { - TopAppBar( - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - title() - } - }, - modifier = modifier, - navigationIcon = { - if (navigationIcon == null) { - Spacer(TitleInsetWithoutIcon) - } else { - Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) { - navigationIcon() + Column( + Modifier.clickable { expanded.value = !expanded.value }, + ) { + Row(modifier = BottomTopHeight) { + TopAppBar( + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + title() + } + }, + modifier = modifier, + navigationIcon = { + if (navigationIcon == null) { + Spacer(TitleInsetWithoutIcon) + } else { + Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) { + navigationIcon() + } + } + }, + actions = actions, + ) + } + + if (expanded.value && extendableRow != null) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { extendableRow() } } - } - }, - actions = actions, - ) + } } - - if (expanded.value && extendableRow != null) { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { extendableRow() } - } - } - } } private val AppBarHeight = 50.dp diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index 2aeb2edae..457be36d0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -114,633 +114,634 @@ import kotlinx.coroutines.launch @Composable fun DrawerContent( - nav: (String) -> Unit, - drawerState: DrawerState, - openSheet: () -> Unit, - accountViewModel: AccountViewModel, + nav: (String) -> Unit, + drawerState: DrawerState, + openSheet: () -> Unit, + accountViewModel: AccountViewModel, ) { - val coroutineScope = rememberCoroutineScope() - val onClickUser = { - nav("User/${accountViewModel.userProfile().pubkeyHex}") - coroutineScope.launch { drawerState.close() } - Unit - } - - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } - - ModalDrawerSheet( - drawerContainerColor = MaterialTheme.colorScheme.background, - drawerTonalElevation = 0.dp, - ) { - Column { - ProfileContent( - baseAccountUser = accountViewModel.account.userProfile(), - modifier = profileContentHeaderModifier, - accountViewModel, - onClickUser, - ) - - Column(drawerSpacing) { - EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel) - } - - FollowingAndFollowerCounts(accountViewModel.account.userProfile(), onClickUser) - - Divider( - thickness = DividerThickness, - modifier = Modifier.padding(top = 20.dp), - ) - - ListContent( - modifier = Modifier.fillMaxWidth().weight(1f), - drawerState, - openSheet, - accountViewModel, - nav, - ) - - BottomContent( - accountViewModel.account.userProfile(), - drawerState, - automaticallyShowProfilePicture, - nav, - ) + val coroutineScope = rememberCoroutineScope() + val onClickUser = { + nav("User/${accountViewModel.userProfile().pubkeyHex}") + coroutineScope.launch { drawerState.close() } + Unit + } + + val automaticallyShowProfilePicture = + remember { + accountViewModel.settings.showProfilePictures.value + } + + ModalDrawerSheet( + drawerContainerColor = MaterialTheme.colorScheme.background, + drawerTonalElevation = 0.dp, + ) { + Column { + ProfileContent( + baseAccountUser = accountViewModel.account.userProfile(), + modifier = profileContentHeaderModifier, + accountViewModel, + onClickUser, + ) + + Column(drawerSpacing) { + EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel) + } + + FollowingAndFollowerCounts(accountViewModel.account.userProfile(), onClickUser) + + Divider( + thickness = DividerThickness, + modifier = Modifier.padding(top = 20.dp), + ) + + ListContent( + modifier = Modifier.fillMaxWidth().weight(1f), + drawerState, + openSheet, + accountViewModel, + nav, + ) + + BottomContent( + accountViewModel.account.userProfile(), + drawerState, + automaticallyShowProfilePicture, + nav, + ) + } } - } } @Composable fun ProfileContent( - baseAccountUser: User, - modifier: Modifier = Modifier, - accountViewModel: AccountViewModel, - onClickUser: () -> Unit, + baseAccountUser: User, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + onClickUser: () -> Unit, ) { - val userInfo by baseAccountUser.live().userMetadataInfo.observeAsState() + val userInfo by baseAccountUser.live().userMetadataInfo.observeAsState() - ProfileContentTemplate( - profilePubHex = baseAccountUser.pubkeyHex, - profileBanner = userInfo?.banner, - profilePicture = userInfo?.profilePicture(), - bestDisplayName = userInfo?.bestDisplayName(), - tags = userInfo?.tags, - modifier = modifier, - accountViewModel = accountViewModel, - onClick = onClickUser, - ) + ProfileContentTemplate( + profilePubHex = baseAccountUser.pubkeyHex, + profileBanner = userInfo?.banner, + profilePicture = userInfo?.profilePicture(), + bestDisplayName = userInfo?.bestDisplayName(), + tags = userInfo?.tags, + modifier = modifier, + accountViewModel = accountViewModel, + onClick = onClickUser, + ) } @Composable fun ProfileContentTemplate( - profilePubHex: HexKey, - profileBanner: String?, - profilePicture: String?, - bestDisplayName: String?, - tags: ImmutableListOfLists?, - modifier: Modifier, - accountViewModel: AccountViewModel, - onClick: () -> Unit, + profilePubHex: HexKey, + profileBanner: String?, + profilePicture: String?, + bestDisplayName: String?, + tags: ImmutableListOfLists?, + modifier: Modifier, + accountViewModel: AccountViewModel, + onClick: () -> Unit, ) { - Box { - if (profileBanner != null) { - AsyncImage( - model = profileBanner, - contentDescription = stringResource(id = R.string.profile_image), - contentScale = ContentScale.FillWidth, - modifier = bannerModifier, - ) - } else { - Image( - painter = painterResource(R.drawable.profile_banner), - contentDescription = stringResource(R.string.profile_banner), - contentScale = ContentScale.FillWidth, - modifier = bannerModifier, - ) - } + Box { + if (profileBanner != null) { + AsyncImage( + model = profileBanner, + contentDescription = stringResource(id = R.string.profile_image), + contentScale = ContentScale.FillWidth, + modifier = bannerModifier, + ) + } else { + Image( + painter = painterResource(R.drawable.profile_banner), + contentDescription = stringResource(R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = bannerModifier, + ) + } - Column(modifier = modifier) { - RobohashFallbackAsyncImage( - robot = profilePubHex, - model = profilePicture, - contentDescription = stringResource(id = R.string.profile_image), - modifier = - Modifier.width(100.dp) - .height(100.dp) - .clip(shape = CircleShape) - .border(3.dp, MaterialTheme.colorScheme.background, CircleShape) - .background(MaterialTheme.colorScheme.background) - .clickable(onClick = onClick), - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, - ) + Column(modifier = modifier) { + RobohashFallbackAsyncImage( + robot = profilePubHex, + model = profilePicture, + contentDescription = stringResource(id = R.string.profile_image), + modifier = + Modifier.width(100.dp) + .height(100.dp) + .clip(shape = CircleShape) + .border(3.dp, MaterialTheme.colorScheme.background, CircleShape) + .background(MaterialTheme.colorScheme.background) + .clickable(onClick = onClick), + loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + ) - if (bestDisplayName != null) { - CreateTextWithEmoji( - text = bestDisplayName, - tags = tags, - modifier = Modifier.padding(top = 7.dp).clickable(onClick = onClick), - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + if (bestDisplayName != null) { + CreateTextWithEmoji( + text = bestDisplayName, + tags = tags, + modifier = Modifier.padding(top = 7.dp).clickable(onClick = onClick), + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } } - } } @Composable private fun EditStatusBoxes( - baseAccountUser: User, - accountViewModel: AccountViewModel, + baseAccountUser: User, + accountViewModel: AccountViewModel, ) { - LoadStatuses(user = baseAccountUser, accountViewModel) { statuses -> - if (statuses.isEmpty()) { - StatusEditBar(accountViewModel = accountViewModel) - } else { - statuses.forEach { - val originalStatus by it.live().content.observeAsState() + LoadStatuses(user = baseAccountUser, accountViewModel) { statuses -> + if (statuses.isEmpty()) { + StatusEditBar(accountViewModel = accountViewModel) + } else { + statuses.forEach { + val originalStatus by it.live().content.observeAsState() - StatusEditBar(originalStatus, it.address, accountViewModel) - } + StatusEditBar(originalStatus, it.address, accountViewModel) + } + } } - } } @Composable fun StatusEditBar( - savedStatus: String? = null, - tag: ATag? = null, - accountViewModel: AccountViewModel, + savedStatus: String? = null, + tag: ATag? = null, + accountViewModel: AccountViewModel, ) { - val focusManager = LocalFocusManager.current + val focusManager = LocalFocusManager.current - val currentStatus = remember { mutableStateOf(savedStatus ?: "") } - val hasChanged = remember { derivedStateOf { currentStatus.value != (savedStatus ?: "") } } + val currentStatus = remember { mutableStateOf(savedStatus ?: "") } + val hasChanged = remember { derivedStateOf { currentStatus.value != (savedStatus ?: "") } } - OutlinedTextField( - value = currentStatus.value, - onValueChange = { currentStatus.value = it }, - label = { Text(text = stringResource(R.string.status_update)) }, - modifier = Modifier.fillMaxWidth(), - placeholder = { - Text( - text = stringResource(R.string.status_update), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - imeAction = ImeAction.Send, - capitalization = KeyboardCapitalization.Sentences, - ), - keyboardActions = - KeyboardActions( - onSend = { - if (tag == null) { - accountViewModel.createStatus(currentStatus.value) - } else { - accountViewModel.updateStatus(tag, currentStatus.value) - } - - focusManager.clearFocus(true) + OutlinedTextField( + value = currentStatus.value, + onValueChange = { currentStatus.value = it }, + label = { Text(text = stringResource(R.string.status_update)) }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringResource(R.string.status_update), + color = MaterialTheme.colorScheme.placeholderText, + ) }, - ), - singleLine = true, - trailingIcon = { - if (hasChanged.value) { - SendButton { - if (tag == null) { - accountViewModel.createStatus(currentStatus.value) - } else { - accountViewModel.updateStatus(tag, currentStatus.value) - } - focusManager.clearFocus(true) - } - } else { - if (tag != null) { - UserStatusDeleteButton { - accountViewModel.deleteStatus(tag) - focusManager.clearFocus(true) - } - } - } - }, - ) + keyboardOptions = + KeyboardOptions.Default.copy( + imeAction = ImeAction.Send, + capitalization = KeyboardCapitalization.Sentences, + ), + keyboardActions = + KeyboardActions( + onSend = { + if (tag == null) { + accountViewModel.createStatus(currentStatus.value) + } else { + accountViewModel.updateStatus(tag, currentStatus.value) + } + + focusManager.clearFocus(true) + }, + ), + singleLine = true, + trailingIcon = { + if (hasChanged.value) { + SendButton { + if (tag == null) { + accountViewModel.createStatus(currentStatus.value) + } else { + accountViewModel.updateStatus(tag, currentStatus.value) + } + focusManager.clearFocus(true) + } + } else { + if (tag != null) { + UserStatusDeleteButton { + accountViewModel.deleteStatus(tag) + focusManager.clearFocus(true) + } + } + } + }, + ) } @Composable fun SendButton(onClick: () -> Unit) { - IconButton( - modifier = Size26Modifier, - onClick = onClick, - ) { - Icon( - imageVector = Icons.Default.Send, - null, - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.placeholderText, - ) - } + IconButton( + modifier = Size26Modifier, + onClick = onClick, + ) { + Icon( + imageVector = Icons.Default.Send, + null, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + } } @Composable fun UserStatusDeleteButton(onClick: () -> Unit) { - IconButton( - modifier = Size26Modifier, - onClick = onClick, - ) { - Icon( - imageVector = Icons.Default.Delete, - null, - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.placeholderText, - ) - } + IconButton( + modifier = Size26Modifier, + onClick = onClick, + ) { + Icon( + imageVector = Icons.Default.Delete, + null, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + } } @Composable private fun FollowingAndFollowerCounts( - baseAccountUser: User, - onClick: () -> Unit, + baseAccountUser: User, + onClick: () -> Unit, ) { - var followingCount by remember { mutableStateOf("--") } - var followerCount by remember { mutableStateOf("--") } + var followingCount by remember { mutableStateOf("--") } + var followerCount by remember { mutableStateOf("--") } - WatchFollow(baseAccountUser = baseAccountUser) { newFollowing -> - if (followingCount != newFollowing) { - followingCount = newFollowing + WatchFollow(baseAccountUser = baseAccountUser) { newFollowing -> + if (followingCount != newFollowing) { + followingCount = newFollowing + } } - } - WatchFollower(baseAccountUser = baseAccountUser) { newFollower -> - if (followerCount != newFollower) { - followerCount = newFollower + WatchFollower(baseAccountUser = baseAccountUser) { newFollower -> + if (followerCount != newFollower) { + followerCount = newFollower + } } - } - Row( - modifier = drawerSpacing.clickable(onClick = onClick), - ) { - Text( - text = followingCount, - fontWeight = FontWeight.Bold, - ) + Row( + modifier = drawerSpacing.clickable(onClick = onClick), + ) { + Text( + text = followingCount, + fontWeight = FontWeight.Bold, + ) - Text(stringResource(R.string.following)) + Text(stringResource(R.string.following)) - Spacer(modifier = DoubleHorzSpacer) + Spacer(modifier = DoubleHorzSpacer) - Text( - text = followerCount, - fontWeight = FontWeight.Bold, - ) + Text( + text = followerCount, + fontWeight = FontWeight.Bold, + ) - Text(stringResource(R.string.followers)) - } + Text(stringResource(R.string.followers)) + } } @Composable fun WatchFollow( - baseAccountUser: User, - onReady: (String) -> Unit, + baseAccountUser: User, + onReady: (String) -> Unit, ) { - val accountUserFollowsState by baseAccountUser.live().follows.observeAsState() + val accountUserFollowsState by baseAccountUser.live().follows.observeAsState() - LaunchedEffect(key1 = accountUserFollowsState) { - launch(Dispatchers.IO) { - onReady(accountUserFollowsState?.user?.cachedFollowCount()?.toString() ?: "--") + LaunchedEffect(key1 = accountUserFollowsState) { + launch(Dispatchers.IO) { + onReady(accountUserFollowsState?.user?.cachedFollowCount()?.toString() ?: "--") + } } - } } @Composable fun WatchFollower( - baseAccountUser: User, - onReady: (String) -> Unit, + baseAccountUser: User, + onReady: (String) -> Unit, ) { - val accountUserFollowersState by baseAccountUser.live().followers.observeAsState() + val accountUserFollowersState by baseAccountUser.live().followers.observeAsState() - LaunchedEffect(key1 = accountUserFollowersState) { - launch(Dispatchers.IO) { - onReady(accountUserFollowersState?.user?.cachedFollowerCount()?.toString() ?: "--") + LaunchedEffect(key1 = accountUserFollowersState) { + launch(Dispatchers.IO) { + onReady(accountUserFollowersState?.user?.cachedFollowerCount()?.toString() ?: "--") + } } - } } @Composable fun ListContent( - modifier: Modifier, - drawerState: DrawerState, - openSheet: () -> Unit, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + modifier: Modifier, + drawerState: DrawerState, + openSheet: () -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val route = remember(accountViewModel) { "User/${accountViewModel.userProfile().pubkeyHex}" } + val route = remember(accountViewModel) { "User/${accountViewModel.userProfile().pubkeyHex}" } - val coroutineScope = rememberCoroutineScope() - var wantsToEditRelays by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + var wantsToEditRelays by remember { mutableStateOf(false) } - var backupDialogOpen by remember { mutableStateOf(false) } - var checked by remember { mutableStateOf(accountViewModel.account.proxy != null) } - var disconnectTorDialog by remember { mutableStateOf(false) } - var conectOrbotDialogOpen by remember { mutableStateOf(false) } - val proxyPort = remember { mutableStateOf(accountViewModel.account.proxyPort.toString()) } - val context = LocalContext.current + var backupDialogOpen by remember { mutableStateOf(false) } + var checked by remember { mutableStateOf(accountViewModel.account.proxy != null) } + var disconnectTorDialog by remember { mutableStateOf(false) } + var conectOrbotDialogOpen by remember { mutableStateOf(false) } + val proxyPort = remember { mutableStateOf(accountViewModel.account.proxyPort.toString()) } + val context = LocalContext.current - Column( - modifier = modifier.fillMaxHeight().verticalScroll(rememberScrollState()), - ) { - NavigationRow( - title = stringResource(R.string.profile), - icon = Route.Profile.icon, - tint = MaterialTheme.colorScheme.primary, - nav = nav, - drawerState = drawerState, - route = route, - ) + Column( + modifier = modifier.fillMaxHeight().verticalScroll(rememberScrollState()), + ) { + NavigationRow( + title = stringResource(R.string.profile), + icon = Route.Profile.icon, + tint = MaterialTheme.colorScheme.primary, + nav = nav, + drawerState = drawerState, + route = route, + ) - NavigationRow( - title = stringResource(R.string.bookmarks), - icon = Route.Bookmarks.icon, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - drawerState = drawerState, - route = Route.Bookmarks.route, - ) + NavigationRow( + title = stringResource(R.string.bookmarks), + icon = Route.Bookmarks.icon, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + drawerState = drawerState, + route = Route.Bookmarks.route, + ) - IconRowRelays( - accountViewModel = accountViewModel, - onClick = { - coroutineScope.launch { drawerState.close() } - wantsToEditRelays = true - }, - ) + IconRowRelays( + accountViewModel = accountViewModel, + onClick = { + coroutineScope.launch { drawerState.close() } + wantsToEditRelays = true + }, + ) - NavigationRow( - title = stringResource(R.string.security_filters), - icon = Route.BlockedUsers.icon, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - drawerState = drawerState, - route = Route.BlockedUsers.route, - ) + NavigationRow( + title = stringResource(R.string.security_filters), + icon = Route.BlockedUsers.icon, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + drawerState = drawerState, + route = Route.BlockedUsers.route, + ) - accountViewModel.account.keyPair.privKey?.let { - IconRow( - title = stringResource(R.string.backup_keys), - icon = R.drawable.ic_key, - tint = MaterialTheme.colorScheme.onBackground, - onClick = { - coroutineScope.launch { drawerState.close() } - backupDialogOpen = true - }, - ) + accountViewModel.account.keyPair.privKey?.let { + IconRow( + title = stringResource(R.string.backup_keys), + icon = R.drawable.ic_key, + tint = MaterialTheme.colorScheme.onBackground, + onClick = { + coroutineScope.launch { drawerState.close() } + backupDialogOpen = true + }, + ) + } + + IconRow( + title = + if (checked) { + stringResource(R.string.disconnect_from_your_orbot_setup) + } else { + stringResource(R.string.connect_via_tor_short) + }, + icon = R.drawable.ic_tor, + tint = MaterialTheme.colorScheme.onBackground, + onLongClick = { + coroutineScope.launch { drawerState.close() } + conectOrbotDialogOpen = true + }, + onClick = { + if (checked) { + disconnectTorDialog = true + } else { + coroutineScope.launch { drawerState.close() } + conectOrbotDialogOpen = true + } + }, + ) + + NavigationRow( + title = stringResource(R.string.settings), + icon = Route.Settings.icon, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + drawerState = drawerState, + route = Route.Settings.route, + ) + + Spacer(modifier = Modifier.weight(1f)) + + IconRow( + title = stringResource(R.string.drawer_accounts), + icon = R.drawable.manage_accounts, + tint = MaterialTheme.colorScheme.onBackground, + onClick = openSheet, + ) } - IconRow( - title = - if (checked) { - stringResource(R.string.disconnect_from_your_orbot_setup) - } else { - stringResource(R.string.connect_via_tor_short) - }, - icon = R.drawable.ic_tor, - tint = MaterialTheme.colorScheme.onBackground, - onLongClick = { - coroutineScope.launch { drawerState.close() } - conectOrbotDialogOpen = true - }, - onClick = { - if (checked) { - disconnectTorDialog = true - } else { - coroutineScope.launch { drawerState.close() } - conectOrbotDialogOpen = true - } - }, - ) - - NavigationRow( - title = stringResource(R.string.settings), - icon = Route.Settings.icon, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - drawerState = drawerState, - route = Route.Settings.route, - ) - - Spacer(modifier = Modifier.weight(1f)) - - IconRow( - title = stringResource(R.string.drawer_accounts), - icon = R.drawable.manage_accounts, - tint = MaterialTheme.colorScheme.onBackground, - onClick = openSheet, - ) - } - - if (wantsToEditRelays) { - NewRelayListView({ wantsToEditRelays = false }, accountViewModel, nav = nav) - } - if (backupDialogOpen) { - AccountBackupDialog(accountViewModel, onClose = { backupDialogOpen = false }) - } - if (conectOrbotDialogOpen) { - ConnectOrbotDialog( - onClose = { conectOrbotDialogOpen = false }, - onPost = { - conectOrbotDialogOpen = false - disconnectTorDialog = false - checked = true - accountViewModel.enableTor(true, proxyPort) - }, - onError = { - accountViewModel.toast( - context.getString(R.string.could_not_connect_to_tor), - it, + if (wantsToEditRelays) { + NewRelayListView({ wantsToEditRelays = false }, accountViewModel, nav = nav) + } + if (backupDialogOpen) { + AccountBackupDialog(accountViewModel, onClose = { backupDialogOpen = false }) + } + if (conectOrbotDialogOpen) { + ConnectOrbotDialog( + onClose = { conectOrbotDialogOpen = false }, + onPost = { + conectOrbotDialogOpen = false + disconnectTorDialog = false + checked = true + accountViewModel.enableTor(true, proxyPort) + }, + onError = { + accountViewModel.toast( + context.getString(R.string.could_not_connect_to_tor), + it, + ) + }, + proxyPort, ) - }, - proxyPort, - ) - } + } - if (disconnectTorDialog) { - AlertDialog( - title = { Text(text = stringResource(R.string.do_you_really_want_to_disable_tor_title)) }, - text = { Text(text = stringResource(R.string.do_you_really_want_to_disable_tor_text)) }, - onDismissRequest = { disconnectTorDialog = false }, - confirmButton = { - TextButton( - onClick = { - disconnectTorDialog = false - checked = false - accountViewModel.enableTor(false, proxyPort) - }, - ) { - Text(text = stringResource(R.string.yes)) - } - }, - dismissButton = { - TextButton( - onClick = { disconnectTorDialog = false }, - ) { - Text(text = stringResource(R.string.no)) - } - }, - ) - } + if (disconnectTorDialog) { + AlertDialog( + title = { Text(text = stringResource(R.string.do_you_really_want_to_disable_tor_title)) }, + text = { Text(text = stringResource(R.string.do_you_really_want_to_disable_tor_text)) }, + onDismissRequest = { disconnectTorDialog = false }, + confirmButton = { + TextButton( + onClick = { + disconnectTorDialog = false + checked = false + accountViewModel.enableTor(false, proxyPort) + }, + ) { + Text(text = stringResource(R.string.yes)) + } + }, + dismissButton = { + TextButton( + onClick = { disconnectTorDialog = false }, + ) { + Text(text = stringResource(R.string.no)) + } + }, + ) + } } @Composable private fun RelayStatus(accountViewModel: AccountViewModel) { - val connectedRelaysText by RelayPool.statusFlow.collectAsStateWithLifecycle(RelayPoolStatus(0, 0)) + val connectedRelaysText by RelayPool.statusFlow.collectAsStateWithLifecycle(RelayPoolStatus(0, 0)) - RenderRelayStatus(connectedRelaysText) + RenderRelayStatus(connectedRelaysText) } @Composable private fun RenderRelayStatus(relayPool: RelayPoolStatus) { - val text by - remember(relayPool) { derivedStateOf { "${relayPool.connected}/${relayPool.available}" } } + val text by + remember(relayPool) { derivedStateOf { "${relayPool.connected}/${relayPool.available}" } } - val placeHolder = MaterialTheme.colorScheme.placeholderText + val placeHolder = MaterialTheme.colorScheme.placeholderText - val color by - remember(relayPool) { - derivedStateOf { - if (relayPool.isConnected) { - placeHolder - } else { - Color.Red + val color by + remember(relayPool) { + derivedStateOf { + if (relayPool.isConnected) { + placeHolder + } else { + Color.Red + } + } } - } - } - Text( - text = text, - color = color, - style = MaterialTheme.typography.titleMedium, - ) + Text( + text = text, + color = color, + style = MaterialTheme.typography.titleMedium, + ) } @Composable fun NavigationRow( - title: String, - icon: Int, - tint: Color, - nav: (String) -> Unit, - drawerState: DrawerState, - route: String, + title: String, + icon: Int, + tint: Color, + nav: (String) -> Unit, + drawerState: DrawerState, + route: String, ) { - val coroutineScope = rememberCoroutineScope() - IconRow( - title, - icon, - tint, - onClick = { - nav(route) - coroutineScope.launch { drawerState.close() } - }, - ) + val coroutineScope = rememberCoroutineScope() + IconRow( + title, + icon, + tint, + onClick = { + nav(route) + coroutineScope.launch { drawerState.close() } + }, + ) } @OptIn(ExperimentalFoundationApi::class) @Composable fun IconRow( - title: String, - icon: Int, - tint: Color, - onClick: () -> Unit, - onLongClick: (() -> Unit)? = null, + title: String, + icon: Int, + tint: Color, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, ) { - Row( - modifier = - Modifier.fillMaxWidth() - .combinedClickable( - onClick = onClick, - onLongClick = onLongClick, - ), - ) { Row( - modifier = IconRowModifier, - verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ), ) { - Icon( - painter = painterResource(icon), - null, - modifier = Size22Modifier, - tint = tint, - ) - Text( - modifier = IconRowTextModifier, - text = title, - fontSize = Font18SP, - ) + Row( + modifier = IconRowModifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(icon), + null, + modifier = Size22Modifier, + tint = tint, + ) + Text( + modifier = IconRowTextModifier, + text = title, + fontSize = Font18SP, + ) + } } - } } @Composable fun IconRowRelays( - accountViewModel: AccountViewModel, - onClick: () -> Unit, + accountViewModel: AccountViewModel, + onClick: () -> Unit, ) { - Row( - modifier = Modifier.fillMaxWidth().clickable { onClick() }, - ) { Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 15.dp, horizontal = 25.dp), - verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clickable { onClick() }, ) { - Icon( - painter = painterResource(R.drawable.relays), - null, - modifier = Modifier.size(22.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 15.dp, horizontal = 25.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.relays), + null, + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) - Text( - modifier = Modifier.padding(start = 16.dp), - text = stringResource(id = R.string.relay_setup), - fontSize = 18.sp, - ) + Text( + modifier = Modifier.padding(start = 16.dp), + text = stringResource(id = R.string.relay_setup), + fontSize = 18.sp, + ) - Spacer(modifier = Modifier.width(Size16dp)) + Spacer(modifier = Modifier.width(Size16dp)) - RelayStatus(accountViewModel = accountViewModel) + RelayStatus(accountViewModel = accountViewModel) + } } - } } @Composable fun BottomContent( - user: User, - drawerState: DrawerState, - loadProfilePicture: Boolean, - nav: (String) -> Unit, + user: User, + drawerState: DrawerState, + loadProfilePicture: Boolean, + nav: (String) -> Unit, ) { - val coroutineScope = rememberCoroutineScope() + val coroutineScope = rememberCoroutineScope() - // store the dialog open or close state - var dialogOpen by remember { mutableStateOf(false) } + // store the dialog open or close state + var dialogOpen by remember { mutableStateOf(false) } - Column(modifier = Modifier) { - Divider( - modifier = Modifier.padding(top = 15.dp), - thickness = DividerThickness, - ) - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 15.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - modifier = Modifier.padding(start = 16.dp), - text = "v" + BuildConfig.VERSION_NAME + "-" + BuildConfig.FLAVOR.uppercase(), - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - ) + Column(modifier = Modifier) { + Divider( + modifier = Modifier.padding(top = 15.dp), + thickness = DividerThickness, + ) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 15.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.padding(start = 16.dp), + text = "v" + BuildConfig.VERSION_NAME + "-" + BuildConfig.FLAVOR.uppercase(), + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + ) /* IconButton( onClick = { @@ -758,33 +759,33 @@ fun BottomContent( tint = MaterialTheme.colorScheme.primary ) }*/ - Box(modifier = Modifier.weight(1F)) - IconButton( - onClick = { - dialogOpen = true - coroutineScope.launch { drawerState.close() } - }, - ) { - Icon( - painter = painterResource(R.drawable.ic_qrcode), - null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } + Box(modifier = Modifier.weight(1F)) + IconButton( + onClick = { + dialogOpen = true + coroutineScope.launch { drawerState.close() } + }, + ) { + Icon( + painter = painterResource(R.drawable.ic_qrcode), + null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } } - } - if (dialogOpen) { - ShowQRDialog( - user, - loadProfilePicture = loadProfilePicture, - onScan = { - dialogOpen = false - coroutineScope.launch { drawerState.close() } - nav(it) - }, - onClose = { dialogOpen = false }, - ) - } + if (dialogOpen) { + ShowQRDialog( + user, + loadProfilePicture = loadProfilePicture, + onScan = { + dialogOpen = false + coroutineScope.launch { drawerState.close() } + nav(it) + }, + onClose = { dialogOpen = false }, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt index 081be1ba9..8b2150edc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt @@ -33,70 +33,70 @@ import com.vitorpamplona.quartz.events.ChatroomKeyable import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent -import java.net.URLEncoder import kotlinx.collections.immutable.persistentSetOf +import java.net.URLEncoder fun routeFor( - note: Note, - loggedIn: User, + note: Note, + loggedIn: User, ): String? { - val noteEvent = note.event + val noteEvent = note.event - if ( - noteEvent is ChannelMessageEvent || - noteEvent is ChannelCreateEvent || - noteEvent is ChannelMetadataEvent - ) { - note.channelHex()?.let { - return "Channel/$it" + if ( + noteEvent is ChannelMessageEvent || + noteEvent is ChannelCreateEvent || + noteEvent is ChannelMetadataEvent + ) { + note.channelHex()?.let { + return "Channel/$it" + } + } else if (noteEvent is LiveActivitiesEvent || noteEvent is LiveActivitiesChatMessageEvent) { + note.channelHex()?.let { + return "Channel/${URLEncoder.encode(it, "utf-8")}" + } + } else if (noteEvent is ChatroomKeyable) { + val room = noteEvent.chatroomKey(loggedIn.pubkeyHex) + loggedIn.createChatroom(room) + return "Room/${room.hashCode()}" + } else if (noteEvent is CommunityDefinitionEvent) { + return "Community/${URLEncoder.encode(note.idHex, "utf-8")}" + } else { + return "Note/${URLEncoder.encode(note.idHex, "utf-8")}" } - } else if (noteEvent is LiveActivitiesEvent || noteEvent is LiveActivitiesChatMessageEvent) { - note.channelHex()?.let { - return "Channel/${URLEncoder.encode(it, "utf-8")}" - } - } else if (noteEvent is ChatroomKeyable) { - val room = noteEvent.chatroomKey(loggedIn.pubkeyHex) - loggedIn.createChatroom(room) - return "Room/${room.hashCode()}" - } else if (noteEvent is CommunityDefinitionEvent) { - return "Community/${URLEncoder.encode(note.idHex, "utf-8")}" - } else { - return "Note/${URLEncoder.encode(note.idHex, "utf-8")}" - } - return null + return null } fun routeToMessage( - user: HexKey, - draftMessage: String?, - accountViewModel: AccountViewModel, + user: HexKey, + draftMessage: String?, + accountViewModel: AccountViewModel, ): String { - val withKey = ChatroomKey(persistentSetOf(user)) - accountViewModel.account.userProfile().createChatroom(withKey) - return if (draftMessage != null) { - "Room/${withKey.hashCode()}?message=$draftMessage" - } else { - "Room/${withKey.hashCode()}" - } + val withKey = ChatroomKey(persistentSetOf(user)) + accountViewModel.account.userProfile().createChatroom(withKey) + return if (draftMessage != null) { + "Room/${withKey.hashCode()}?message=$draftMessage" + } else { + "Room/${withKey.hashCode()}" + } } fun routeToMessage( - user: User, - draftMessage: String?, - accountViewModel: AccountViewModel, + user: User, + draftMessage: String?, + accountViewModel: AccountViewModel, ): String { - return routeToMessage(user.pubkeyHex, draftMessage, accountViewModel) + return routeToMessage(user.pubkeyHex, draftMessage, accountViewModel) } fun routeFor(note: Channel): String { - return "Channel/${note.idHex}" + return "Channel/${note.idHex}" } fun routeFor(user: User): String { - return "User/${user.pubkeyHex}" + return "User/${user.pubkeyHex}" } fun authorRouteFor(note: Note): String { - return "User/${note.author?.pubkeyHex}" + return "User/${note.author?.pubkeyHex}" } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index c9c0e01ea..f33b457de 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -55,179 +55,179 @@ import kotlinx.collections.immutable.toImmutableList @Immutable sealed class Route( - val route: String, - val base: String = route.substringBefore("?"), - val icon: Int, - val notifSize: Modifier = Modifier.size(Size23dp), - val iconSize: Modifier = Modifier.size(Size20dp), - val hasNewItems: (Account, Set) -> Boolean = { _, _ -> - false - }, - val arguments: ImmutableList = persistentListOf(), + val route: String, + val base: String = route.substringBefore("?"), + val icon: Int, + val notifSize: Modifier = Modifier.size(Size23dp), + val iconSize: Modifier = Modifier.size(Size20dp), + val hasNewItems: (Account, Set) -> Boolean = { _, _ -> + false + }, + val arguments: ImmutableList = persistentListOf(), ) { - object Home : - Route( - route = "Home?nip47={nip47}", - icon = R.drawable.ic_home, - notifSize = Modifier.size(Size25dp), - iconSize = Modifier.size(Size24dp), - arguments = - listOf( - navArgument("nip47") { - type = NavType.StringType - nullable = true - defaultValue = null + object Home : + Route( + route = "Home?nip47={nip47}", + icon = R.drawable.ic_home, + notifSize = Modifier.size(Size25dp), + iconSize = Modifier.size(Size24dp), + arguments = + listOf( + navArgument("nip47") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ) + .toImmutableList(), + hasNewItems = { accountViewModel, newNotes -> + HomeLatestItem.hasNewItems(accountViewModel, newNotes) }, - ) - .toImmutableList(), - hasNewItems = { accountViewModel, newNotes -> - HomeLatestItem.hasNewItems(accountViewModel, newNotes) - }, - ) + ) - object Global : - Route( - route = "Global", - icon = R.drawable.ic_globe, - ) + object Global : + Route( + route = "Global", + icon = R.drawable.ic_globe, + ) - object Search : - Route( - route = "Search", - icon = R.drawable.ic_search, - ) + object Search : + Route( + route = "Search", + icon = R.drawable.ic_search, + ) - object Video : - Route( - route = "Video", - icon = R.drawable.ic_video, - ) + object Video : + Route( + route = "Video", + icon = R.drawable.ic_video, + ) - object Discover : - Route( - route = "Discover", - icon = R.drawable.ic_sensors, - hasNewItems = { accountViewModel, newNotes -> - DiscoverLatestItem.hasNewItems(accountViewModel, newNotes) - }, - ) - - object Notification : - Route( - route = "Notification", - icon = R.drawable.ic_notifications, - hasNewItems = { accountViewModel, newNotes -> - NotificationLatestItem.hasNewItems(accountViewModel, newNotes) - }, - ) - - object Message : - Route( - route = "Message", - icon = R.drawable.ic_dm, - hasNewItems = { accountViewModel, newNotes -> - MessagesLatestItem.hasNewItems(accountViewModel, newNotes) - }, - ) - - object BlockedUsers : - Route( - route = "BlockedUsers", - icon = R.drawable.ic_security, - ) - - object Bookmarks : - Route( - route = "Bookmarks", - icon = R.drawable.ic_bookmarks, - ) - - object Profile : - Route( - route = "User/{id}", - icon = R.drawable.ic_profile, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), - ) - - object Note : - Route( - route = "Note/{id}", - icon = R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), - ) - - object Hashtag : - Route( - route = "Hashtag/{id}", - icon = R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), - ) - - object Geohash : - Route( - route = "Geohash/{id}", - icon = R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), - ) - - object Community : - Route( - route = "Community/{id}", - icon = R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), - ) - - object Room : - Route( - route = "Room/{id}?message={message}", - icon = R.drawable.ic_moments, - arguments = - listOf( - navArgument("id") { type = NavType.StringType }, - navArgument("message") { - type = NavType.StringType - nullable = true - defaultValue = null + object Discover : + Route( + route = "Discover", + icon = R.drawable.ic_sensors, + hasNewItems = { accountViewModel, newNotes -> + DiscoverLatestItem.hasNewItems(accountViewModel, newNotes) }, - ) - .toImmutableList(), - ) + ) - object RoomByAuthor : - Route( - route = "RoomByAuthor/{id}", - icon = R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), - ) + object Notification : + Route( + route = "Notification", + icon = R.drawable.ic_notifications, + hasNewItems = { accountViewModel, newNotes -> + NotificationLatestItem.hasNewItems(accountViewModel, newNotes) + }, + ) - object Channel : - Route( - route = "Channel/{id}", - icon = R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), - ) + object Message : + Route( + route = "Message", + icon = R.drawable.ic_dm, + hasNewItems = { accountViewModel, newNotes -> + MessagesLatestItem.hasNewItems(accountViewModel, newNotes) + }, + ) - object Event : - Route( - route = "Event/{id}", - icon = R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), - ) + object BlockedUsers : + Route( + route = "BlockedUsers", + icon = R.drawable.ic_security, + ) - object Settings : - Route( - route = "Settings", - icon = R.drawable.ic_settings, - ) + object Bookmarks : + Route( + route = "Bookmarks", + icon = R.drawable.ic_bookmarks, + ) - companion object { - val InvertedLayouts = - setOf( - Channel.route, - Room.route, - RoomByAuthor.route, - ) - } + object Profile : + Route( + route = "User/{id}", + icon = R.drawable.ic_profile, + arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), + ) + + object Note : + Route( + route = "Note/{id}", + icon = R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), + ) + + object Hashtag : + Route( + route = "Hashtag/{id}", + icon = R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), + ) + + object Geohash : + Route( + route = "Geohash/{id}", + icon = R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), + ) + + object Community : + Route( + route = "Community/{id}", + icon = R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), + ) + + object Room : + Route( + route = "Room/{id}?message={message}", + icon = R.drawable.ic_moments, + arguments = + listOf( + navArgument("id") { type = NavType.StringType }, + navArgument("message") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ) + .toImmutableList(), + ) + + object RoomByAuthor : + Route( + route = "RoomByAuthor/{id}", + icon = R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), + ) + + object Channel : + Route( + route = "Channel/{id}", + icon = R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), + ) + + object Event : + Route( + route = "Event/{id}", + icon = R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), + ) + + object Settings : + Route( + route = "Settings", + icon = R.drawable.ic_settings, + ) + + companion object { + val InvertedLayouts = + setOf( + Channel.route, + Room.route, + RoomByAuthor.route, + ) + } } // ** @@ -236,219 +236,219 @@ sealed class Route( // ** @Composable fun currentRoute(navController: NavHostController): String? { - val navBackStackEntry by navController.currentBackStackEntryAsState() - return navBackStackEntry?.destination?.route + val navBackStackEntry by navController.currentBackStackEntryAsState() + return navBackStackEntry?.destination?.route } open class LatestItem { - var newestItemPerAccount: Map = mapOf() + var newestItemPerAccount: Map = mapOf() - fun getNewestItem(account: Account): Note? { - return newestItemPerAccount[account.userProfile().pubkeyHex] - } - - fun clearNewestItem(account: Account) { - val userHex = account.userProfile().pubkeyHex - if (newestItemPerAccount.contains(userHex)) { - newestItemPerAccount = newestItemPerAccount - userHex - } - } - - fun updateNewestItem( - newNotes: Set, - account: Account, - filter: AdditiveFeedFilter, - ): Note? { - val newestItem = newestItemPerAccount[account.userProfile().pubkeyHex] - - // Block list got updated - if (newestItem == null || !account.isAcceptable(newestItem)) { - newestItemPerAccount = - newestItemPerAccount + - Pair( - account.userProfile().pubkeyHex, - filterMore(filter.feed(), account).firstOrNull { it.createdAt() != null }, - ) - } else { - newestItemPerAccount = - newestItemPerAccount + - Pair( - account.userProfile().pubkeyHex, - filter.sort(filterMore(filter.applyFilter(newNotes), account) + newestItem).first(), - ) + fun getNewestItem(account: Account): Note? { + return newestItemPerAccount[account.userProfile().pubkeyHex] } - return newestItemPerAccount[account.userProfile().pubkeyHex] - } + fun clearNewestItem(account: Account) { + val userHex = account.userProfile().pubkeyHex + if (newestItemPerAccount.contains(userHex)) { + newestItemPerAccount = newestItemPerAccount - userHex + } + } - open fun filterMore( - newItems: Set, - account: Account, - ): Set { - return newItems - } + fun updateNewestItem( + newNotes: Set, + account: Account, + filter: AdditiveFeedFilter, + ): Note? { + val newestItem = newestItemPerAccount[account.userProfile().pubkeyHex] - open fun filterMore( - newItems: List, - account: Account, - ): List { - return newItems - } + // Block list got updated + if (newestItem == null || !account.isAcceptable(newestItem)) { + newestItemPerAccount = + newestItemPerAccount + + Pair( + account.userProfile().pubkeyHex, + filterMore(filter.feed(), account).firstOrNull { it.createdAt() != null }, + ) + } else { + newestItemPerAccount = + newestItemPerAccount + + Pair( + account.userProfile().pubkeyHex, + filter.sort(filterMore(filter.applyFilter(newNotes), account) + newestItem).first(), + ) + } + + return newestItemPerAccount[account.userProfile().pubkeyHex] + } + + open fun filterMore( + newItems: Set, + account: Account, + ): Set { + return newItems + } + + open fun filterMore( + newItems: List, + account: Account, + ): List { + return newItems + } } object HomeLatestItem : LatestItem() { - fun hasNewItems( - account: Account, - newNotes: Set, - ): Boolean { - checkNotInMainThread() + fun hasNewItems( + account: Account, + newNotes: Set, + ): Boolean { + checkNotInMainThread() - val lastTime = account.loadLastRead("HomeFollows") + val lastTime = account.loadLastRead("HomeFollows") - val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter(account)) + val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter(account)) - return (newestItem?.createdAt() ?: 0) > lastTime - } + return (newestItem?.createdAt() ?: 0) > lastTime + } } object DiscoverLatestItem : LatestItem() { - fun hasNewItems( - account: Account, - newNotes: Set, - ): Boolean { - checkNotInMainThread() + fun hasNewItems( + account: Account, + newNotes: Set, + ): Boolean { + checkNotInMainThread() - val lastTime = account.loadLastRead(Route.Discover.base + "Live") + val lastTime = account.loadLastRead(Route.Discover.base + "Live") - val newestItem = updateNewestItem(newNotes, account, DiscoverLiveNowFeedFilter(account)) + val newestItem = updateNewestItem(newNotes, account, DiscoverLiveNowFeedFilter(account)) - val noteEvent = newestItem?.event + val noteEvent = newestItem?.event - val dateToUse = - if (noteEvent is LiveActivitiesEvent) { - noteEvent.starts() ?: newestItem.createdAt() - } else { - newestItem?.createdAt() - } + val dateToUse = + if (noteEvent is LiveActivitiesEvent) { + noteEvent.starts() ?: newestItem.createdAt() + } else { + newestItem?.createdAt() + } - return (dateToUse ?: 0) > lastTime - } + return (dateToUse ?: 0) > lastTime + } } object NotificationLatestItem : LatestItem() { - fun hasNewItems( - account: Account, - newNotes: Set, - ): Boolean { - checkNotInMainThread() + fun hasNewItems( + account: Account, + newNotes: Set, + ): Boolean { + checkNotInMainThread() - val lastTime = account.loadLastRead("Notification") + val lastTime = account.loadLastRead("Notification") - val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter(account)) + val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter(account)) - return (newestItem?.createdAt() ?: 0) > lastTime - } + return (newestItem?.createdAt() ?: 0) > lastTime + } } object MessagesLatestItem : LatestItem() { - fun hasNewItems( - account: Account, - newNotes: Set, - ): Boolean { - checkNotInMainThread() + fun hasNewItems( + account: Account, + newNotes: Set, + ): Boolean { + checkNotInMainThread() - // Checks if the current newest item is still unread. - // If so, there is no need to check anything else - if (isNew(getNewestItem(account), account)) { - return true + // Checks if the current newest item is still unread. + // If so, there is no need to check anything else + if (isNew(getNewestItem(account), account)) { + return true + } + + clearNewestItem(account) + + // gets the newest of the unread items + val newestItem = updateNewestItem(newNotes, account, ChatroomListKnownFeedFilter(account)) + + return isNew(newestItem, account) } - clearNewestItem(account) + fun isNew( + it: Note?, + account: Account, + ): Boolean { + if (it == null) return false - // gets the newest of the unread items - val newestItem = updateNewestItem(newNotes, account, ChatroomListKnownFeedFilter(account)) - - return isNew(newestItem, account) - } - - fun isNew( - it: Note?, - account: Account, - ): Boolean { - if (it == null) return false - - val currentUser = account.userProfile().pubkeyHex - val room = (it.event as? ChatroomKeyable)?.chatroomKey(currentUser) - return if (room != null) { - val lastRead = account.loadLastRead("Room/${room.hashCode()}") - (it.createdAt() ?: 0) > lastRead - } else { - false + val currentUser = account.userProfile().pubkeyHex + val room = (it.event as? ChatroomKeyable)?.chatroomKey(currentUser) + return if (room != null) { + val lastRead = account.loadLastRead("Room/${room.hashCode()}") + (it.createdAt() ?: 0) > lastRead + } else { + false + } } - } - override fun filterMore( - newItems: Set, - account: Account, - ): Set { - return newItems.filter { isNew(it, account) }.toSet() - } + override fun filterMore( + newItems: Set, + account: Account, + ): Set { + return newItems.filter { isNew(it, account) }.toSet() + } - override fun filterMore( - newItems: List, - account: Account, - ): List { - return newItems.filter { isNew(it, account) } - } + override fun filterMore( + newItems: List, + account: Account, + ): List { + return newItems.filter { isNew(it, account) } + } } fun getRouteWithArguments(navController: NavHostController): String? { - val currentEntry = navController.currentBackStackEntry ?: return null - return getRouteWithArguments(currentEntry.destination, currentEntry.arguments) + val currentEntry = navController.currentBackStackEntry ?: return null + return getRouteWithArguments(currentEntry.destination, currentEntry.arguments) } fun getRouteWithArguments(navState: State): String? { - return navState.value?.let { getRouteWithArguments(it.destination, it.arguments) } + return navState.value?.let { getRouteWithArguments(it.destination, it.arguments) } } private fun getRouteWithArguments( - destination: NavDestination, - arguments: Bundle?, + destination: NavDestination, + arguments: Bundle?, ): String? { - var route = destination.route ?: return null - arguments?.let { bundle -> - destination.arguments.forEach { - val key = it.key - val value = it.value.type[bundle, key]?.toString() - if (value == null) { - val keyStart = route.indexOf("{$key}") - // if it is a parameter, removes the complete segment `var={key}` and adjust connectors `#`, - // `&` or `&` - if (keyStart > 0 && route[keyStart - 1] == '=') { - val end = keyStart + "{$key}".length - var start = keyStart - for (i in keyStart downTo 0) { - if (route[i] == '#' || route[i] == '?' || route[i] == '&') { - start = i + 1 - break + var route = destination.route ?: return null + arguments?.let { bundle -> + destination.arguments.forEach { + val key = it.key + val value = it.value.type[bundle, key]?.toString() + if (value == null) { + val keyStart = route.indexOf("{$key}") + // if it is a parameter, removes the complete segment `var={key}` and adjust connectors `#`, + // `&` or `&` + if (keyStart > 0 && route[keyStart - 1] == '=') { + val end = keyStart + "{$key}".length + var start = keyStart + for (i in keyStart downTo 0) { + if (route[i] == '#' || route[i] == '?' || route[i] == '&') { + start = i + 1 + break + } + } + if (end < route.length && route[end] == '&') { + route = route.removeRange(start, end + 1) + } else if (end < route.length && route[end] == '#') { + route = route.removeRange(start - 1, end) + } else if (end == route.length) { + route = route.removeRange(start - 1, end) + } else { + route = route.removeRange(start, end) + } + } else { + route = route.replaceFirst("{$key}", "") + } + } else { + route = route.replaceFirst("{$key}", value) } - } - if (end < route.length && route[end] == '&') { - route = route.removeRange(start, end + 1) - } else if (end < route.length && route[end] == '#') { - route = route.removeRange(start - 1, end) - } else if (end == route.length) { - route = route.removeRange(start - 1, end) - } else { - route = route.removeRange(start, end) - } - } else { - route = route.replaceFirst("{$key}", "") } - } else { - route = route.replaceFirst("{$key}", value) - } } - } - return route + return route } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt index 2733aad93..a8cc82304 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt @@ -64,130 +64,130 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable fun BadgeCompose( - likeSetCard: BadgeCard, - isInnerNote: Boolean = false, - routeForLastRead: String, - showHidden: Boolean = false, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + likeSetCard: BadgeCard, + isInnerNote: Boolean = false, + routeForLastRead: String, + showHidden: Boolean = false, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteState by likeSetCard.note.live().metadata.observeAsState() - val note = noteState?.note + val noteState by likeSetCard.note.live().metadata.observeAsState() + val note = noteState?.note - val context = LocalContext.current.applicationContext + val context = LocalContext.current.applicationContext - val popupExpanded = remember { mutableStateOf(false) } - val enablePopup = remember { { popupExpanded.value = true } } + val popupExpanded = remember { mutableStateOf(false) } + val enablePopup = remember { { popupExpanded.value = true } } - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - if (note == null) { - BlankNote(Modifier, isInnerNote) - } else { - val defaultBackgroundColor = MaterialTheme.colorScheme.background - val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } - val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor + if (note == null) { + BlankNote(Modifier, isInnerNote) + } else { + val defaultBackgroundColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } + val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor - LaunchedEffect(key1 = likeSetCard) { - accountViewModel.loadAndMarkAsRead(routeForLastRead, likeSetCard.createdAt()) { isNew -> - val newBackgroundColor = - if (isNew) { - newItemColor.compositeOver(defaultBackgroundColor) - } else { - defaultBackgroundColor - } + LaunchedEffect(key1 = likeSetCard) { + accountViewModel.loadAndMarkAsRead(routeForLastRead, likeSetCard.createdAt()) { isNew -> + val newBackgroundColor = + if (isNew) { + newItemColor.compositeOver(defaultBackgroundColor) + } else { + defaultBackgroundColor + } - if (backgroundColor.value != newBackgroundColor) { - backgroundColor.value = newBackgroundColor - } - } - } - - Column( - modifier = - Modifier.background(backgroundColor.value) - .combinedClickable( - onClick = { - scope.launch { - routeFor( - note, - accountViewModel.userProfile(), - ) - ?.let { nav(it) } - } - }, - onLongClick = enablePopup, - ), - ) { - Row( - modifier = - Modifier.padding( - start = if (!isInnerNote) 12.dp else 0.dp, - end = if (!isInnerNote) 12.dp else 0.dp, - top = 10.dp, - ), - ) { - // Draws the like picture outside the boosted card. - if (!isInnerNote) { - Box( - modifier = Modifier.width(55.dp).padding(0.dp), - ) { - Icon( - imageVector = Icons.Default.MilitaryTech, - null, - modifier = Modifier.size(25.dp).align(Alignment.TopEnd), - tint = MaterialTheme.colorScheme.primary, - ) - } - } - - Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) { - Row { - Text( - stringResource(R.string.new_badge_award_notif), - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 5.dp).weight(1f), - ) - - Text( - timeAgo(note.createdAt(), context = context), - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - ) - - IconButton( - modifier = Modifier.then(Modifier.size(24.dp)), - onClick = enablePopup, - ) { - Icon( - imageVector = Icons.Default.MoreVert, - null, - modifier = Modifier.size(15.dp), - tint = MaterialTheme.colorScheme.placeholderText, - ) - - NoteDropDownMenu(note, popupExpanded, accountViewModel) + if (backgroundColor.value != newBackgroundColor) { + backgroundColor.value = newBackgroundColor + } + } + } + + Column( + modifier = + Modifier.background(backgroundColor.value) + .combinedClickable( + onClick = { + scope.launch { + routeFor( + note, + accountViewModel.userProfile(), + ) + ?.let { nav(it) } + } + }, + onLongClick = enablePopup, + ), + ) { + Row( + modifier = + Modifier.padding( + start = if (!isInnerNote) 12.dp else 0.dp, + end = if (!isInnerNote) 12.dp else 0.dp, + top = 10.dp, + ), + ) { + // Draws the like picture outside the boosted card. + if (!isInnerNote) { + Box( + modifier = Modifier.width(55.dp).padding(0.dp), + ) { + Icon( + imageVector = Icons.Default.MilitaryTech, + null, + modifier = Modifier.size(25.dp).align(Alignment.TopEnd), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + + Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) { + Row { + Text( + stringResource(R.string.new_badge_award_notif), + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 5.dp).weight(1f), + ) + + Text( + timeAgo(note.createdAt(), context = context), + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + + IconButton( + modifier = Modifier.then(Modifier.size(24.dp)), + onClick = enablePopup, + ) { + Icon( + imageVector = Icons.Default.MoreVert, + null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) + + NoteDropDownMenu(note, popupExpanded, accountViewModel) + } + } + + note.replyTo?.firstOrNull()?.let { + NoteCompose( + baseNote = it, + routeForLastRead = null, + isBoostedNote = true, + showHidden = showHidden, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = DividerThickness, + ) + } } - } - - note.replyTo?.firstOrNull()?.let { - NoteCompose( - baseNote = it, - routeForLastRead = null, - isBoostedNote = true, - showHidden = showHidden, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - Divider( - modifier = Modifier.padding(top = 10.dp), - thickness = DividerThickness, - ) } - } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt index d63529350..dd0ea16ee 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt @@ -48,103 +48,103 @@ import kotlinx.collections.immutable.ImmutableSet @Composable fun BlankNote( - modifier: Modifier = Modifier, - showDivider: Boolean = false, - idHex: String? = null, + modifier: Modifier = Modifier, + showDivider: Boolean = false, + idHex: String? = null, ) { - Column(modifier = modifier) { - Row { - Column { - Row( - modifier = - Modifier.padding( - start = 20.dp, - end = 20.dp, - bottom = 8.dp, - top = 15.dp, - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Text( - text = stringResource(R.string.post_not_found) + if (idHex != null) ": $idHex" else "", - modifier = Modifier.padding(30.dp), - color = Color.Gray, - ) - } + Column(modifier = modifier) { + Row { + Column { + Row( + modifier = + Modifier.padding( + start = 20.dp, + end = 20.dp, + bottom = 8.dp, + top = 15.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.post_not_found) + if (idHex != null) ": $idHex" else "", + modifier = Modifier.padding(30.dp), + color = Color.Gray, + ) + } - if (!showDivider) { - Divider( - modifier = Modifier.padding(vertical = 10.dp), - thickness = DividerThickness, - ) + if (!showDivider) { + Divider( + modifier = Modifier.padding(vertical = 10.dp), + thickness = DividerThickness, + ) + } + } } - } } - } } @OptIn(ExperimentalLayoutApi::class) @Composable fun HiddenNote( - reports: ImmutableSet, - isHiddenAuthor: Boolean, - accountViewModel: AccountViewModel, - modifier: Modifier = Modifier, - isQuote: Boolean = false, - nav: (String) -> Unit, - onClick: () -> Unit, + reports: ImmutableSet, + isHiddenAuthor: Boolean, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, + isQuote: Boolean = false, + nav: (String) -> Unit, + onClick: () -> Unit, ) { - Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { - Row( - modifier = Modifier.padding(start = if (!isQuote) 30.dp else 25.dp, end = 20.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(30.dp), - ) { - Text( - text = stringResource(R.string.post_was_flagged_as_inappropriate_by), - color = Color.Gray, - ) - FlowRow(modifier = Modifier.padding(top = 10.dp)) { - if (isHiddenAuthor) { - UserPicture( - user = accountViewModel.userProfile(), - size = Size35dp, - nav = nav, - accountViewModel = accountViewModel, - ) - } - reports.forEach { - NoteAuthorPicture( - baseNote = it, - size = Size35dp, - nav = nav, - accountViewModel = accountViewModel, - ) - } - } - - Button( - modifier = Modifier.padding(top = 10.dp), - onClick = onClick, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - contentColor = MaterialTheme.colorScheme.primary, - ), - contentPadding = ButtonPadding, + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Row( + modifier = Modifier.padding(start = if (!isQuote) 30.dp else 25.dp, end = 20.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, ) { - Text(text = stringResource(R.string.show_anyway), color = Color.White) - } - } - } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(30.dp), + ) { + Text( + text = stringResource(R.string.post_was_flagged_as_inappropriate_by), + color = Color.Gray, + ) + FlowRow(modifier = Modifier.padding(top = 10.dp)) { + if (isHiddenAuthor) { + UserPicture( + user = accountViewModel.userProfile(), + size = Size35dp, + nav = nav, + accountViewModel = accountViewModel, + ) + } + reports.forEach { + NoteAuthorPicture( + baseNote = it, + size = Size35dp, + nav = nav, + accountViewModel = accountViewModel, + ) + } + } - Divider( - thickness = DividerThickness, - ) - } + Button( + modifier = Modifier.padding(top = 10.dp), + onClick = onClick, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(R.string.show_anyway), color = Color.White) + } + } + } + + Divider( + thickness = DividerThickness, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt index 3ed3c0782..a12e5a2e6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt @@ -111,933 +111,934 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable fun ChannelCardCompose( - baseNote: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - parentBackgroundColor: MutableState? = null, - forceEventKind: Int?, - showHidden: Boolean = false, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + forceEventKind: Int?, + showHidden: Boolean = false, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) + val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) - Crossfade(targetState = hasEvent, label = "ChannelCardCompose") { - if (it) { - if (forceEventKind == null || baseNote.event?.kind() == forceEventKind) { - CheckHiddenChannelCardCompose( - baseNote, - routeForLastRead, - modifier, - parentBackgroundColor, - showHidden, - accountViewModel, - nav, - ) - } - } else { - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup, - -> - BlankNote( - remember { - modifier.combinedClickable( - onClick = {}, - onLongClick = showPopup, - ) - }, - false, - ) - } + Crossfade(targetState = hasEvent, label = "ChannelCardCompose") { + if (it) { + if (forceEventKind == null || baseNote.event?.kind() == forceEventKind) { + CheckHiddenChannelCardCompose( + baseNote, + routeForLastRead, + modifier, + parentBackgroundColor, + showHidden, + accountViewModel, + nav, + ) + } + } else { + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup, + -> + BlankNote( + remember { + modifier.combinedClickable( + onClick = {}, + onLongClick = showPopup, + ) + }, + false, + ) + } + } } - } } @Composable fun CheckHiddenChannelCardCompose( - note: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - parentBackgroundColor: MutableState? = null, - showHidden: Boolean, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + showHidden: Boolean, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (showHidden) { - val state by remember { - mutableStateOf( - AccountViewModel.NoteComposeReportState(), - ) - } + if (showHidden) { + val state by remember { + mutableStateOf( + AccountViewModel.NoteComposeReportState(), + ) + } - RenderChannelCardReportState( - state = state, - note = note, - routeForLastRead = routeForLastRead, - modifier = modifier, - parentBackgroundColor = parentBackgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } else { - val isHidden by - accountViewModel.account.liveHiddenUsers - .map { note.isHiddenFor(it) } - .distinctUntilChanged() - .observeAsState(accountViewModel.isNoteHidden(note)) - - Crossfade(targetState = isHidden, label = "CheckHiddenChannelCardCompose") { - if (!it) { - LoadedChannelCardCompose( - note, - routeForLastRead, - modifier, - parentBackgroundColor, - accountViewModel, - nav, + RenderChannelCardReportState( + state = state, + note = note, + routeForLastRead = routeForLastRead, + modifier = modifier, + parentBackgroundColor = parentBackgroundColor, + accountViewModel = accountViewModel, + nav = nav, ) - } + } else { + val isHidden by + accountViewModel.account.liveHiddenUsers + .map { note.isHiddenFor(it) } + .distinctUntilChanged() + .observeAsState(accountViewModel.isNoteHidden(note)) + + Crossfade(targetState = isHidden, label = "CheckHiddenChannelCardCompose") { + if (!it) { + LoadedChannelCardCompose( + note, + routeForLastRead, + modifier, + parentBackgroundColor, + accountViewModel, + nav, + ) + } + } } - } } @Composable fun LoadedChannelCardCompose( - note: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var state by remember { - mutableStateOf( - AccountViewModel.NoteComposeReportState(), - ) - } - - val scope = rememberCoroutineScope() - - WatchForReports(note, accountViewModel) { newState -> - if (state != newState) { - scope.launch(Dispatchers.Main) { state = newState } + var state by remember { + mutableStateOf( + AccountViewModel.NoteComposeReportState(), + ) } - } - Crossfade(targetState = state, label = "CheckHiddenChannelCardCompose") { - RenderChannelCardReportState( - it, - note, - routeForLastRead, - modifier, - parentBackgroundColor, - accountViewModel, - nav, - ) - } + val scope = rememberCoroutineScope() + + WatchForReports(note, accountViewModel) { newState -> + if (state != newState) { + scope.launch(Dispatchers.Main) { state = newState } + } + } + + Crossfade(targetState = state, label = "CheckHiddenChannelCardCompose") { + RenderChannelCardReportState( + it, + note, + routeForLastRead, + modifier, + parentBackgroundColor, + accountViewModel, + nav, + ) + } } @Composable fun RenderChannelCardReportState( - state: AccountViewModel.NoteComposeReportState, - note: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + state: AccountViewModel.NoteComposeReportState, + note: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var showReportedNote by remember { mutableStateOf(false) } + var showReportedNote by remember { mutableStateOf(false) } - Crossfade( - targetState = !state.isAcceptable && !showReportedNote, - label = "CheckHiddenChannelCardCompose", - ) { showHiddenNote -> - if (showHiddenNote) { - HiddenNote( - state.relevantReports, - state.isHiddenAuthor, - accountViewModel, - modifier, - false, - nav, - onClick = { showReportedNote = true }, - ) - } else { - NormalChannelCard( - note, - routeForLastRead, - modifier, - parentBackgroundColor, - accountViewModel, - nav, - ) + Crossfade( + targetState = !state.isAcceptable && !showReportedNote, + label = "CheckHiddenChannelCardCompose", + ) { showHiddenNote -> + if (showHiddenNote) { + HiddenNote( + state.relevantReports, + state.isHiddenAuthor, + accountViewModel, + modifier, + false, + nav, + onClick = { showReportedNote = true }, + ) + } else { + NormalChannelCard( + note, + routeForLastRead, + modifier, + parentBackgroundColor, + accountViewModel, + nav, + ) + } } - } } @Composable fun NormalChannelCard( - baseNote: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> - CheckNewAndRenderChannelCard( - baseNote, - routeForLastRead, - modifier, - parentBackgroundColor, - accountViewModel, - showPopup, - nav, - ) - } + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> + CheckNewAndRenderChannelCard( + baseNote, + routeForLastRead, + modifier, + parentBackgroundColor, + accountViewModel, + showPopup, + nav, + ) + } } @Composable private fun CheckNewAndRenderChannelCard( - baseNote: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - showPopup: () -> Unit, - nav: (String) -> Unit, + baseNote: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + showPopup: () -> Unit, + nav: (String) -> Unit, ) { - val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor - val defaultBackgroundColor = MaterialTheme.colorScheme.background - val backgroundColor = remember { - mutableStateOf( - parentBackgroundColor?.value ?: defaultBackgroundColor, - ) - } + val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor + val defaultBackgroundColor = MaterialTheme.colorScheme.background + val backgroundColor = + remember { + mutableStateOf( + parentBackgroundColor?.value ?: defaultBackgroundColor, + ) + } - LaunchedEffect(key1 = routeForLastRead, key2 = parentBackgroundColor?.value) { - routeForLastRead?.let { - accountViewModel.loadAndMarkAsRead(routeForLastRead, baseNote.createdAt()) { isNew -> - val newBackgroundColor = - if (isNew) { - if (parentBackgroundColor != null) { - newItemColor.compositeOver(parentBackgroundColor.value) - } else { - newItemColor.compositeOver(defaultBackgroundColor) + LaunchedEffect(key1 = routeForLastRead, key2 = parentBackgroundColor?.value) { + routeForLastRead?.let { + accountViewModel.loadAndMarkAsRead(routeForLastRead, baseNote.createdAt()) { isNew -> + val newBackgroundColor = + if (isNew) { + if (parentBackgroundColor != null) { + newItemColor.compositeOver(parentBackgroundColor.value) + } else { + newItemColor.compositeOver(defaultBackgroundColor) + } + } else { + parentBackgroundColor?.value ?: defaultBackgroundColor + } + + if (newBackgroundColor != backgroundColor.value) { + launch(Dispatchers.Main) { backgroundColor.value = newBackgroundColor } + } } - } else { - parentBackgroundColor?.value ?: defaultBackgroundColor - } - - if (newBackgroundColor != backgroundColor.value) { - launch(Dispatchers.Main) { backgroundColor.value = newBackgroundColor } } - } + ?: run { + val newBackgroundColor = parentBackgroundColor?.value ?: defaultBackgroundColor + if (newBackgroundColor != backgroundColor.value) { + launch(Dispatchers.Main) { backgroundColor.value = newBackgroundColor } + } + } } - ?: run { - val newBackgroundColor = parentBackgroundColor?.value ?: defaultBackgroundColor - if (newBackgroundColor != backgroundColor.value) { - launch(Dispatchers.Main) { backgroundColor.value = newBackgroundColor } - } - } - } - ClickableNote( - baseNote = baseNote, - backgroundColor = backgroundColor, - modifier = modifier, - accountViewModel = accountViewModel, - showPopup = showPopup, - nav = nav, - ) { - InnerChannelCardWithReactions( - baseNote = baseNote, - accountViewModel = accountViewModel, - nav = nav, - ) - } + ClickableNote( + baseNote = baseNote, + backgroundColor = backgroundColor, + modifier = modifier, + accountViewModel = accountViewModel, + showPopup = showPopup, + nav = nav, + ) { + InnerChannelCardWithReactions( + baseNote = baseNote, + accountViewModel = accountViewModel, + nav = nav, + ) + } } @Composable fun InnerChannelCardWithReactions( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - when (remember { baseNote.event }) { - is LiveActivitiesEvent -> { - InnerCardRow(baseNote, accountViewModel, nav) + when (remember { baseNote.event }) { + is LiveActivitiesEvent -> { + InnerCardRow(baseNote, accountViewModel, nav) + } + is CommunityDefinitionEvent -> { + InnerCardRow(baseNote, accountViewModel, nav) + } + is ChannelCreateEvent -> { + InnerCardRow(baseNote, accountViewModel, nav) + } + is ClassifiedsEvent -> { + InnerCardBox(baseNote, accountViewModel, nav) + } } - is CommunityDefinitionEvent -> { - InnerCardRow(baseNote, accountViewModel, nav) - } - is ChannelCreateEvent -> { - InnerCardRow(baseNote, accountViewModel, nav) - } - is ClassifiedsEvent -> { - InnerCardBox(baseNote, accountViewModel, nav) - } - } } @Composable fun InnerCardRow( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Column(StdPadding) { - SensitivityWarning( - note = baseNote, - accountViewModel = accountViewModel, - ) { - RenderNoteRow( - baseNote, - accountViewModel, - nav, - ) + Column(StdPadding) { + SensitivityWarning( + note = baseNote, + accountViewModel = accountViewModel, + ) { + RenderNoteRow( + baseNote, + accountViewModel, + nav, + ) + } } - } - Divider( - thickness = DividerThickness, - ) + Divider( + thickness = DividerThickness, + ) } @Composable fun InnerCardBox( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Column(HalfPadding) { - SensitivityWarning( - note = baseNote, - accountViewModel = accountViewModel, - ) { - RenderClassifiedsThumb(baseNote, accountViewModel, nav) + Column(HalfPadding) { + SensitivityWarning( + note = baseNote, + accountViewModel = accountViewModel, + ) { + RenderClassifiedsThumb(baseNote, accountViewModel, nav) + } } - } } @Composable private fun RenderNoteRow( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - when (remember { baseNote.event }) { - is LiveActivitiesEvent -> { - RenderLiveActivityThumb(baseNote, accountViewModel, nav) + when (remember { baseNote.event }) { + is LiveActivitiesEvent -> { + RenderLiveActivityThumb(baseNote, accountViewModel, nav) + } + is CommunityDefinitionEvent -> { + RenderCommunitiesThumb(baseNote, accountViewModel, nav) + } + is ChannelCreateEvent -> { + RenderChannelThumb(baseNote, accountViewModel, nav) + } } - is CommunityDefinitionEvent -> { - RenderCommunitiesThumb(baseNote, accountViewModel, nav) - } - is ChannelCreateEvent -> { - RenderChannelThumb(baseNote, accountViewModel, nav) - } - } } @Immutable data class ClassifiedsThumb( - val image: String?, - val title: String?, - val price: Price?, + val image: String?, + val title: String?, + val price: Price?, ) @Composable fun RenderClassifiedsThumb( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = baseNote.event as? ClassifiedsEvent ?: return + val noteEvent = baseNote.event as? ClassifiedsEvent ?: return - val card by - baseNote - .live() - .metadata - .map { - val noteEvent = it.note.event as? ClassifiedsEvent + val card by + baseNote + .live() + .metadata + .map { + val noteEvent = it.note.event as? ClassifiedsEvent - ClassifiedsThumb( - image = noteEvent?.image(), - title = noteEvent?.title(), - price = noteEvent?.price(), - ) - } - .distinctUntilChanged() - .observeAsState( - ClassifiedsThumb( - image = noteEvent.image(), - title = noteEvent.title(), - price = noteEvent.price(), - ), - ) + ClassifiedsThumb( + image = noteEvent?.image(), + title = noteEvent?.title(), + price = noteEvent?.price(), + ) + } + .distinctUntilChanged() + .observeAsState( + ClassifiedsThumb( + image = noteEvent.image(), + title = noteEvent.title(), + price = noteEvent.price(), + ), + ) - RenderClassifiedsThumb(card, baseNote.author) + RenderClassifiedsThumb(card, baseNote.author) } @Preview @Composable fun RenderClassifiedsThumbPreview() { - Surface(Modifier.size(200.dp)) { - RenderClassifiedsThumb( - card = - ClassifiedsThumb( - image = null, - title = "Like New", - price = Price("800000", "SATS", null), - ), - author = null, - ) - } + Surface(Modifier.size(200.dp)) { + RenderClassifiedsThumb( + card = + ClassifiedsThumb( + image = null, + title = "Like New", + price = Price("800000", "SATS", null), + ), + author = null, + ) + } } @Composable fun RenderClassifiedsThumb( - card: ClassifiedsThumb, - author: User?, + card: ClassifiedsThumb, + author: User?, ) { - Box( - Modifier.fillMaxWidth().aspectRatio(1f), - contentAlignment = BottomStart, - ) { - card.image?.let { - AsyncImage( - model = it, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } - ?: run { author?.let { DisplayAuthorBanner(it) } } - - Row( - Modifier.fillMaxWidth().background(Color.Black.copy(0.6f)).padding(Size5dp), - horizontalArrangement = Arrangement.SpaceBetween, + Box( + Modifier.fillMaxWidth().aspectRatio(1f), + contentAlignment = BottomStart, ) { - card.title?.let { - Text( - text = it, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = Color.White, - modifier = Modifier.weight(1f), - ) - } + card.image?.let { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + ?: run { author?.let { DisplayAuthorBanner(it) } } - card.price?.let { - val priceTag = - remember(card) { - val newAmount = it.amount.toBigDecimalOrNull()?.let { showAmountAxis(it) } ?: it.amount - - if (it.frequency != null && it.currency != null) { - "$newAmount ${it.currency}/${it.frequency}" - } else if (it.currency != null) { - "$newAmount ${it.currency}" - } else { - newAmount + Row( + Modifier.fillMaxWidth().background(Color.Black.copy(0.6f)).padding(Size5dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + card.title?.let { + Text( + text = it, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color.White, + modifier = Modifier.weight(1f), + ) } - } - Text( - text = priceTag, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = Color.White, - ) - } + card.price?.let { + val priceTag = + remember(card) { + val newAmount = it.amount.toBigDecimalOrNull()?.let { showAmountAxis(it) } ?: it.amount + + if (it.frequency != null && it.currency != null) { + "$newAmount ${it.currency}/${it.frequency}" + } else if (it.currency != null) { + "$newAmount ${it.currency}" + } else { + newAmount + } + } + + Text( + text = priceTag, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color.White, + ) + } + } } - } } @Immutable data class LiveActivityCard( - val name: String, - val cover: String?, - val media: String?, - val subject: String?, - val content: String?, - val participants: ImmutableList, - val status: String?, - val starts: Long?, + val name: String, + val cover: String?, + val media: String?, + val subject: String?, + val content: String?, + val participants: ImmutableList, + val status: String?, + val starts: Long?, ) @Composable fun RenderLiveActivityThumb( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return + val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return - val card by - baseNote - .live() - .metadata - .map { - val noteEvent = it.note.event as? LiveActivitiesEvent + val card by + baseNote + .live() + .metadata + .map { + val noteEvent = it.note.event as? LiveActivitiesEvent - LiveActivityCard( - name = noteEvent?.dTag() ?: "", - cover = noteEvent?.image()?.ifBlank { null }, - media = noteEvent?.streaming(), - subject = noteEvent?.title()?.ifBlank { null }, - content = noteEvent?.summary(), - participants = noteEvent?.participants()?.toImmutableList() ?: persistentListOf(), - status = noteEvent?.status(), - starts = noteEvent?.starts(), - ) - } - .distinctUntilChanged() - .observeAsState( - LiveActivityCard( - name = noteEvent.dTag(), - cover = noteEvent.image()?.ifBlank { null }, - media = noteEvent.streaming(), - subject = noteEvent.title()?.ifBlank { null }, - content = noteEvent.summary(), - participants = noteEvent.participants().toImmutableList(), - status = noteEvent.status(), - starts = noteEvent.starts(), - ), - ) + LiveActivityCard( + name = noteEvent?.dTag() ?: "", + cover = noteEvent?.image()?.ifBlank { null }, + media = noteEvent?.streaming(), + subject = noteEvent?.title()?.ifBlank { null }, + content = noteEvent?.summary(), + participants = noteEvent?.participants()?.toImmutableList() ?: persistentListOf(), + status = noteEvent?.status(), + starts = noteEvent?.starts(), + ) + } + .distinctUntilChanged() + .observeAsState( + LiveActivityCard( + name = noteEvent.dTag(), + cover = noteEvent.image()?.ifBlank { null }, + media = noteEvent.streaming(), + subject = noteEvent.title()?.ifBlank { null }, + content = noteEvent.summary(), + participants = noteEvent.participants().toImmutableList(), + status = noteEvent.status(), + starts = noteEvent.starts(), + ), + ) - Column( - modifier = Modifier.fillMaxWidth(), - ) { - Box( - contentAlignment = TopEnd, - modifier = Modifier.aspectRatio(ratio = 16f / 9f).fillMaxWidth(), + Column( + modifier = Modifier.fillMaxWidth(), ) { - card.cover?.let { - AsyncImage( - model = it, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize().clip(QuoteBorder), - ) - } - ?: run { baseNote.author?.let { DisplayAuthorBanner(it) } } - - Box(Modifier.padding(10.dp)) { - Crossfade(targetState = card.status, label = "RenderLiveActivityThumb") { - when (it) { - STATUS_LIVE -> { - val url = card.media - if (url.isNullOrBlank()) { - LiveFlag() - } else { - CheckIfUrlIsOnline(url, accountViewModel) { isOnline -> - if (isOnline) { - LiveFlag() - } else { - OfflineFlag() - } - } - } - } - STATUS_ENDED -> { - EndedFlag() - } - STATUS_PLANNED -> { - ScheduledFlag(card.starts) - } - else -> { - EndedFlag() - } - } - } - } - - LoadParticipants(card.participants, baseNote, accountViewModel) { participantUsers -> Box( - Modifier.padding(10.dp).align(BottomStart), + contentAlignment = TopEnd, + modifier = Modifier.aspectRatio(ratio = 16f / 9f).fillMaxWidth(), ) { - if (participantUsers.isNotEmpty()) { - Gallery(participantUsers, accountViewModel) - } + card.cover?.let { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().clip(QuoteBorder), + ) + } + ?: run { baseNote.author?.let { DisplayAuthorBanner(it) } } + + Box(Modifier.padding(10.dp)) { + Crossfade(targetState = card.status, label = "RenderLiveActivityThumb") { + when (it) { + STATUS_LIVE -> { + val url = card.media + if (url.isNullOrBlank()) { + LiveFlag() + } else { + CheckIfUrlIsOnline(url, accountViewModel) { isOnline -> + if (isOnline) { + LiveFlag() + } else { + OfflineFlag() + } + } + } + } + STATUS_ENDED -> { + EndedFlag() + } + STATUS_PLANNED -> { + ScheduledFlag(card.starts) + } + else -> { + EndedFlag() + } + } + } + } + + LoadParticipants(card.participants, baseNote, accountViewModel) { participantUsers -> + Box( + Modifier.padding(10.dp).align(BottomStart), + ) { + if (participantUsers.isNotEmpty()) { + Gallery(participantUsers, accountViewModel) + } + } + } } - } + + Spacer(modifier = DoubleVertSpacer) + + ChannelHeader( + channelHex = remember { baseNote.idHex }, + showVideo = false, + showBottomDiviser = false, + showFlag = false, + sendToChannel = true, + modifier = remember { Modifier.padding(start = 0.dp, end = 0.dp, top = 5.dp, bottom = 5.dp) }, + accountViewModel = accountViewModel, + nav = nav, + ) } - - Spacer(modifier = DoubleVertSpacer) - - ChannelHeader( - channelHex = remember { baseNote.idHex }, - showVideo = false, - showBottomDiviser = false, - showFlag = false, - sendToChannel = true, - modifier = remember { Modifier.padding(start = 0.dp, end = 0.dp, top = 5.dp, bottom = 5.dp) }, - accountViewModel = accountViewModel, - nav = nav, - ) - } } @Immutable data class CommunityCard( - val name: String, - val description: String?, - val cover: String?, - val moderators: ImmutableList, + val name: String, + val description: String?, + val cover: String?, + val moderators: ImmutableList, ) @Composable fun RenderCommunitiesThumb( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = baseNote.event as? CommunityDefinitionEvent ?: return + val noteEvent = baseNote.event as? CommunityDefinitionEvent ?: return - val card by - baseNote - .live() - .metadata - .map { - val noteEvent = it.note.event as? CommunityDefinitionEvent + val card by + baseNote + .live() + .metadata + .map { + val noteEvent = it.note.event as? CommunityDefinitionEvent - CommunityCard( - name = noteEvent?.dTag() ?: "", - description = noteEvent?.description(), - cover = noteEvent?.image()?.ifBlank { null }, - moderators = noteEvent?.moderators()?.toImmutableList() ?: persistentListOf(), - ) - } - .distinctUntilChanged() - .observeAsState( - CommunityCard( - name = noteEvent.dTag(), - description = noteEvent.description(), - cover = noteEvent.image()?.ifBlank { null }, - moderators = noteEvent.moderators().toImmutableList(), - ), - ) + CommunityCard( + name = noteEvent?.dTag() ?: "", + description = noteEvent?.description(), + cover = noteEvent?.image()?.ifBlank { null }, + moderators = noteEvent?.moderators()?.toImmutableList() ?: persistentListOf(), + ) + } + .distinctUntilChanged() + .observeAsState( + CommunityCard( + name = noteEvent.dTag(), + description = noteEvent.description(), + cover = noteEvent.image()?.ifBlank { null }, + moderators = noteEvent.moderators().toImmutableList(), + ), + ) - LeftPictureLayout( - onImage = { - card.cover?.let { - Box(contentAlignment = BottomStart) { - AsyncImage( - model = it, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize().clip(QuoteBorder), - ) - } - } - ?: run { baseNote.author?.let { DisplayAuthorBanner(it) } } - }, - onTitleRow = { - Text( - text = card.name, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) + LeftPictureLayout( + onImage = { + card.cover?.let { + Box(contentAlignment = BottomStart) { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().clip(QuoteBorder), + ) + } + } + ?: run { baseNote.author?.let { DisplayAuthorBanner(it) } } + }, + onTitleRow = { + Text( + text = card.name, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) - Spacer(modifier = StdHorzSpacer) - LikeReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav, - ) - Spacer(modifier = StdHorzSpacer) - ZapReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav = nav, - ) - }, - onDescription = { - card.description?.let { - Spacer(modifier = StdVertSpacer) - Row { - Text( - text = it, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - fontSize = 14.sp, - ) - } - } - }, - onBottomRow = { - Spacer(modifier = StdVertSpacer) - LoadModerators(card.moderators, baseNote, accountViewModel) { participantUsers -> - if (participantUsers.isNotEmpty()) { - Gallery(participantUsers, accountViewModel) - } - } - }, - ) + Spacer(modifier = StdHorzSpacer) + LikeReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav, + ) + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + onDescription = { + card.description?.let { + Spacer(modifier = StdVertSpacer) + Row { + Text( + text = it, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + fontSize = 14.sp, + ) + } + } + }, + onBottomRow = { + Spacer(modifier = StdVertSpacer) + LoadModerators(card.moderators, baseNote, accountViewModel) { participantUsers -> + if (participantUsers.isNotEmpty()) { + Gallery(participantUsers, accountViewModel) + } + } + }, + ) } @Composable fun LoadModerators( - moderators: ImmutableList, - baseNote: Note, - accountViewModel: AccountViewModel, - content: @Composable (ImmutableList) -> Unit, + moderators: ImmutableList, + baseNote: Note, + accountViewModel: AccountViewModel, + content: @Composable (ImmutableList) -> Unit, ) { - var participantUsers by remember { - mutableStateOf>( - persistentListOf(), - ) - } - - LaunchedEffect(key1 = moderators) { - launch(Dispatchers.IO) { - val hosts = - moderators.mapNotNull { part -> - if (part.key != baseNote.author?.pubkeyHex) { - LocalCache.checkGetOrCreateUser(part.key) - } else { - null - } - } - - val followingKeySet = accountViewModel.account.liveDiscoveryFollowLists.value?.users - val allParticipants = - ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts) - - val newParticipantUsers = - if (followingKeySet == null) { - val allFollows = accountViewModel.account.userProfile().cachedFollowingKeySet() - val followingParticipants = - ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts) - - (hosts + followingParticipants + (allParticipants - followingParticipants)) - .toImmutableList() - } else { - (hosts + allParticipants).toImmutableList() - } - - if (!equalImmutableLists(newParticipantUsers, participantUsers)) { - participantUsers = newParticipantUsers - } + var participantUsers by remember { + mutableStateOf>( + persistentListOf(), + ) } - } - content(participantUsers) + LaunchedEffect(key1 = moderators) { + launch(Dispatchers.IO) { + val hosts = + moderators.mapNotNull { part -> + if (part.key != baseNote.author?.pubkeyHex) { + LocalCache.checkGetOrCreateUser(part.key) + } else { + null + } + } + + val followingKeySet = accountViewModel.account.liveDiscoveryFollowLists.value?.users + val allParticipants = + ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts) + + val newParticipantUsers = + if (followingKeySet == null) { + val allFollows = accountViewModel.account.userProfile().cachedFollowingKeySet() + val followingParticipants = + ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts) + + (hosts + followingParticipants + (allParticipants - followingParticipants)) + .toImmutableList() + } else { + (hosts + allParticipants).toImmutableList() + } + + if (!equalImmutableLists(newParticipantUsers, participantUsers)) { + participantUsers = newParticipantUsers + } + } + } + + content(participantUsers) } @Composable private fun LoadParticipants( - participants: ImmutableList, - baseNote: Note, - accountViewModel: AccountViewModel, - inner: @Composable (ImmutableList) -> Unit, + participants: ImmutableList, + baseNote: Note, + accountViewModel: AccountViewModel, + inner: @Composable (ImmutableList) -> Unit, ) { - var participantUsers by remember { - mutableStateOf>( - persistentListOf(), - ) - } - - LaunchedEffect(key1 = participants) { - launch(Dispatchers.IO) { - val hosts = - participants.mapNotNull { part -> - if (part.key != baseNote.author?.pubkeyHex) { - LocalCache.checkGetOrCreateUser(part.key) - } else { - null - } - } - - val hostsAuthor = hosts + (baseNote.author?.let { listOf(it) } ?: emptyList()) - - val followingKeySet = accountViewModel.account.liveDiscoveryFollowLists.value?.users - - val allParticipants = - ParticipantListBuilder() - .followsThatParticipateOn(baseNote, followingKeySet) - .minus(hostsAuthor) - - val newParticipantUsers = - if (followingKeySet == null) { - val allFollows = accountViewModel.account.userProfile().cachedFollowingKeySet() - val followingParticipants = - ParticipantListBuilder() - .followsThatParticipateOn(baseNote, allFollows) - .minus(hostsAuthor) - - (hosts + followingParticipants + (allParticipants - followingParticipants)) - .toImmutableList() - } else { - (hosts + allParticipants).toImmutableList() - } - - if (!equalImmutableLists(newParticipantUsers, participantUsers)) { - participantUsers = newParticipantUsers - } - } - } - - inner(participantUsers) -} - -@Composable -fun RenderChannelThumb( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = baseNote.event as? ChannelCreateEvent ?: return - - LoadChannel(baseChannelHex = baseNote.idHex, accountViewModel) { - RenderChannelThumb(baseNote = baseNote, channel = it, accountViewModel, nav) - } -} - -@Composable -fun RenderChannelThumb( - baseNote: Note, - channel: Channel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val channelUpdates by channel.live.observeAsState() - - val name = remember(channelUpdates) { channelUpdates?.channel?.toBestDisplayName() ?: "" } - val description = remember(channelUpdates) { channelUpdates?.channel?.summary() } - val cover by - remember(channelUpdates) { - derivedStateOf { channelUpdates?.channel?.profilePicture()?.ifBlank { null } } - } - - var participantUsers by - remember(baseNote) { - mutableStateOf>( - persistentListOf(), - ) - } - - LaunchedEffect(key1 = channelUpdates) { - launch(Dispatchers.IO) { - val followingKeySet = accountViewModel.account.liveDiscoveryFollowLists.value?.users - val allParticipants = - ParticipantListBuilder() - .followsThatParticipateOn(baseNote, followingKeySet) - .toImmutableList() - - val newParticipantUsers = - if (followingKeySet == null) { - val allFollows = accountViewModel.account.userProfile().cachedFollowingKeySet() - val followingParticipants = - ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).toList() - - (followingParticipants + (allParticipants - followingParticipants)).toImmutableList() - } else { - allParticipants.toImmutableList() - } - - if (!equalImmutableLists(newParticipantUsers, participantUsers)) { - participantUsers = newParticipantUsers - } - } - } - - LeftPictureLayout( - onImage = { - cover?.let { - Box(contentAlignment = BottomStart) { - AsyncImage( - model = it, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize().clip(QuoteBorder), - ) - } - } - ?: run { baseNote.author?.let { DisplayAuthorBanner(it) } } - }, - onTitleRow = { - Text( - text = name, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - - Spacer(modifier = StdHorzSpacer) - LikeReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav, - ) - Spacer(modifier = StdHorzSpacer) - ZapReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav = nav, - ) - }, - onDescription = { - description?.let { - Text( - text = it, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - fontSize = 14.sp, + var participantUsers by remember { + mutableStateOf>( + persistentListOf(), ) - } - }, - onBottomRow = { - if (participantUsers.isNotEmpty()) { - Spacer(modifier = StdVertSpacer) - Row { Gallery(participantUsers, accountViewModel) } - } - }, - ) + } + + LaunchedEffect(key1 = participants) { + launch(Dispatchers.IO) { + val hosts = + participants.mapNotNull { part -> + if (part.key != baseNote.author?.pubkeyHex) { + LocalCache.checkGetOrCreateUser(part.key) + } else { + null + } + } + + val hostsAuthor = hosts + (baseNote.author?.let { listOf(it) } ?: emptyList()) + + val followingKeySet = accountViewModel.account.liveDiscoveryFollowLists.value?.users + + val allParticipants = + ParticipantListBuilder() + .followsThatParticipateOn(baseNote, followingKeySet) + .minus(hostsAuthor) + + val newParticipantUsers = + if (followingKeySet == null) { + val allFollows = accountViewModel.account.userProfile().cachedFollowingKeySet() + val followingParticipants = + ParticipantListBuilder() + .followsThatParticipateOn(baseNote, allFollows) + .minus(hostsAuthor) + + (hosts + followingParticipants + (allParticipants - followingParticipants)) + .toImmutableList() + } else { + (hosts + allParticipants).toImmutableList() + } + + if (!equalImmutableLists(newParticipantUsers, participantUsers)) { + participantUsers = newParticipantUsers + } + } + } + + inner(participantUsers) +} + +@Composable +fun RenderChannelThumb( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = baseNote.event as? ChannelCreateEvent ?: return + + LoadChannel(baseChannelHex = baseNote.idHex, accountViewModel) { + RenderChannelThumb(baseNote = baseNote, channel = it, accountViewModel, nav) + } +} + +@Composable +fun RenderChannelThumb( + baseNote: Note, + channel: Channel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val channelUpdates by channel.live.observeAsState() + + val name = remember(channelUpdates) { channelUpdates?.channel?.toBestDisplayName() ?: "" } + val description = remember(channelUpdates) { channelUpdates?.channel?.summary() } + val cover by + remember(channelUpdates) { + derivedStateOf { channelUpdates?.channel?.profilePicture()?.ifBlank { null } } + } + + var participantUsers by + remember(baseNote) { + mutableStateOf>( + persistentListOf(), + ) + } + + LaunchedEffect(key1 = channelUpdates) { + launch(Dispatchers.IO) { + val followingKeySet = accountViewModel.account.liveDiscoveryFollowLists.value?.users + val allParticipants = + ParticipantListBuilder() + .followsThatParticipateOn(baseNote, followingKeySet) + .toImmutableList() + + val newParticipantUsers = + if (followingKeySet == null) { + val allFollows = accountViewModel.account.userProfile().cachedFollowingKeySet() + val followingParticipants = + ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).toList() + + (followingParticipants + (allParticipants - followingParticipants)).toImmutableList() + } else { + allParticipants.toImmutableList() + } + + if (!equalImmutableLists(newParticipantUsers, participantUsers)) { + participantUsers = newParticipantUsers + } + } + } + + LeftPictureLayout( + onImage = { + cover?.let { + Box(contentAlignment = BottomStart) { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().clip(QuoteBorder), + ) + } + } + ?: run { baseNote.author?.let { DisplayAuthorBanner(it) } } + }, + onTitleRow = { + Text( + text = name, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Spacer(modifier = StdHorzSpacer) + LikeReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav, + ) + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + onDescription = { + description?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + fontSize = 14.sp, + ) + } + }, + onBottomRow = { + if (participantUsers.isNotEmpty()) { + Spacer(modifier = StdVertSpacer) + Row { Gallery(participantUsers, accountViewModel) } + } + }, + ) } @OptIn(ExperimentalLayoutApi::class) @Composable fun Gallery( - users: ImmutableList, - accountViewModel: AccountViewModel, + users: ImmutableList, + accountViewModel: AccountViewModel, ) { - FlowRow(verticalArrangement = Arrangement.Center) { - users.take(6).forEach { ClickableUserPicture(it, Size35dp, accountViewModel) } + FlowRow(verticalArrangement = Arrangement.Center) { + users.take(6).forEach { ClickableUserPicture(it, Size35dp, accountViewModel) } - if (users.size > 6) { - Text( - text = remember(users) { " + " + (showCount(users.size - 6)) }, - fontSize = 13.sp, - color = MaterialTheme.colorScheme.onSurface, - ) + if (users.size > 6) { + Text( + text = remember(users) { " + " + (showCount(users.size - 6)) }, + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurface, + ) + } } - } } @Composable fun DisplayAuthorBanner(author: User) { - val picture by - author - .live() - .metadata - .map { it.user.info?.banner?.ifBlank { null } ?: it.user.info?.picture?.ifBlank { null } } - .observeAsState() + val picture by + author + .live() + .metadata + .map { it.user.info?.banner?.ifBlank { null } ?: it.user.info?.picture?.ifBlank { null } } + .observeAsState() - AsyncImage( - model = picture, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize().clip(QuoteBorder), - ) + AsyncImage( + model = picture, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().clip(QuoteBorder), + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt index b477b910e..2a8689102 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt @@ -82,465 +82,466 @@ import kotlinx.coroutines.launch @Composable fun ChatroomHeaderCompose( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) + val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) - if (hasEvent) { - ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav) - } else { - BlankNote() - } + if (hasEvent) { + ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav) + } else { + BlankNote() + } } @Composable fun ChatroomComposeChannelOrUser( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val channelHex by remember(baseNote) { derivedStateOf { baseNote.channelHex() } } + val channelHex by remember(baseNote) { derivedStateOf { baseNote.channelHex() } } - if (channelHex != null) { - ChatroomChannel(channelHex!!, baseNote, accountViewModel, nav) - } else { - ChatroomPrivateMessages(baseNote, accountViewModel, nav) - } + if (channelHex != null) { + ChatroomChannel(channelHex!!, baseNote, accountViewModel, nav) + } else { + ChatroomPrivateMessages(baseNote, accountViewModel, nav) + } } @Composable private fun ChatroomPrivateMessages( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val userRoom by - remember(baseNote) { - derivedStateOf { - (baseNote.event as? ChatroomKeyable)?.chatroomKey(accountViewModel.userProfile().pubkeyHex) - } - } + val userRoom by + remember(baseNote) { + derivedStateOf { + (baseNote.event as? ChatroomKeyable)?.chatroomKey(accountViewModel.userProfile().pubkeyHex) + } + } - Crossfade(userRoom, label = "ChatroomPrivateMessages") { room -> - if (room != null) { - UserRoomCompose(baseNote, room, accountViewModel, nav) - } else { - Box(emptyLineItemModifier) { - // Makes sure just a max amount of objects are loaded. - } + Crossfade(userRoom, label = "ChatroomPrivateMessages") { room -> + if (room != null) { + UserRoomCompose(baseNote, room, accountViewModel, nav) + } else { + Box(emptyLineItemModifier) { + // Makes sure just a max amount of objects are loaded. + } + } } - } } @Composable private fun ChatroomChannel( - channelHex: HexKey, - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + channelHex: HexKey, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LoadChannel(baseChannelHex = channelHex, accountViewModel) { channel -> - ChannelRoomCompose(baseNote, channel, accountViewModel, nav) - } + LoadChannel(baseChannelHex = channelHex, accountViewModel) { channel -> + ChannelRoomCompose(baseNote, channel, accountViewModel, nav) + } } @Composable private fun ChannelRoomCompose( - note: Note, - channel: Channel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + channel: Channel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val authorState by note.author!!.live().metadata.observeAsState() - val authorName = remember(note, authorState) { authorState?.user?.toBestDisplayName() } + val authorState by note.author!!.live().metadata.observeAsState() + val authorName = remember(note, authorState) { authorState?.user?.toBestDisplayName() } - val chanHex = remember { channel.idHex } + val chanHex = remember { channel.idHex } - val channelState by channel.live.observeAsState() - val channelPicture by remember(note, channelState) { derivedStateOf { channel.profilePicture() } } - val channelName by remember(note, channelState) { derivedStateOf { channel.toBestDisplayName() } } + val channelState by channel.live.observeAsState() + val channelPicture by remember(note, channelState) { derivedStateOf { channel.profilePicture() } } + val channelName by remember(note, channelState) { derivedStateOf { channel.toBestDisplayName() } } - val noteEvent = note.event + val noteEvent = note.event - val route = remember(note) { "Channel/$chanHex" } + val route = remember(note) { "Channel/$chanHex" } - val description = - if (noteEvent is ChannelCreateEvent) { - stringResource(R.string.channel_created) - } else if (noteEvent is ChannelMetadataEvent) { - "${stringResource(R.string.channel_information_changed_to)} " - } else { - noteEvent?.content() + val description = + if (noteEvent is ChannelCreateEvent) { + stringResource(R.string.channel_created) + } else if (noteEvent is ChannelMetadataEvent) { + "${stringResource(R.string.channel_information_changed_to)} " + } else { + noteEvent?.content() + } + + val hasNewMessages = remember { mutableStateOf(false) } + + val automaticallyShowProfilePicture = + remember { + accountViewModel.settings.showProfilePictures.value + } + + WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages -> + if (hasNewMessages.value != newHasNewMessages) { + hasNewMessages.value = newHasNewMessages + } } - val hasNewMessages = remember { mutableStateOf(false) } - - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } - - WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages -> - if (hasNewMessages.value != newHasNewMessages) { - hasNewMessages.value = newHasNewMessages - } - } - - ChannelName( - channelIdHex = chanHex, - channelPicture = channelPicture, - channelTitle = { modifier -> ChannelTitleWithLabelInfo(channelName, modifier) }, - channelLastTime = remember(note) { note.createdAt() }, - channelLastContent = remember(note, authorState) { "$authorName: $description" }, - hasNewMessages = hasNewMessages, - loadProfilePicture = automaticallyShowProfilePicture, - onClick = { nav(route) }, - ) + ChannelName( + channelIdHex = chanHex, + channelPicture = channelPicture, + channelTitle = { modifier -> ChannelTitleWithLabelInfo(channelName, modifier) }, + channelLastTime = remember(note) { note.createdAt() }, + channelLastContent = remember(note, authorState) { "$authorName: $description" }, + hasNewMessages = hasNewMessages, + loadProfilePicture = automaticallyShowProfilePicture, + onClick = { nav(route) }, + ) } @Composable private fun ChannelTitleWithLabelInfo( - channelName: String, - modifier: Modifier, + channelName: String, + modifier: Modifier, ) { - val label = stringResource(id = R.string.public_chat) - val placeHolderColor = MaterialTheme.colorScheme.placeholderText - val channelNameAndBoostInfo = - remember(channelName) { - buildAnnotatedString { - withStyle( - SpanStyle( - fontWeight = FontWeight.Bold, - ), - ) { - append(channelName) + val label = stringResource(id = R.string.public_chat) + val placeHolderColor = MaterialTheme.colorScheme.placeholderText + val channelNameAndBoostInfo = + remember(channelName) { + buildAnnotatedString { + withStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + ), + ) { + append(channelName) + } + + withStyle( + SpanStyle( + color = placeHolderColor, + fontWeight = FontWeight.Normal, + ), + ) { + append(" $label") + } + } } - withStyle( - SpanStyle( - color = placeHolderColor, - fontWeight = FontWeight.Normal, - ), - ) { - append(" $label") - } - } - } - - Text( - text = channelNameAndBoostInfo, - fontWeight = FontWeight.Bold, - modifier = modifier, - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Text( + text = channelNameAndBoostInfo, + fontWeight = FontWeight.Bold, + modifier = modifier, + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } @Composable private fun UserRoomCompose( - note: Note, - room: ChatroomKey, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + room: ChatroomKey, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val hasNewMessages = remember { mutableStateOf(false) } + val hasNewMessages = remember { mutableStateOf(false) } - val route = remember(room) { "Room/${room.hashCode()}" } + val route = remember(room) { "Room/${room.hashCode()}" } - val createAt by remember(note) { derivedStateOf { note.createdAt() } } + val createAt by remember(note) { derivedStateOf { note.createdAt() } } - WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages -> - if (hasNewMessages.value != newHasNewMessages) { - hasNewMessages.value = newHasNewMessages + WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages -> + if (hasNewMessages.value != newHasNewMessages) { + hasNewMessages.value = newHasNewMessages + } } - } - LoadDecryptedContentOrNull(note, accountViewModel) { content -> - ChannelName( - channelPicture = { - NonClickableUserPictures( - users = room.users, - accountViewModel = accountViewModel, - size = Size55dp, + LoadDecryptedContentOrNull(note, accountViewModel) { content -> + ChannelName( + channelPicture = { + NonClickableUserPictures( + users = room.users, + accountViewModel = accountViewModel, + size = Size55dp, + ) + }, + channelTitle = { RoomNameDisplay(room, it, accountViewModel) }, + channelLastTime = createAt, + channelLastContent = content, + hasNewMessages = hasNewMessages, + onClick = { nav(route) }, ) - }, - channelTitle = { RoomNameDisplay(room, it, accountViewModel) }, - channelLastTime = createAt, - channelLastContent = content, - hasNewMessages = hasNewMessages, - onClick = { nav(route) }, - ) - } + } } @Composable fun RoomNameDisplay( - room: ChatroomKey, - modifier: Modifier, - accountViewModel: AccountViewModel, + room: ChatroomKey, + modifier: Modifier, + accountViewModel: AccountViewModel, ) { - val roomSubject by - accountViewModel - .userProfile() - .live() - .messages - .map { it.user.privateChatrooms[room]?.subject } - .distinctUntilChanged() - .observeAsState(accountViewModel.userProfile().privateChatrooms[room]?.subject) + val roomSubject by + accountViewModel + .userProfile() + .live() + .messages + .map { it.user.privateChatrooms[room]?.subject } + .distinctUntilChanged() + .observeAsState(accountViewModel.userProfile().privateChatrooms[room]?.subject) - Crossfade(targetState = roomSubject, modifier, label = "RoomNameDisplay") { - if (!it.isNullOrBlank()) { - if (room.users.size > 1) { - DisplayRoomSubject(it) - } else { - DisplayUserAndSubject(room.users.first(), it, accountViewModel) - } - } else { - DisplayUserSetAsSubject(room, accountViewModel) + Crossfade(targetState = roomSubject, modifier, label = "RoomNameDisplay") { + if (!it.isNullOrBlank()) { + if (room.users.size > 1) { + DisplayRoomSubject(it) + } else { + DisplayUserAndSubject(room.users.first(), it, accountViewModel) + } + } else { + DisplayUserSetAsSubject(room, accountViewModel) + } } - } } @Composable private fun DisplayUserAndSubject( - user: HexKey, - subject: String, - accountViewModel: AccountViewModel, + user: HexKey, + subject: String, + accountViewModel: AccountViewModel, ) { - Row { - Text( - text = subject, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = " - ", - fontWeight = FontWeight.Bold, - maxLines = 1, - ) - LoadUser(baseUserHex = user, accountViewModel = accountViewModel) { - it?.let { UsernameDisplay(it, Modifier.weight(1f)) } + Row { + Text( + text = subject, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = " - ", + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + LoadUser(baseUserHex = user, accountViewModel = accountViewModel) { + it?.let { UsernameDisplay(it, Modifier.weight(1f)) } + } } - } } @Composable fun DisplayUserSetAsSubject( - room: ChatroomKey, - accountViewModel: AccountViewModel, - fontWeight: FontWeight = FontWeight.Bold, + room: ChatroomKey, + accountViewModel: AccountViewModel, + fontWeight: FontWeight = FontWeight.Bold, ) { - val userList = remember(room) { room.users.toList() } + val userList = remember(room) { room.users.toList() } - if (userList.size == 1) { - // Regular Design - Row { - LoadUser(baseUserHex = userList[0], accountViewModel) { - it?.let { UsernameDisplay(it, Modifier.weight(1f), fontWeight = fontWeight) } - } - } - } else { - Row { - userList.take(4).forEachIndexedExtended { index, isFirst, isLast, value -> - LoadUser(baseUserHex = value, accountViewModel) { - it?.let { ShortUsernameDisplay(baseUser = it, fontWeight = fontWeight) } + if (userList.size == 1) { + // Regular Design + Row { + LoadUser(baseUserHex = userList[0], accountViewModel) { + it?.let { UsernameDisplay(it, Modifier.weight(1f), fontWeight = fontWeight) } + } } + } else { + Row { + userList.take(4).forEachIndexedExtended { index, isFirst, isLast, value -> + LoadUser(baseUserHex = value, accountViewModel) { + it?.let { ShortUsernameDisplay(baseUser = it, fontWeight = fontWeight) } + } - if (!isLast) { - Text( - text = ", ", - fontWeight = fontWeight, - maxLines = 1, - ) + if (!isLast) { + Text( + text = ", ", + fontWeight = fontWeight, + maxLines = 1, + ) + } + } } - } } - } } @Composable fun DisplayRoomSubject( - roomSubject: String, - fontWeight: FontWeight = FontWeight.Bold, + roomSubject: String, + fontWeight: FontWeight = FontWeight.Bold, ) { - Row { - Text( - text = roomSubject, - fontWeight = fontWeight, - maxLines = 1, - ) - } + Row { + Text( + text = roomSubject, + fontWeight = fontWeight, + maxLines = 1, + ) + } } @Composable fun ShortUsernameDisplay( - baseUser: User, - weight: Modifier = Modifier, - fontWeight: FontWeight = FontWeight.Bold, + baseUser: User, + weight: Modifier = Modifier, + fontWeight: FontWeight = FontWeight.Bold, ) { - val userName by - baseUser - .live() - .metadata - .map { it.user.toBestShortFirstName() } - .distinctUntilChanged() - .observeAsState(baseUser.toBestShortFirstName()) + val userName by + baseUser + .live() + .metadata + .map { it.user.toBestShortFirstName() } + .distinctUntilChanged() + .observeAsState(baseUser.toBestShortFirstName()) - Crossfade(targetState = userName, modifier = weight) { - CreateTextWithEmoji( - text = it, - tags = baseUser.info?.tags, - fontWeight = fontWeight, - maxLines = 1, - ) - } + Crossfade(targetState = userName, modifier = weight) { + CreateTextWithEmoji( + text = it, + tags = baseUser.info?.tags, + fontWeight = fontWeight, + maxLines = 1, + ) + } } @Composable private fun WatchNotificationChanges( - note: Note, - route: String, - accountViewModel: AccountViewModel, - onNewStatus: (Boolean) -> Unit, + note: Note, + route: String, + accountViewModel: AccountViewModel, + onNewStatus: (Boolean) -> Unit, ) { - LaunchedEffect(key1 = note, accountViewModel.accountMarkAsReadUpdates.intValue) { - launch(Dispatchers.IO) { - note.event?.createdAt()?.let { - val lastTime = accountViewModel.account.loadLastRead(route) - onNewStatus(it > lastTime) - } + LaunchedEffect(key1 = note, accountViewModel.accountMarkAsReadUpdates.intValue) { + launch(Dispatchers.IO) { + note.event?.createdAt()?.let { + val lastTime = accountViewModel.account.loadLastRead(route) + onNewStatus(it > lastTime) + } + } } - } } @Composable fun LoadUser( - baseUserHex: String, - accountViewModel: AccountViewModel, - content: @Composable (User?) -> Unit, + baseUserHex: String, + accountViewModel: AccountViewModel, + content: @Composable (User?) -> Unit, ) { - var user by - remember(baseUserHex) { mutableStateOf(accountViewModel.getUserIfExists(baseUserHex)) } + var user by + remember(baseUserHex) { mutableStateOf(accountViewModel.getUserIfExists(baseUserHex)) } - if (user == null) { - LaunchedEffect(key1 = baseUserHex) { - accountViewModel.checkGetOrCreateUser(baseUserHex) { newUser -> - if (user != newUser) { - user = newUser + if (user == null) { + LaunchedEffect(key1 = baseUserHex) { + accountViewModel.checkGetOrCreateUser(baseUserHex) { newUser -> + if (user != newUser) { + user = newUser + } + } } - } } - } - content(user) + content(user) } @Composable fun ChannelName( - channelIdHex: String, - channelPicture: String?, - channelTitle: @Composable (Modifier) -> Unit, - channelLastTime: Long?, - channelLastContent: String?, - hasNewMessages: MutableState, - loadProfilePicture: Boolean, - onClick: () -> Unit, + channelIdHex: String, + channelPicture: String?, + channelTitle: @Composable (Modifier) -> Unit, + channelLastTime: Long?, + channelLastContent: String?, + hasNewMessages: MutableState, + loadProfilePicture: Boolean, + onClick: () -> Unit, ) { - ChannelName( - channelPicture = { - RobohashFallbackAsyncImage( - robot = channelIdHex, - model = channelPicture, - contentDescription = stringResource(R.string.channel_image), - modifier = AccountPictureModifier, - loadProfilePicture = loadProfilePicture, - ) - }, - channelTitle, - channelLastTime, - channelLastContent, - hasNewMessages, - onClick, - ) + ChannelName( + channelPicture = { + RobohashFallbackAsyncImage( + robot = channelIdHex, + model = channelPicture, + contentDescription = stringResource(R.string.channel_image), + modifier = AccountPictureModifier, + loadProfilePicture = loadProfilePicture, + ) + }, + channelTitle, + channelLastTime, + channelLastContent, + hasNewMessages, + onClick, + ) } @Composable fun ChannelName( - channelPicture: @Composable () -> Unit, - channelTitle: @Composable (Modifier) -> Unit, - channelLastTime: Long?, - channelLastContent: String?, - hasNewMessages: MutableState, - onClick: () -> Unit, + channelPicture: @Composable () -> Unit, + channelTitle: @Composable (Modifier) -> Unit, + channelLastTime: Long?, + channelLastContent: String?, + hasNewMessages: MutableState, + onClick: () -> Unit, ) { - ChatHeaderLayout( - channelPicture = channelPicture, - firstRow = { - channelTitle(Modifier.weight(1f)) - TimeAgo(channelLastTime) - }, - secondRow = { - if (channelLastContent != null) { - Text( - channelLastContent, - color = MaterialTheme.colorScheme.grayText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - modifier = Modifier.weight(1f), - ) - } else { - Text( - stringResource(R.string.referenced_event_not_found), - color = MaterialTheme.colorScheme.grayText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - } + ChatHeaderLayout( + channelPicture = channelPicture, + firstRow = { + channelTitle(Modifier.weight(1f)) + TimeAgo(channelLastTime) + }, + secondRow = { + if (channelLastContent != null) { + Text( + channelLastContent, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + modifier = Modifier.weight(1f), + ) + } else { + Text( + stringResource(R.string.referenced_event_not_found), + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } - if (hasNewMessages.value) { - NewItemsBubble() - } - }, - onClick = onClick, - ) + if (hasNewMessages.value) { + NewItemsBubble() + } + }, + onClick = onClick, + ) } @Composable private fun TimeAgo(channelLastTime: Long?) { - if (channelLastTime == null) return + if (channelLastTime == null) return - val context = LocalContext.current - val timeAgo = remember(channelLastTime) { timeAgo(channelLastTime, context) } - Text( - text = timeAgo, - color = MaterialTheme.colorScheme.grayText, - maxLines = 1, - ) + val context = LocalContext.current + val timeAgo = remember(channelLastTime) { timeAgo(channelLastTime, context) } + Text( + text = timeAgo, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + ) } @Composable fun NewItemsBubble() { - Box( - modifier = - Modifier.padding(start = 3.dp) - .width(10.dp) - .height(10.dp) - .clip(shape = CircleShape) - .background(MaterialTheme.colorScheme.primary), - contentAlignment = Alignment.Center, - ) { - Text( - "", - color = Color.White, - textAlign = TextAlign.Center, - fontSize = 12.sp, - maxLines = 1, - modifier = Modifier.wrapContentHeight().align(Alignment.Center), - ) - } + Box( + modifier = + Modifier.padding(start = 3.dp) + .width(10.dp) + .height(10.dp) + .clip(shape = CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Text( + "", + color = Color.White, + textAlign = TextAlign.Center, + fontSize = 12.sp, + maxLines = 1, + modifier = Modifier.wrapContentHeight().align(Alignment.Center), + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index 88bbce3cb..842f65f86 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -100,690 +100,695 @@ import com.vitorpamplona.quartz.events.toImmutableListOfLists @OptIn(ExperimentalFoundationApi::class) @Composable fun ChatroomMessageCompose( - baseNote: Note, - routeForLastRead: String?, - innerQuote: Boolean = false, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - onWantsToReply: (Note) -> Unit, + baseNote: Note, + routeForLastRead: String?, + innerQuote: Boolean = false, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onWantsToReply: (Note) -> Unit, ) { - val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) + val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) - Crossfade(targetState = hasEvent) { - if (it) { - CheckHiddenChatMessage( - baseNote, - routeForLastRead, - innerQuote, - parentBackgroundColor, - accountViewModel, - nav, - onWantsToReply, - ) - } else { - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup, - -> - BlankNote( - remember { - Modifier.combinedClickable( - onClick = {}, - onLongClick = showPopup, + Crossfade(targetState = hasEvent) { + if (it) { + CheckHiddenChatMessage( + baseNote, + routeForLastRead, + innerQuote, + parentBackgroundColor, + accountViewModel, + nav, + onWantsToReply, ) - }, - ) - } + } else { + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup, + -> + BlankNote( + remember { + Modifier.combinedClickable( + onClick = {}, + onLongClick = showPopup, + ) + }, + ) + } + } } - } } @Composable fun CheckHiddenChatMessage( - baseNote: Note, - routeForLastRead: String?, - innerQuote: Boolean = false, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - onWantsToReply: (Note) -> Unit, + baseNote: Note, + routeForLastRead: String?, + innerQuote: Boolean = false, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onWantsToReply: (Note) -> Unit, ) { - val isHidden by - remember { - accountViewModel.account.liveHiddenUsers - .map { baseNote.isHiddenFor(it) } - .distinctUntilChanged() - } - .observeAsState(accountViewModel.isNoteHidden(baseNote)) + val isHidden by + remember { + accountViewModel.account.liveHiddenUsers + .map { baseNote.isHiddenFor(it) } + .distinctUntilChanged() + } + .observeAsState(accountViewModel.isNoteHidden(baseNote)) - if (!isHidden) { - LoadedChatMessageCompose( - baseNote, - routeForLastRead, - innerQuote, - parentBackgroundColor, - accountViewModel, - nav, - onWantsToReply, - ) - } + if (!isHidden) { + LoadedChatMessageCompose( + baseNote, + routeForLastRead, + innerQuote, + parentBackgroundColor, + accountViewModel, + nav, + onWantsToReply, + ) + } } @Composable fun LoadedChatMessageCompose( - baseNote: Note, - routeForLastRead: String?, - innerQuote: Boolean = false, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - onWantsToReply: (Note) -> Unit, + baseNote: Note, + routeForLastRead: String?, + innerQuote: Boolean = false, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onWantsToReply: (Note) -> Unit, ) { - var state by remember { - mutableStateOf( - AccountViewModel.NoteComposeReportState(), - ) - } - - WatchForReports(baseNote, accountViewModel) { newState -> - if (state != newState) { - state = newState - } - } - - var showReportedNote by remember { mutableStateOf(false) } - - val showHiddenNote by - remember(state, showReportedNote) { - derivedStateOf { !state.isAcceptable && !showReportedNote } + var state by remember { + mutableStateOf( + AccountViewModel.NoteComposeReportState(), + ) } - Crossfade(targetState = showHiddenNote) { - if (it) { - HiddenNote( - state.relevantReports, - state.isHiddenAuthor, - accountViewModel, - Modifier, - innerQuote, - nav, - onClick = { showReportedNote = true }, - ) - } else { - val canPreview by + WatchForReports(baseNote, accountViewModel) { newState -> + if (state != newState) { + state = newState + } + } + + var showReportedNote by remember { mutableStateOf(false) } + + val showHiddenNote by remember(state, showReportedNote) { - derivedStateOf { (!state.isAcceptable && showReportedNote) || state.canPreview } + derivedStateOf { !state.isAcceptable && !showReportedNote } } - NormalChatNote( - baseNote, - routeForLastRead, - innerQuote, - canPreview, - parentBackgroundColor, - accountViewModel, - nav, - onWantsToReply, - ) + Crossfade(targetState = showHiddenNote) { + if (it) { + HiddenNote( + state.relevantReports, + state.isHiddenAuthor, + accountViewModel, + Modifier, + innerQuote, + nav, + onClick = { showReportedNote = true }, + ) + } else { + val canPreview by + remember(state, showReportedNote) { + derivedStateOf { (!state.isAcceptable && showReportedNote) || state.canPreview } + } + + NormalChatNote( + baseNote, + routeForLastRead, + innerQuote, + canPreview, + parentBackgroundColor, + accountViewModel, + nav, + onWantsToReply, + ) + } } - } } @OptIn(ExperimentalFoundationApi::class) @Composable fun NormalChatNote( - note: Note, - routeForLastRead: String?, - innerQuote: Boolean = false, - canPreview: Boolean = true, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - onWantsToReply: (Note) -> Unit, + note: Note, + routeForLastRead: String?, + innerQuote: Boolean = false, + canPreview: Boolean = true, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onWantsToReply: (Note) -> Unit, ) { - val drawAuthorInfo by - remember(note) { - derivedStateOf { - val noteEvent = note.event - if (accountViewModel.isLoggedUser(note.author)) { - false // never shows the user's pictures - } else if (noteEvent is PrivateDmEvent) { - false // one-on-one, never shows it. - } else if (noteEvent is ChatMessageEvent) { - // only shows in a group chat. - noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex).users.size > 1 - } else { - true + val drawAuthorInfo by + remember(note) { + derivedStateOf { + val noteEvent = note.event + if (accountViewModel.isLoggedUser(note.author)) { + false // never shows the user's pictures + } else if (noteEvent is PrivateDmEvent) { + false // one-on-one, never shows it. + } else if (noteEvent is ChatMessageEvent) { + // only shows in a group chat. + noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex).users.size > 1 + } else { + true + } + } } - } - } - val loggedInColors = MaterialTheme.colorScheme.mediumImportanceLink - val otherColors = MaterialTheme.colorScheme.subtleBorder - val defaultBackground = MaterialTheme.colorScheme.background + val loggedInColors = MaterialTheme.colorScheme.mediumImportanceLink + val otherColors = MaterialTheme.colorScheme.subtleBorder + val defaultBackground = MaterialTheme.colorScheme.background - val backgroundBubbleColor = remember { - if (accountViewModel.isLoggedUser(note.author)) { - mutableStateOf( - loggedInColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground), - ) - } else { - mutableStateOf(otherColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground)) - } - } - val alignment: Arrangement.Horizontal = remember { - if (accountViewModel.isLoggedUser(note.author)) { - Arrangement.End - } else { - Arrangement.Start - } - } - val shape: Shape = remember { - if (accountViewModel.isLoggedUser(note.author)) { - ChatBubbleShapeMe - } else { - ChatBubbleShapeThem - } - } - - if (routeForLastRead != null) { - LaunchedEffect(key1 = routeForLastRead) { - accountViewModel.loadAndMarkAsRead(routeForLastRead, note.createdAt()) {} - } - } - - Column { - Row( - modifier = if (innerQuote) ChatPaddingInnerQuoteModifier else ChatPaddingModifier, - horizontalArrangement = alignment, - ) { - val availableBubbleSize = remember { mutableIntStateOf(0) } - var popupExpanded by remember { mutableStateOf(false) } - - val modif2 = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier - - val clickableModifier = remember { - Modifier.combinedClickable( - onClick = { - if (note.event is ChannelCreateEvent) { - nav("Channel/${note.idHex}") + val backgroundBubbleColor = + remember { + if (accountViewModel.isLoggedUser(note.author)) { + mutableStateOf( + loggedInColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground), + ) + } else { + mutableStateOf(otherColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground)) } - }, - onLongClick = { popupExpanded = true }, - ) - } - - Row( - horizontalArrangement = alignment, - modifier = - modif2.onSizeChanged { - if (availableBubbleSize.intValue != it.width) { - availableBubbleSize.intValue = it.width + } + val alignment: Arrangement.Horizontal = + remember { + if (accountViewModel.isLoggedUser(note.author)) { + Arrangement.End + } else { + Arrangement.Start } - }, - ) { - Surface( - color = backgroundBubbleColor.value, - shape = shape, - modifier = clickableModifier, + } + val shape: Shape = + remember { + if (accountViewModel.isLoggedUser(note.author)) { + ChatBubbleShapeMe + } else { + ChatBubbleShapeThem + } + } + + if (routeForLastRead != null) { + LaunchedEffect(key1 = routeForLastRead) { + accountViewModel.loadAndMarkAsRead(routeForLastRead, note.createdAt()) {} + } + } + + Column { + Row( + modifier = if (innerQuote) ChatPaddingInnerQuoteModifier else ChatPaddingModifier, + horizontalArrangement = alignment, ) { - RenderBubble( - note, - drawAuthorInfo, - alignment, - innerQuote, - backgroundBubbleColor, - onWantsToReply, - canPreview, - availableBubbleSize, - accountViewModel, - nav, - ) - } - } + val availableBubbleSize = remember { mutableIntStateOf(0) } + var popupExpanded by remember { mutableStateOf(false) } - NoteQuickActionMenu( - note = note, - popupExpanded = popupExpanded, - onDismiss = { popupExpanded = false }, - accountViewModel = accountViewModel, - ) + val modif2 = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier + + val clickableModifier = + remember { + Modifier.combinedClickable( + onClick = { + if (note.event is ChannelCreateEvent) { + nav("Channel/${note.idHex}") + } + }, + onLongClick = { popupExpanded = true }, + ) + } + + Row( + horizontalArrangement = alignment, + modifier = + modif2.onSizeChanged { + if (availableBubbleSize.intValue != it.width) { + availableBubbleSize.intValue = it.width + } + }, + ) { + Surface( + color = backgroundBubbleColor.value, + shape = shape, + modifier = clickableModifier, + ) { + RenderBubble( + note, + drawAuthorInfo, + alignment, + innerQuote, + backgroundBubbleColor, + onWantsToReply, + canPreview, + availableBubbleSize, + accountViewModel, + nav, + ) + } + } + + NoteQuickActionMenu( + note = note, + popupExpanded = popupExpanded, + onDismiss = { popupExpanded = false }, + accountViewModel = accountViewModel, + ) + } } - } } @Composable private fun RenderBubble( - baseNote: Note, - drawAuthorInfo: Boolean, - alignment: Arrangement.Horizontal, - innerQuote: Boolean, - backgroundBubbleColor: MutableState, - onWantsToReply: (Note) -> Unit, - canPreview: Boolean, - availableBubbleSize: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + drawAuthorInfo: Boolean, + alignment: Arrangement.Horizontal, + innerQuote: Boolean, + backgroundBubbleColor: MutableState, + onWantsToReply: (Note) -> Unit, + canPreview: Boolean, + availableBubbleSize: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val bubbleSize = remember { mutableIntStateOf(0) } + val bubbleSize = remember { mutableIntStateOf(0) } - val bubbleModifier = remember { - Modifier.padding(start = 10.dp, end = 5.dp, bottom = 5.dp).onSizeChanged { - if (bubbleSize.intValue != it.width) { - bubbleSize.intValue = it.width - } + val bubbleModifier = + remember { + Modifier.padding(start = 10.dp, end = 5.dp, bottom = 5.dp).onSizeChanged { + if (bubbleSize.intValue != it.width) { + bubbleSize.intValue = it.width + } + } + } + + Column(modifier = bubbleModifier) { + MessageBubbleLines( + drawAuthorInfo, + baseNote, + alignment, + nav, + innerQuote, + backgroundBubbleColor, + accountViewModel, + onWantsToReply, + canPreview, + bubbleSize, + availableBubbleSize, + ) } - } - - Column(modifier = bubbleModifier) { - MessageBubbleLines( - drawAuthorInfo, - baseNote, - alignment, - nav, - innerQuote, - backgroundBubbleColor, - accountViewModel, - onWantsToReply, - canPreview, - bubbleSize, - availableBubbleSize, - ) - } } @Composable private fun MessageBubbleLines( - drawAuthorInfo: Boolean, - baseNote: Note, - alignment: Arrangement.Horizontal, - nav: (String) -> Unit, - innerQuote: Boolean, - backgroundBubbleColor: MutableState, - accountViewModel: AccountViewModel, - onWantsToReply: (Note) -> Unit, - canPreview: Boolean, - bubbleSize: MutableState, - availableBubbleSize: MutableState, + drawAuthorInfo: Boolean, + baseNote: Note, + alignment: Arrangement.Horizontal, + nav: (String) -> Unit, + innerQuote: Boolean, + backgroundBubbleColor: MutableState, + accountViewModel: AccountViewModel, + onWantsToReply: (Note) -> Unit, + canPreview: Boolean, + bubbleSize: MutableState, + availableBubbleSize: MutableState, ) { - if (drawAuthorInfo) { - DrawAuthorInfo( - baseNote, - alignment, - accountViewModel.settings.showProfilePictures.value, - nav, - ) - } else { - Spacer(modifier = StdVertSpacer) - } + if (drawAuthorInfo) { + DrawAuthorInfo( + baseNote, + alignment, + accountViewModel.settings.showProfilePictures.value, + nav, + ) + } else { + Spacer(modifier = StdVertSpacer) + } - RenderReplyRow( - note = baseNote, - innerQuote = innerQuote, - backgroundBubbleColor = backgroundBubbleColor, - accountViewModel = accountViewModel, - nav = nav, - onWantsToReply = onWantsToReply, - ) - - NoteRow( - note = baseNote, - canPreview = canPreview, - backgroundBubbleColor = backgroundBubbleColor, - accountViewModel = accountViewModel, - nav = nav, - ) - - ConstrainedStatusRow( - bubbleSize = bubbleSize, - availableBubbleSize = availableBubbleSize, - firstColumn = { - IncognitoBadge(baseNote) - ChatTimeAgo(baseNote) - RelayBadgesHorizontal(baseNote, accountViewModel, nav = nav) - Spacer(modifier = DoubleHorzSpacer) - }, - secondColumn = { - LikeReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav) - Spacer(modifier = StdHorzSpacer) - ZapReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav) - Spacer(modifier = DoubleHorzSpacer) - ReplyReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.placeholderText, + RenderReplyRow( + note = baseNote, + innerQuote = innerQuote, + backgroundBubbleColor = backgroundBubbleColor, accountViewModel = accountViewModel, - showCounter = false, - iconSizeModifier = Size15Modifier, - ) { - onWantsToReply(baseNote) - } - Spacer(modifier = StdHorzSpacer) - }, - ) + nav = nav, + onWantsToReply = onWantsToReply, + ) + + NoteRow( + note = baseNote, + canPreview = canPreview, + backgroundBubbleColor = backgroundBubbleColor, + accountViewModel = accountViewModel, + nav = nav, + ) + + ConstrainedStatusRow( + bubbleSize = bubbleSize, + availableBubbleSize = availableBubbleSize, + firstColumn = { + IncognitoBadge(baseNote) + ChatTimeAgo(baseNote) + RelayBadgesHorizontal(baseNote, accountViewModel, nav = nav) + Spacer(modifier = DoubleHorzSpacer) + }, + secondColumn = { + LikeReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav) + Spacer(modifier = StdHorzSpacer) + ZapReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav) + Spacer(modifier = DoubleHorzSpacer) + ReplyReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.placeholderText, + accountViewModel = accountViewModel, + showCounter = false, + iconSizeModifier = Size15Modifier, + ) { + onWantsToReply(baseNote) + } + Spacer(modifier = StdHorzSpacer) + }, + ) } @Composable private fun RenderReplyRow( - note: Note, - innerQuote: Boolean, - backgroundBubbleColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - onWantsToReply: (Note) -> Unit, + note: Note, + innerQuote: Boolean, + backgroundBubbleColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onWantsToReply: (Note) -> Unit, ) { - val hasReply by remember { derivedStateOf { !innerQuote && note.replyTo?.lastOrNull() != null } } + val hasReply by remember { derivedStateOf { !innerQuote && note.replyTo?.lastOrNull() != null } } - if (hasReply) { - RenderReply(note, backgroundBubbleColor, accountViewModel, nav, onWantsToReply) - } + if (hasReply) { + RenderReply(note, backgroundBubbleColor, accountViewModel, nav, onWantsToReply) + } } @Composable private fun RenderReply( - note: Note, - backgroundBubbleColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - onWantsToReply: (Note) -> Unit, + note: Note, + backgroundBubbleColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onWantsToReply: (Note) -> Unit, ) { - Row(verticalAlignment = Alignment.CenterVertically) { - val replyTo by remember { derivedStateOf { note.replyTo?.lastOrNull() } } - replyTo?.let { note -> - ChatroomMessageCompose( - note, - null, - innerQuote = true, - parentBackgroundColor = backgroundBubbleColor, - accountViewModel = accountViewModel, - nav = nav, - onWantsToReply = onWantsToReply, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + val replyTo by remember { derivedStateOf { note.replyTo?.lastOrNull() } } + replyTo?.let { note -> + ChatroomMessageCompose( + note, + null, + innerQuote = true, + parentBackgroundColor = backgroundBubbleColor, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = onWantsToReply, + ) + } } - } } @Composable private fun NoteRow( - note: Note, - canPreview: Boolean, - backgroundBubbleColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + canPreview: Boolean, + backgroundBubbleColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Row(verticalAlignment = Alignment.CenterVertically) { - when (remember(note) { note.event }) { - is ChannelCreateEvent -> { - RenderCreateChannelNote(note) - } - is ChannelMetadataEvent -> { - RenderChangeChannelMetadataNote(note) - } - else -> { - RenderRegularTextNote( - note, - canPreview, - backgroundBubbleColor, - accountViewModel, - nav, - ) - } + Row(verticalAlignment = Alignment.CenterVertically) { + when (remember(note) { note.event }) { + is ChannelCreateEvent -> { + RenderCreateChannelNote(note) + } + is ChannelMetadataEvent -> { + RenderChangeChannelMetadataNote(note) + } + else -> { + RenderRegularTextNote( + note, + canPreview, + backgroundBubbleColor, + accountViewModel, + nav, + ) + } + } } - } } @Composable private fun ConstrainedStatusRow( - bubbleSize: MutableState, - availableBubbleSize: MutableState, - firstColumn: @Composable () -> Unit, - secondColumn: @Composable () -> Unit, + bubbleSize: MutableState, + availableBubbleSize: MutableState, + firstColumn: @Composable () -> Unit, + secondColumn: @Composable () -> Unit, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = - with(LocalDensity.current) { - Modifier.padding(top = Size5dp) - .height(Size20dp) - .widthIn( - bubbleSize.value.toDp(), - availableBubbleSize.value.toDp(), - ) - }, - ) { - Column(modifier = ReactionRowHeightChat) { - Row( + Row( verticalAlignment = Alignment.CenterVertically, - modifier = ReactionRowHeightChat, - ) { - firstColumn() - } - } + horizontalArrangement = Arrangement.SpaceBetween, + modifier = + with(LocalDensity.current) { + Modifier.padding(top = Size5dp) + .height(Size20dp) + .widthIn( + bubbleSize.value.toDp(), + availableBubbleSize.value.toDp(), + ) + }, + ) { + Column(modifier = ReactionRowHeightChat) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = ReactionRowHeightChat, + ) { + firstColumn() + } + } - Column(modifier = ReactionRowHeightChat) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = ReactionRowHeightChat, - ) { - secondColumn() - } + Column(modifier = ReactionRowHeightChat) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = ReactionRowHeightChat, + ) { + secondColumn() + } + } } - } } @Composable fun IncognitoBadge(baseNote: Note) { - if (baseNote.event is ChatMessageEvent) { - Icon( - painter = painterResource(id = R.drawable.incognito), - null, - modifier = Modifier.padding(top = 1.dp).size(14.dp), - tint = MaterialTheme.colorScheme.placeholderText, - ) - Spacer(modifier = StdHorzSpacer) - } else if (baseNote.event is PrivateDmEvent) { - Icon( - painter = painterResource(id = R.drawable.incognito_off), - null, - modifier = Modifier.padding(top = 1.dp).size(14.dp), - tint = MaterialTheme.colorScheme.placeholderText, - ) - Spacer(modifier = StdHorzSpacer) - } + if (baseNote.event is ChatMessageEvent) { + Icon( + painter = painterResource(id = R.drawable.incognito), + null, + modifier = Modifier.padding(top = 1.dp).size(14.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) + Spacer(modifier = StdHorzSpacer) + } else if (baseNote.event is PrivateDmEvent) { + Icon( + painter = painterResource(id = R.drawable.incognito_off), + null, + modifier = Modifier.padding(top = 1.dp).size(14.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) + Spacer(modifier = StdHorzSpacer) + } } @Composable fun ChatTimeAgo(baseNote: Note) { - val nowStr = stringResource(id = R.string.now) + val nowStr = stringResource(id = R.string.now) - val time by - remember(baseNote) { derivedStateOf { timeAgoShort(baseNote.createdAt() ?: 0, nowStr) } } + val time by + remember(baseNote) { derivedStateOf { timeAgoShort(baseNote.createdAt() ?: 0, nowStr) } } - Text( - text = time, - color = MaterialTheme.colorScheme.placeholderText, - fontSize = Font12SP, - maxLines = 1, - ) + Text( + text = time, + color = MaterialTheme.colorScheme.placeholderText, + fontSize = Font12SP, + maxLines = 1, + ) } @Composable private fun RenderRegularTextNote( - note: Note, - canPreview: Boolean, - backgroundBubbleColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + canPreview: Boolean, + backgroundBubbleColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val tags = remember(note.event) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - val modifier = remember { Modifier.padding(top = 5.dp) } + val tags = remember(note.event) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + val modifier = remember { Modifier.padding(top = 5.dp) } - LoadDecryptedContentOrNull(note = note, accountViewModel = accountViewModel) { eventContent -> - if (eventContent != null) { - SensitivityWarning( - note = note, - accountViewModel = accountViewModel, - ) { - TranslatableRichTextViewer( - content = eventContent!!, - canPreview = canPreview, - modifier = modifier, - tags = tags, - backgroundColor = backgroundBubbleColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } else { - TranslatableRichTextViewer( - content = stringResource(id = R.string.could_not_decrypt_the_message), - canPreview = true, - modifier = modifier, - tags = tags, - backgroundColor = backgroundBubbleColor, - accountViewModel = accountViewModel, - nav = nav, - ) + LoadDecryptedContentOrNull(note = note, accountViewModel = accountViewModel) { eventContent -> + if (eventContent != null) { + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + TranslatableRichTextViewer( + content = eventContent!!, + canPreview = canPreview, + modifier = modifier, + tags = tags, + backgroundColor = backgroundBubbleColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } else { + TranslatableRichTextViewer( + content = stringResource(id = R.string.could_not_decrypt_the_message), + canPreview = true, + modifier = modifier, + tags = tags, + backgroundColor = backgroundBubbleColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } } - } } @Composable private fun RenderChangeChannelMetadataNote(note: Note) { - val noteEvent = note.event as? ChannelMetadataEvent ?: return + val noteEvent = note.event as? ChannelMetadataEvent ?: return - val channelInfo = noteEvent.channelInfo() - val text = - note.author?.toBestDisplayName().toString() + - " ${stringResource(R.string.changed_chat_name_to)} '" + - (channelInfo.name ?: "") + - "', ${stringResource(R.string.description_to)} '" + - (channelInfo.about ?: "") + - "', ${stringResource(R.string.and_picture_to)} '" + - (channelInfo.picture ?: "") + - "'" + val channelInfo = noteEvent.channelInfo() + val text = + note.author?.toBestDisplayName().toString() + + " ${stringResource(R.string.changed_chat_name_to)} '" + + (channelInfo.name ?: "") + + "', ${stringResource(R.string.description_to)} '" + + (channelInfo.about ?: "") + + "', ${stringResource(R.string.and_picture_to)} '" + + (channelInfo.picture ?: "") + + "'" - CreateTextWithEmoji( - text = text, - tags = remember { note.author?.info?.latestMetadata?.tags?.toImmutableListOfLists() }, - ) + CreateTextWithEmoji( + text = text, + tags = remember { note.author?.info?.latestMetadata?.tags?.toImmutableListOfLists() }, + ) } @Composable private fun RenderCreateChannelNote(note: Note) { - val noteEvent = note.event as? ChannelCreateEvent ?: return - val channelInfo = remember { noteEvent.channelInfo() } + val noteEvent = note.event as? ChannelCreateEvent ?: return + val channelInfo = remember { noteEvent.channelInfo() } - val text = - note.author?.toBestDisplayName().toString() + - " ${stringResource(R.string.created)} " + - (channelInfo.name ?: "") + - " ${stringResource(R.string.with_description_of)} '" + - (channelInfo.about ?: "") + - "', ${stringResource(R.string.and_picture)} '" + - (channelInfo.picture ?: "") + - "'" + val text = + note.author?.toBestDisplayName().toString() + + " ${stringResource(R.string.created)} " + + (channelInfo.name ?: "") + + " ${stringResource(R.string.with_description_of)} '" + + (channelInfo.about ?: "") + + "', ${stringResource(R.string.and_picture)} '" + + (channelInfo.picture ?: "") + + "'" - CreateTextWithEmoji( - text = text, - tags = remember { note.author?.info?.latestMetadata?.tags?.toImmutableListOfLists() }, - ) + CreateTextWithEmoji( + text = text, + tags = remember { note.author?.info?.latestMetadata?.tags?.toImmutableListOfLists() }, + ) } @Composable private fun DrawAuthorInfo( - baseNote: Note, - alignment: Arrangement.Horizontal, - loadProfilePicture: Boolean, - nav: (String) -> Unit, + baseNote: Note, + alignment: Arrangement.Horizontal, + loadProfilePicture: Boolean, + nav: (String) -> Unit, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = alignment, - modifier = Modifier.padding(top = Size10dp), - ) { - DisplayAndWatchNoteAuthor(baseNote, loadProfilePicture, nav) - } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = alignment, + modifier = Modifier.padding(top = Size10dp), + ) { + DisplayAndWatchNoteAuthor(baseNote, loadProfilePicture, nav) + } } @Composable private fun DisplayAndWatchNoteAuthor( - baseNote: Note, - loadProfilePicture: Boolean, - nav: (String) -> Unit, + baseNote: Note, + loadProfilePicture: Boolean, + nav: (String) -> Unit, ) { - val author = remember { baseNote.author } - author?.let { WatchAndDisplayUser(it, loadProfilePicture, nav) } + val author = remember { baseNote.author } + author?.let { WatchAndDisplayUser(it, loadProfilePicture, nav) } } @Composable private fun WatchAndDisplayUser( - author: User, - loadProfilePicture: Boolean, - nav: (String) -> Unit, + author: User, + loadProfilePicture: Boolean, + nav: (String) -> Unit, ) { - val pubkeyHex = remember { author.pubkeyHex } - val route = remember { "User/${author.pubkeyHex}" } + val pubkeyHex = remember { author.pubkeyHex } + val route = remember { "User/${author.pubkeyHex}" } - val userState by author.live().metadata.observeAsState() + val userState by author.live().metadata.observeAsState() - val userDisplayName by - remember(userState) { derivedStateOf { userState?.user?.toBestDisplayName() } } + val userDisplayName by + remember(userState) { derivedStateOf { userState?.user?.toBestDisplayName() } } - val userProfilePicture by - remember(userState) { derivedStateOf { userState?.user?.profilePicture() } } + val userProfilePicture by + remember(userState) { derivedStateOf { userState?.user?.profilePicture() } } - val userTags by - remember(userState) { - derivedStateOf { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } - } + val userTags by + remember(userState) { + derivedStateOf { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } + } - UserIcon(pubkeyHex, userProfilePicture, loadProfilePicture, nav, route) + UserIcon(pubkeyHex, userProfilePicture, loadProfilePicture, nav, route) - userDisplayName?.let { DisplayMessageUsername(it, userTags, route, nav) } + userDisplayName?.let { DisplayMessageUsername(it, userTags, route, nav) } } @Composable private fun UserIcon( - pubkeyHex: String, - userProfilePicture: String?, - loadProfilePicture: Boolean, - nav: (String) -> Unit, - route: String, + pubkeyHex: String, + userProfilePicture: String?, + loadProfilePicture: Boolean, + nav: (String) -> Unit, + route: String, ) { - RobohashFallbackAsyncImage( - robot = pubkeyHex, - model = userProfilePicture, - contentDescription = stringResource(id = R.string.profile_image), - loadProfilePicture = loadProfilePicture, - modifier = - remember { - Modifier.width(Size25dp) - .height(Size25dp) - .clip(shape = CircleShape) - .clickable(onClick = { nav(route) }) - }, - ) + RobohashFallbackAsyncImage( + robot = pubkeyHex, + model = userProfilePicture, + contentDescription = stringResource(id = R.string.profile_image), + loadProfilePicture = loadProfilePicture, + modifier = + remember { + Modifier.width(Size25dp) + .height(Size25dp) + .clip(shape = CircleShape) + .clickable(onClick = { nav(route) }) + }, + ) } @Composable private fun DisplayMessageUsername( - userDisplayName: String, - userTags: ImmutableListOfLists?, - route: String, - nav: (String) -> Unit, + userDisplayName: String, + userTags: ImmutableListOfLists?, + route: String, + nav: (String) -> Unit, ) { - Spacer(modifier = StdHorzSpacer) - CreateClickableTextWithEmoji( - clickablePart = userDisplayName, - suffix = "", - maxLines = 1, - tags = userTags, - fontWeight = FontWeight.Bold, - overrideColor = MaterialTheme.colorScheme.onBackground, - route = route, - nav = nav, - ) + Spacer(modifier = StdHorzSpacer) + CreateClickableTextWithEmoji( + clickablePart = userDisplayName, + suffix = "", + maxLines = 1, + tags = userTags, + fontWeight = FontWeight.Bold, + overrideColor = MaterialTheme.colorScheme.onBackground, + route = route, + nav = nav, + ) - Spacer(modifier = StdHorzSpacer) - DrawPlayName(userDisplayName) + Spacer(modifier = StdHorzSpacer) + DrawPlayName(userDisplayName) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt index d0b504159..514e11796 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt @@ -60,416 +60,416 @@ import com.vitorpamplona.amethyst.ui.theme.subtleButton @Composable fun AmethystIcon(iconSize: Dp) { - Icon( - painter = painterResource(R.drawable.amethyst), - null, - modifier = Modifier.size(iconSize), - tint = Color.Unspecified, - ) + Icon( + painter = painterResource(R.drawable.amethyst), + null, + modifier = Modifier.size(iconSize), + tint = Color.Unspecified, + ) } @Composable fun FollowingIcon(iconSize: Dp) { - Icon( - painter = painterResource(R.drawable.following), - contentDescription = stringResource(id = R.string.following), - modifier = remember(iconSize) { Modifier.size(iconSize) }, - tint = Color.Unspecified, - ) + Icon( + painter = painterResource(R.drawable.following), + contentDescription = stringResource(id = R.string.following), + modifier = remember(iconSize) { Modifier.size(iconSize) }, + tint = Color.Unspecified, + ) } @Composable fun ArrowBackIcon() { - Icon( - imageVector = Icons.Default.ArrowBack, - contentDescription = stringResource(R.string.back), - tint = MaterialTheme.colorScheme.grayText, - ) + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = stringResource(R.string.back), + tint = MaterialTheme.colorScheme.grayText, + ) } @Composable fun MessageIcon(modifier: Modifier) { - Icon( - painter = painterResource(R.drawable.ic_dm), - null, - modifier = modifier, - tint = MaterialTheme.colorScheme.primary, - ) + Icon( + painter = painterResource(R.drawable.ic_dm), + null, + modifier = modifier, + tint = MaterialTheme.colorScheme.primary, + ) } @Composable fun DownloadForOfflineIcon( - iconSize: Dp, - tint: Color = MaterialTheme.colorScheme.primary, + iconSize: Dp, + tint: Color = MaterialTheme.colorScheme.primary, ) { - Icon( - imageVector = Icons.Default.DownloadForOffline, - null, - modifier = remember(iconSize) { Modifier.size(iconSize) }, - tint = tint, - ) + Icon( + imageVector = Icons.Default.DownloadForOffline, + null, + modifier = remember(iconSize) { Modifier.size(iconSize) }, + tint = tint, + ) } @Composable fun HashCheckIcon(iconSize: Dp) { - Icon( - painter = painterResource(R.drawable.original), - contentDescription = stringResource(id = R.string.hash_verification_passed), - modifier = remember(iconSize) { Modifier.size(iconSize) }, - tint = Color.Unspecified, - ) + Icon( + painter = painterResource(R.drawable.original), + contentDescription = stringResource(id = R.string.hash_verification_passed), + modifier = remember(iconSize) { Modifier.size(iconSize) }, + tint = Color.Unspecified, + ) } @Composable fun HashCheckFailedIcon(iconSize: Dp) { - Icon( - imageVector = Icons.Default.Report, - contentDescription = stringResource(id = R.string.hash_verification_failed), - modifier = remember(iconSize) { Modifier.size(iconSize) }, - tint = Color.Red, - ) + Icon( + imageVector = Icons.Default.Report, + contentDescription = stringResource(id = R.string.hash_verification_failed), + modifier = remember(iconSize) { Modifier.size(iconSize) }, + tint = Color.Red, + ) } @Composable fun LikedIcon(iconSize: Dp) { - LikedIcon(modifier = remember(iconSize) { Modifier.size(iconSize) }) + LikedIcon(modifier = remember(iconSize) { Modifier.size(iconSize) }) } @Composable fun LikedIcon(modifier: Modifier) { - Icon( - painter = painterResource(R.drawable.ic_liked), - null, - modifier = modifier, - tint = Color.Unspecified, - ) + Icon( + painter = painterResource(R.drawable.ic_liked), + null, + modifier = modifier, + tint = Color.Unspecified, + ) } @Composable fun LikeIcon( - iconSizeModifier: Modifier, - grayTint: Color, + iconSizeModifier: Modifier, + grayTint: Color, ) { - Icon( - painter = painterResource(R.drawable.ic_like), - null, - modifier = iconSizeModifier, - tint = grayTint, - ) + Icon( + painter = painterResource(R.drawable.ic_like), + null, + modifier = iconSizeModifier, + tint = grayTint, + ) } @Composable fun RepostedIcon( - modifier: Modifier, - tint: Color = Color.Unspecified, + modifier: Modifier, + tint: Color = Color.Unspecified, ) { - Icon( - painter = painterResource(R.drawable.ic_retweeted), - null, - modifier = modifier, - tint = tint, - ) + Icon( + painter = painterResource(R.drawable.ic_retweeted), + null, + modifier = modifier, + tint = tint, + ) } @Composable fun LightningAddressIcon( - modifier: Modifier, - tint: Color = Color.Unspecified, + modifier: Modifier, + tint: Color = Color.Unspecified, ) { - Icon( - imageVector = Icons.Default.Bolt, - contentDescription = stringResource(R.string.lightning_address), - tint = tint, - modifier = modifier, - ) + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.lightning_address), + tint = tint, + modifier = modifier, + ) } @Composable fun ZappedIcon(iconSize: Dp) { - ZappedIcon(modifier = remember(iconSize) { Modifier.size(iconSize) }) + ZappedIcon(modifier = remember(iconSize) { Modifier.size(iconSize) }) } @Composable fun ZappedIcon(modifier: Modifier) { - ZapIcon(modifier = modifier, BitcoinOrange) + ZapIcon(modifier = modifier, BitcoinOrange) } @Composable fun ZapIcon( - modifier: Modifier, - tint: Color = Color.Unspecified, + modifier: Modifier, + tint: Color = Color.Unspecified, ) { - Icon( - imageVector = Icons.Default.Bolt, - contentDescription = stringResource(R.string.zaps), - tint = tint, - modifier = modifier, - ) + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.zaps), + tint = tint, + modifier = modifier, + ) } @Composable fun CashuIcon(modifier: Modifier) { - Icon( - painter = painterResource(R.drawable.cashu), - "Cashu", - tint = Color.Unspecified, - modifier = modifier, - ) + Icon( + painter = painterResource(R.drawable.cashu), + "Cashu", + tint = Color.Unspecified, + modifier = modifier, + ) } @Composable fun CopyIcon( - modifier: Modifier, - tint: Color = Color.Unspecified, + modifier: Modifier, + tint: Color = Color.Unspecified, ) { - Icon( - imageVector = Icons.Default.ContentCopy, - stringResource(id = R.string.copy_to_clipboard), - tint = tint, - modifier = modifier, - ) + Icon( + imageVector = Icons.Default.ContentCopy, + stringResource(id = R.string.copy_to_clipboard), + tint = tint, + modifier = modifier, + ) } @Composable fun OpenInNewIcon( - modifier: Modifier, - tint: Color = Color.Unspecified, + modifier: Modifier, + tint: Color = Color.Unspecified, ) { - Icon( - imageVector = Icons.Default.OpenInNew, - stringResource(id = R.string.copy_to_clipboard), - tint = tint, - modifier = modifier, - ) + Icon( + imageVector = Icons.Default.OpenInNew, + stringResource(id = R.string.copy_to_clipboard), + tint = tint, + modifier = modifier, + ) } @Composable fun ExpandLessIcon(modifier: Modifier) { - Icon( - imageVector = Icons.Default.ExpandLess, - null, - modifier = modifier, - tint = MaterialTheme.colorScheme.subtleButton, - ) + Icon( + imageVector = Icons.Default.ExpandLess, + null, + modifier = modifier, + tint = MaterialTheme.colorScheme.subtleButton, + ) } @Composable fun ExpandMoreIcon(modifier: Modifier) { - Icon( - imageVector = Icons.Default.ExpandMore, - null, - modifier = modifier, - tint = MaterialTheme.colorScheme.subtleButton, - ) + Icon( + imageVector = Icons.Default.ExpandMore, + null, + modifier = modifier, + tint = MaterialTheme.colorScheme.subtleButton, + ) } @Composable fun CommentIcon( - iconSizeModifier: Modifier, - tint: Color, + iconSizeModifier: Modifier, + tint: Color, ) { - Icon( - painter = painterResource(R.drawable.ic_comment), - contentDescription = null, - modifier = iconSizeModifier, - tint = tint, - ) + Icon( + painter = painterResource(R.drawable.ic_comment), + contentDescription = null, + modifier = iconSizeModifier, + tint = tint, + ) } @Composable fun ViewCountIcon( - modifier: Modifier, - tint: Color = Color.Unspecified, + modifier: Modifier, + tint: Color = Color.Unspecified, ) { - Icon( - imageVector = Icons.Outlined.BarChart, - null, - modifier = modifier, - tint = tint, - ) + Icon( + imageVector = Icons.Outlined.BarChart, + null, + modifier = modifier, + tint = tint, + ) } @Composable fun PollIcon() { - Icon( - painter = painterResource(R.drawable.ic_poll), - null, - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.onBackground, - ) + Icon( + painter = painterResource(R.drawable.ic_poll), + null, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) } @Composable fun RegularPostIcon() { - Icon( - painter = painterResource(R.drawable.ic_lists), - null, - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.onBackground, - ) + Icon( + painter = painterResource(R.drawable.ic_lists), + null, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) } @Composable fun CancelIcon() { - Icon( - imageVector = Icons.Default.Cancel, - null, - modifier = Size30Modifier, - tint = MaterialTheme.colorScheme.placeholderText, - ) + Icon( + imageVector = Icons.Default.Cancel, + null, + modifier = Size30Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) } @Composable fun CloseIcon() { - Icon( - painter = painterResource(id = R.drawable.ic_close), - contentDescription = stringResource(id = R.string.cancel), - modifier = Size20Modifier, - ) + Icon( + painter = painterResource(id = R.drawable.ic_close), + contentDescription = stringResource(id = R.string.cancel), + modifier = Size20Modifier, + ) } @Composable fun MutedIcon() { - Icon( - imageVector = Icons.Default.VolumeOff, - contentDescription = stringResource(id = R.string.muted_button), - tint = MaterialTheme.colorScheme.onBackground, - modifier = Size30Modifier, - ) + Icon( + imageVector = Icons.Default.VolumeOff, + contentDescription = stringResource(id = R.string.muted_button), + tint = MaterialTheme.colorScheme.onBackground, + modifier = Size30Modifier, + ) } @Composable fun MuteIcon() { - Icon( - imageVector = Icons.Default.VolumeUp, - contentDescription = stringResource(id = R.string.mute_button), - tint = MaterialTheme.colorScheme.onBackground, - modifier = Size30Modifier, - ) + Icon( + imageVector = Icons.Default.VolumeUp, + contentDescription = stringResource(id = R.string.mute_button), + tint = MaterialTheme.colorScheme.onBackground, + modifier = Size30Modifier, + ) } @Composable fun SearchIcon( - modifier: Modifier, - tint: Color = Color.Unspecified, + modifier: Modifier, + tint: Color = Color.Unspecified, ) { - Icon( - painter = painterResource(R.drawable.ic_search), - contentDescription = stringResource(id = R.string.search_button), - modifier = modifier, - tint = tint, - ) + Icon( + painter = painterResource(R.drawable.ic_search), + contentDescription = stringResource(id = R.string.search_button), + modifier = modifier, + tint = tint, + ) } @Composable fun PlayIcon( - modifier: Modifier, - tint: Color, + modifier: Modifier, + tint: Color, ) { - Icon( - imageVector = Icons.Outlined.PlayCircle, - contentDescription = null, - modifier = modifier, - tint = tint, - ) + Icon( + imageVector = Icons.Outlined.PlayCircle, + contentDescription = null, + modifier = modifier, + tint = tint, + ) } @Composable fun PinIcon( - modifier: Modifier, - tint: Color, + modifier: Modifier, + tint: Color, ) { - Icon( - imageVector = Icons.Default.PushPin, - contentDescription = null, - modifier = modifier, - tint = tint, - ) + Icon( + imageVector = Icons.Default.PushPin, + contentDescription = null, + modifier = modifier, + tint = tint, + ) } @Composable fun LyricsIcon( - modifier: Modifier, - tint: Color, + modifier: Modifier, + tint: Color, ) { - Icon( - painter = painterResource(id = R.drawable.lyrics_on), - contentDescription = null, - modifier = modifier, - tint = tint, - ) + Icon( + painter = painterResource(id = R.drawable.lyrics_on), + contentDescription = null, + modifier = modifier, + tint = tint, + ) } @Composable fun LyricsOffIcon( - modifier: Modifier, - tint: Color, + modifier: Modifier, + tint: Color, ) { - Icon( - painter = painterResource(id = R.drawable.lyrics_off), - contentDescription = null, - modifier = modifier, - tint = tint, - ) + Icon( + painter = painterResource(id = R.drawable.lyrics_off), + contentDescription = null, + modifier = modifier, + tint = tint, + ) } @Composable fun ClearTextIcon() { - Icon( - imageVector = Icons.Default.Clear, - contentDescription = stringResource(R.string.clear), - ) + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(R.string.clear), + ) } @Composable fun LinkIcon( - modifier: Modifier, - tint: Color, + modifier: Modifier, + tint: Color, ) { - Icon( - imageVector = Icons.Default.Link, - contentDescription = stringResource(R.string.website), - modifier = modifier, - tint = tint, - ) + Icon( + imageVector = Icons.Default.Link, + contentDescription = stringResource(R.string.website), + modifier = modifier, + tint = tint, + ) } @Composable fun VerticalDotsIcon() { - Icon( - imageVector = Icons.Default.MoreVert, - null, - modifier = Size18Modifier, - tint = MaterialTheme.colorScheme.placeholderText, - ) + Icon( + imageVector = Icons.Default.MoreVert, + null, + modifier = Size18Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) } @Composable fun NIP05CheckingIcon(modifier: Modifier) { - Icon( - imageVector = Icons.Default.Downloading, - contentDescription = stringResource(id = R.string.nip05_checking), - modifier = modifier, - tint = Color.Yellow, - ) + Icon( + imageVector = Icons.Default.Downloading, + contentDescription = stringResource(id = R.string.nip05_checking), + modifier = modifier, + tint = Color.Yellow, + ) } @Composable fun NIP05VerifiedIcon(modifier: Modifier) { - Icon( - painter = painterResource(R.drawable.nip_05), - contentDescription = stringResource(id = R.string.nip05_verified), - modifier = modifier, - tint = Color.Unspecified, - ) + Icon( + painter = painterResource(R.drawable.nip_05), + contentDescription = stringResource(id = R.string.nip05_verified), + modifier = modifier, + tint = Color.Unspecified, + ) } @Composable fun NIP05FailedVerification(modifier: Modifier) { - Icon( - imageVector = Icons.Default.Report, - contentDescription = stringResource(id = R.string.nip05_failed), - modifier = modifier, - tint = Color.Red, - ) + Icon( + imageVector = Icons.Default.Report, + contentDescription = stringResource(id = R.string.nip05_failed), + modifier = modifier, + tint = Color.Red, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt index 8d95745c2..08deac29f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt @@ -52,89 +52,89 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable fun MessageSetCompose( - messageSetCard: MessageSetCard, - routeForLastRead: String, - showHidden: Boolean = false, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + messageSetCard: MessageSetCard, + routeForLastRead: String, + showHidden: Boolean = false, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val baseNote = remember { messageSetCard.note } + val baseNote = remember { messageSetCard.note } - val popupExpanded = remember { mutableStateOf(false) } - val enablePopup = remember { { popupExpanded.value = true } } + val popupExpanded = remember { mutableStateOf(false) } + val enablePopup = remember { { popupExpanded.value = true } } - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - val defaultBackgroundColor = MaterialTheme.colorScheme.background - val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } - val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor + val defaultBackgroundColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } + val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor - LaunchedEffect(key1 = messageSetCard) { - accountViewModel.loadAndMarkAsRead(routeForLastRead, messageSetCard.createdAt()) { isNew -> - val newBackgroundColor = - if (isNew) { - newItemColor.compositeOver(defaultBackgroundColor) - } else { - defaultBackgroundColor + LaunchedEffect(key1 = messageSetCard) { + accountViewModel.loadAndMarkAsRead(routeForLastRead, messageSetCard.createdAt()) { isNew -> + val newBackgroundColor = + if (isNew) { + newItemColor.compositeOver(defaultBackgroundColor) + } else { + defaultBackgroundColor + } + + if (backgroundColor.value != newBackgroundColor) { + backgroundColor.value = newBackgroundColor + } + } + } + + val columnModifier = + remember(backgroundColor.value) { + Modifier.background(backgroundColor.value) + .padding( + start = 12.dp, + end = 12.dp, + top = 10.dp, + ) + .combinedClickable( + onClick = { + scope.launch { + routeFor( + baseNote, + accountViewModel.userProfile(), + ) + ?.let { nav(it) } + } + }, + onLongClick = enablePopup, + ) + .fillMaxWidth() } - if (backgroundColor.value != newBackgroundColor) { - backgroundColor.value = newBackgroundColor - } - } - } - - val columnModifier = - remember(backgroundColor.value) { - Modifier.background(backgroundColor.value) - .padding( - start = 12.dp, - end = 12.dp, - top = 10.dp, - ) - .combinedClickable( - onClick = { - scope.launch { - routeFor( - baseNote, - accountViewModel.userProfile(), + Column(columnModifier) { + Row(Modifier.fillMaxWidth()) { + Box( + modifier = remember { Modifier.width(55.dp).padding(top = 5.dp, end = 5.dp) }, + ) { + MessageIcon( + remember { Modifier.size(16.dp).align(Alignment.TopEnd) }, ) - ?.let { nav(it) } } - }, - onLongClick = enablePopup, + + Column(modifier = remember { Modifier.padding(start = 10.dp) }) { + NoteCompose( + baseNote = baseNote, + routeForLastRead = null, + isBoostedNote = true, + addMarginTop = false, + showHidden = showHidden, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + + NoteDropDownMenu(baseNote, popupExpanded, accountViewModel) + } + } + + Divider( + thickness = DividerThickness, ) - .fillMaxWidth() } - - Column(columnModifier) { - Row(Modifier.fillMaxWidth()) { - Box( - modifier = remember { Modifier.width(55.dp).padding(top = 5.dp, end = 5.dp) }, - ) { - MessageIcon( - remember { Modifier.size(16.dp).align(Alignment.TopEnd) }, - ) - } - - Column(modifier = remember { Modifier.padding(start = 10.dp) }) { - NoteCompose( - baseNote = baseNote, - routeForLastRead = null, - isBoostedNote = true, - addMarginTop = false, - showHidden = showHidden, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - - NoteDropDownMenu(baseNote, popupExpanded, accountViewModel) - } - } - - Divider( - thickness = DividerThickness, - ) - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 8d0ae48ee..66a84440d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -91,325 +91,326 @@ import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor import com.vitorpamplona.amethyst.ui.theme.overPictureBackground import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier import com.vitorpamplona.quartz.events.EmptyTagList -import kotlin.time.ExperimentalTime import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlin.time.ExperimentalTime @OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class) @Composable fun MultiSetCompose( - multiSetCard: MultiSetCard, - routeForLastRead: String, - showHidden: Boolean = false, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + multiSetCard: MultiSetCard, + routeForLastRead: String, + showHidden: Boolean = false, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val baseNote = remember { multiSetCard.note } + val baseNote = remember { multiSetCard.note } - val popupExpanded = remember { mutableStateOf(false) } - val enablePopup = remember { { popupExpanded.value = true } } + val popupExpanded = remember { mutableStateOf(false) } + val enablePopup = remember { { popupExpanded.value = true } } - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - val defaultBackgroundColor = MaterialTheme.colorScheme.background - val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } - val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor + val defaultBackgroundColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } + val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor - LaunchedEffect(key1 = multiSetCard) { - accountViewModel.loadAndMarkAsRead(routeForLastRead, multiSetCard.maxCreatedAt) { isNew -> - val newBackgroundColor = - if (isNew) { - newItemColor.compositeOver(defaultBackgroundColor) - } else { - defaultBackgroundColor + LaunchedEffect(key1 = multiSetCard) { + accountViewModel.loadAndMarkAsRead(routeForLastRead, multiSetCard.maxCreatedAt) { isNew -> + val newBackgroundColor = + if (isNew) { + newItemColor.compositeOver(defaultBackgroundColor) + } else { + defaultBackgroundColor + } + + if (backgroundColor.value != newBackgroundColor) { + launch(Dispatchers.Main) { backgroundColor.value = newBackgroundColor } + } + } + } + + val columnModifier = + remember(backgroundColor.value) { + Modifier.fillMaxWidth() + .background(backgroundColor.value) + .combinedClickable( + onClick = { + scope.launch { routeFor(baseNote, accountViewModel.userProfile())?.let { nav(it) } } + }, + onLongClick = enablePopup, + ) + .padding( + start = 12.dp, + end = 12.dp, + top = 10.dp, + ) } - if (backgroundColor.value != newBackgroundColor) { - launch(Dispatchers.Main) { backgroundColor.value = newBackgroundColor } - } - } - } + Column(modifier = columnModifier) { + Galeries(multiSetCard, backgroundColor, accountViewModel, nav) - val columnModifier = - remember(backgroundColor.value) { - Modifier.fillMaxWidth() - .background(backgroundColor.value) - .combinedClickable( - onClick = { - scope.launch { routeFor(baseNote, accountViewModel.userProfile())?.let { nav(it) } } - }, - onLongClick = enablePopup, - ) - .padding( - start = 12.dp, - end = 12.dp, - top = 10.dp, + Row(Modifier.fillMaxWidth()) { + Spacer(modifier = WidthAuthorPictureModifierWithPadding) + + NoteCompose( + baseNote = baseNote, + routeForLastRead = null, + modifier = remember { Modifier.padding(top = 5.dp) }, + isBoostedNote = true, + showHidden = showHidden, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + + NoteDropDownMenu(baseNote, popupExpanded, accountViewModel) + } + + Divider( + thickness = DividerThickness, ) } - - Column(modifier = columnModifier) { - Galeries(multiSetCard, backgroundColor, accountViewModel, nav) - - Row(Modifier.fillMaxWidth()) { - Spacer(modifier = WidthAuthorPictureModifierWithPadding) - - NoteCompose( - baseNote = baseNote, - routeForLastRead = null, - modifier = remember { Modifier.padding(top = 5.dp) }, - isBoostedNote = true, - showHidden = showHidden, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - - NoteDropDownMenu(baseNote, popupExpanded, accountViewModel) - } - - Divider( - thickness = DividerThickness, - ) - } } @Composable private fun Galeries( - multiSetCard: MultiSetCard, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + multiSetCard: MultiSetCard, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val hasZapEvents by remember { derivedStateOf { multiSetCard.zapEvents.isNotEmpty() } } - val hasBoostEvents by remember { derivedStateOf { multiSetCard.boostEvents.isNotEmpty() } } - val hasLikeEvents by remember { derivedStateOf { multiSetCard.likeEvents.isNotEmpty() } } + val hasZapEvents by remember { derivedStateOf { multiSetCard.zapEvents.isNotEmpty() } } + val hasBoostEvents by remember { derivedStateOf { multiSetCard.boostEvents.isNotEmpty() } } + val hasLikeEvents by remember { derivedStateOf { multiSetCard.likeEvents.isNotEmpty() } } - if (hasZapEvents) { - var zapEvents by - remember(multiSetCard.zapEvents) { - mutableStateOf( - accountViewModel.cachedDecryptAmountMessageInGroup(multiSetCard.zapEvents), - ) - } + if (hasZapEvents) { + var zapEvents by + remember(multiSetCard.zapEvents) { + mutableStateOf( + accountViewModel.cachedDecryptAmountMessageInGroup(multiSetCard.zapEvents), + ) + } - LaunchedEffect(key1 = Unit) { - accountViewModel.decryptAmountMessageInGroup(multiSetCard.zapEvents) { zapEvents = it } + LaunchedEffect(key1 = Unit) { + accountViewModel.decryptAmountMessageInGroup(multiSetCard.zapEvents) { zapEvents = it } + } + + RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel) } - RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel) - } - - if (hasBoostEvents) { - RenderBoostGallery(multiSetCard.boostEvents, nav, accountViewModel) - } - - if (hasLikeEvents) { - multiSetCard.likeEventsByType.forEach { - RenderLikeGallery(it.key, it.value, nav, accountViewModel) + if (hasBoostEvents) { + RenderBoostGallery(multiSetCard.boostEvents, nav, accountViewModel) + } + + if (hasLikeEvents) { + multiSetCard.likeEventsByType.forEach { + RenderLikeGallery(it.key, it.value, nav, accountViewModel) + } } - } } @Composable fun RenderLikeGallery( - reactionType: String, - likeEvents: ImmutableList, - nav: (String) -> Unit, - accountViewModel: AccountViewModel, + reactionType: String, + likeEvents: ImmutableList, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - if (likeEvents.isNotEmpty()) { - Row(Modifier.fillMaxWidth()) { - Box( - modifier = NotificationIconModifier, - ) { - val modifier = remember { Modifier.align(Alignment.TopEnd) } + if (likeEvents.isNotEmpty()) { + Row(Modifier.fillMaxWidth()) { + Box( + modifier = NotificationIconModifier, + ) { + val modifier = remember { Modifier.align(Alignment.TopEnd) } - if (reactionType.startsWith(":")) { - val noStartColon = reactionType.removePrefix(":") - val url = noStartColon.substringAfter(":") + if (reactionType.startsWith(":")) { + val noStartColon = reactionType.removePrefix(":") + val url = noStartColon.substringAfter(":") - val renderable = - listOf( - ImageUrlType(url), - ) - .toImmutableList() + val renderable = + listOf( + ImageUrlType(url), + ) + .toImmutableList() - InLineIconRenderer( - renderable, - style = SpanStyle(color = Color.White), - maxLines = 1, - modifier = modifier, - ) - } else { - when (val shortReaction = reactionType) { - "+" -> LikedIcon(modifier.size(Size18dp)) - "-" -> Text(text = "\uD83D\uDC4E", modifier = modifier) - else -> Text(text = shortReaction, modifier = modifier) - } + InLineIconRenderer( + renderable, + style = SpanStyle(color = Color.White), + maxLines = 1, + modifier = modifier, + ) + } else { + when (val shortReaction = reactionType) { + "+" -> LikedIcon(modifier.size(Size18dp)) + "-" -> Text(text = "\uD83D\uDC4E", modifier = modifier) + else -> Text(text = shortReaction, modifier = modifier) + } + } + } + + AuthorGallery(likeEvents, nav, accountViewModel) } - } - - AuthorGallery(likeEvents, nav, accountViewModel) } - } } @Composable fun RenderZapGallery( - zapEvents: ImmutableList, - backgroundColor: MutableState, - nav: (String) -> Unit, - accountViewModel: AccountViewModel, + zapEvents: ImmutableList, + backgroundColor: MutableState, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - Row(Modifier.fillMaxWidth()) { - Box( - modifier = WidthAuthorPictureModifier, - ) { - ZappedIcon( - modifier = remember { Modifier.size(Size25dp).align(Alignment.TopEnd) }, - ) - } + Row(Modifier.fillMaxWidth()) { + Box( + modifier = WidthAuthorPictureModifier, + ) { + ZappedIcon( + modifier = remember { Modifier.size(Size25dp).align(Alignment.TopEnd) }, + ) + } - AuthorGalleryZaps(zapEvents, backgroundColor, nav, accountViewModel) - } + AuthorGalleryZaps(zapEvents, backgroundColor, nav, accountViewModel) + } } @Composable fun RenderBoostGallery( - boostEvents: ImmutableList, - nav: (String) -> Unit, - accountViewModel: AccountViewModel, + boostEvents: ImmutableList, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - Row( - modifier = Modifier.fillMaxWidth(), - ) { - Box( - modifier = NotificationIconModifierSmaller, + Row( + modifier = Modifier.fillMaxWidth(), ) { - RepostedIcon( - modifier = remember { Modifier.size(Size19dp).align(Alignment.TopEnd) }, - ) - } + Box( + modifier = NotificationIconModifierSmaller, + ) { + RepostedIcon( + modifier = remember { Modifier.size(Size19dp).align(Alignment.TopEnd) }, + ) + } - AuthorGallery(boostEvents, nav, accountViewModel) - } + AuthorGallery(boostEvents, nav, accountViewModel) + } } @Composable fun RenderBoostGallery( - noteToGetBoostEvents: NoteState, - nav: (String) -> Unit, - accountViewModel: AccountViewModel, + noteToGetBoostEvents: NoteState, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - Row( - modifier = Modifier.fillMaxWidth(), - ) { - Box( - modifier = NotificationIconModifierSmaller, + Row( + modifier = Modifier.fillMaxWidth(), ) { - RepostedIcon( - modifier = remember { Modifier.size(Size19dp).align(Alignment.TopEnd) }, - ) - } + Box( + modifier = NotificationIconModifierSmaller, + ) { + RepostedIcon( + modifier = remember { Modifier.size(Size19dp).align(Alignment.TopEnd) }, + ) + } - AuthorGallery(noteToGetBoostEvents, nav, accountViewModel) - } + AuthorGallery(noteToGetBoostEvents, nav, accountViewModel) + } } @Composable fun MapZaps( - zaps: ImmutableList, - accountViewModel: AccountViewModel, - content: @Composable (ImmutableList) -> Unit, + zaps: ImmutableList, + accountViewModel: AccountViewModel, + content: @Composable (ImmutableList) -> Unit, ) { - var zapEvents by - remember(zaps) { - mutableStateOf>(persistentListOf()) + var zapEvents by + remember(zaps) { + mutableStateOf>(persistentListOf()) + } + + LaunchedEffect(key1 = zaps) { + accountViewModel.decryptAmountMessageInGroup(zaps) { zapEvents = it } } - LaunchedEffect(key1 = zaps) { - accountViewModel.decryptAmountMessageInGroup(zaps) { zapEvents = it } - } - - content(zapEvents) + content(zapEvents) } @OptIn(ExperimentalLayoutApi::class) @Composable fun AuthorGalleryZaps( - authorNotes: ImmutableList, - backgroundColor: MutableState, - nav: (String) -> Unit, - accountViewModel: AccountViewModel, + authorNotes: ImmutableList, + backgroundColor: MutableState, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - Column(modifier = StdStartPadding) { - FlowRow { authorNotes.forEach { RenderState(it, backgroundColor, accountViewModel, nav) } } - } + Column(modifier = StdStartPadding) { + FlowRow { authorNotes.forEach { RenderState(it, backgroundColor, accountViewModel, nav) } } + } } @Immutable data class ZapAmountCommentNotification( - val user: User?, - val comment: String?, - val amount: String?, + val user: User?, + val comment: String?, + val amount: String?, ) @Composable private fun ParseAuthorCommentAndAmount( - zapRequest: Note, - zapEvent: Note?, - accountViewModel: AccountViewModel, - onReady: @Composable (MutableState) -> Unit, + zapRequest: Note, + zapEvent: Note?, + accountViewModel: AccountViewModel, + onReady: @Composable (MutableState) -> Unit, ) { - val content = remember { - mutableStateOf( - ZapAmountCommentNotification( - user = zapRequest.author, - comment = null, - amount = null, - ), - ) - } + val content = + remember { + mutableStateOf( + ZapAmountCommentNotification( + user = zapRequest.author, + comment = null, + amount = null, + ), + ) + } - LaunchedEffect(key1 = zapRequest.idHex, key2 = zapEvent?.idHex) { - accountViewModel.decryptAmountMessage(zapRequest, zapEvent) { newState -> - if (newState != null) { - content.value = newState - } + LaunchedEffect(key1 = zapRequest.idHex, key2 = zapEvent?.idHex) { + accountViewModel.decryptAmountMessage(zapRequest, zapEvent) { newState -> + if (newState != null) { + content.value = newState + } + } } - } - onReady(content) + onReady(content) } fun click( - content: ZapAmountCommentNotification, - nav: (String) -> Unit, + content: ZapAmountCommentNotification, + nav: (String) -> Unit, ) { - content.user?.let { nav(routeFor(it)) } + content.user?.let { nav(routeFor(it)) } } @Composable private fun RenderState( - content: ZapAmountCommentNotification, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + content: ZapAmountCommentNotification, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Row( - modifier = Modifier.clickable { click(content, nav) }, - verticalAlignment = Alignment.CenterVertically, - ) { - DisplayAuthorCommentAndAmount( - authorComment = content, - backgroundColor = backgroundColor, - nav = nav, - accountViewModel = accountViewModel, - ) - } + Row( + modifier = Modifier.clickable { click(content, nav) }, + verticalAlignment = Alignment.CenterVertically, + ) { + DisplayAuthorCommentAndAmount( + authorComment = content, + backgroundColor = backgroundColor, + nav = nav, + accountViewModel = accountViewModel, + ) + } } val amountBoxModifier = Modifier.size(Size35dp).clip(shape = CircleShape) @@ -422,169 +423,170 @@ val commentTextSize = 12.sp @Composable private fun DisplayAuthorCommentAndAmount( - authorComment: ZapAmountCommentNotification, - backgroundColor: MutableState, - nav: (String) -> Unit, - accountViewModel: AccountViewModel, + authorComment: ZapAmountCommentNotification, + backgroundColor: MutableState, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - Box(modifier = Size35Modifier, contentAlignment = Alignment.BottomCenter) { - WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor( - authorComment.user, - accountViewModel, - ) - authorComment.amount?.let { CrossfadeToDisplayAmount(it) } - } + Box(modifier = Size35Modifier, contentAlignment = Alignment.BottomCenter) { + WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor( + authorComment.user, + accountViewModel, + ) + authorComment.amount?.let { CrossfadeToDisplayAmount(it) } + } - authorComment.comment?.let { - CrossfadeToDisplayComment(it, backgroundColor, nav, accountViewModel) - } + authorComment.comment?.let { + CrossfadeToDisplayComment(it, backgroundColor, nav, accountViewModel) + } } @Composable fun CrossfadeToDisplayAmount(amount: String) { - Box( - modifier = amountBoxModifier, - contentAlignment = Alignment.BottomCenter, - ) { - val backgroundColor = MaterialTheme.colorScheme.overPictureBackground Box( - modifier = remember { Modifier.width(Size35dp).background(backgroundColor) }, - contentAlignment = Alignment.BottomCenter, + modifier = amountBoxModifier, + contentAlignment = Alignment.BottomCenter, ) { - Text( - text = amount, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.bitcoinColor, - fontSize = commentTextSize, - modifier = bottomPadding1dp, - ) + val backgroundColor = MaterialTheme.colorScheme.overPictureBackground + Box( + modifier = remember { Modifier.width(Size35dp).background(backgroundColor) }, + contentAlignment = Alignment.BottomCenter, + ) { + Text( + text = amount, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.bitcoinColor, + fontSize = commentTextSize, + modifier = bottomPadding1dp, + ) + } } - } } @Composable fun CrossfadeToDisplayComment( - comment: String, - backgroundColor: MutableState, - nav: (String) -> Unit, - accountViewModel: AccountViewModel, + comment: String, + backgroundColor: MutableState, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - TranslatableRichTextViewer( - content = comment, - canPreview = true, - tags = EmptyTagList, - modifier = textBoxModifier, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) + TranslatableRichTextViewer( + content = comment, + canPreview = true, + tags = EmptyTagList, + modifier = textBoxModifier, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) } @OptIn(ExperimentalLayoutApi::class) @Composable fun AuthorGallery( - authorNotes: ImmutableList, - nav: (String) -> Unit, - accountViewModel: AccountViewModel, + authorNotes: ImmutableList, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - Column(modifier = StdStartPadding) { - FlowRow { authorNotes.forEach { note -> BoxedAuthor(note, nav, accountViewModel) } } - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun AuthorGallery( - noteToGetBoostEvents: NoteState, - nav: (String) -> Unit, - accountViewModel: AccountViewModel, -) { - Column(modifier = StdStartPadding) { - FlowRow { - noteToGetBoostEvents.note.boosts.forEach { note -> BoxedAuthor(note, nav, accountViewModel) } + Column(modifier = StdStartPadding) { + FlowRow { authorNotes.forEach { note -> BoxedAuthor(note, nav, accountViewModel) } } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun AuthorGallery( + noteToGetBoostEvents: NoteState, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, +) { + Column(modifier = StdStartPadding) { + FlowRow { + noteToGetBoostEvents.note.boosts.forEach { note -> BoxedAuthor(note, nav, accountViewModel) } + } } - } } @Composable private fun BoxedAuthor( - note: Note, - nav: (String) -> Unit, - accountViewModel: AccountViewModel, + note: Note, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - Box(modifier = Size35Modifier.clickable(onClick = { nav(authorRouteFor(note)) })) { - WatchNoteAuthor(note) { targetAuthor -> - Crossfade(targetState = targetAuthor, modifier = Size35Modifier) { author -> - WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor( - author, - accountViewModel, - ) - } + Box(modifier = Size35Modifier.clickable(onClick = { nav(authorRouteFor(note)) })) { + WatchNoteAuthor(note) { targetAuthor -> + Crossfade(targetState = targetAuthor, modifier = Size35Modifier) { author -> + WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor( + author, + accountViewModel, + ) + } + } } - } } @Composable fun WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor( - author: User?, - accountViewModel: AccountViewModel, + author: User?, + accountViewModel: AccountViewModel, ) { - if (author != null) { - WatchUserMetadataAndFollowsAndRenderUserProfilePicture(author, accountViewModel) - } else { - DisplayBlankAuthor(Size35dp) - } + if (author != null) { + WatchUserMetadataAndFollowsAndRenderUserProfilePicture(author, accountViewModel) + } else { + DisplayBlankAuthor(Size35dp) + } } @Composable fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture( - author: User, - accountViewModel: AccountViewModel, + author: User, + accountViewModel: AccountViewModel, ) { - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } + val automaticallyShowProfilePicture = + remember { + accountViewModel.settings.showProfilePictures.value + } - WatchUserMetadata(author) { baseUserPicture -> - // Crossfade(targetState = baseUserPicture) { userPicture -> - RobohashFallbackAsyncImage( - robot = author.pubkeyHex, - model = baseUserPicture, - contentDescription = stringResource(id = R.string.profile_image), - modifier = MaterialTheme.colorScheme.profile35dpModifier, - contentScale = ContentScale.Crop, - loadProfilePicture = automaticallyShowProfilePicture, - ) - // } - } - - WatchUserFollows(author.pubkeyHex, accountViewModel) { isFollowing -> - // Crossfade(targetState = isFollowing) { - if (isFollowing) { - Box(modifier = Size35Modifier, contentAlignment = Alignment.TopEnd) { - FollowingIcon(Size10dp) - } + WatchUserMetadata(author) { baseUserPicture -> + // Crossfade(targetState = baseUserPicture) { userPicture -> + RobohashFallbackAsyncImage( + robot = author.pubkeyHex, + model = baseUserPicture, + contentDescription = stringResource(id = R.string.profile_image), + modifier = MaterialTheme.colorScheme.profile35dpModifier, + contentScale = ContentScale.Crop, + loadProfilePicture = automaticallyShowProfilePicture, + ) + // } + } + + WatchUserFollows(author.pubkeyHex, accountViewModel) { isFollowing -> + // Crossfade(targetState = isFollowing) { + if (isFollowing) { + Box(modifier = Size35Modifier, contentAlignment = Alignment.TopEnd) { + FollowingIcon(Size10dp) + } + } + // } } - // } - } } @Composable private fun WatchNoteAuthor( - baseNote: Note, - onContent: @Composable (User?) -> Unit, + baseNote: Note, + onContent: @Composable (User?) -> Unit, ) { - val author by baseNote.live().authorChanges.observeAsState(baseNote.author) + val author by baseNote.live().authorChanges.observeAsState(baseNote.author) - onContent(author) + onContent(author) } @Composable private fun WatchUserMetadata( - author: User, - onNewMetadata: @Composable (String?) -> Unit, + author: User, + onNewMetadata: @Composable (String?) -> Unit, ) { - val userProfile by author.live().profilePictureChanges.observeAsState(author.profilePicture()) + val userProfile by author.live().profilePictureChanges.observeAsState(author.profilePicture()) - onNewMetadata(userProfile) + onNewMetadata(userProfile) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index 31aa9fa81..78562ba91 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -73,338 +73,338 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.UserMetadata import com.vitorpamplona.quartz.utils.TimeUtils -import kotlin.time.Duration.Companion.seconds import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.seconds @Composable fun nip05VerificationAsAState( - userMetadata: UserMetadata, - pubkeyHex: String, - accountViewModel: AccountViewModel, + userMetadata: UserMetadata, + pubkeyHex: String, + accountViewModel: AccountViewModel, ): MutableState { - val nip05Verified = - remember(userMetadata.nip05) { - // starts with null if must verify or already filled in if verified in the last hour - val default = - if ((userMetadata.nip05LastVerificationTime ?: 0) > TimeUtils.oneHourAgo()) { - userMetadata.nip05Verified - } else { - null + val nip05Verified = + remember(userMetadata.nip05) { + // starts with null if must verify or already filled in if verified in the last hour + val default = + if ((userMetadata.nip05LastVerificationTime ?: 0) > TimeUtils.oneHourAgo()) { + userMetadata.nip05Verified + } else { + null + } + + mutableStateOf(default) } - mutableStateOf(default) - } - - if (nip05Verified.value == null) { - LaunchedEffect(key1 = userMetadata.nip05) { - accountViewModel.verifyNip05(userMetadata, pubkeyHex) { newVerificationStatus -> - if (nip05Verified.value != newVerificationStatus) { - nip05Verified.value = newVerificationStatus + if (nip05Verified.value == null) { + LaunchedEffect(key1 = userMetadata.nip05) { + accountViewModel.verifyNip05(userMetadata, pubkeyHex) { newVerificationStatus -> + if (nip05Verified.value != newVerificationStatus) { + nip05Verified.value = newVerificationStatus + } + } } - } } - } - return nip05Verified + return nip05Verified } @Composable fun ObserveDisplayNip05Status( - baseNote: Note, - columnModifier: Modifier = Modifier, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + columnModifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val author by baseNote.live().authorChanges.observeAsState() + val author by baseNote.live().authorChanges.observeAsState() - author?.let { ObserveDisplayNip05Status(it, columnModifier, accountViewModel, nav) } + author?.let { ObserveDisplayNip05Status(it, columnModifier, accountViewModel, nav) } } @Composable fun ObserveDisplayNip05Status( - baseUser: User, - columnModifier: Modifier = Modifier, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseUser: User, + columnModifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val nip05 by baseUser.live().nip05Changes.observeAsState(baseUser.nip05()) + val nip05 by baseUser.live().nip05Changes.observeAsState(baseUser.nip05()) - LoadStatuses(baseUser, accountViewModel) { statuses -> - Crossfade( - targetState = nip05, - modifier = columnModifier, - label = "ObserveDisplayNip05StatusCrossfade", - ) { - VerifyAndDisplayNIP05OrStatusLine( - it, - statuses, - baseUser, - columnModifier, - accountViewModel, - nav, - ) + LoadStatuses(baseUser, accountViewModel) { statuses -> + Crossfade( + targetState = nip05, + modifier = columnModifier, + label = "ObserveDisplayNip05StatusCrossfade", + ) { + VerifyAndDisplayNIP05OrStatusLine( + it, + statuses, + baseUser, + columnModifier, + accountViewModel, + nav, + ) + } } - } } @Composable private fun VerifyAndDisplayNIP05OrStatusLine( - nip05: String?, - statuses: ImmutableList, - baseUser: User, - columnModifier: Modifier = Modifier, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + nip05: String?, + statuses: ImmutableList, + baseUser: User, + columnModifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Column(modifier = columnModifier) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (nip05 != null) { - val nip05Verified = - nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex, accountViewModel) + Column(modifier = columnModifier) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (nip05 != null) { + val nip05Verified = + nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex, accountViewModel) - if (nip05Verified.value != true) { - DisplayNIP05(nip05, nip05Verified) - } else if (!statuses.isEmpty()) { - RotateStatuses(statuses, accountViewModel, nav) - } else { - DisplayNIP05(nip05, nip05Verified) + if (nip05Verified.value != true) { + DisplayNIP05(nip05, nip05Verified) + } else if (!statuses.isEmpty()) { + RotateStatuses(statuses, accountViewModel, nav) + } else { + DisplayNIP05(nip05, nip05Verified) + } + } else { + if (!statuses.isEmpty()) { + RotateStatuses(statuses, accountViewModel, nav) + } else { + DisplayUsersNpub(baseUser.pubkeyDisplayHex()) + } + } } - } else { - if (!statuses.isEmpty()) { - RotateStatuses(statuses, accountViewModel, nav) - } else { - DisplayUsersNpub(baseUser.pubkeyDisplayHex()) - } - } } - } } @Composable fun RotateStatuses( - statuses: ImmutableList, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + statuses: ImmutableList, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var indexToDisplay by remember(statuses) { mutableIntStateOf(0) } + var indexToDisplay by remember(statuses) { mutableIntStateOf(0) } - DisplayStatus(statuses[indexToDisplay], accountViewModel, nav) + DisplayStatus(statuses[indexToDisplay], accountViewModel, nav) - if (statuses.size > 1) { - LaunchedEffect(Unit) { - while (true) { - delay(10.seconds) - indexToDisplay = (indexToDisplay + 1) % statuses.size - } + if (statuses.size > 1) { + LaunchedEffect(Unit) { + while (true) { + delay(10.seconds) + indexToDisplay = (indexToDisplay + 1) % statuses.size + } + } } - } } @Composable fun DisplayUsersNpub(npub: String) { - Text( - text = npub, - fontSize = 14.sp, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Text( + text = npub, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } @Composable fun DisplayStatus( - addressableNote: AddressableNote, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + addressableNote: AddressableNote, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteState by addressableNote.live().metadata.observeAsState() + val noteState by addressableNote.live().metadata.observeAsState() - val content = remember(noteState) { addressableNote.event?.content() ?: "" } - val type = remember(noteState) { (addressableNote.event as? AddressableEvent)?.dTag() ?: "" } - val url = remember(noteState) { addressableNote.event?.firstTaggedUrl()?.ifBlank { null } } - val nostrATag = remember(noteState) { addressableNote.event?.firstTaggedAddress() } - val nostrHexID = - remember(noteState) { addressableNote.event?.firstTaggedEvent()?.ifBlank { null } } + val content = remember(noteState) { addressableNote.event?.content() ?: "" } + val type = remember(noteState) { (addressableNote.event as? AddressableEvent)?.dTag() ?: "" } + val url = remember(noteState) { addressableNote.event?.firstTaggedUrl()?.ifBlank { null } } + val nostrATag = remember(noteState) { addressableNote.event?.firstTaggedAddress() } + val nostrHexID = + remember(noteState) { addressableNote.event?.firstTaggedEvent()?.ifBlank { null } } - when (type) { - "music" -> - Icon( - painter = painterResource(id = R.drawable.tunestr), - null, - modifier = Size15Modifier.padding(end = Size5dp), - tint = MaterialTheme.colorScheme.placeholderText, - ) - else -> {} - } - - Text( - text = content, - fontSize = Font14SP, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - if (url != null) { - val uri = LocalUriHandler.current - Spacer(modifier = StdHorzSpacer) - IconButton( - modifier = Size15Modifier, - onClick = { runCatching { uri.openUri(url.trim()) } }, - ) { - Icon( - imageVector = Icons.Default.OpenInNew, - null, - modifier = Size15Modifier, - tint = MaterialTheme.colorScheme.lessImportantLink, - ) + when (type) { + "music" -> + Icon( + painter = painterResource(id = R.drawable.tunestr), + null, + modifier = Size15Modifier.padding(end = Size5dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) + else -> {} } - } else if (nostrATag != null) { - LoadAddressableNote(nostrATag, accountViewModel) { note -> - if (note != null) { + + Text( + text = content, + fontSize = Font14SP, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (url != null) { + val uri = LocalUriHandler.current Spacer(modifier = StdHorzSpacer) IconButton( - modifier = Size15Modifier, - onClick = { - routeFor( - note, - accountViewModel.userProfile(), - ) - ?.let { nav(it) } - }, - ) { - Icon( - imageVector = Icons.Default.OpenInNew, - null, modifier = Size15Modifier, - tint = MaterialTheme.colorScheme.lessImportantLink, - ) - } - } - } - } else if (nostrHexID != null) { - LoadNote(baseNoteHex = nostrHexID, accountViewModel) { - if (it != null) { - Spacer(modifier = StdHorzSpacer) - IconButton( - modifier = Size15Modifier, - onClick = { - routeFor( - it, - accountViewModel.userProfile(), - ) - ?.let { nav(it) } - }, + onClick = { runCatching { uri.openUri(url.trim()) } }, ) { - Icon( - imageVector = Icons.Default.OpenInNew, - null, - modifier = Size15Modifier, - tint = MaterialTheme.colorScheme.lessImportantLink, - ) + Icon( + imageVector = Icons.Default.OpenInNew, + null, + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.lessImportantLink, + ) + } + } else if (nostrATag != null) { + LoadAddressableNote(nostrATag, accountViewModel) { note -> + if (note != null) { + Spacer(modifier = StdHorzSpacer) + IconButton( + modifier = Size15Modifier, + onClick = { + routeFor( + note, + accountViewModel.userProfile(), + ) + ?.let { nav(it) } + }, + ) { + Icon( + imageVector = Icons.Default.OpenInNew, + null, + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.lessImportantLink, + ) + } + } + } + } else if (nostrHexID != null) { + LoadNote(baseNoteHex = nostrHexID, accountViewModel) { + if (it != null) { + Spacer(modifier = StdHorzSpacer) + IconButton( + modifier = Size15Modifier, + onClick = { + routeFor( + it, + accountViewModel.userProfile(), + ) + ?.let { nav(it) } + }, + ) { + Icon( + imageVector = Icons.Default.OpenInNew, + null, + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.lessImportantLink, + ) + } + } } - } } - } } @Composable private fun DisplayNIP05( - nip05: String, - nip05Verified: MutableState, + nip05: String, + nip05Verified: MutableState, ) { - val uri = LocalUriHandler.current - val (user, domain) = - remember(nip05) { - val parts = nip05.split("@") - if (parts.size == 1) { - listOf("_", parts[0]) - } else { - listOf(parts[0], parts[1]) - } + val uri = LocalUriHandler.current + val (user, domain) = + remember(nip05) { + val parts = nip05.split("@") + if (parts.size == 1) { + listOf("_", parts[0]) + } else { + listOf(parts[0], parts[1]) + } + } + + if (user != "_") { + Text( + text = remember(nip05) { AnnotatedString(user) }, + fontSize = Font14SP, + color = MaterialTheme.colorScheme.nip05, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } - if (user != "_") { - Text( - text = remember(nip05) { AnnotatedString(user) }, - fontSize = Font14SP, - color = MaterialTheme.colorScheme.nip05, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + NIP05VerifiedSymbol(nip05Verified, NIP05IconSize) + + ClickableText( + text = remember(nip05) { AnnotatedString(domain) }, + onClick = { runCatching { uri.openUri("https://$domain") } }, + style = + LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP), + maxLines = 1, + overflow = TextOverflow.Visible, ) - } - - NIP05VerifiedSymbol(nip05Verified, NIP05IconSize) - - ClickableText( - text = remember(nip05) { AnnotatedString(domain) }, - onClick = { runCatching { uri.openUri("https://$domain") } }, - style = - LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP), - maxLines = 1, - overflow = TextOverflow.Visible, - ) } @Composable private fun NIP05VerifiedSymbol( - nip05Verified: MutableState, - modifier: Modifier, + nip05Verified: MutableState, + modifier: Modifier, ) { - Crossfade(targetState = nip05Verified.value) { - when (it) { - null -> NIP05CheckingIcon(modifier = modifier) - true -> NIP05VerifiedIcon(modifier = modifier) - false -> NIP05FailedVerification(modifier = modifier) + Crossfade(targetState = nip05Verified.value) { + when (it) { + null -> NIP05CheckingIcon(modifier = modifier) + true -> NIP05VerifiedIcon(modifier = modifier) + false -> NIP05FailedVerification(modifier = modifier) + } } - } } @Composable fun DisplayNip05ProfileStatus( - user: User, - accountViewModel: AccountViewModel, + user: User, + accountViewModel: AccountViewModel, ) { - val uri = LocalUriHandler.current + val uri = LocalUriHandler.current - user.nip05()?.let { nip05 -> - if (nip05.split("@").size <= 2) { - val nip05Verified = nip05VerificationAsAState(user.info!!, user.pubkeyHex, accountViewModel) - Row(verticalAlignment = Alignment.CenterVertically) { - NIP05VerifiedSymbol(nip05Verified, Size16Modifier) - var domainPadStart = 5.dp + user.nip05()?.let { nip05 -> + if (nip05.split("@").size <= 2) { + val nip05Verified = nip05VerificationAsAState(user.info!!, user.pubkeyHex, accountViewModel) + Row(verticalAlignment = Alignment.CenterVertically) { + NIP05VerifiedSymbol(nip05Verified, Size16Modifier) + var domainPadStart = 5.dp - val (user, domain) = - remember(nip05) { - val parts = nip05.split("@") - if (parts.size == 1) { - listOf("_", parts[0]) - } else { - listOf(parts[0], parts[1]) + val (user, domain) = + remember(nip05) { + val parts = nip05.split("@") + if (parts.size == 1) { + listOf("_", parts[0]) + } else { + listOf(parts[0], parts[1]) + } + } + + if (user != "_") { + Text( + text = remember { AnnotatedString(user + "@") }, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + domainPadStart = 0.dp + } + + ClickableText( + text = AnnotatedString(domain), + onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = domainPadStart), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } - } - - if (user != "_") { - Text( - text = remember { AnnotatedString(user + "@") }, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - domainPadStart = 0.dp } - - ClickableText( - text = AnnotatedString(domain), - onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = domainPadStart), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index eb3d3acf8..676ae6645 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -228,333 +228,93 @@ import com.vitorpamplona.quartz.events.VideoEvent import com.vitorpamplona.quartz.events.VideoHorizontalEvent import com.vitorpamplona.quartz.events.VideoVerticalEvent import com.vitorpamplona.quartz.events.toImmutableListOfLists -import java.io.File -import java.net.URL -import java.util.Locale import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.io.File +import java.net.URL +import java.util.Locale @OptIn(ExperimentalFoundationApi::class) @Composable fun NoteCompose( - baseNote: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - isBoostedNote: Boolean = false, - isQuotedNote: Boolean = false, - unPackReply: Boolean = true, - makeItShort: Boolean = false, - addMarginTop: Boolean = true, - showHidden: Boolean = false, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + isBoostedNote: Boolean = false, + isQuotedNote: Boolean = false, + unPackReply: Boolean = true, + makeItShort: Boolean = false, + addMarginTop: Boolean = true, + showHidden: Boolean = false, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) + val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) - Crossfade(targetState = hasEvent, label = "Event presence") { - if (it) { - CheckHiddenNoteCompose( - note = baseNote, - routeForLastRead = routeForLastRead, - modifier = modifier, - isBoostedNote = isBoostedNote, - isQuotedNote = isQuotedNote, - unPackReply = unPackReply, - makeItShort = makeItShort, - addMarginTop = addMarginTop, - showHidden = showHidden, - parentBackgroundColor = parentBackgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } else { - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup, - -> - BlankNote( - remember { - modifier.combinedClickable( - onClick = {}, - onLongClick = showPopup, + Crossfade(targetState = hasEvent, label = "Event presence") { + if (it) { + CheckHiddenNoteCompose( + note = baseNote, + routeForLastRead = routeForLastRead, + modifier = modifier, + isBoostedNote = isBoostedNote, + isQuotedNote = isQuotedNote, + unPackReply = unPackReply, + makeItShort = makeItShort, + addMarginTop = addMarginTop, + showHidden = showHidden, + parentBackgroundColor = parentBackgroundColor, + accountViewModel = accountViewModel, + nav = nav, ) - }, - isBoostedNote || isQuotedNote, - ) - } + } else { + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup, + -> + BlankNote( + remember { + modifier.combinedClickable( + onClick = {}, + onLongClick = showPopup, + ) + }, + isBoostedNote || isQuotedNote, + ) + } + } } - } } @Composable fun CheckHiddenNoteCompose( - note: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - isBoostedNote: Boolean = false, - isQuotedNote: Boolean = false, - unPackReply: Boolean = true, - makeItShort: Boolean = false, - addMarginTop: Boolean = true, - showHidden: Boolean = false, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + isBoostedNote: Boolean = false, + isQuotedNote: Boolean = false, + unPackReply: Boolean = true, + makeItShort: Boolean = false, + addMarginTop: Boolean = true, + showHidden: Boolean = false, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (showHidden) { - // Ignores reports as well - val state by - remember(note) { - mutableStateOf( - AccountViewModel.NoteComposeReportState(), - ) - } + if (showHidden) { + // Ignores reports as well + val state by + remember(note) { + mutableStateOf( + AccountViewModel.NoteComposeReportState(), + ) + } - RenderReportState( - state = state, - note = note, - routeForLastRead = routeForLastRead, - modifier = modifier, - isBoostedNote = isBoostedNote, - isQuotedNote = isQuotedNote, - unPackReply = unPackReply, - makeItShort = makeItShort, - addMarginTop = addMarginTop, - parentBackgroundColor = parentBackgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } else { - val isHidden by - remember(note) { - accountViewModel.account.liveHiddenUsers - .map { note.isHiddenFor(it) } - .distinctUntilChanged() - } - .observeAsState(accountViewModel.isNoteHidden(note)) - - Crossfade(targetState = isHidden, label = "CheckHiddenNoteCompose") { - if (!it) { - LoadedNoteCompose( - note = note, - routeForLastRead = routeForLastRead, - modifier = modifier, - isBoostedNote = isBoostedNote, - isQuotedNote = isQuotedNote, - unPackReply = unPackReply, - makeItShort = makeItShort, - addMarginTop = addMarginTop, - parentBackgroundColor = parentBackgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } -} - -@Composable -fun LoadedNoteCompose( - note: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - isBoostedNote: Boolean = false, - isQuotedNote: Boolean = false, - unPackReply: Boolean = true, - makeItShort: Boolean = false, - addMarginTop: Boolean = true, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - var state by - remember(note) { - mutableStateOf( - AccountViewModel.NoteComposeReportState(), - ) - } - - WatchForReports(note, accountViewModel) { newState -> - if (state != newState) { - state = newState - } - } - - Crossfade(targetState = state, label = "LoadedNoteCompose") { - RenderReportState( - it, - note, - routeForLastRead, - modifier, - isBoostedNote, - isQuotedNote, - unPackReply, - makeItShort, - addMarginTop, - parentBackgroundColor, - accountViewModel, - nav, - ) - } -} - -@Composable -fun RenderReportState( - state: AccountViewModel.NoteComposeReportState, - note: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - isBoostedNote: Boolean = false, - isQuotedNote: Boolean = false, - unPackReply: Boolean = true, - makeItShort: Boolean = false, - addMarginTop: Boolean = true, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - var showReportedNote by remember(note) { mutableStateOf(false) } - - Crossfade(targetState = !state.isAcceptable && !showReportedNote, label = "RenderReportState") { - showHiddenNote -> - if (showHiddenNote) { - HiddenNote( - state.relevantReports, - state.isHiddenAuthor, - accountViewModel, - modifier, - isBoostedNote, - nav, - onClick = { showReportedNote = true }, - ) - } else { - val canPreview = (!state.isAcceptable && showReportedNote) || state.canPreview - - NormalNote( - baseNote = note, - routeForLastRead = routeForLastRead, - modifier = modifier, - isBoostedNote = isBoostedNote, - isQuotedNote = isQuotedNote, - unPackReply = unPackReply, - makeItShort = makeItShort, - addMarginTop = addMarginTop, - canPreview = canPreview, - parentBackgroundColor = parentBackgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } -} - -@Composable -fun WatchForReports( - note: Note, - accountViewModel: AccountViewModel, - onChange: (AccountViewModel.NoteComposeReportState) -> Unit, -) { - val userFollowsState by accountViewModel.userFollows.observeAsState() - val noteReportsState by note.live().reports.observeAsState() - val userBlocks by accountViewModel.account.flowHiddenUsers.collectAsStateWithLifecycle() - - LaunchedEffect(key1 = noteReportsState, key2 = userFollowsState, userBlocks) { - accountViewModel.isNoteAcceptable(note, onChange) - } -} - -@Composable -fun NormalNote( - baseNote: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - isBoostedNote: Boolean = false, - isQuotedNote: Boolean = false, - unPackReply: Boolean = true, - makeItShort: Boolean = false, - addMarginTop: Boolean = true, - canPreview: Boolean = true, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - if (isQuotedNote || isBoostedNote) { - when (baseNote.event) { - is ChannelCreateEvent, - is ChannelMetadataEvent, -> - ChannelHeader( - channelNote = baseNote, - showVideo = !makeItShort, - showBottomDiviser = true, - sendToChannel = true, - accountViewModel = accountViewModel, - nav = nav, - ) - is CommunityDefinitionEvent -> - (baseNote as? AddressableNote)?.let { - CommunityHeader( - baseNote = it, - showBottomDiviser = true, - sendToCommunity = true, - accountViewModel = accountViewModel, - nav = nav, - ) - } - is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote) - else -> - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { - showPopup, - -> - CheckNewAndRenderNote( - baseNote, - routeForLastRead, - modifier, - isBoostedNote, - isQuotedNote, - unPackReply, - makeItShort, - addMarginTop, - canPreview, - parentBackgroundColor, - accountViewModel, - showPopup, - nav, - ) - } - } - } else { - when (baseNote.event) { - is ChannelCreateEvent, - is ChannelMetadataEvent, -> - ChannelHeader( - channelNote = baseNote, - showVideo = !makeItShort, - showBottomDiviser = true, - sendToChannel = true, - accountViewModel = accountViewModel, - nav = nav, - ) - is CommunityDefinitionEvent -> - (baseNote as? AddressableNote)?.let { - CommunityHeader( - baseNote = it, - showBottomDiviser = true, - sendToCommunity = true, - accountViewModel = accountViewModel, - nav = nav, - ) - } - is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote) - is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, accountViewModel) - is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, accountViewModel) - else -> - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { - showPopup, - -> - CheckNewAndRenderNote( - baseNote = baseNote, + RenderReportState( + state = state, + note = note, routeForLastRead = routeForLastRead, modifier = modifier, isBoostedNote = isBoostedNote, @@ -562,3130 +322,3378 @@ fun NormalNote( unPackReply = unPackReply, makeItShort = makeItShort, addMarginTop = addMarginTop, - canPreview = canPreview, parentBackgroundColor = parentBackgroundColor, accountViewModel = accountViewModel, - showPopup = showPopup, nav = nav, - ) + ) + } else { + val isHidden by + remember(note) { + accountViewModel.account.liveHiddenUsers + .map { note.isHiddenFor(it) } + .distinctUntilChanged() + } + .observeAsState(accountViewModel.isNoteHidden(note)) + + Crossfade(targetState = isHidden, label = "CheckHiddenNoteCompose") { + if (!it) { + LoadedNoteCompose( + note = note, + routeForLastRead = routeForLastRead, + modifier = modifier, + isBoostedNote = isBoostedNote, + isQuotedNote = isQuotedNote, + unPackReply = unPackReply, + makeItShort = makeItShort, + addMarginTop = addMarginTop, + parentBackgroundColor = parentBackgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} + +@Composable +fun LoadedNoteCompose( + note: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + isBoostedNote: Boolean = false, + isQuotedNote: Boolean = false, + unPackReply: Boolean = true, + makeItShort: Boolean = false, + addMarginTop: Boolean = true, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + var state by + remember(note) { + mutableStateOf( + AccountViewModel.NoteComposeReportState(), + ) + } + + WatchForReports(note, accountViewModel) { newState -> + if (state != newState) { + state = newState + } + } + + Crossfade(targetState = state, label = "LoadedNoteCompose") { + RenderReportState( + it, + note, + routeForLastRead, + modifier, + isBoostedNote, + isQuotedNote, + unPackReply, + makeItShort, + addMarginTop, + parentBackgroundColor, + accountViewModel, + nav, + ) + } +} + +@Composable +fun RenderReportState( + state: AccountViewModel.NoteComposeReportState, + note: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + isBoostedNote: Boolean = false, + isQuotedNote: Boolean = false, + unPackReply: Boolean = true, + makeItShort: Boolean = false, + addMarginTop: Boolean = true, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + var showReportedNote by remember(note) { mutableStateOf(false) } + + Crossfade(targetState = !state.isAcceptable && !showReportedNote, label = "RenderReportState") { + showHiddenNote -> + if (showHiddenNote) { + HiddenNote( + state.relevantReports, + state.isHiddenAuthor, + accountViewModel, + modifier, + isBoostedNote, + nav, + onClick = { showReportedNote = true }, + ) + } else { + val canPreview = (!state.isAcceptable && showReportedNote) || state.canPreview + + NormalNote( + baseNote = note, + routeForLastRead = routeForLastRead, + modifier = modifier, + isBoostedNote = isBoostedNote, + isQuotedNote = isQuotedNote, + unPackReply = unPackReply, + makeItShort = makeItShort, + addMarginTop = addMarginTop, + canPreview = canPreview, + parentBackgroundColor = parentBackgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +fun WatchForReports( + note: Note, + accountViewModel: AccountViewModel, + onChange: (AccountViewModel.NoteComposeReportState) -> Unit, +) { + val userFollowsState by accountViewModel.userFollows.observeAsState() + val noteReportsState by note.live().reports.observeAsState() + val userBlocks by accountViewModel.account.flowHiddenUsers.collectAsStateWithLifecycle() + + LaunchedEffect(key1 = noteReportsState, key2 = userFollowsState, userBlocks) { + accountViewModel.isNoteAcceptable(note, onChange) + } +} + +@Composable +fun NormalNote( + baseNote: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + isBoostedNote: Boolean = false, + isQuotedNote: Boolean = false, + unPackReply: Boolean = true, + makeItShort: Boolean = false, + addMarginTop: Boolean = true, + canPreview: Boolean = true, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + if (isQuotedNote || isBoostedNote) { + when (baseNote.event) { + is ChannelCreateEvent, + is ChannelMetadataEvent, + -> + ChannelHeader( + channelNote = baseNote, + showVideo = !makeItShort, + showBottomDiviser = true, + sendToChannel = true, + accountViewModel = accountViewModel, + nav = nav, + ) + is CommunityDefinitionEvent -> + (baseNote as? AddressableNote)?.let { + CommunityHeader( + baseNote = it, + showBottomDiviser = true, + sendToCommunity = true, + accountViewModel = accountViewModel, + nav = nav, + ) + } + is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote) + else -> + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { + showPopup, + -> + CheckNewAndRenderNote( + baseNote, + routeForLastRead, + modifier, + isBoostedNote, + isQuotedNote, + unPackReply, + makeItShort, + addMarginTop, + canPreview, + parentBackgroundColor, + accountViewModel, + showPopup, + nav, + ) + } + } + } else { + when (baseNote.event) { + is ChannelCreateEvent, + is ChannelMetadataEvent, + -> + ChannelHeader( + channelNote = baseNote, + showVideo = !makeItShort, + showBottomDiviser = true, + sendToChannel = true, + accountViewModel = accountViewModel, + nav = nav, + ) + is CommunityDefinitionEvent -> + (baseNote as? AddressableNote)?.let { + CommunityHeader( + baseNote = it, + showBottomDiviser = true, + sendToCommunity = true, + accountViewModel = accountViewModel, + nav = nav, + ) + } + is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote) + is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, accountViewModel) + is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, accountViewModel) + else -> + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { + showPopup, + -> + CheckNewAndRenderNote( + baseNote = baseNote, + routeForLastRead = routeForLastRead, + modifier = modifier, + isBoostedNote = isBoostedNote, + isQuotedNote = isQuotedNote, + unPackReply = unPackReply, + makeItShort = makeItShort, + addMarginTop = addMarginTop, + canPreview = canPreview, + parentBackgroundColor = parentBackgroundColor, + accountViewModel = accountViewModel, + showPopup = showPopup, + nav = nav, + ) + } } } - } } @Composable fun CommunityHeader( - baseNote: AddressableNote, - showBottomDiviser: Boolean, - sendToCommunity: Boolean, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: AddressableNote, + showBottomDiviser: Boolean, + sendToCommunity: Boolean, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val expanded = remember { mutableStateOf(false) } + val expanded = remember { mutableStateOf(false) } - Column(Modifier.fillMaxWidth()) { - Column( - verticalArrangement = Arrangement.Center, - modifier = - Modifier.clickable { - if (sendToCommunity) { - routeFor(baseNote, accountViewModel.userProfile())?.let { nav(it) } - } else { - expanded.value = !expanded.value - } - }, - ) { - ShortCommunityHeader( - baseNote = baseNote, - accountViewModel = accountViewModel, - nav = nav, - ) + Column(Modifier.fillMaxWidth()) { + Column( + verticalArrangement = Arrangement.Center, + modifier = + Modifier.clickable { + if (sendToCommunity) { + routeFor(baseNote, accountViewModel.userProfile())?.let { nav(it) } + } else { + expanded.value = !expanded.value + } + }, + ) { + ShortCommunityHeader( + baseNote = baseNote, + accountViewModel = accountViewModel, + nav = nav, + ) - if (expanded.value) { - Column(Modifier.verticalScroll(rememberScrollState())) { - LongCommunityHeader( - baseNote = baseNote, - lineModifier = modifier, - accountViewModel = accountViewModel, - nav = nav, - ) + if (expanded.value) { + Column(Modifier.verticalScroll(rememberScrollState())) { + LongCommunityHeader( + baseNote = baseNote, + lineModifier = modifier, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } } - } - } - if (showBottomDiviser) { - Divider( - thickness = DividerThickness, - ) + if (showBottomDiviser) { + Divider( + thickness = DividerThickness, + ) + } } - } } @Composable fun LongCommunityHeader( - baseNote: AddressableNote, - lineModifier: Modifier = Modifier.padding(horizontal = Size10dp, vertical = Size5dp), - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: AddressableNote, + lineModifier: Modifier = Modifier.padding(horizontal = Size10dp, vertical = Size5dp), + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteState by baseNote.live().metadata.observeAsState() - val noteEvent = - remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return + val noteState by baseNote.live().metadata.observeAsState() + val noteEvent = + remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return - Row( - lineModifier, - ) { - val rulesLabel = stringResource(id = R.string.rules) - val summary = - remember(noteState) { - val subject = noteEvent.subject()?.ifEmpty { null } - val body = noteEvent.description()?.ifBlank { null } - val rules = noteEvent.rules()?.ifBlank { null } - - if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) { - if (rules == null) { - "### $subject\n$body" - } else { - "### $subject\n$body\n\n### $rulesLabel\n\n$rules" - } - } else { - if (rules == null) { - body - } else { - "$body\n\n$rulesLabel\n$rules" - } - } - } - - Column( - Modifier.weight(1f), - ) { - Row(verticalAlignment = CenterVertically) { - val defaultBackground = MaterialTheme.colorScheme.background - val background = remember { mutableStateOf(defaultBackground) } - - TranslatableRichTextViewer( - content = summary ?: stringResource(id = R.string.community_no_descriptor), - canPreview = false, - tags = EmptyTagList, - backgroundColor = background, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (summary != null && noteEvent.hasHashtags()) { - DisplayUncitedHashtags( - remember(noteEvent) { noteEvent.hashtags().toImmutableList() }, - summary ?: "", - nav, - ) - } - } - - Column { - Row { - Spacer(DoubleHorzSpacer) - LongCommunityActionOptions(baseNote, accountViewModel, nav) - } - } - } - - Row( - lineModifier, - verticalAlignment = CenterVertically, - ) { - Text( - text = stringResource(id = R.string.owner), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(75.dp), - ) - Spacer(DoubleHorzSpacer) - NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp) - Spacer(DoubleHorzSpacer) - NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }) - } - - var participantUsers by - remember(baseNote) { - mutableStateOf>>( - persistentListOf(), - ) - } - - LaunchedEffect(key1 = noteState) { - val participants = (noteState?.note?.event as? CommunityDefinitionEvent)?.moderators() - - if (participants != null) { - accountViewModel.loadParticipants(participants) { newParticipantUsers -> - if ( - newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers) - ) { - participantUsers = newParticipantUsers - } - } - } - } - - participantUsers.forEach { Row( - lineModifier.clickable { nav("User/${it.second.pubkeyHex}") }, - verticalAlignment = CenterVertically, + lineModifier, ) { - it.first.role?.let { it1 -> - Text( - text = it1.capitalize(Locale.ROOT), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(75.dp), - ) - } - Spacer(DoubleHorzSpacer) - ClickableUserPicture(it.second, Size25dp, accountViewModel) - Spacer(DoubleHorzSpacer) - UsernameDisplay(it.second, remember { Modifier.weight(1f) }) - } - } + val rulesLabel = stringResource(id = R.string.rules) + val summary = + remember(noteState) { + val subject = noteEvent.subject()?.ifEmpty { null } + val body = noteEvent.description()?.ifBlank { null } + val rules = noteEvent.rules()?.ifBlank { null } - Row( - lineModifier, - verticalAlignment = CenterVertically, - ) { - Text( - text = stringResource(id = R.string.created_at), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(75.dp), - ) - Spacer(DoubleHorzSpacer) - NormalTimeAgo(baseNote = baseNote, Modifier.weight(1f)) - MoreOptionsButton(baseNote, accountViewModel) - } + if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) { + if (rules == null) { + "### $subject\n$body" + } else { + "### $subject\n$body\n\n### $rulesLabel\n\n$rules" + } + } else { + if (rules == null) { + body + } else { + "$body\n\n$rulesLabel\n$rules" + } + } + } + + Column( + Modifier.weight(1f), + ) { + Row(verticalAlignment = CenterVertically) { + val defaultBackground = MaterialTheme.colorScheme.background + val background = remember { mutableStateOf(defaultBackground) } + + TranslatableRichTextViewer( + content = summary ?: stringResource(id = R.string.community_no_descriptor), + canPreview = false, + tags = EmptyTagList, + backgroundColor = background, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (summary != null && noteEvent.hasHashtags()) { + DisplayUncitedHashtags( + remember(noteEvent) { noteEvent.hashtags().toImmutableList() }, + summary ?: "", + nav, + ) + } + } + + Column { + Row { + Spacer(DoubleHorzSpacer) + LongCommunityActionOptions(baseNote, accountViewModel, nav) + } + } + } + + Row( + lineModifier, + verticalAlignment = CenterVertically, + ) { + Text( + text = stringResource(id = R.string.owner), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(75.dp), + ) + Spacer(DoubleHorzSpacer) + NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp) + Spacer(DoubleHorzSpacer) + NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }) + } + + var participantUsers by + remember(baseNote) { + mutableStateOf>>( + persistentListOf(), + ) + } + + LaunchedEffect(key1 = noteState) { + val participants = (noteState?.note?.event as? CommunityDefinitionEvent)?.moderators() + + if (participants != null) { + accountViewModel.loadParticipants(participants) { newParticipantUsers -> + if ( + newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers) + ) { + participantUsers = newParticipantUsers + } + } + } + } + + participantUsers.forEach { + Row( + lineModifier.clickable { nav("User/${it.second.pubkeyHex}") }, + verticalAlignment = CenterVertically, + ) { + it.first.role?.let { it1 -> + Text( + text = it1.capitalize(Locale.ROOT), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(75.dp), + ) + } + Spacer(DoubleHorzSpacer) + ClickableUserPicture(it.second, Size25dp, accountViewModel) + Spacer(DoubleHorzSpacer) + UsernameDisplay(it.second, remember { Modifier.weight(1f) }) + } + } + + Row( + lineModifier, + verticalAlignment = CenterVertically, + ) { + Text( + text = stringResource(id = R.string.created_at), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(75.dp), + ) + Spacer(DoubleHorzSpacer) + NormalTimeAgo(baseNote = baseNote, Modifier.weight(1f)) + MoreOptionsButton(baseNote, accountViewModel) + } } @Composable fun ShortCommunityHeader( - baseNote: AddressableNote, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: AddressableNote, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteState by baseNote.live().metadata.observeAsState() - val noteEvent = - remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return + val noteState by baseNote.live().metadata.observeAsState() + val noteEvent = + remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } + val automaticallyShowProfilePicture = + remember { + accountViewModel.settings.showProfilePictures.value + } - Row(verticalAlignment = CenterVertically) { - noteEvent.image()?.let { - RobohashFallbackAsyncImage( - robot = baseNote.idHex, - model = it, - contentDescription = stringResource(R.string.profile_image), - contentScale = ContentScale.Crop, - modifier = HeaderPictureModifier, - loadProfilePicture = automaticallyShowProfilePicture, - ) + Row(verticalAlignment = CenterVertically) { + noteEvent.image()?.let { + RobohashFallbackAsyncImage( + robot = baseNote.idHex, + model = it, + contentDescription = stringResource(R.string.profile_image), + contentScale = ContentScale.Crop, + modifier = HeaderPictureModifier, + loadProfilePicture = automaticallyShowProfilePicture, + ) + } + + Column( + modifier = Modifier.padding(start = 10.dp).height(Size35dp).weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Row(verticalAlignment = CenterVertically) { + Text( + text = remember(noteState) { noteEvent.dTag() }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Row( + modifier = Modifier.height(Size35dp).padding(start = 5.dp), + verticalAlignment = CenterVertically, + ) { + ShortCommunityActionOptions(baseNote, accountViewModel, nav) + } } - - Column( - modifier = Modifier.padding(start = 10.dp).height(Size35dp).weight(1f), - verticalArrangement = Arrangement.Center, - ) { - Row(verticalAlignment = CenterVertically) { - Text( - text = remember(noteState) { noteEvent.dTag() }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - - Row( - modifier = Modifier.height(Size35dp).padding(start = 5.dp), - verticalAlignment = CenterVertically, - ) { - ShortCommunityActionOptions(baseNote, accountViewModel, nav) - } - } } @Composable private fun ShortCommunityActionOptions( - note: AddressableNote, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: AddressableNote, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Spacer(modifier = StdHorzSpacer) - LikeReaction( - baseNote = note, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav = nav, - ) - Spacer(modifier = StdHorzSpacer) - ZapReaction( - baseNote = note, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav = nav, - ) + Spacer(modifier = StdHorzSpacer) + LikeReaction( + baseNote = note, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = note, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) - WatchAddressableNoteFollows(note, accountViewModel) { isFollowing -> - if (!isFollowing) { - Spacer(modifier = StdHorzSpacer) - JoinCommunityButton(accountViewModel, note, nav) + WatchAddressableNoteFollows(note, accountViewModel) { isFollowing -> + if (!isFollowing) { + Spacer(modifier = StdHorzSpacer) + JoinCommunityButton(accountViewModel, note, nav) + } } - } } @Composable fun WatchAddressableNoteFollows( - note: AddressableNote, - accountViewModel: AccountViewModel, - onFollowChanges: @Composable (Boolean) -> Unit, + note: AddressableNote, + accountViewModel: AccountViewModel, + onFollowChanges: @Composable (Boolean) -> Unit, ) { - val showFollowingMark by - remember { - accountViewModel.userFollows - .map { it.user.latestContactList?.isTaggedAddressableNote(note.idHex) ?: false } - .distinctUntilChanged() - } - .observeAsState(false) + val showFollowingMark by + remember { + accountViewModel.userFollows + .map { it.user.latestContactList?.isTaggedAddressableNote(note.idHex) ?: false } + .distinctUntilChanged() + } + .observeAsState(false) - onFollowChanges(showFollowingMark) + onFollowChanges(showFollowingMark) } @Composable private fun LongCommunityActionOptions( - note: AddressableNote, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: AddressableNote, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - WatchAddressableNoteFollows(note, accountViewModel) { isFollowing -> - if (isFollowing) { - LeaveCommunityButton(accountViewModel, note, nav) + WatchAddressableNoteFollows(note, accountViewModel) { isFollowing -> + if (isFollowing) { + LeaveCommunityButton(accountViewModel, note, nav) + } } - } } @Composable private fun CheckNewAndRenderNote( - baseNote: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - isBoostedNote: Boolean = false, - isQuotedNote: Boolean = false, - unPackReply: Boolean = true, - makeItShort: Boolean = false, - addMarginTop: Boolean = true, - canPreview: Boolean = true, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - showPopup: () -> Unit, - nav: (String) -> Unit, + baseNote: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + isBoostedNote: Boolean = false, + isQuotedNote: Boolean = false, + unPackReply: Boolean = true, + makeItShort: Boolean = false, + addMarginTop: Boolean = true, + canPreview: Boolean = true, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + showPopup: () -> Unit, + nav: (String) -> Unit, ) { - val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor - val defaultBackgroundColor = MaterialTheme.colorScheme.background - val backgroundColor = - remember(baseNote) { - mutableStateOf(parentBackgroundColor?.value ?: defaultBackgroundColor) - } + val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor + val defaultBackgroundColor = MaterialTheme.colorScheme.background + val backgroundColor = + remember(baseNote) { + mutableStateOf(parentBackgroundColor?.value ?: defaultBackgroundColor) + } - LaunchedEffect(key1 = routeForLastRead, key2 = parentBackgroundColor?.value) { - routeForLastRead?.let { - accountViewModel.loadAndMarkAsRead(it, baseNote.createdAt()) { isNew -> - val newBackgroundColor = - if (isNew) { - if (parentBackgroundColor != null) { - newItemColor.compositeOver(parentBackgroundColor.value) - } else { - newItemColor.compositeOver(defaultBackgroundColor) + LaunchedEffect(key1 = routeForLastRead, key2 = parentBackgroundColor?.value) { + routeForLastRead?.let { + accountViewModel.loadAndMarkAsRead(it, baseNote.createdAt()) { isNew -> + val newBackgroundColor = + if (isNew) { + if (parentBackgroundColor != null) { + newItemColor.compositeOver(parentBackgroundColor.value) + } else { + newItemColor.compositeOver(defaultBackgroundColor) + } + } else { + parentBackgroundColor?.value ?: defaultBackgroundColor + } + + if (newBackgroundColor != backgroundColor.value) { + launch(Dispatchers.Main) { backgroundColor.value = newBackgroundColor } + } } - } else { - parentBackgroundColor?.value ?: defaultBackgroundColor - } - - if (newBackgroundColor != backgroundColor.value) { - launch(Dispatchers.Main) { backgroundColor.value = newBackgroundColor } } - } + ?: run { + val newBackgroundColor = parentBackgroundColor?.value ?: defaultBackgroundColor + + if (newBackgroundColor != backgroundColor.value) { + launch(Dispatchers.Main) { backgroundColor.value = newBackgroundColor } + } + } } - ?: run { - val newBackgroundColor = parentBackgroundColor?.value ?: defaultBackgroundColor - if (newBackgroundColor != backgroundColor.value) { - launch(Dispatchers.Main) { backgroundColor.value = newBackgroundColor } - } - } - } - - ClickableNote( - baseNote = baseNote, - backgroundColor = backgroundColor, - modifier = modifier, - accountViewModel = accountViewModel, - showPopup = showPopup, - nav = nav, - ) { - InnerNoteWithReactions( - baseNote = baseNote, - backgroundColor = backgroundColor, - isBoostedNote = isBoostedNote, - isQuotedNote = isQuotedNote, - addMarginTop = addMarginTop, - unPackReply = unPackReply, - makeItShort = makeItShort, - canPreview = canPreview, - accountViewModel = accountViewModel, - nav = nav, - ) - } + ClickableNote( + baseNote = baseNote, + backgroundColor = backgroundColor, + modifier = modifier, + accountViewModel = accountViewModel, + showPopup = showPopup, + nav = nav, + ) { + InnerNoteWithReactions( + baseNote = baseNote, + backgroundColor = backgroundColor, + isBoostedNote = isBoostedNote, + isQuotedNote = isQuotedNote, + addMarginTop = addMarginTop, + unPackReply = unPackReply, + makeItShort = makeItShort, + canPreview = canPreview, + accountViewModel = accountViewModel, + nav = nav, + ) + } } @Composable @OptIn(ExperimentalFoundationApi::class) fun ClickableNote( - baseNote: Note, - modifier: Modifier, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - showPopup: () -> Unit, - nav: (String) -> Unit, - content: @Composable () -> Unit, + baseNote: Note, + modifier: Modifier, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + showPopup: () -> Unit, + nav: (String) -> Unit, + content: @Composable () -> Unit, ) { - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - val updatedModifier = - remember(baseNote, backgroundColor.value) { - modifier - .combinedClickable( - onClick = { - scope.launch { - val redirectToNote = - if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { - baseNote.replyTo?.lastOrNull() ?: baseNote - } else { - baseNote - } - routeFor(redirectToNote, accountViewModel.userProfile())?.let { nav(it) } - } - }, - onLongClick = showPopup, - ) - .background(backgroundColor.value) - } + val updatedModifier = + remember(baseNote, backgroundColor.value) { + modifier + .combinedClickable( + onClick = { + scope.launch { + val redirectToNote = + if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { + baseNote.replyTo?.lastOrNull() ?: baseNote + } else { + baseNote + } + routeFor(redirectToNote, accountViewModel.userProfile())?.let { nav(it) } + } + }, + onLongClick = showPopup, + ) + .background(backgroundColor.value) + } - Column(modifier = updatedModifier) { content() } + Column(modifier = updatedModifier) { content() } } @Composable fun InnerNoteWithReactions( - baseNote: Note, - backgroundColor: MutableState, - isBoostedNote: Boolean, - isQuotedNote: Boolean, - addMarginTop: Boolean, - unPackReply: Boolean, - makeItShort: Boolean, - canPreview: Boolean, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + backgroundColor: MutableState, + isBoostedNote: Boolean, + isQuotedNote: Boolean, + addMarginTop: Boolean, + unPackReply: Boolean, + makeItShort: Boolean, + canPreview: Boolean, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val notBoostedNorQuote = !isBoostedNote && !isQuotedNote + val notBoostedNorQuote = !isBoostedNote && !isQuotedNote + + Row( + modifier = + if (!isBoostedNote && addMarginTop) { + normalWithTopMarginNoteModifier + } else if (!isBoostedNote) { + normalNoteModifier + } else { + boostedNoteModifier + }, + ) { + if (notBoostedNorQuote) { + Column(WidthAuthorPictureModifier) { + AuthorAndRelayInformation(baseNote, accountViewModel, nav) + } + Spacer(modifier = DoubleHorzSpacer) + } + + Column(Modifier.fillMaxWidth()) { + val showSecondRow = + baseNote.event !is RepostEvent && + baseNote.event !is GenericRepostEvent && + !isBoostedNote && + !isQuotedNote + NoteBody( + baseNote = baseNote, + showAuthorPicture = isQuotedNote, + unPackReply = unPackReply, + makeItShort = makeItShort, + canPreview = canPreview, + showSecondRow = showSecondRow, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + + val isNotRepost = baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent + + if (isNotRepost) { + if (makeItShort) { + if (isBoostedNote) { + } else { + Spacer(modifier = DoubleVertSpacer) + } + } else { + ReactionsRow( + baseNote = baseNote, + showReactionDetail = notBoostedNorQuote, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } - Row( - modifier = - if (!isBoostedNote && addMarginTop) { - normalWithTopMarginNoteModifier - } else if (!isBoostedNote) { - normalNoteModifier - } else { - boostedNoteModifier - }, - ) { if (notBoostedNorQuote) { - Column(WidthAuthorPictureModifier) { - AuthorAndRelayInformation(baseNote, accountViewModel, nav) - } - Spacer(modifier = DoubleHorzSpacer) + Divider( + thickness = DividerThickness, + ) } - - Column(Modifier.fillMaxWidth()) { - val showSecondRow = - baseNote.event !is RepostEvent && - baseNote.event !is GenericRepostEvent && - !isBoostedNote && - !isQuotedNote - NoteBody( - baseNote = baseNote, - showAuthorPicture = isQuotedNote, - unPackReply = unPackReply, - makeItShort = makeItShort, - canPreview = canPreview, - showSecondRow = showSecondRow, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - - val isNotRepost = baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent - - if (isNotRepost) { - if (makeItShort) { - if (isBoostedNote) {} else { - Spacer(modifier = DoubleVertSpacer) - } - } else { - ReactionsRow( - baseNote = baseNote, - showReactionDetail = notBoostedNorQuote, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - - if (notBoostedNorQuote) { - Divider( - thickness = DividerThickness, - ) - } } @Composable private fun NoteBody( - baseNote: Note, - showAuthorPicture: Boolean = false, - unPackReply: Boolean = true, - makeItShort: Boolean = false, - canPreview: Boolean = true, - showSecondRow: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + showAuthorPicture: Boolean = false, + unPackReply: Boolean = true, + makeItShort: Boolean = false, + canPreview: Boolean = true, + showSecondRow: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - FirstUserInfoRow( - baseNote = baseNote, - showAuthorPicture = showAuthorPicture, - accountViewModel = accountViewModel, - nav = nav, - ) - - if (showSecondRow) { - SecondUserInfoRow( - baseNote, - accountViewModel, - nav, + FirstUserInfoRow( + baseNote = baseNote, + showAuthorPicture = showAuthorPicture, + accountViewModel = accountViewModel, + nav = nav, ) - } - if (baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent) { - Spacer(modifier = Modifier.height(3.dp)) - } + if (showSecondRow) { + SecondUserInfoRow( + baseNote, + accountViewModel, + nav, + ) + } - if (!makeItShort) { - ReplyRow( - baseNote, - unPackReply, - backgroundColor, - accountViewModel, - nav, + if (baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent) { + Spacer(modifier = Modifier.height(3.dp)) + } + + if (!makeItShort) { + ReplyRow( + baseNote, + unPackReply, + backgroundColor, + accountViewModel, + nav, + ) + } + + RenderNoteRow( + baseNote, + backgroundColor, + makeItShort, + canPreview, + accountViewModel, + nav, ) - } - RenderNoteRow( - baseNote, - backgroundColor, - makeItShort, - canPreview, - accountViewModel, - nav, - ) - - val noteEvent = baseNote.event - val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false } - if (zapSplits && noteEvent != null) { - Spacer(modifier = HalfDoubleVertSpacer) - DisplayZapSplits(noteEvent, accountViewModel, nav) - } + val noteEvent = baseNote.event + val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false } + if (zapSplits && noteEvent != null) { + Spacer(modifier = HalfDoubleVertSpacer) + DisplayZapSplits(noteEvent, accountViewModel, nav) + } } @Composable private fun RenderNoteRow( - baseNote: Note, - backgroundColor: MutableState, - makeItShort: Boolean, - canPreview: Boolean, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + backgroundColor: MutableState, + makeItShort: Boolean, + canPreview: Boolean, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = baseNote.event - when (noteEvent) { - is AppDefinitionEvent -> { - RenderAppDefinition(baseNote, accountViewModel, nav) + val noteEvent = baseNote.event + when (noteEvent) { + is AppDefinitionEvent -> { + RenderAppDefinition(baseNote, accountViewModel, nav) + } + is AudioTrackEvent -> { + RenderAudioTrack(baseNote, accountViewModel, nav) + } + is AudioHeaderEvent -> { + RenderAudioHeader(baseNote, accountViewModel, nav) + } + is ReactionEvent -> { + RenderReaction(baseNote, backgroundColor, accountViewModel, nav) + } + is RepostEvent -> { + RenderRepost(baseNote, backgroundColor, accountViewModel, nav) + } + is GenericRepostEvent -> { + RenderRepost(baseNote, backgroundColor, accountViewModel, nav) + } + is ReportEvent -> { + RenderReport(baseNote, backgroundColor, accountViewModel, nav) + } + is LongTextNoteEvent -> { + RenderLongFormContent(baseNote, accountViewModel, nav) + } + is BadgeAwardEvent -> { + RenderBadgeAward(baseNote, backgroundColor, accountViewModel, nav) + } + is PeopleListEvent -> { + DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav) + } + is RelaySetEvent -> { + DisplayRelaySet(baseNote, backgroundColor, accountViewModel, nav) + } + is PinListEvent -> { + RenderPinListEvent(baseNote, backgroundColor, accountViewModel, nav) + } + is EmojiPackEvent -> { + RenderEmojiPack(baseNote, true, backgroundColor, accountViewModel) + } + is LiveActivitiesEvent -> { + RenderLiveActivityEvent(baseNote, accountViewModel, nav) + } + is PrivateDmEvent -> { + RenderPrivateMessage( + baseNote, + makeItShort, + canPreview, + backgroundColor, + accountViewModel, + nav, + ) + } + is ClassifiedsEvent -> { + RenderClassifieds( + noteEvent, + baseNote, + accountViewModel, + nav, + ) + } + is HighlightEvent -> { + RenderHighlight( + baseNote, + makeItShort, + canPreview, + backgroundColor, + accountViewModel, + nav, + ) + } + is PollNoteEvent -> { + RenderPoll( + baseNote, + makeItShort, + canPreview, + backgroundColor, + accountViewModel, + nav, + ) + } + is FileHeaderEvent -> { + FileHeaderDisplay(baseNote, true, accountViewModel) + } + is VideoHorizontalEvent -> { + VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) + } + is VideoVerticalEvent -> { + VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) + } + is FileStorageHeaderEvent -> { + FileStorageHeaderDisplay(baseNote, true, accountViewModel) + } + is CommunityPostApprovalEvent -> { + RenderPostApproval( + baseNote, + makeItShort, + canPreview, + backgroundColor, + accountViewModel, + nav, + ) + } + else -> { + RenderTextEvent( + baseNote, + makeItShort, + canPreview, + backgroundColor, + accountViewModel, + nav, + ) + } } - is AudioTrackEvent -> { - RenderAudioTrack(baseNote, accountViewModel, nav) - } - is AudioHeaderEvent -> { - RenderAudioHeader(baseNote, accountViewModel, nav) - } - is ReactionEvent -> { - RenderReaction(baseNote, backgroundColor, accountViewModel, nav) - } - is RepostEvent -> { - RenderRepost(baseNote, backgroundColor, accountViewModel, nav) - } - is GenericRepostEvent -> { - RenderRepost(baseNote, backgroundColor, accountViewModel, nav) - } - is ReportEvent -> { - RenderReport(baseNote, backgroundColor, accountViewModel, nav) - } - is LongTextNoteEvent -> { - RenderLongFormContent(baseNote, accountViewModel, nav) - } - is BadgeAwardEvent -> { - RenderBadgeAward(baseNote, backgroundColor, accountViewModel, nav) - } - is PeopleListEvent -> { - DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav) - } - is RelaySetEvent -> { - DisplayRelaySet(baseNote, backgroundColor, accountViewModel, nav) - } - is PinListEvent -> { - RenderPinListEvent(baseNote, backgroundColor, accountViewModel, nav) - } - is EmojiPackEvent -> { - RenderEmojiPack(baseNote, true, backgroundColor, accountViewModel) - } - is LiveActivitiesEvent -> { - RenderLiveActivityEvent(baseNote, accountViewModel, nav) - } - is PrivateDmEvent -> { - RenderPrivateMessage( - baseNote, - makeItShort, - canPreview, - backgroundColor, - accountViewModel, - nav, - ) - } - is ClassifiedsEvent -> { - RenderClassifieds( - noteEvent, - baseNote, - accountViewModel, - nav, - ) - } - is HighlightEvent -> { - RenderHighlight( - baseNote, - makeItShort, - canPreview, - backgroundColor, - accountViewModel, - nav, - ) - } - is PollNoteEvent -> { - RenderPoll( - baseNote, - makeItShort, - canPreview, - backgroundColor, - accountViewModel, - nav, - ) - } - is FileHeaderEvent -> { - FileHeaderDisplay(baseNote, true, accountViewModel) - } - is VideoHorizontalEvent -> { - VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) - } - is VideoVerticalEvent -> { - VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) - } - is FileStorageHeaderEvent -> { - FileStorageHeaderDisplay(baseNote, true, accountViewModel) - } - is CommunityPostApprovalEvent -> { - RenderPostApproval( - baseNote, - makeItShort, - canPreview, - backgroundColor, - accountViewModel, - nav, - ) - } - else -> { - RenderTextEvent( - baseNote, - makeItShort, - canPreview, - backgroundColor, - accountViewModel, - nav, - ) - } - } } @Composable fun LoadDecryptedContent( - note: Note, - accountViewModel: AccountViewModel, - inner: @Composable (String) -> Unit, + note: Note, + accountViewModel: AccountViewModel, + inner: @Composable (String) -> Unit, ) { - var decryptedContent by - remember(note.event) { - mutableStateOf( - accountViewModel.cachedDecrypt(note), - ) - } + var decryptedContent by + remember(note.event) { + mutableStateOf( + accountViewModel.cachedDecrypt(note), + ) + } - decryptedContent?.let { inner(it) } - ?: run { - LaunchedEffect(key1 = decryptedContent) { - accountViewModel.decrypt(note) { decryptedContent = it } - } - } + decryptedContent?.let { inner(it) } + ?: run { + LaunchedEffect(key1 = decryptedContent) { + accountViewModel.decrypt(note) { decryptedContent = it } + } + } } @Composable fun LoadDecryptedContentOrNull( - note: Note, - accountViewModel: AccountViewModel, - inner: @Composable (String?) -> Unit, + note: Note, + accountViewModel: AccountViewModel, + inner: @Composable (String?) -> Unit, ) { - var decryptedContent by - remember(note.event) { - mutableStateOf( - accountViewModel.cachedDecrypt(note), - ) + var decryptedContent by + remember(note.event) { + mutableStateOf( + accountViewModel.cachedDecrypt(note), + ) + } + + if (decryptedContent == null) { + LaunchedEffect(key1 = decryptedContent) { + accountViewModel.decrypt(note) { decryptedContent = it } + } } - if (decryptedContent == null) { - LaunchedEffect(key1 = decryptedContent) { - accountViewModel.decrypt(note) { decryptedContent = it } - } - } - - inner(decryptedContent) + inner(decryptedContent) } @Composable fun RenderTextEvent( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LoadDecryptedContent(note, accountViewModel) { body -> - val eventContent by - remember(note.event) { - derivedStateOf { - val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } + LoadDecryptedContent(note, accountViewModel) { body -> + val eventContent by + remember(note.event) { + derivedStateOf { + val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } - if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) { - "### $subject\n$body" - } else { - body - } + if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) { + "### $subject\n$body" + } else { + body + } + } + } + + val isAuthorTheLoggedUser = remember(note.event) { accountViewModel.isLoggedUser(note.author) } + + if (makeItShort && isAuthorTheLoggedUser) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + val modifier = remember(note) { Modifier.fillMaxWidth() } + val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + modifier = modifier, + tags = tags, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (note.event?.hasHashtags() == true) { + val hashtags = + remember(note.event) { note.event?.hashtags()?.toImmutableList() ?: persistentListOf() } + DisplayUncitedHashtags(hashtags, eventContent, nav) + } } - } - - val isAuthorTheLoggedUser = remember(note.event) { accountViewModel.isLoggedUser(note.author) } - - if (makeItShort && isAuthorTheLoggedUser) { - Text( - text = eventContent, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } else { - SensitivityWarning( - note = note, - accountViewModel = accountViewModel, - ) { - val modifier = remember(note) { Modifier.fillMaxWidth() } - val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - - TranslatableRichTextViewer( - content = eventContent, - canPreview = canPreview && !makeItShort, - modifier = modifier, - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (note.event?.hasHashtags() == true) { - val hashtags = - remember(note.event) { note.event?.hashtags()?.toImmutableList() ?: persistentListOf() } - DisplayUncitedHashtags(hashtags, eventContent, nav) - } } - } } @Composable fun RenderPoll( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = note.event as? PollNoteEvent ?: return - val eventContent = remember(note) { noteEvent.content() } + val noteEvent = note.event as? PollNoteEvent ?: return + val eventContent = remember(note) { noteEvent.content() } - if (makeItShort && accountViewModel.isLoggedUser(note.author)) { - Text( - text = eventContent, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } else { - val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + if (makeItShort && accountViewModel.isLoggedUser(note.author)) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - SensitivityWarning( - note = note, - accountViewModel = accountViewModel, - ) { - TranslatableRichTextViewer( - content = eventContent, - canPreview = canPreview && !makeItShort, - modifier = remember { Modifier.fillMaxWidth() }, - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + modifier = remember { Modifier.fillMaxWidth() }, + tags = tags, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) - PollNote( - note, - canPreview = canPreview && !makeItShort, - backgroundColor, - accountViewModel, - nav, - ) + PollNote( + note, + canPreview = canPreview && !makeItShort, + backgroundColor, + accountViewModel, + nav, + ) + } + + if (noteEvent.hasHashtags()) { + val hashtags = remember { noteEvent.hashtags().toImmutableList() } + DisplayUncitedHashtags(hashtags, eventContent, nav) + } } - - if (noteEvent.hasHashtags()) { - val hashtags = remember { noteEvent.hashtags().toImmutableList() } - DisplayUncitedHashtags(hashtags, eventContent, nav) - } - } } @OptIn(ExperimentalFoundationApi::class) @Composable fun RenderAppDefinition( - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = note.event as? AppDefinitionEvent ?: return + val noteEvent = note.event as? AppDefinitionEvent ?: return - var metadata by remember { mutableStateOf(null) } + var metadata by remember { mutableStateOf(null) } - LaunchedEffect(key1 = noteEvent) { - launch(Dispatchers.Default) { metadata = noteEvent.appMetaData() } - } - - metadata?.let { - Box { - val clipboardManager = LocalClipboardManager.current - val uri = LocalUriHandler.current - - if (!it.banner.isNullOrBlank()) { - var zoomImageDialogOpen by remember { mutableStateOf(false) } - - AsyncImage( - model = it.banner, - contentDescription = stringResource(id = R.string.profile_image), - contentScale = ContentScale.FillWidth, - modifier = - Modifier.fillMaxWidth() - .height(125.dp) - .combinedClickable( - onClick = {}, - onLongClick = { clipboardManager.setText(AnnotatedString(it.banner!!)) }, - ), - ) - - if (zoomImageDialogOpen) { - ZoomableImageDialog( - imageUrl = figureOutMimeType(it.banner!!), - onDismiss = { zoomImageDialogOpen = false }, - accountViewModel = accountViewModel, - ) - } - } else { - Image( - painter = painterResource(R.drawable.profile_banner), - contentDescription = stringResource(id = R.string.profile_banner), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth().height(125.dp), - ) - } - - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp).padding(top = 75.dp), - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Bottom, - ) { - var zoomImageDialogOpen by remember { mutableStateOf(false) } - - Box(Modifier.size(100.dp)) { - it.picture?.let { - AsyncImage( - model = it, - contentDescription = null, - contentScale = ContentScale.FillWidth, - modifier = - Modifier.border( - 3.dp, - MaterialTheme.colorScheme.background, - CircleShape, - ) - .clip(shape = CircleShape) - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .combinedClickable( - onClick = { zoomImageDialogOpen = true }, - onLongClick = { clipboardManager.setText(AnnotatedString(it)) }, - ), - ) - } - } - - if (zoomImageDialogOpen) { - ZoomableImageDialog( - imageUrl = figureOutMimeType(it.banner!!), - onDismiss = { zoomImageDialogOpen = false }, - accountViewModel = accountViewModel, - ) - } - - Spacer(Modifier.weight(1f)) - - Row( - modifier = Modifier.height(Size35dp).padding(bottom = 3.dp), - ) {} - } - - val name = remember(it) { it.anyName() } - name?.let { - Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 7.dp)) { - CreateTextWithEmoji( - text = it, - tags = remember { (note.event?.tags() ?: emptyArray()).toImmutableListOfLists() }, - fontWeight = FontWeight.Bold, - fontSize = 25.sp, - ) - } - } - - val website = remember(it) { it.website } - if (!website.isNullOrEmpty()) { - Row(verticalAlignment = CenterVertically) { - LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText) - - ClickableText( - text = AnnotatedString(website.removePrefix("https://")), - onClick = { website.let { runCatching { uri.openUri(it) } } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp), - ) - } - } - - it.about?.let { - Row( - modifier = Modifier.padding(top = 5.dp, bottom = 5.dp), - ) { - val tags = - remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - val bgColor = MaterialTheme.colorScheme.background - val backgroundColor = remember { mutableStateOf(bgColor) } - TranslatableRichTextViewer( - content = it, - canPreview = false, - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } + LaunchedEffect(key1 = noteEvent) { + launch(Dispatchers.Default) { metadata = noteEvent.appMetaData() } + } + + metadata?.let { + Box { + val clipboardManager = LocalClipboardManager.current + val uri = LocalUriHandler.current + + if (!it.banner.isNullOrBlank()) { + var zoomImageDialogOpen by remember { mutableStateOf(false) } + + AsyncImage( + model = it.banner, + contentDescription = stringResource(id = R.string.profile_image), + contentScale = ContentScale.FillWidth, + modifier = + Modifier.fillMaxWidth() + .height(125.dp) + .combinedClickable( + onClick = {}, + onLongClick = { clipboardManager.setText(AnnotatedString(it.banner!!)) }, + ), + ) + + if (zoomImageDialogOpen) { + ZoomableImageDialog( + imageUrl = figureOutMimeType(it.banner!!), + onDismiss = { zoomImageDialogOpen = false }, + accountViewModel = accountViewModel, + ) + } + } else { + Image( + painter = painterResource(R.drawable.profile_banner), + contentDescription = stringResource(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth().height(125.dp), + ) + } + + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp).padding(top = 75.dp), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, + ) { + var zoomImageDialogOpen by remember { mutableStateOf(false) } + + Box(Modifier.size(100.dp)) { + it.picture?.let { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = + Modifier.border( + 3.dp, + MaterialTheme.colorScheme.background, + CircleShape, + ) + .clip(shape = CircleShape) + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .combinedClickable( + onClick = { zoomImageDialogOpen = true }, + onLongClick = { clipboardManager.setText(AnnotatedString(it)) }, + ), + ) + } + } + + if (zoomImageDialogOpen) { + ZoomableImageDialog( + imageUrl = figureOutMimeType(it.banner!!), + onDismiss = { zoomImageDialogOpen = false }, + accountViewModel = accountViewModel, + ) + } + + Spacer(Modifier.weight(1f)) + + Row( + modifier = Modifier.height(Size35dp).padding(bottom = 3.dp), + ) {} + } + + val name = remember(it) { it.anyName() } + name?.let { + Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 7.dp)) { + CreateTextWithEmoji( + text = it, + tags = remember { (note.event?.tags() ?: emptyArray()).toImmutableListOfLists() }, + fontWeight = FontWeight.Bold, + fontSize = 25.sp, + ) + } + } + + val website = remember(it) { it.website } + if (!website.isNullOrEmpty()) { + Row(verticalAlignment = CenterVertically) { + LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText) + + ClickableText( + text = AnnotatedString(website.removePrefix("https://")), + onClick = { website.let { runCatching { uri.openUri(it) } } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp), + ) + } + } + + it.about?.let { + Row( + modifier = Modifier.padding(top = 5.dp, bottom = 5.dp), + ) { + val tags = + remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + val bgColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(bgColor) } + TranslatableRichTextViewer( + content = it, + canPreview = false, + tags = tags, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + } } - } } @Composable private fun RenderHighlight( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val quote = remember { (note.event as? HighlightEvent)?.quote() ?: "" } - val author = remember { (note.event as? HighlightEvent)?.author() } - val url = remember { (note.event as? HighlightEvent)?.inUrl() } - val postHex = remember { (note.event as? HighlightEvent)?.taggedAddresses()?.firstOrNull() } + val quote = remember { (note.event as? HighlightEvent)?.quote() ?: "" } + val author = remember { (note.event as? HighlightEvent)?.author() } + val url = remember { (note.event as? HighlightEvent)?.inUrl() } + val postHex = remember { (note.event as? HighlightEvent)?.taggedAddresses()?.firstOrNull() } - DisplayHighlight( - highlight = quote, - authorHex = author, - url = url, - postAddress = postHex, - makeItShort = makeItShort, - canPreview = canPreview, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) + DisplayHighlight( + highlight = quote, + authorHex = author, + url = url, + postAddress = postHex, + makeItShort = makeItShort, + canPreview = canPreview, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) } @Composable private fun RenderPrivateMessage( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = note.event as? PrivateDmEvent ?: return + val noteEvent = note.event as? PrivateDmEvent ?: return - val withMe = remember { noteEvent.with(accountViewModel.userProfile().pubkeyHex) } - if (withMe) { - LoadDecryptedContent(note, accountViewModel) { eventContent -> - val modifier = remember(note.event?.id()) { Modifier.fillMaxWidth() } - val isAuthorTheLoggedUser = - remember(note.event?.id()) { accountViewModel.isLoggedUser(note.author) } + val withMe = remember { noteEvent.with(accountViewModel.userProfile().pubkeyHex) } + if (withMe) { + LoadDecryptedContent(note, accountViewModel) { eventContent -> + val modifier = remember(note.event?.id()) { Modifier.fillMaxWidth() } + val isAuthorTheLoggedUser = + remember(note.event?.id()) { accountViewModel.isLoggedUser(note.author) } - val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - if (makeItShort && isAuthorTheLoggedUser) { - Text( - text = eventContent, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } else { - SensitivityWarning( - note = note, - accountViewModel = accountViewModel, - ) { - TranslatableRichTextViewer( - content = eventContent, - canPreview = canPreview && !makeItShort, - modifier = modifier, - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } + if (makeItShort && isAuthorTheLoggedUser) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + modifier = modifier, + tags = tags, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } - if (noteEvent.hasHashtags()) { - val hashtags = - remember(note.event?.id()) { - note.event?.hashtags()?.toImmutableList() ?: persistentListOf() + if (noteEvent.hasHashtags()) { + val hashtags = + remember(note.event?.id()) { + note.event?.hashtags()?.toImmutableList() ?: persistentListOf() + } + DisplayUncitedHashtags(hashtags, eventContent, nav) + } } - DisplayUncitedHashtags(hashtags, eventContent, nav) } - } - } - } else { - val recipient = noteEvent.recipientPubKeyBytes()?.toNpub() ?: "Someone" + } else { + val recipient = noteEvent.recipientPubKeyBytes()?.toNpub() ?: "Someone" - TranslatableRichTextViewer( - stringResource( - id = R.string.private_conversation_notification, - "@${note.author?.pubkeyNpub()}", - "@$recipient", - ), - canPreview = !makeItShort, - Modifier.fillMaxWidth(), - EmptyTagList, - backgroundColor, - accountViewModel, - nav, - ) - } + TranslatableRichTextViewer( + stringResource( + id = R.string.private_conversation_notification, + "@${note.author?.pubkeyNpub()}", + "@$recipient", + ), + canPreview = !makeItShort, + Modifier.fillMaxWidth(), + EmptyTagList, + backgroundColor, + accountViewModel, + nav, + ) + } } @Composable fun DisplayRelaySet( - baseNote: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = baseNote.event as? RelaySetEvent ?: return + val noteEvent = baseNote.event as? RelaySetEvent ?: return - val relays by - remember(baseNote) { - mutableStateOf( - noteEvent.relays().map { RelayBriefInfoCache.RelayBriefInfo(it) }.toImmutableList(), - ) - } - - var expanded by remember { mutableStateOf(false) } - - val toMembersShow = - if (expanded) { - relays - } else { - relays.take(3) - } - - val relayListName by remember { derivedStateOf { "#${noteEvent.dTag()}" } } - - val relayDescription by remember { derivedStateOf { noteEvent.description() } } - - Text( - text = relayListName, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth().padding(5.dp), - textAlign = TextAlign.Center, - ) - - relayDescription?.let { - Text( - text = it, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth().padding(5.dp), - textAlign = TextAlign.Center, - color = Color.Gray, - ) - } - - Box { - Column(modifier = Modifier.padding(top = 5.dp)) { - toMembersShow.forEach { relay -> - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) { - Text( - text = relay.displayUrl, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(start = 10.dp, bottom = 5.dp).weight(1f), - ) - - Column(modifier = Modifier.padding(start = 10.dp)) { - RelayOptionsAction(relay.url, accountViewModel, nav) - } + val relays by + remember(baseNote) { + mutableStateOf( + noteEvent.relays().map { RelayBriefInfoCache.RelayBriefInfo(it) }.toImmutableList(), + ) } - } + + var expanded by remember { mutableStateOf(false) } + + val toMembersShow = + if (expanded) { + relays + } else { + relays.take(3) + } + + val relayListName by remember { derivedStateOf { "#${noteEvent.dTag()}" } } + + val relayDescription by remember { derivedStateOf { noteEvent.description() } } + + Text( + text = relayListName, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(5.dp), + textAlign = TextAlign.Center, + ) + + relayDescription?.let { + Text( + text = it, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(5.dp), + textAlign = TextAlign.Center, + color = Color.Gray, + ) } - if (relays.size > 3 && !expanded) { - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = - Modifier.align(Alignment.BottomCenter) - .fillMaxWidth() - .background(getGradient(backgroundColor)), - ) { - ShowMoreButton { expanded = !expanded } - } + Box { + Column(modifier = Modifier.padding(top = 5.dp)) { + toMembersShow.forEach { relay -> + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) { + Text( + text = relay.displayUrl, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 10.dp, bottom = 5.dp).weight(1f), + ) + + Column(modifier = Modifier.padding(start = 10.dp)) { + RelayOptionsAction(relay.url, accountViewModel, nav) + } + } + } + } + + if (relays.size > 3 && !expanded) { + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = + Modifier.align(Alignment.BottomCenter) + .fillMaxWidth() + .background(getGradient(backgroundColor)), + ) { + ShowMoreButton { expanded = !expanded } + } + } } - } } @Composable private fun RelayOptionsAction( - relay: String, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + relay: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val userStateRelayInfo by accountViewModel.account.userProfile().live().relayInfo.observeAsState() - val isCurrentlyOnTheUsersList by - remember(userStateRelayInfo) { - derivedStateOf { - userStateRelayInfo?.user?.latestContactList?.relays()?.none { it.key == relay } == true - } + val userStateRelayInfo by accountViewModel.account.userProfile().live().relayInfo.observeAsState() + val isCurrentlyOnTheUsersList by + remember(userStateRelayInfo) { + derivedStateOf { + userStateRelayInfo?.user?.latestContactList?.relays()?.none { it.key == relay } == true + } + } + + var wantsToAddRelay by remember { mutableStateOf("") } + + if (wantsToAddRelay.isNotEmpty()) { + NewRelayListView({ wantsToAddRelay = "" }, accountViewModel, wantsToAddRelay, nav = nav) } - var wantsToAddRelay by remember { mutableStateOf("") } - - if (wantsToAddRelay.isNotEmpty()) { - NewRelayListView({ wantsToAddRelay = "" }, accountViewModel, wantsToAddRelay, nav = nav) - } - - if (isCurrentlyOnTheUsersList) { - AddRelayButton { wantsToAddRelay = relay } - } else { - RemoveRelayButton { wantsToAddRelay = relay } - } + if (isCurrentlyOnTheUsersList) { + AddRelayButton { wantsToAddRelay = relay } + } else { + RemoveRelayButton { wantsToAddRelay = relay } + } } @OptIn(ExperimentalLayoutApi::class) @Composable fun DisplayPeopleList( - baseNote: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = baseNote.event as? PeopleListEvent ?: return + val noteEvent = baseNote.event as? PeopleListEvent ?: return - var members by remember { mutableStateOf>(persistentListOf()) } + var members by remember { mutableStateOf>(persistentListOf()) } - var expanded by remember { mutableStateOf(false) } + var expanded by remember { mutableStateOf(false) } - val toMembersShow = - if (expanded) { - members - } else { - members.take(3) - } - - val name by remember { derivedStateOf { "#${noteEvent.dTag()}" } } - - Text( - text = name, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth().padding(5.dp), - textAlign = TextAlign.Center, - ) - - LaunchedEffect(Unit) { accountViewModel.loadUsers(noteEvent.bookmarkedPeople()) { members = it } } - - Box { - FlowRow(modifier = Modifier.padding(top = 5.dp)) { - toMembersShow.forEach { user -> - Row(modifier = Modifier.fillMaxWidth()) { - UserCompose( - user, - overallModifier = Modifier, - accountViewModel = accountViewModel, - nav = nav, - ) + val toMembersShow = + if (expanded) { + members + } else { + members.take(3) } - } - } - if (members.size > 3 && !expanded) { - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = - Modifier.align(Alignment.BottomCenter) - .fillMaxWidth() - .background(getGradient(backgroundColor)), - ) { - ShowMoreButton { expanded = !expanded } - } + val name by remember { derivedStateOf { "#${noteEvent.dTag()}" } } + + Text( + text = name, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(5.dp), + textAlign = TextAlign.Center, + ) + + LaunchedEffect(Unit) { accountViewModel.loadUsers(noteEvent.bookmarkedPeople()) { members = it } } + + Box { + FlowRow(modifier = Modifier.padding(top = 5.dp)) { + toMembersShow.forEach { user -> + Row(modifier = Modifier.fillMaxWidth()) { + UserCompose( + user, + overallModifier = Modifier, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + + if (members.size > 3 && !expanded) { + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = + Modifier.align(Alignment.BottomCenter) + .fillMaxWidth() + .background(getGradient(backgroundColor)), + ) { + ShowMoreButton { expanded = !expanded } + } + } } - } } @OptIn(ExperimentalLayoutApi::class) @Composable private fun RenderBadgeAward( - note: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (note.replyTo.isNullOrEmpty()) return + if (note.replyTo.isNullOrEmpty()) return - val noteEvent = note.event as? BadgeAwardEvent ?: return - var awardees by remember { mutableStateOf>(listOf()) } + val noteEvent = note.event as? BadgeAwardEvent ?: return + var awardees by remember { mutableStateOf>(listOf()) } - Text(text = stringResource(R.string.award_granted_to)) + Text(text = stringResource(R.string.award_granted_to)) - LaunchedEffect(key1 = note) { accountViewModel.loadUsers(noteEvent.awardees()) { awardees = it } } + LaunchedEffect(key1 = note) { accountViewModel.loadUsers(noteEvent.awardees()) { awardees = it } } - FlowRow(modifier = Modifier.padding(top = 5.dp)) { - awardees.take(100).forEach { user -> - Row( - modifier = Modifier.size(size = Size35dp).clickable { nav("User/${user.pubkeyHex}") }, - verticalAlignment = CenterVertically, - ) { - ClickableUserPicture( - baseUser = user, - accountViewModel = accountViewModel, - size = Size35dp, + FlowRow(modifier = Modifier.padding(top = 5.dp)) { + awardees.take(100).forEach { user -> + Row( + modifier = Modifier.size(size = Size35dp).clickable { nav("User/${user.pubkeyHex}") }, + verticalAlignment = CenterVertically, + ) { + ClickableUserPicture( + baseUser = user, + accountViewModel = accountViewModel, + size = Size35dp, + ) + } + } + + if (awardees.size > 100) { + Text(" and ${awardees.size - 100} others", maxLines = 1) + } + } + + note.replyTo?.firstOrNull()?.let { + NoteCompose( + it, + modifier = Modifier, + isBoostedNote = false, + isQuotedNote = true, + unPackReply = false, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, ) - } } - - if (awardees.size > 100) { - Text(" and ${awardees.size - 100} others", maxLines = 1) - } - } - - note.replyTo?.firstOrNull()?.let { - NoteCompose( - it, - modifier = Modifier, - isBoostedNote = false, - isQuotedNote = true, - unPackReply = false, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } } @Composable private fun RenderReaction( - note: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - note.replyTo?.lastOrNull()?.let { - NoteCompose( - it, - modifier = Modifier, - isBoostedNote = true, - unPackReply = false, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, + note.replyTo?.lastOrNull()?.let { + NoteCompose( + it, + modifier = Modifier, + isBoostedNote = true, + unPackReply = false, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + // Reposts have trash in their contents. + val refactorReactionText = if (note.event?.content() == "+") "โค" else note.event?.content() ?: "" + + Text( + text = refactorReactionText, + maxLines = 1, ) - } - - // Reposts have trash in their contents. - val refactorReactionText = if (note.event?.content() == "+") "โค" else note.event?.content() ?: "" - - Text( - text = refactorReactionText, - maxLines = 1, - ) } @Composable fun RenderRepost( - note: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val boostedNote = remember { note.replyTo?.lastOrNull() } + val boostedNote = remember { note.replyTo?.lastOrNull() } - boostedNote?.let { - NoteCompose( - it, - modifier = Modifier, - isBoostedNote = true, - unPackReply = false, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } + boostedNote?.let { + NoteCompose( + it, + modifier = Modifier, + isBoostedNote = true, + unPackReply = false, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } } @Composable fun RenderPostApproval( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (note.replyTo.isNullOrEmpty()) return + if (note.replyTo.isNullOrEmpty()) return - val noteEvent = note.event as? CommunityPostApprovalEvent ?: return + val noteEvent = note.event as? CommunityPostApprovalEvent ?: return - Column(Modifier.fillMaxWidth()) { - noteEvent.communities().forEach { - LoadAddressableNote(it, accountViewModel) { - it?.let { - NoteCompose( - it, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) + Column(Modifier.fillMaxWidth()) { + noteEvent.communities().forEach { + LoadAddressableNote(it, accountViewModel) { + it?.let { + NoteCompose( + it, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } } - } - } - Text( - text = stringResource(id = R.string.community_approved_posts), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth().padding(5.dp), - textAlign = TextAlign.Center, - ) + Text( + text = stringResource(id = R.string.community_approved_posts), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(5.dp), + textAlign = TextAlign.Center, + ) - note.replyTo?.forEach { - NoteCompose( - it, - modifier = MaterialTheme.colorScheme.replyModifier, - unPackReply = false, - makeItShort = true, - isQuotedNote = true, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) + note.replyTo?.forEach { + NoteCompose( + it, + modifier = MaterialTheme.colorScheme.replyModifier, + unPackReply = false, + makeItShort = true, + isQuotedNote = true, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } } - } } @Composable fun LoadAddressableNote( - aTagHex: String, - accountViewModel: AccountViewModel, - content: @Composable (AddressableNote?) -> Unit, + aTagHex: String, + accountViewModel: AccountViewModel, + content: @Composable (AddressableNote?) -> Unit, ) { - var note by - remember(aTagHex) { - mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTagHex)) - } - - if (note == null) { - LaunchedEffect(key1 = aTagHex) { - accountViewModel.checkGetOrCreateAddressableNote(aTagHex) { newNote -> - if (newNote != note) { - note = newNote + var note by + remember(aTagHex) { + mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTagHex)) } - } - } - } - content(note) + if (note == null) { + LaunchedEffect(key1 = aTagHex) { + accountViewModel.checkGetOrCreateAddressableNote(aTagHex) { newNote -> + if (newNote != note) { + note = newNote + } + } + } + } + + content(note) } @Composable fun LoadAddressableNote( - aTag: ATag, - accountViewModel: AccountViewModel, - content: @Composable (AddressableNote?) -> Unit, + aTag: ATag, + accountViewModel: AccountViewModel, + content: @Composable (AddressableNote?) -> Unit, ) { - var note by - remember(aTag) { - mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTag.toTag())) - } - - if (note == null) { - LaunchedEffect(key1 = aTag) { - accountViewModel.getOrCreateAddressableNote(aTag) { newNote -> - if (newNote != note) { - note = newNote + var note by + remember(aTag) { + mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTag.toTag())) } - } - } - } - content(note) + if (note == null) { + LaunchedEffect(key1 = aTag) { + accountViewModel.getOrCreateAddressableNote(aTag) { newNote -> + if (newNote != note) { + note = newNote + } + } + } + } + + content(note) } @Composable public fun RenderEmojiPack( - baseNote: Note, - actionable: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - onClick: ((EmojiUrl) -> Unit)? = null, + baseNote: Note, + actionable: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + onClick: ((EmojiUrl) -> Unit)? = null, ) { - val noteEvent by - baseNote - .live() - .metadata - .map { it.note.event } - .distinctUntilChanged() - .observeAsState(baseNote.event) + val noteEvent by + baseNote + .live() + .metadata + .map { it.note.event } + .distinctUntilChanged() + .observeAsState(baseNote.event) - if (noteEvent == null || noteEvent !is EmojiPackEvent) return + if (noteEvent == null || noteEvent !is EmojiPackEvent) return - (noteEvent as? EmojiPackEvent)?.let { - RenderEmojiPack( - noteEvent = it, - baseNote = baseNote, - actionable = actionable, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - onClick = onClick, - ) - } + (noteEvent as? EmojiPackEvent)?.let { + RenderEmojiPack( + noteEvent = it, + baseNote = baseNote, + actionable = actionable, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + onClick = onClick, + ) + } } @OptIn(ExperimentalLayoutApi::class) @Composable public fun RenderEmojiPack( - noteEvent: EmojiPackEvent, - baseNote: Note, - actionable: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - onClick: ((EmojiUrl) -> Unit)? = null, + noteEvent: EmojiPackEvent, + baseNote: Note, + actionable: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + onClick: ((EmojiUrl) -> Unit)? = null, ) { - var expanded by remember { mutableStateOf(false) } + var expanded by remember { mutableStateOf(false) } - val allEmojis = remember(noteEvent) { noteEvent.taggedEmojis() } + val allEmojis = remember(noteEvent) { noteEvent.taggedEmojis() } - val emojisToShow = - if (expanded) { - allEmojis - } else { - allEmojis.take(60) - } - - Row(verticalAlignment = CenterVertically) { - Text( - text = remember(noteEvent) { "#${noteEvent.dTag()}" }, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1F).padding(5.dp), - textAlign = TextAlign.Center, - ) - - if (actionable) { - EmojiListOptions(accountViewModel, baseNote) - } - } - - Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) { - FlowRow(modifier = Modifier.padding(top = 5.dp)) { - emojisToShow.forEach { emoji -> - if (onClick != null) { - IconButton(onClick = { onClick(emoji) }, modifier = Size35Modifier) { - AsyncImage( - model = emoji.url, - contentDescription = null, - modifier = Size35Modifier, - ) - } + val emojisToShow = + if (expanded) { + allEmojis } else { - Box( - modifier = Size35Modifier, - contentAlignment = Alignment.Center, - ) { - AsyncImage( - model = emoji.url, - contentDescription = null, - modifier = Size35Modifier, - ) - } + allEmojis.take(60) + } + + Row(verticalAlignment = CenterVertically) { + Text( + text = remember(noteEvent) { "#${noteEvent.dTag()}" }, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1F).padding(5.dp), + textAlign = TextAlign.Center, + ) + + if (actionable) { + EmojiListOptions(accountViewModel, baseNote) } - } } - if (allEmojis.size > 60 && !expanded) { - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = - Modifier.align(Alignment.BottomCenter) - .fillMaxWidth() - .background(getGradient(backgroundColor)), - ) { - ShowMoreButton { expanded = !expanded } - } + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) { + FlowRow(modifier = Modifier.padding(top = 5.dp)) { + emojisToShow.forEach { emoji -> + if (onClick != null) { + IconButton(onClick = { onClick(emoji) }, modifier = Size35Modifier) { + AsyncImage( + model = emoji.url, + contentDescription = null, + modifier = Size35Modifier, + ) + } + } else { + Box( + modifier = Size35Modifier, + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = emoji.url, + contentDescription = null, + modifier = Size35Modifier, + ) + } + } + } + } + + if (allEmojis.size > 60 && !expanded) { + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = + Modifier.align(Alignment.BottomCenter) + .fillMaxWidth() + .background(getGradient(backgroundColor)), + ) { + ShowMoreButton { expanded = !expanded } + } + } } - } } @Composable private fun EmojiListOptions( - accountViewModel: AccountViewModel, - emojiPackNote: Note, + accountViewModel: AccountViewModel, + emojiPackNote: Note, ) { - LoadAddressableNote( - aTag = - ATag( - EmojiPackSelectionEvent.KIND, - accountViewModel.userProfile().pubkeyHex, - "", - null, - ), - accountViewModel, - ) { - it?.let { usersEmojiList -> - val hasAddedThis by - remember { - usersEmojiList - .live() - .metadata - .map { usersEmojiList.event?.isTaggedAddressableNote(emojiPackNote.idHex) } - .distinctUntilChanged() - } - .observeAsState() + LoadAddressableNote( + aTag = + ATag( + EmojiPackSelectionEvent.KIND, + accountViewModel.userProfile().pubkeyHex, + "", + null, + ), + accountViewModel, + ) { + it?.let { usersEmojiList -> + val hasAddedThis by + remember { + usersEmojiList + .live() + .metadata + .map { usersEmojiList.event?.isTaggedAddressableNote(emojiPackNote.idHex) } + .distinctUntilChanged() + } + .observeAsState() - Crossfade(targetState = hasAddedThis, label = "EmojiListOptions") { - if (it != true) { - AddButton { accountViewModel.addEmojiPack(usersEmojiList, emojiPackNote) } - } else { - RemoveButton { accountViewModel.removeEmojiPack(usersEmojiList, emojiPackNote) } + Crossfade(targetState = hasAddedThis, label = "EmojiListOptions") { + if (it != true) { + AddButton { accountViewModel.addEmojiPack(usersEmojiList, emojiPackNote) } + } else { + RemoveButton { accountViewModel.removeEmojiPack(usersEmojiList, emojiPackNote) } + } + } } - } } - } } @OptIn(ExperimentalLayoutApi::class) @Composable fun RenderPinListEvent( - baseNote: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = baseNote.event as? PinListEvent ?: return + val noteEvent = baseNote.event as? PinListEvent ?: return - val pins by remember { mutableStateOf(noteEvent.pins()) } + val pins by remember { mutableStateOf(noteEvent.pins()) } - var expanded by remember { mutableStateOf(false) } + var expanded by remember { mutableStateOf(false) } - val pinsToShow = - if (expanded) { - pins - } else { - pins.take(3) - } - - Text( - text = "#${noteEvent.dTag()}", - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth().padding(5.dp), - textAlign = TextAlign.Center, - ) - - Box { - FlowRow(modifier = Modifier.padding(top = 5.dp)) { - pinsToShow.forEach { pin -> - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) { - PinIcon( - modifier = Size15Modifier, - tint = MaterialTheme.colorScheme.onBackground.copy(0.32f), - ) - - Spacer(modifier = Modifier.width(5.dp)) - - TranslatableRichTextViewer( - content = pin, - canPreview = true, - tags = EmptyTagList, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) + val pinsToShow = + if (expanded) { + pins + } else { + pins.take(3) } - } - } - if (pins.size > 3 && !expanded) { - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = - Modifier.align(Alignment.BottomCenter) - .fillMaxWidth() - .background(getGradient(backgroundColor)), - ) { - ShowMoreButton { expanded = !expanded } - } + Text( + text = "#${noteEvent.dTag()}", + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(5.dp), + textAlign = TextAlign.Center, + ) + + Box { + FlowRow(modifier = Modifier.padding(top = 5.dp)) { + pinsToShow.forEach { pin -> + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) { + PinIcon( + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.onBackground.copy(0.32f), + ) + + Spacer(modifier = Modifier.width(5.dp)) + + TranslatableRichTextViewer( + content = pin, + canPreview = true, + tags = EmptyTagList, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + + if (pins.size > 3 && !expanded) { + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = + Modifier.align(Alignment.BottomCenter) + .fillMaxWidth() + .background(getGradient(backgroundColor)), + ) { + ShowMoreButton { expanded = !expanded } + } + } } - } } fun getGradient(backgroundColor: MutableState): Brush { - return Brush.verticalGradient( - colors = - listOf( - backgroundColor.value.copy(alpha = 0f), - backgroundColor.value, - ), - ) + return Brush.verticalGradient( + colors = + listOf( + backgroundColor.value.copy(alpha = 0f), + backgroundColor.value, + ), + ) } @Composable private fun RenderAudioTrack( - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = note.event as? AudioTrackEvent ?: return + val noteEvent = note.event as? AudioTrackEvent ?: return - AudioTrackHeader(noteEvent, note, accountViewModel, nav) + AudioTrackHeader(noteEvent, note, accountViewModel, nav) } @Composable private fun RenderAudioHeader( - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = note.event as? AudioHeaderEvent ?: return + val noteEvent = note.event as? AudioHeaderEvent ?: return - AudioHeader(noteEvent, note, accountViewModel, nav) + AudioHeader(noteEvent, note, accountViewModel, nav) } @Composable private fun RenderLongFormContent( - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = note.event as? LongTextNoteEvent ?: return + val noteEvent = note.event as? LongTextNoteEvent ?: return - LongFormHeader(noteEvent, note, accountViewModel) + LongFormHeader(noteEvent, note, accountViewModel) } @Composable private fun RenderReport( - note: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = note.event as? ReportEvent ?: return + val noteEvent = note.event as? ReportEvent ?: return - val base = remember { (noteEvent.reportedPost() + noteEvent.reportedAuthor()) } + val base = remember { (noteEvent.reportedPost() + noteEvent.reportedAuthor()) } - val reportType = - base - .map { - when (it.reportType) { - ReportEvent.ReportType.EXPLICIT -> stringResource(R.string.explicit_content) - ReportEvent.ReportType.NUDITY -> stringResource(R.string.nudity) - ReportEvent.ReportType.PROFANITY -> stringResource(R.string.profanity_hateful_speech) - ReportEvent.ReportType.SPAM -> stringResource(R.string.spam) - ReportEvent.ReportType.IMPERSONATION -> stringResource(R.string.impersonation) - ReportEvent.ReportType.ILLEGAL -> stringResource(R.string.illegal_behavior) + val reportType = + base + .map { + when (it.reportType) { + ReportEvent.ReportType.EXPLICIT -> stringResource(R.string.explicit_content) + ReportEvent.ReportType.NUDITY -> stringResource(R.string.nudity) + ReportEvent.ReportType.PROFANITY -> stringResource(R.string.profanity_hateful_speech) + ReportEvent.ReportType.SPAM -> stringResource(R.string.spam) + ReportEvent.ReportType.IMPERSONATION -> stringResource(R.string.impersonation) + ReportEvent.ReportType.ILLEGAL -> stringResource(R.string.illegal_behavior) + } + } + .toSet() + .joinToString(", ") + + val content = + remember { + reportType + (note.event?.content()?.ifBlank { null }?.let { ": $it" } ?: "") } - } - .toSet() - .joinToString(", ") - val content = remember { - reportType + (note.event?.content()?.ifBlank { null }?.let { ": $it" } ?: "") - } - - TranslatableRichTextViewer( - content = content, - canPreview = true, - modifier = Modifier, - tags = EmptyTagList, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - - note.replyTo?.lastOrNull()?.let { - NoteCompose( - baseNote = it, - isQuotedNote = true, - modifier = - Modifier.padding(top = 5.dp) - .fillMaxWidth() - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder, - ), - unPackReply = false, - makeItShort = true, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, + TranslatableRichTextViewer( + content = content, + canPreview = true, + modifier = Modifier, + tags = EmptyTagList, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, ) - } + + note.replyTo?.lastOrNull()?.let { + NoteCompose( + baseNote = it, + isQuotedNote = true, + modifier = + Modifier.padding(top = 5.dp) + .fillMaxWidth() + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + unPackReply = false, + makeItShort = true, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } } @Composable private fun ReplyRow( - note: Note, - unPackReply: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + unPackReply: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = note.event + val noteEvent = note.event - val showReply by - remember(note) { - derivedStateOf { - noteEvent is BaseTextNoteEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser()) - } - } - - val showChannelInfo by - remember(note) { - derivedStateOf { - if (noteEvent is ChannelMessageEvent || noteEvent is LiveActivitiesChatMessageEvent) { - note.channelHex() - } else { - null - } - } - } - - showChannelInfo?.let { - ChannelHeader( - channelHex = it, - showVideo = false, - showBottomDiviser = false, - sendToChannel = true, - modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (showReply) { - val replyingDirectlyTo = - remember(note) { - if (noteEvent is BaseTextNoteEvent) { - val replyingTo = noteEvent.replyingTo() - if (replyingTo != null) { - note.replyTo?.firstOrNull { - // important to test both ids in case it's a replaceable event. - it.idHex == replyingTo || it.event?.id() == replyingTo + val showReply by + remember(note) { + derivedStateOf { + noteEvent is BaseTextNoteEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser()) } - } else { - note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND } - } - } else { - note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND } } - } - if (replyingDirectlyTo != null && unPackReply) { - ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav) - Spacer(modifier = StdVertSpacer) - } else if (showChannelInfo != null) { - val replies = remember { note.replyTo?.toImmutableList() } - val mentions = remember { - (note.event as? BaseTextNoteEvent)?.mentions()?.toImmutableList() ?: persistentListOf() - } - ReplyInformationChannel(replies, mentions, accountViewModel, nav) + val showChannelInfo by + remember(note) { + derivedStateOf { + if (noteEvent is ChannelMessageEvent || noteEvent is LiveActivitiesChatMessageEvent) { + note.channelHex() + } else { + null + } + } + } + + showChannelInfo?.let { + ChannelHeader( + channelHex = it, + showVideo = false, + showBottomDiviser = false, + sendToChannel = true, + modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (showReply) { + val replyingDirectlyTo = + remember(note) { + if (noteEvent is BaseTextNoteEvent) { + val replyingTo = noteEvent.replyingTo() + if (replyingTo != null) { + note.replyTo?.firstOrNull { + // important to test both ids in case it's a replaceable event. + it.idHex == replyingTo || it.event?.id() == replyingTo + } + } else { + note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND } + } + } else { + note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND } + } + } + if (replyingDirectlyTo != null && unPackReply) { + ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav) + Spacer(modifier = StdVertSpacer) + } else if (showChannelInfo != null) { + val replies = remember { note.replyTo?.toImmutableList() } + val mentions = + remember { + (note.event as? BaseTextNoteEvent)?.mentions()?.toImmutableList() ?: persistentListOf() + } + + ReplyInformationChannel(replies, mentions, accountViewModel, nav) + } } - } } @Composable private fun ReplyNoteComposition( - replyingDirectlyTo: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + replyingDirectlyTo: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val replyBackgroundColor = remember { mutableStateOf(backgroundColor.value) } - val defaultReplyBackground = MaterialTheme.colorScheme.replyBackground + val replyBackgroundColor = remember { mutableStateOf(backgroundColor.value) } + val defaultReplyBackground = MaterialTheme.colorScheme.replyBackground - LaunchedEffect(key1 = backgroundColor.value, key2 = defaultReplyBackground) { - launch(Dispatchers.Default) { - val newReplyBackgroundColor = defaultReplyBackground.compositeOver(backgroundColor.value) - if (replyBackgroundColor.value != newReplyBackgroundColor) { - replyBackgroundColor.value = newReplyBackgroundColor - } + LaunchedEffect(key1 = backgroundColor.value, key2 = defaultReplyBackground) { + launch(Dispatchers.Default) { + val newReplyBackgroundColor = defaultReplyBackground.compositeOver(backgroundColor.value) + if (replyBackgroundColor.value != newReplyBackgroundColor) { + replyBackgroundColor.value = newReplyBackgroundColor + } + } } - } - NoteCompose( - baseNote = replyingDirectlyTo, - isQuotedNote = true, - modifier = MaterialTheme.colorScheme.replyModifier, - unPackReply = false, - makeItShort = true, - parentBackgroundColor = replyBackgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) + NoteCompose( + baseNote = replyingDirectlyTo, + isQuotedNote = true, + modifier = MaterialTheme.colorScheme.replyModifier, + unPackReply = false, + makeItShort = true, + parentBackgroundColor = replyBackgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) } @Composable fun SecondUserInfoRow( - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = remember { note.event } ?: return - val noteAuthor = remember { note.author } ?: return + val noteEvent = remember { note.event } ?: return + val noteAuthor = remember { note.author } ?: return - Row( - verticalAlignment = CenterVertically, - modifier = UserNameMaxRowHeight, - ) { - ObserveDisplayNip05Status(noteAuthor, remember { Modifier.weight(1f) }, accountViewModel, nav) + Row( + verticalAlignment = CenterVertically, + modifier = UserNameMaxRowHeight, + ) { + ObserveDisplayNip05Status(noteAuthor, remember { Modifier.weight(1f) }, accountViewModel, nav) - val geo = remember { noteEvent.getGeoHash() } - if (geo != null) { - Spacer(StdHorzSpacer) - DisplayLocation(geo, nav) + val geo = remember { noteEvent.getGeoHash() } + if (geo != null) { + Spacer(StdHorzSpacer) + DisplayLocation(geo, nav) + } + + val baseReward = remember { noteEvent.getReward()?.let { Reward(it) } } + if (baseReward != null) { + Spacer(StdHorzSpacer) + DisplayReward(baseReward, note, accountViewModel, nav) + } + + val pow = remember { noteEvent.getPoWRank() } + if (pow > 20) { + Spacer(StdHorzSpacer) + DisplayPoW(pow) + } } - - val baseReward = remember { noteEvent.getReward()?.let { Reward(it) } } - if (baseReward != null) { - Spacer(StdHorzSpacer) - DisplayReward(baseReward, note, accountViewModel, nav) - } - - val pow = remember { noteEvent.getPoWRank() } - if (pow > 20) { - Spacer(StdHorzSpacer) - DisplayPoW(pow) - } - } } @Composable fun LoadStatuses( - user: User, - accountViewModel: AccountViewModel, - content: @Composable (ImmutableList) -> Unit, + user: User, + accountViewModel: AccountViewModel, + content: @Composable (ImmutableList) -> Unit, ) { - var statuses: ImmutableList by remember { mutableStateOf(persistentListOf()) } + var statuses: ImmutableList by remember { mutableStateOf(persistentListOf()) } - val userStatus by user.live().statuses.observeAsState() + val userStatus by user.live().statuses.observeAsState() - LaunchedEffect(key1 = userStatus) { - accountViewModel.findStatusesForUser(userStatus?.user ?: user) { newStatuses -> - if (!equalImmutableLists(statuses, newStatuses)) { - statuses = newStatuses - } + LaunchedEffect(key1 = userStatus) { + accountViewModel.findStatusesForUser(userStatus?.user ?: user) { newStatuses -> + if (!equalImmutableLists(statuses, newStatuses)) { + statuses = newStatuses + } + } } - } - content(statuses) + content(statuses) } @Composable fun LoadCityName( - geohash: GeoHash, - content: @Composable (String) -> Unit, + geohash: GeoHash, + content: @Composable (String) -> Unit, ) { - val context = LocalContext.current - var cityName by remember(geohash) { mutableStateOf(geohash.toString()) } + val context = LocalContext.current + var cityName by remember(geohash) { mutableStateOf(geohash.toString()) } - LaunchedEffect(key1 = geohash) { - launch(Dispatchers.IO) { - val newCityName = - ReverseGeoLocationUtil().execute(geohash.toLocation(), context)?.ifBlank { null } - if (newCityName != null && newCityName != cityName) { - cityName = newCityName - } + LaunchedEffect(key1 = geohash) { + launch(Dispatchers.IO) { + val newCityName = + ReverseGeoLocationUtil().execute(geohash.toLocation(), context)?.ifBlank { null } + if (newCityName != null && newCityName != cityName) { + cityName = newCityName + } + } } - } - content(cityName) + content(cityName) } @Composable fun DisplayLocation( - geohashStr: String, - nav: (String) -> Unit, + geohashStr: String, + nav: (String) -> Unit, ) { - val geoHash = runCatching { geohashStr.toGeoHash() }.getOrNull() - if (geoHash != null) { - LoadCityName(geoHash) { cityName -> - ClickableText( - text = AnnotatedString(cityName), - onClick = { nav("Geohash/$geoHash") }, - style = - LocalTextStyle.current.copy( - color = - MaterialTheme.colorScheme.primary.copy( - alpha = 0.52f, - ), - fontSize = Font14SP, - fontWeight = FontWeight.Bold, - ), - maxLines = 1, - ) + val geoHash = runCatching { geohashStr.toGeoHash() }.getOrNull() + if (geoHash != null) { + LoadCityName(geoHash) { cityName -> + ClickableText( + text = AnnotatedString(cityName), + onClick = { nav("Geohash/$geoHash") }, + style = + LocalTextStyle.current.copy( + color = + MaterialTheme.colorScheme.primary.copy( + alpha = 0.52f, + ), + fontSize = Font14SP, + fontWeight = FontWeight.Bold, + ), + maxLines = 1, + ) + } } - } } @Composable fun FirstUserInfoRow( - baseNote: Note, - showAuthorPicture: Boolean, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + showAuthorPicture: Boolean, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Row(verticalAlignment = CenterVertically, modifier = remember { UserNameRowHeight }) { - val isRepost by - remember(baseNote) { - derivedStateOf { baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent } - } + Row(verticalAlignment = CenterVertically, modifier = remember { UserNameRowHeight }) { + val isRepost by + remember(baseNote) { + derivedStateOf { baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent } + } - val isCommunityPost by - remember(baseNote) { - derivedStateOf { - baseNote.event?.isTaggedAddressableKind(CommunityDefinitionEvent.KIND) == true + val isCommunityPost by + remember(baseNote) { + derivedStateOf { + baseNote.event?.isTaggedAddressableKind(CommunityDefinitionEvent.KIND) == true + } + } + + val textColor = if (isRepost) MaterialTheme.colorScheme.grayText else Color.Unspecified + + if (showAuthorPicture) { + NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp) + Spacer(HalfPadding) + NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }, textColor = textColor) + } else { + NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }, textColor = textColor) } - } - val textColor = if (isRepost) MaterialTheme.colorScheme.grayText else Color.Unspecified + if (isRepost) { + BoostedMark() + } else if (isCommunityPost) { + DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav) + } else { + DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav) + } - if (showAuthorPicture) { - NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp) - Spacer(HalfPadding) - NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }, textColor = textColor) - } else { - NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }, textColor = textColor) + TimeAgo(baseNote) + + MoreOptionsButton(baseNote, accountViewModel) } - - if (isRepost) { - BoostedMark() - } else if (isCommunityPost) { - DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav) - } else { - DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav) - } - - TimeAgo(baseNote) - - MoreOptionsButton(baseNote, accountViewModel) - } } @Composable private fun BoostedMark() { - Text( - stringResource(id = R.string.boosted), - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - modifier = HalfStartPadding, - ) + Text( + stringResource(id = R.string.boosted), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + modifier = HalfStartPadding, + ) } @Composable fun MoreOptionsButton( - baseNote: Note, - accountViewModel: AccountViewModel, + baseNote: Note, + accountViewModel: AccountViewModel, ) { - val popupExpanded = remember { mutableStateOf(false) } - val enablePopup = remember { { popupExpanded.value = true } } + val popupExpanded = remember { mutableStateOf(false) } + val enablePopup = remember { { popupExpanded.value = true } } - IconButton( - modifier = Size24Modifier, - onClick = enablePopup, - ) { - VerticalDotsIcon() + IconButton( + modifier = Size24Modifier, + onClick = enablePopup, + ) { + VerticalDotsIcon() - NoteDropDownMenu( - baseNote, - popupExpanded, - accountViewModel, - ) - } + NoteDropDownMenu( + baseNote, + popupExpanded, + accountViewModel, + ) + } } @Composable fun TimeAgo(note: Note) { - val time = remember(note) { note.createdAt() } ?: return - TimeAgo(time) + val time = remember(note) { note.createdAt() } ?: return + TimeAgo(time) } @Composable fun TimeAgo(time: Long) { - val context = LocalContext.current - val timeStr by remember(time) { mutableStateOf(timeAgo(time, context = context)) } + val context = LocalContext.current + val timeStr by remember(time) { mutableStateOf(timeAgo(time, context = context)) } - Text( - text = timeStr, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - ) + Text( + text = timeStr, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) } @Composable private fun AuthorAndRelayInformation( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - // Draws the boosted picture outside the boosted card. - Box(modifier = Size55Modifier, contentAlignment = Alignment.BottomEnd) { - RenderAuthorImages(baseNote, nav, accountViewModel) - } + // Draws the boosted picture outside the boosted card. + Box(modifier = Size55Modifier, contentAlignment = Alignment.BottomEnd) { + RenderAuthorImages(baseNote, nav, accountViewModel) + } - BadgeBox(baseNote, accountViewModel, nav) + BadgeBox(baseNote, accountViewModel, nav) } @Composable private fun BadgeBox( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { - baseNote.replyTo?.lastOrNull()?.let { RelayBadges(it, accountViewModel, nav) } - } else { - RelayBadges(baseNote, accountViewModel, nav) - } + if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { + baseNote.replyTo?.lastOrNull()?.let { RelayBadges(it, accountViewModel, nav) } + } else { + RelayBadges(baseNote, accountViewModel, nav) + } } @Composable private fun RenderAuthorImages( - baseNote: Note, - nav: (String) -> Unit, - accountViewModel: AccountViewModel, + baseNote: Note, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - val isRepost = baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent + val isRepost = baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent - if (isRepost) { - val baseRepost = baseNote.replyTo?.lastOrNull() - if (baseRepost != null) { - RepostNoteAuthorPicture(baseNote, baseRepost, accountViewModel, nav) + if (isRepost) { + val baseRepost = baseNote.replyTo?.lastOrNull() + if (baseRepost != null) { + RepostNoteAuthorPicture(baseNote, baseRepost, accountViewModel, nav) + } else { + NoteAuthorPicture(baseNote, nav, accountViewModel, Size55dp) + } } else { - NoteAuthorPicture(baseNote, nav, accountViewModel, Size55dp) + NoteAuthorPicture(baseNote, nav, accountViewModel, Size55dp) } - } else { - NoteAuthorPicture(baseNote, nav, accountViewModel, Size55dp) - } - if (baseNote.event is ChannelMessageEvent) { - val baseChannelHex = remember(baseNote) { baseNote.channelHex() } - if (baseChannelHex != null) { - LoadChannel(baseChannelHex, accountViewModel) { channel -> - ChannelNotePicture( - channel, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, - ) - } + if (baseNote.event is ChannelMessageEvent) { + val baseChannelHex = remember(baseNote) { baseNote.channelHex() } + if (baseChannelHex != null) { + LoadChannel(baseChannelHex, accountViewModel) { channel -> + ChannelNotePicture( + channel, + loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + ) + } + } } - } } @Composable fun LoadChannel( - baseChannelHex: String, - accountViewModel: AccountViewModel, - content: @Composable (Channel) -> Unit, + baseChannelHex: String, + accountViewModel: AccountViewModel, + content: @Composable (Channel) -> Unit, ) { - var channel by - remember(baseChannelHex) { - mutableStateOf(accountViewModel.getChannelIfExists(baseChannelHex)) + var channel by + remember(baseChannelHex) { + mutableStateOf(accountViewModel.getChannelIfExists(baseChannelHex)) + } + + if (channel == null) { + LaunchedEffect(key1 = baseChannelHex) { + accountViewModel.checkGetOrCreateChannel(baseChannelHex) { newChannel -> + launch(Dispatchers.Main) { channel = newChannel } + } + } } - if (channel == null) { - LaunchedEffect(key1 = baseChannelHex) { - accountViewModel.checkGetOrCreateChannel(baseChannelHex) { newChannel -> - launch(Dispatchers.Main) { channel = newChannel } - } - } - } - - channel?.let { content(it) } + channel?.let { content(it) } } @Composable private fun ChannelNotePicture( - baseChannel: Channel, - loadProfilePicture: Boolean, + baseChannel: Channel, + loadProfilePicture: Boolean, ) { - val model by - baseChannel.live.map { it.channel.profilePicture() }.distinctUntilChanged().observeAsState() + val model by + baseChannel.live.map { it.channel.profilePicture() }.distinctUntilChanged().observeAsState() - Box(Size30Modifier) { - RobohashFallbackAsyncImage( - robot = baseChannel.idHex, - model = model, - contentDescription = stringResource(R.string.group_picture), - modifier = MaterialTheme.colorScheme.channelNotePictureModifier, - loadProfilePicture = loadProfilePicture, - ) - } + Box(Size30Modifier) { + RobohashFallbackAsyncImage( + robot = baseChannel.idHex, + model = model, + contentDescription = stringResource(R.string.group_picture), + modifier = MaterialTheme.colorScheme.channelNotePictureModifier, + loadProfilePicture = loadProfilePicture, + ) + } } @Composable private fun RepostNoteAuthorPicture( - baseNote: Note, - baseRepost: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + baseRepost: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - GenericRepostLayout( - baseAuthorPicture = { - NoteAuthorPicture( - baseNote = baseNote, - nav = nav, - accountViewModel = accountViewModel, - size = Size34dp, - ) - }, - repostAuthorPicture = { - NoteAuthorPicture( - baseNote = baseRepost, - nav = nav, - accountViewModel = accountViewModel, - size = Size34dp, - ) - }, - ) + GenericRepostLayout( + baseAuthorPicture = { + NoteAuthorPicture( + baseNote = baseNote, + nav = nav, + accountViewModel = accountViewModel, + size = Size34dp, + ) + }, + repostAuthorPicture = { + NoteAuthorPicture( + baseNote = baseRepost, + nav = nav, + accountViewModel = accountViewModel, + size = Size34dp, + ) + }, + ) } @Composable fun DisplayHighlight( - highlight: String, - authorHex: String?, - url: String?, - postAddress: ATag?, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + highlight: String, + authorHex: String?, + url: String?, + postAddress: ATag?, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val quote = remember { - highlight.split("\n").joinToString("\n") { "> *${it.removeSuffix(" ")}*" } - } + val quote = + remember { + highlight.split("\n").joinToString("\n") { "> *${it.removeSuffix(" ")}*" } + } - TranslatableRichTextViewer( - quote, - canPreview = canPreview && !makeItShort, - remember { Modifier.fillMaxWidth() }, - EmptyTagList, - backgroundColor, - accountViewModel, - nav, - ) + TranslatableRichTextViewer( + quote, + canPreview = canPreview && !makeItShort, + remember { Modifier.fillMaxWidth() }, + EmptyTagList, + backgroundColor, + accountViewModel, + nav, + ) - DisplayQuoteAuthor(authorHex ?: "", url, postAddress, accountViewModel, nav) + DisplayQuoteAuthor(authorHex ?: "", url, postAddress, accountViewModel, nav) } @OptIn(ExperimentalLayoutApi::class) @Composable private fun DisplayQuoteAuthor( - authorHex: String, - url: String?, - postAddress: ATag?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + authorHex: String, + url: String?, + postAddress: ATag?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var userBase by remember { mutableStateOf(accountViewModel.getUserIfExists(authorHex)) } + var userBase by remember { mutableStateOf(accountViewModel.getUserIfExists(authorHex)) } - if (userBase == null) { - LaunchedEffect(Unit) { - accountViewModel.checkGetOrCreateUser(authorHex) { newUserBase -> userBase = newUserBase } + if (userBase == null) { + LaunchedEffect(Unit) { + accountViewModel.checkGetOrCreateUser(authorHex) { newUserBase -> userBase = newUserBase } + } } - } - val spaceWidth = measureSpaceWidth(textStyle = LocalTextStyle.current) + val spaceWidth = measureSpaceWidth(textStyle = LocalTextStyle.current) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(spaceWidth), - verticalArrangement = Arrangement.Center, - ) { - userBase?.let { userBase -> LoadAndDisplayUser(userBase, nav) } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + verticalArrangement = Arrangement.Center, + ) { + userBase?.let { userBase -> LoadAndDisplayUser(userBase, nav) } - url?.let { url -> LoadAndDisplayUrl(url) } + url?.let { url -> LoadAndDisplayUrl(url) } - postAddress?.let { address -> LoadAndDisplayPost(address, accountViewModel, nav) } - } + postAddress?.let { address -> LoadAndDisplayPost(address, accountViewModel, nav) } + } } @Composable private fun LoadAndDisplayPost( - postAddress: ATag, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + postAddress: ATag, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LoadAddressableNote(aTag = postAddress, accountViewModel) { - it?.let { note -> - val noteEvent by - note.live().metadata.map { it.note.event }.distinctUntilChanged().observeAsState(note.event) + LoadAddressableNote(aTag = postAddress, accountViewModel) { + it?.let { note -> + val noteEvent by + note.live().metadata.map { it.note.event }.distinctUntilChanged().observeAsState(note.event) - val title = remember(noteEvent) { (noteEvent as? LongTextNoteEvent)?.title() } + val title = remember(noteEvent) { (noteEvent as? LongTextNoteEvent)?.title() } - title?.let { - Text(remember { "-" }, maxLines = 1) - ClickableText( - text = AnnotatedString(title), - onClick = { routeFor(note, accountViewModel.userProfile())?.let { nav(it) } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - ) - } + title?.let { + Text(remember { "-" }, maxLines = 1) + ClickableText( + text = AnnotatedString(title), + onClick = { routeFor(note, accountViewModel.userProfile())?.let { nav(it) } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + ) + } + } } - } } @Composable private fun LoadAndDisplayUrl(url: String) { - val validatedUrl = remember { - try { - URL(url) - } catch (e: Exception) { - Log.w("Note Compose", "Invalid URI: $url") - null - } - } + val validatedUrl = + remember { + try { + URL(url) + } catch (e: Exception) { + Log.w("Note Compose", "Invalid URI: $url") + null + } + } - validatedUrl?.host?.let { host -> - Text(remember { "-" }, maxLines = 1) - ClickableUrl(urlText = host, url = url) - } + validatedUrl?.host?.let { host -> + Text(remember { "-" }, maxLines = 1) + ClickableUrl(urlText = host, url = url) + } } @Composable private fun LoadAndDisplayUser( - userBase: User, - nav: (String) -> Unit, + userBase: User, + nav: (String) -> Unit, ) { - val route = remember { "User/${userBase.pubkeyHex}" } + val route = remember { "User/${userBase.pubkeyHex}" } - val userState by userBase.live().metadata.observeAsState() - val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } - val userTags = - remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } + val userState by userBase.live().metadata.observeAsState() + val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } + val userTags = + remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } - if (userDisplayName != null) { - CreateClickableTextWithEmoji( - clickablePart = userDisplayName, - suffix = " ", - maxLines = 1, - route = route, - nav = nav, - tags = userTags, - ) - } + if (userDisplayName != null) { + CreateClickableTextWithEmoji( + clickablePart = userDisplayName, + suffix = " ", + maxLines = 1, + route = route, + nav = nav, + tags = userTags, + ) + } } @Composable fun BadgeDisplay(baseNote: Note) { - val background = MaterialTheme.colorScheme.background - val badgeData = baseNote.event as? BadgeDefinitionEvent ?: return + val background = MaterialTheme.colorScheme.background + val badgeData = baseNote.event as? BadgeDefinitionEvent ?: return - val image = remember { badgeData.thumb()?.ifBlank { null } ?: badgeData.image() } - val name = remember { badgeData.name() } - val description = remember { badgeData.description() } + val image = remember { badgeData.thumb()?.ifBlank { null } ?: badgeData.image() } + val name = remember { badgeData.name() } + val description = remember { badgeData.description() } - var backgroundFromImage by remember { mutableStateOf(Pair(background, background)) } - var imageResult by remember { mutableStateOf(null) } + var backgroundFromImage by remember { mutableStateOf(Pair(background, background)) } + var imageResult by remember { mutableStateOf(null) } - LaunchedEffect(key1 = imageResult) { - launch(Dispatchers.IO) { - imageResult?.let { - val backgroundColor = - it.drawable.toBitmap(200, 200).copy(Bitmap.Config.ARGB_8888, false).get(0, 199) - val colorFromImage = Color(backgroundColor) - val textBackground = - if (colorFromImage.luminance() > 0.5) { - lightColorScheme().onBackground - } else { - darkColorScheme().onBackground - } + LaunchedEffect(key1 = imageResult) { + launch(Dispatchers.IO) { + imageResult?.let { + val backgroundColor = + it.drawable.toBitmap(200, 200).copy(Bitmap.Config.ARGB_8888, false).get(0, 199) + val colorFromImage = Color(backgroundColor) + val textBackground = + if (colorFromImage.luminance() > 0.5) { + lightColorScheme().onBackground + } else { + darkColorScheme().onBackground + } - launch(Dispatchers.Main) { backgroundFromImage = Pair(colorFromImage, textBackground) } - } + launch(Dispatchers.Main) { backgroundFromImage = Pair(colorFromImage, textBackground) } + } + } } - } - Row( - modifier = - Modifier.padding(10.dp) - .clip(shape = CutCornerShape(20, 20, 20, 20)) - .border( - 5.dp, - MaterialTheme.colorScheme.mediumImportanceLink, - CutCornerShape(20), - ) - .background(backgroundFromImage.first), - ) { - RenderBadge( - image, - name, - backgroundFromImage.second, - description, + Row( + modifier = + Modifier.padding(10.dp) + .clip(shape = CutCornerShape(20, 20, 20, 20)) + .border( + 5.dp, + MaterialTheme.colorScheme.mediumImportanceLink, + CutCornerShape(20), + ) + .background(backgroundFromImage.first), ) { - if (imageResult == null) { - imageResult = it.result - } + RenderBadge( + image, + name, + backgroundFromImage.second, + description, + ) { + if (imageResult == null) { + imageResult = it.result + } + } } - } } @Composable private fun RenderBadge( - image: String?, - name: String?, - backgroundFromImage: Color, - description: String?, - onSuccess: (AsyncImagePainter.State.Success) -> Unit, + image: String?, + name: String?, + backgroundFromImage: Color, + description: String?, + onSuccess: (AsyncImagePainter.State.Success) -> Unit, ) { - Column { - image.let { - AsyncImage( - model = it, - contentDescription = - stringResource( - R.string.badge_award_image_for, - name ?: "", - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - onSuccess = onSuccess, - ) - } + Column { + image.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.badge_award_image_for, + name ?: "", + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + onSuccess = onSuccess, + ) + } - name?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp), - color = backgroundFromImage, - ) - } + name?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp), + color = backgroundFromImage, + ) + } - description?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, bottom = 10.dp), - color = Color.Gray, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } } - } } @Composable fun FileHeaderDisplay( - note: Note, - roundedCorner: Boolean, - accountViewModel: AccountViewModel, + note: Note, + roundedCorner: Boolean, + accountViewModel: AccountViewModel, ) { - val event = (note.event as? FileHeaderEvent) ?: return - val fullUrl = event.url() ?: return + val event = (note.event as? FileHeaderEvent) ?: return + val fullUrl = event.url() ?: return - val content by - remember(note) { - val blurHash = event.blurhash() - val hash = event.hash() - val dimensions = event.dimensions() - val description = event.alt() ?: event.content - val isImage = - imageExtensions.any { - removeQueryParamsForExtensionComparison(fullUrl).lowercase().endsWith(it) + val content by + remember(note) { + val blurHash = event.blurhash() + val hash = event.hash() + val dimensions = event.dimensions() + val description = event.alt() ?: event.content + val isImage = + imageExtensions.any { + removeQueryParamsForExtensionComparison(fullUrl).lowercase().endsWith(it) + } + val uri = note.toNostrUri() + + mutableStateOf( + if (isImage) { + ZoomableUrlImage( + url = fullUrl, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + ) + } else { + ZoomableUrlVideo( + url = fullUrl, + description = description, + hash = hash, + dim = dimensions, + uri = uri, + authorName = note.author?.toBestDisplayName(), + ) + }, + ) } - val uri = note.toNostrUri() - mutableStateOf( - if (isImage) { - ZoomableUrlImage( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - ) - } else { - ZoomableUrlVideo( - url = fullUrl, - description = description, - hash = hash, - dim = dimensions, - uri = uri, - authorName = note.author?.toBestDisplayName(), - ) - }, - ) + SensitivityWarning(note = note, accountViewModel = accountViewModel) { + ZoomableContentView( + content = content, + roundedCorner = roundedCorner, + accountViewModel = accountViewModel, + ) } - - SensitivityWarning(note = note, accountViewModel = accountViewModel) { - ZoomableContentView( - content = content, - roundedCorner = roundedCorner, - accountViewModel = accountViewModel, - ) - } } @Composable fun VideoDisplay( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val event = (note.event as? VideoEvent) ?: return - val fullUrl = event.url() ?: return + val event = (note.event as? VideoEvent) ?: return + val fullUrl = event.url() ?: return - val title = event.title() - val summary = event.content.ifBlank { null }?.takeIf { title != it } - val image = event.thumb() ?: event.image() - val isYouTube = fullUrl.contains("youtube.com") || fullUrl.contains("youtu.be") - val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + val title = event.title() + val summary = event.content.ifBlank { null }?.takeIf { title != it } + val image = event.thumb() ?: event.image() + val isYouTube = fullUrl.contains("youtube.com") || fullUrl.contains("youtu.be") + val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - val content by - remember(note) { - val blurHash = event.blurhash() - val hash = event.hash() - val dimensions = event.dimensions() - val description = event.alt() ?: event.content - val isImage = - imageExtensions.any { - removeQueryParamsForExtensionComparison(fullUrl).lowercase().endsWith(it) - } - val uri = note.toNostrUri() + val content by + remember(note) { + val blurHash = event.blurhash() + val hash = event.hash() + val dimensions = event.dimensions() + val description = event.alt() ?: event.content + val isImage = + imageExtensions.any { + removeQueryParamsForExtensionComparison(fullUrl).lowercase().endsWith(it) + } + val uri = note.toNostrUri() - mutableStateOf( - if (isImage) { - ZoomableUrlImage( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - ) - } else { - ZoomableUrlVideo( - url = fullUrl, - description = description, - hash = hash, - dim = dimensions, - uri = uri, - authorName = note.author?.toBestDisplayName(), - artworkUri = event.thumb() ?: event.image(), - ) - }, - ) - } - - SensitivityWarning(note = note, accountViewModel = accountViewModel) { - Column( - modifier = Modifier.fillMaxWidth().padding(top = 5.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (isYouTube) { - val uri = LocalUriHandler.current - Row( - modifier = Modifier.clickable { runCatching { uri.openUri(fullUrl) } }, - ) { - image?.let { - AsyncImage( - model = it, - contentDescription = - stringResource( - R.string.preview_card_image_for, - it, - ), - contentScale = ContentScale.FillWidth, - modifier = MaterialTheme.colorScheme.imageModifier, + mutableStateOf( + if (isImage) { + ZoomableUrlImage( + url = fullUrl, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + ) + } else { + ZoomableUrlVideo( + url = fullUrl, + description = description, + hash = hash, + dim = dimensions, + uri = uri, + authorName = note.author?.toBestDisplayName(), + artworkUri = event.thumb() ?: event.image(), + ) + }, ) - } - ?: CreateImageHeader(note, accountViewModel) } - } else { - ZoomableContentView( - content = content, - roundedCorner = true, - accountViewModel = accountViewModel, - ) - } - title?.let { - Text( - text = it, - fontWeight = FontWeight.Bold, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth().padding(top = 5.dp), - ) - } - - summary?.let { - TranslatableRichTextViewer( - content = it, - canPreview = canPreview && !makeItShort, - modifier = Modifier.fillMaxWidth(), - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (event.hasHashtags()) { - Row( - Modifier.fillMaxWidth(), + SensitivityWarning(note = note, accountViewModel = accountViewModel) { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 5.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - DisplayUncitedHashtags( - remember(event) { event.hashtags().toImmutableList() }, - summary ?: "", - nav, - ) + if (isYouTube) { + val uri = LocalUriHandler.current + Row( + modifier = Modifier.clickable { runCatching { uri.openUri(fullUrl) } }, + ) { + image?.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + modifier = MaterialTheme.colorScheme.imageModifier, + ) + } + ?: CreateImageHeader(note, accountViewModel) + } + } else { + ZoomableContentView( + content = content, + roundedCorner = true, + accountViewModel = accountViewModel, + ) + } + + title?.let { + Text( + text = it, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(top = 5.dp), + ) + } + + summary?.let { + TranslatableRichTextViewer( + content = it, + canPreview = canPreview && !makeItShort, + modifier = Modifier.fillMaxWidth(), + tags = tags, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (event.hasHashtags()) { + Row( + Modifier.fillMaxWidth(), + ) { + DisplayUncitedHashtags( + remember(event) { event.hashtags().toImmutableList() }, + summary ?: "", + nav, + ) + } + } } - } } - } } @Composable fun FileStorageHeaderDisplay( - baseNote: Note, - roundedCorner: Boolean, - accountViewModel: AccountViewModel, + baseNote: Note, + roundedCorner: Boolean, + accountViewModel: AccountViewModel, ) { - val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return - val dataEventId = eventHeader.dataEventId() ?: return + val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return + val dataEventId = eventHeader.dataEventId() ?: return - LoadNote(baseNoteHex = dataEventId, accountViewModel) { contentNote -> - if (contentNote != null) { - ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, accountViewModel) + LoadNote(baseNoteHex = dataEventId, accountViewModel) { contentNote -> + if (contentNote != null) { + ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, accountViewModel) + } } - } } @Composable private fun ObserverAndRenderNIP95( - header: Note, - content: Note, - roundedCorner: Boolean, - accountViewModel: AccountViewModel, + header: Note, + content: Note, + roundedCorner: Boolean, + accountViewModel: AccountViewModel, ) { - val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return + val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return - val appContext = LocalContext.current.applicationContext + val appContext = LocalContext.current.applicationContext - val noteState by content.live().metadata.observeAsState() + val noteState by content.live().metadata.observeAsState() - val content by - remember(noteState) { - // Creates a new object when the event arrives to force an update of the image. - val note = noteState?.note - val uri = header.toNostrUri() - val localDir = note?.idHex?.let { File(File(appContext.cacheDir, "NIP95"), it) } - val blurHash = eventHeader.blurhash() - val dimensions = eventHeader.dimensions() - val description = eventHeader.alt() ?: eventHeader.content - val mimeType = eventHeader.mimeType() + val content by + remember(noteState) { + // Creates a new object when the event arrives to force an update of the image. + val note = noteState?.note + val uri = header.toNostrUri() + val localDir = note?.idHex?.let { File(File(appContext.cacheDir, "NIP95"), it) } + val blurHash = eventHeader.blurhash() + val dimensions = eventHeader.dimensions() + val description = eventHeader.alt() ?: eventHeader.content + val mimeType = eventHeader.mimeType() - val newContent = - if (mimeType?.startsWith("image") == true) { - ZoomableLocalImage( - localFile = localDir, - mimeType = mimeType, - description = description, - blurhash = blurHash, - dim = dimensions, - isVerified = true, - uri = uri, - ) - } else { - ZoomableLocalVideo( - localFile = localDir, - mimeType = mimeType, - description = description, - dim = dimensions, - isVerified = true, - uri = uri, - authorName = header.author?.toBestDisplayName(), - ) + val newContent = + if (mimeType?.startsWith("image") == true) { + ZoomableLocalImage( + localFile = localDir, + mimeType = mimeType, + description = description, + blurhash = blurHash, + dim = dimensions, + isVerified = true, + uri = uri, + ) + } else { + ZoomableLocalVideo( + localFile = localDir, + mimeType = mimeType, + description = description, + dim = dimensions, + isVerified = true, + uri = uri, + authorName = header.author?.toBestDisplayName(), + ) + } + + mutableStateOf(newContent) } - mutableStateOf(newContent) + Crossfade(targetState = content) { + if (it != null) { + SensitivityWarning(note = header, accountViewModel = accountViewModel) { + ZoomableContentView( + content = it, + roundedCorner = roundedCorner, + accountViewModel = accountViewModel, + ) + } + } } - - Crossfade(targetState = content) { - if (it != null) { - SensitivityWarning(note = header, accountViewModel = accountViewModel) { - ZoomableContentView( - content = it, - roundedCorner = roundedCorner, - accountViewModel = accountViewModel, - ) - } - } - } } @Composable fun AudioTrackHeader( - noteEvent: AudioTrackEvent, - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + noteEvent: AudioTrackEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val media = remember { noteEvent.media() } - val cover = remember { noteEvent.cover() } - val subject = remember { noteEvent.subject() } - val content = remember { noteEvent.content() } - val participants = remember { noteEvent.participants() } + val media = remember { noteEvent.media() } + val cover = remember { noteEvent.cover() } + val subject = remember { noteEvent.subject() } + val content = remember { noteEvent.content() } + val participants = remember { noteEvent.participants() } - var participantUsers by remember { mutableStateOf>>(emptyList()) } + var participantUsers by remember { mutableStateOf>>(emptyList()) } - LaunchedEffect(key1 = participants) { - accountViewModel.loadParticipants(participants) { participantUsers = it } - } - - Row(modifier = Modifier.padding(top = 5.dp)) { - Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - Row { - subject?.let { - Row( - verticalAlignment = CenterVertically, - modifier = Modifier.padding(top = 5.dp, bottom = 5.dp), - ) { - Text( - text = it, - fontWeight = FontWeight.Bold, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } - - participantUsers.forEach { - Row( - verticalAlignment = CenterVertically, - modifier = - Modifier.padding(top = 5.dp, start = 10.dp, end = 10.dp).clickable { - nav("User/${it.second.pubkeyHex}") - }, - ) { - ClickableUserPicture(it.second, 25.dp, accountViewModel) - Spacer(Modifier.width(5.dp)) - UsernameDisplay(it.second, Modifier.weight(1f)) - Spacer(Modifier.width(5.dp)) - it.first.role?.let { - Text( - text = it.capitalize(Locale.ROOT), - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - ) - } - } - } - - media?.let { media -> - Row( - verticalAlignment = CenterVertically, - ) { - cover?.let { cover -> - LoadThumbAndThenVideoView( - videoUri = media, - title = noteEvent.subject(), - thumbUri = cover, - authorName = note.author?.toBestDisplayName(), - roundedCorner = true, - nostrUriCallback = "nostr:${note.toNEvent()}", - accountViewModel = accountViewModel, - ) - } - ?: VideoView( - videoUri = media, - title = noteEvent.subject(), - authorName = note.author?.toBestDisplayName(), - roundedCorner = true, - accountViewModel = accountViewModel, - ) - } - } + LaunchedEffect(key1 = participants) { + accountViewModel.loadParticipants(participants) { participantUsers = it } + } + + Row(modifier = Modifier.padding(top = 5.dp)) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Row { + subject?.let { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(top = 5.dp, bottom = 5.dp), + ) { + Text( + text = it, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + + participantUsers.forEach { + Row( + verticalAlignment = CenterVertically, + modifier = + Modifier.padding(top = 5.dp, start = 10.dp, end = 10.dp).clickable { + nav("User/${it.second.pubkeyHex}") + }, + ) { + ClickableUserPicture(it.second, 25.dp, accountViewModel) + Spacer(Modifier.width(5.dp)) + UsernameDisplay(it.second, Modifier.weight(1f)) + Spacer(Modifier.width(5.dp)) + it.first.role?.let { + Text( + text = it.capitalize(Locale.ROOT), + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + } + } + } + + media?.let { media -> + Row( + verticalAlignment = CenterVertically, + ) { + cover?.let { cover -> + LoadThumbAndThenVideoView( + videoUri = media, + title = noteEvent.subject(), + thumbUri = cover, + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + nostrUriCallback = "nostr:${note.toNEvent()}", + accountViewModel = accountViewModel, + ) + } + ?: VideoView( + videoUri = media, + title = noteEvent.subject(), + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + accountViewModel = accountViewModel, + ) + } + } + } } - } } @Composable fun AudioHeader( - noteEvent: AudioHeaderEvent, - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + noteEvent: AudioHeaderEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val media = remember { noteEvent.stream() ?: noteEvent.download() } - val waveform = remember { noteEvent.wavefrom()?.toImmutableList()?.ifEmpty { null } } - val content = remember { noteEvent.content().ifBlank { null } } + val media = remember { noteEvent.stream() ?: noteEvent.download() } + val waveform = remember { noteEvent.wavefrom()?.toImmutableList()?.ifEmpty { null } } + val content = remember { noteEvent.content().ifBlank { null } } - val defaultBackground = MaterialTheme.colorScheme.background - val background = remember { mutableStateOf(defaultBackground) } - val tags = remember(noteEvent) { noteEvent.tags()?.toImmutableListOfLists() ?: EmptyTagList } + val defaultBackground = MaterialTheme.colorScheme.background + val background = remember { mutableStateOf(defaultBackground) } + val tags = remember(noteEvent) { noteEvent.tags()?.toImmutableListOfLists() ?: EmptyTagList } - Row(modifier = Modifier.padding(top = 5.dp)) { - Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - media?.let { media -> - Row( - verticalAlignment = CenterVertically, - ) { - VideoView( - videoUri = media, - waveform = waveform, - title = noteEvent.subject(), - authorName = note.author?.toBestDisplayName(), - roundedCorner = true, - accountViewModel = accountViewModel, - nostrUriCallback = note.toNostrUri(), - ) + Row(modifier = Modifier.padding(top = 5.dp)) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + media?.let { media -> + Row( + verticalAlignment = CenterVertically, + ) { + VideoView( + videoUri = media, + waveform = waveform, + title = noteEvent.subject(), + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + accountViewModel = accountViewModel, + nostrUriCallback = note.toNostrUri(), + ) + } + } + + content?.let { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.fillMaxWidth().padding(top = 5.dp), + ) { + TranslatableRichTextViewer( + content = it, + canPreview = true, + tags = tags, + backgroundColor = background, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + + if (noteEvent.hasHashtags()) { + Row(Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) { + val hashtags = remember(noteEvent) { noteEvent.hashtags().toImmutableList() } + DisplayUncitedHashtags(hashtags, content ?: "", nav) + } + } } - } - - content?.let { - Row( - verticalAlignment = CenterVertically, - modifier = Modifier.fillMaxWidth().padding(top = 5.dp), - ) { - TranslatableRichTextViewer( - content = it, - canPreview = true, - tags = tags, - backgroundColor = background, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - - if (noteEvent.hasHashtags()) { - Row(Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) { - val hashtags = remember(noteEvent) { noteEvent.hashtags().toImmutableList() } - DisplayUncitedHashtags(hashtags, content ?: "", nav) - } - } } - } } @Composable fun RenderLiveActivityEvent( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Row(modifier = Modifier.padding(top = 5.dp)) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - RenderLiveActivityEventInner(baseNote = baseNote, accountViewModel, nav) + Row(modifier = Modifier.padding(top = 5.dp)) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + RenderLiveActivityEventInner(baseNote = baseNote, accountViewModel, nav) + } } - } } @Composable fun RenderLiveActivityEventInner( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return + val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return - val eventUpdates by baseNote.live().metadata.observeAsState() + val eventUpdates by baseNote.live().metadata.observeAsState() - val media = remember(eventUpdates) { noteEvent.streaming() } - val cover = remember(eventUpdates) { noteEvent.image() } - val subject = remember(eventUpdates) { noteEvent.title() } - val content = remember(eventUpdates) { noteEvent.summary() } - val participants = remember(eventUpdates) { noteEvent.participants() } - val status = remember(eventUpdates) { noteEvent.status() } - val starts = remember(eventUpdates) { noteEvent.starts() } + val media = remember(eventUpdates) { noteEvent.streaming() } + val cover = remember(eventUpdates) { noteEvent.image() } + val subject = remember(eventUpdates) { noteEvent.title() } + val content = remember(eventUpdates) { noteEvent.summary() } + val participants = remember(eventUpdates) { noteEvent.participants() } + val status = remember(eventUpdates) { noteEvent.status() } + val starts = remember(eventUpdates) { noteEvent.starts() } - Row( - verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = 5.dp).fillMaxWidth(), - ) { - subject?.let { - Text( - text = it, - fontWeight = FontWeight.Bold, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - } - - Spacer(modifier = StdHorzSpacer) - - Crossfade(targetState = status, label = "RenderLiveActivityEventInner") { - when (it) { - STATUS_LIVE -> { - media?.let { CrossfadeCheckIfUrlIsOnline(it, accountViewModel) { LiveFlag() } } - } - STATUS_PLANNED -> { - ScheduledFlag(starts) - } - } - } - } - - var participantUsers by remember { - mutableStateOf>>( - persistentListOf(), - ) - } - - LaunchedEffect(key1 = eventUpdates) { - accountViewModel.loadParticipants(participants) { newParticipantUsers -> - if (!equalImmutableLists(newParticipantUsers, participantUsers)) { - participantUsers = newParticipantUsers - } - } - } - - media?.let { media -> - if (status == STATUS_LIVE) { - CheckIfUrlIsOnline(media, accountViewModel) { isOnline -> - if (isOnline) { - Row( - verticalAlignment = CenterVertically, - ) { - VideoView( - videoUri = media, - title = subject, - artworkUri = cover, - authorName = baseNote.author?.toBestDisplayName(), - roundedCorner = true, - accountViewModel = accountViewModel, - nostrUriCallback = "nostr:${baseNote.toNEvent()}", - ) - } - } else { - Row( - verticalAlignment = CenterVertically, - modifier = Modifier.padding(10.dp).height(100.dp), - ) { - Text( - text = stringResource(id = R.string.live_stream_is_offline), - color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.Bold, - ) - } - } - } - } else if (status == STATUS_ENDED) { - Row( - verticalAlignment = CenterVertically, - modifier = Modifier.padding(10.dp).height(100.dp), - ) { - Text( - text = stringResource(id = R.string.live_stream_has_ended), - color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.Bold, - ) - } - } - } - - participantUsers.forEach { Row( - verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = 5.dp).clickable { nav("User/${it.second.pubkeyHex}") }, + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = 5.dp).fillMaxWidth(), ) { - ClickableUserPicture(it.second, 25.dp, accountViewModel) - Spacer(StdHorzSpacer) - UsernameDisplay(it.second, Modifier.weight(1f)) - Spacer(StdHorzSpacer) - it.first.role?.let { - Text( - text = it.capitalize(Locale.ROOT), - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - ) - } + subject?.let { + Text( + text = it, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + + Spacer(modifier = StdHorzSpacer) + + Crossfade(targetState = status, label = "RenderLiveActivityEventInner") { + when (it) { + STATUS_LIVE -> { + media?.let { CrossfadeCheckIfUrlIsOnline(it, accountViewModel) { LiveFlag() } } + } + STATUS_PLANNED -> { + ScheduledFlag(starts) + } + } + } + } + + var participantUsers by remember { + mutableStateOf>>( + persistentListOf(), + ) + } + + LaunchedEffect(key1 = eventUpdates) { + accountViewModel.loadParticipants(participants) { newParticipantUsers -> + if (!equalImmutableLists(newParticipantUsers, participantUsers)) { + participantUsers = newParticipantUsers + } + } + } + + media?.let { media -> + if (status == STATUS_LIVE) { + CheckIfUrlIsOnline(media, accountViewModel) { isOnline -> + if (isOnline) { + Row( + verticalAlignment = CenterVertically, + ) { + VideoView( + videoUri = media, + title = subject, + artworkUri = cover, + authorName = baseNote.author?.toBestDisplayName(), + roundedCorner = true, + accountViewModel = accountViewModel, + nostrUriCallback = "nostr:${baseNote.toNEvent()}", + ) + } + } else { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(10.dp).height(100.dp), + ) { + Text( + text = stringResource(id = R.string.live_stream_is_offline), + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.Bold, + ) + } + } + } + } else if (status == STATUS_ENDED) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(10.dp).height(100.dp), + ) { + Text( + text = stringResource(id = R.string.live_stream_has_ended), + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.Bold, + ) + } + } + } + + participantUsers.forEach { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = 5.dp).clickable { nav("User/${it.second.pubkeyHex}") }, + ) { + ClickableUserPicture(it.second, 25.dp, accountViewModel) + Spacer(StdHorzSpacer) + UsernameDisplay(it.second, Modifier.weight(1f)) + Spacer(StdHorzSpacer) + it.first.role?.let { + Text( + text = it.capitalize(Locale.ROOT), + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + } + } } - } } @Composable private fun LongFormHeader( - noteEvent: LongTextNoteEvent, - note: Note, - accountViewModel: AccountViewModel, + noteEvent: LongTextNoteEvent, + note: Note, + accountViewModel: AccountViewModel, ) { - val image = remember(noteEvent) { noteEvent.image() } - val title = remember(noteEvent) { noteEvent.title() } - val summary = - remember(noteEvent) { - noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } - } - - Row( - modifier = - Modifier.padding(top = Size5dp) - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder, - ), - ) { - Column { - val automaticallyShowUrlPreview = remember { accountViewModel.settings.showUrlPreview.value } - - if (automaticallyShowUrlPreview) { - image?.let { - AsyncImage( - model = it, - contentDescription = - stringResource( - R.string.preview_card_image_for, - it, - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) + val image = remember(noteEvent) { noteEvent.image() } + val title = remember(noteEvent) { noteEvent.title() } + val summary = + remember(noteEvent) { + noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } } - ?: CreateImageHeader(note, accountViewModel) - } - title?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp), - ) - } + Row( + modifier = + Modifier.padding(top = Size5dp) + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + ) { + Column { + val automaticallyShowUrlPreview = remember { accountViewModel.settings.showUrlPreview.value } - summary?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, bottom = 10.dp), - color = Color.Gray, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } + if (automaticallyShowUrlPreview) { + image?.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } + ?: CreateImageHeader(note, accountViewModel) + } + + title?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp), + ) + } + + summary?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } } - } } @Composable private fun RenderClassifieds( - noteEvent: ClassifiedsEvent, - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + noteEvent: ClassifiedsEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val image = remember(noteEvent) { noteEvent.image() } - val title = remember(noteEvent) { noteEvent.title() } - val summary = - remember(noteEvent) { noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } } - val price = remember(noteEvent) { noteEvent.price() } - val location = remember(noteEvent) { noteEvent.location() } + val image = remember(noteEvent) { noteEvent.image() } + val title = remember(noteEvent) { noteEvent.title() } + val summary = + remember(noteEvent) { noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } } + val price = remember(noteEvent) { noteEvent.price() } + val location = remember(noteEvent) { noteEvent.location() } - Row( - modifier = - Modifier.clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder, - ), - ) { - Column { - Row { - image?.let { - AsyncImage( - model = it, - contentDescription = - stringResource( - R.string.preview_card_image_for, - it, - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) - } - ?: CreateImageHeader(note, accountViewModel) - } - - Row( - Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp), - verticalAlignment = CenterVertically, - ) { - title?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - } - - price?.let { - val priceTag = - remember(noteEvent) { - val newAmount = - price.amount.toBigDecimalOrNull()?.let { showAmount(it) } ?: price.amount - - if (price.frequency != null && price.currency != null) { - "$newAmount ${price.currency}/${price.frequency}" - } else if (price.currency != null) { - "$newAmount ${price.currency}" - } else { - newAmount - } + Row( + modifier = + Modifier.clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + ) { + Column { + Row { + image?.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } + ?: CreateImageHeader(note, accountViewModel) } - Text( - text = priceTag, - maxLines = 1, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - modifier = remember { Modifier.clip(SmallBorder).padding(start = 5.dp) }, - ) - } - } + Row( + Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp), + verticalAlignment = CenterVertically, + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } - if (summary != null || location != null) { - Row( - Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp), - verticalAlignment = CenterVertically, - ) { - summary?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.weight(1f), - color = Color.Gray, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } + price?.let { + val priceTag = + remember(noteEvent) { + val newAmount = + price.amount.toBigDecimalOrNull()?.let { showAmount(it) } ?: price.amount + + if (price.frequency != null && price.currency != null) { + "$newAmount ${price.currency}/${price.frequency}" + } else if (price.currency != null) { + "$newAmount ${price.currency}" + } else { + newAmount + } + } + + Text( + text = priceTag, + maxLines = 1, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = remember { Modifier.clip(SmallBorder).padding(start = 5.dp) }, + ) + } + } + + if (summary != null || location != null) { + Row( + Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp), + verticalAlignment = CenterVertically, + ) { + summary?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } /* Column { @@ -3727,45 +3735,45 @@ private fun RenderClassifieds( } */ - } - } + } + } - Spacer(modifier = DoubleVertSpacer) + Spacer(modifier = DoubleVertSpacer) + } } - } } @Composable fun CreateImageHeader( - note: Note, - accountViewModel: AccountViewModel, + note: Note, + accountViewModel: AccountViewModel, ) { - val banner = remember(note.author?.info) { note.author?.info?.banner } + val banner = remember(note.author?.info) { note.author?.info?.banner } - Box { - banner?.let { - AsyncImage( - model = it, - contentDescription = - stringResource( - R.string.preview_card_image_for, - it, - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) - } - ?: Image( - painter = painterResource(R.drawable.profile_banner), - contentDescription = stringResource(R.string.profile_banner), - contentScale = ContentScale.FillWidth, - modifier = remember { Modifier.fillMaxWidth().height(150.dp) }, - ) + Box { + banner?.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } + ?: Image( + painter = painterResource(R.drawable.profile_banner), + contentDescription = stringResource(R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = remember { Modifier.fillMaxWidth().height(150.dp) }, + ) - Box( - remember { Modifier.width(75.dp).height(75.dp).padding(10.dp).align(Alignment.BottomStart) }, - ) { - NoteAuthorPicture(baseNote = note, accountViewModel = accountViewModel, size = Size55dp) + Box( + remember { Modifier.width(75.dp).height(75.dp).padding(10.dp).align(Alignment.BottomStart) }, + ) { + NoteAuthorPicture(baseNote = note, accountViewModel = accountViewModel, size = Size55dp) + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index c34728a84..8cb89cf64 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -97,467 +97,466 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch private fun lightenColor( - color: Color, - amount: Float, + color: Color, + amount: Float, ): Color { - var argb = color.toArgb() - val hslOut = floatArrayOf(0f, 0f, 0f) - ColorUtils.colorToHSL(argb, hslOut) - hslOut[2] += amount - argb = ColorUtils.HSLToColor(hslOut) - return Color(argb) + var argb = color.toArgb() + val hslOut = floatArrayOf(0f, 0f, 0f) + ColorUtils.colorToHSL(argb, hslOut) + hslOut[2] += amount + argb = ColorUtils.HSLToColor(hslOut) + return Color(argb) } val externalLinkForNote = { note: Note -> - if (note is AddressableNote) { - if (note.event?.getReward() != null) { - "https://nostrbounties.com/b/${note.address().toNAddr()}" - } else if (note.event is PeopleListEvent) { - "https://listr.lol/a/${note.address()?.toNAddr()}" - } else if (note.event is AudioTrackEvent) { - "https://zapstr.live/?track=${note.address()?.toNAddr()}" + if (note is AddressableNote) { + if (note.event?.getReward() != null) { + "https://nostrbounties.com/b/${note.address().toNAddr()}" + } else if (note.event is PeopleListEvent) { + "https://listr.lol/a/${note.address()?.toNAddr()}" + } else if (note.event is AudioTrackEvent) { + "https://zapstr.live/?track=${note.address()?.toNAddr()}" + } else { + "https://habla.news/a/${note.address()?.toNAddr()}" + } } else { - "https://habla.news/a/${note.address()?.toNAddr()}" + if (note.event is FileHeaderEvent) { + "https://filestr.vercel.app/e/${note.toNEvent()}" + } else { + "https://njump.me/${note.toNEvent()}" + } } - } else { - if (note.event is FileHeaderEvent) { - "https://filestr.vercel.app/e/${note.toNEvent()}" - } else { - "https://njump.me/${note.toNEvent()}" - } - } } @Composable private fun VerticalDivider(color: Color) = - Divider( - color = color, - modifier = Modifier.fillMaxHeight().width(1.dp), - ) + Divider( + color = color, + modifier = Modifier.fillMaxHeight().width(1.dp), + ) @Composable fun LongPressToQuickAction( - baseNote: Note, - accountViewModel: AccountViewModel, - content: @Composable (() -> Unit) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + content: @Composable (() -> Unit) -> Unit, ) { - val popupExpanded = remember { mutableStateOf(false) } - val showPopup = remember { { popupExpanded.value = true } } - val hidePopup = remember { { popupExpanded.value = false } } + val popupExpanded = remember { mutableStateOf(false) } + val showPopup = remember { { popupExpanded.value = true } } + val hidePopup = remember { { popupExpanded.value = false } } - content(showPopup) + content(showPopup) - NoteQuickActionMenu(baseNote, popupExpanded.value, hidePopup, accountViewModel) + NoteQuickActionMenu(baseNote, popupExpanded.value, hidePopup, accountViewModel) } @Composable fun NoteQuickActionMenu( - note: Note, - popupExpanded: Boolean, - onDismiss: () -> Unit, - accountViewModel: AccountViewModel, + note: Note, + popupExpanded: Boolean, + onDismiss: () -> Unit, + accountViewModel: AccountViewModel, ) { - val showSelectTextDialog = remember { mutableStateOf(false) } - val showDeleteAlertDialog = remember { mutableStateOf(false) } - val showBlockAlertDialog = remember { mutableStateOf(false) } - val showReportDialog = remember { mutableStateOf(false) } + val showSelectTextDialog = remember { mutableStateOf(false) } + val showDeleteAlertDialog = remember { mutableStateOf(false) } + val showBlockAlertDialog = remember { mutableStateOf(false) } + val showReportDialog = remember { mutableStateOf(false) } - if (popupExpanded) { - RenderMainPopup( - accountViewModel, - note, - onDismiss, - showBlockAlertDialog, - showDeleteAlertDialog, - showReportDialog, - ) - } - - if (showSelectTextDialog.value) { - val decryptedNote = remember { mutableStateOf(null) } - - LaunchedEffect(key1 = Unit) { accountViewModel.decrypt(note) { decryptedNote.value = it } } - - decryptedNote.value?.let { - SelectTextDialog(it) { - showSelectTextDialog.value = false - decryptedNote.value = null - } + if (popupExpanded) { + RenderMainPopup( + accountViewModel, + note, + onDismiss, + showBlockAlertDialog, + showDeleteAlertDialog, + showReportDialog, + ) } - } - if (showDeleteAlertDialog.value) { - DeleteAlertDialog(note, accountViewModel) { - showDeleteAlertDialog.value = false - onDismiss() - } - } + if (showSelectTextDialog.value) { + val decryptedNote = remember { mutableStateOf(null) } - if (showBlockAlertDialog.value) { - BlockAlertDialog(note, accountViewModel) { - showBlockAlertDialog.value = false - onDismiss() - } - } + LaunchedEffect(key1 = Unit) { accountViewModel.decrypt(note) { decryptedNote.value = it } } - if (showReportDialog.value) { - ReportNoteDialog(note, accountViewModel) { - showReportDialog.value = false - onDismiss() + decryptedNote.value?.let { + SelectTextDialog(it) { + showSelectTextDialog.value = false + decryptedNote.value = null + } + } + } + + if (showDeleteAlertDialog.value) { + DeleteAlertDialog(note, accountViewModel) { + showDeleteAlertDialog.value = false + onDismiss() + } + } + + if (showBlockAlertDialog.value) { + BlockAlertDialog(note, accountViewModel) { + showBlockAlertDialog.value = false + onDismiss() + } + } + + if (showReportDialog.value) { + ReportNoteDialog(note, accountViewModel) { + showReportDialog.value = false + onDismiss() + } } - } } @Composable private fun RenderMainPopup( - accountViewModel: AccountViewModel, - note: Note, - onDismiss: () -> Unit, - showBlockAlertDialog: MutableState, - showDeleteAlertDialog: MutableState, - showReportDialog: MutableState, + accountViewModel: AccountViewModel, + note: Note, + onDismiss: () -> Unit, + showBlockAlertDialog: MutableState, + showDeleteAlertDialog: MutableState, + showReportDialog: MutableState, ) { - val context = LocalContext.current - val primaryLight = lightenColor(MaterialTheme.colorScheme.primary, 0.1f) - val cardShape = RoundedCornerShape(5.dp) - val clipboardManager = LocalClipboardManager.current - val scope = rememberCoroutineScope() + val context = LocalContext.current + val primaryLight = lightenColor(MaterialTheme.colorScheme.primary, 0.1f) + val cardShape = RoundedCornerShape(5.dp) + val clipboardManager = LocalClipboardManager.current + val scope = rememberCoroutineScope() - val backgroundColor = - if (MaterialTheme.colorScheme.isLight) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.secondaryButtonBackground - } - - val showToast = { stringResource: Int -> - scope.launch { - Toast.makeText( - context, - context.getString(stringResource), - Toast.LENGTH_SHORT, - ) - .show() - } - } - - val isOwnNote = accountViewModel.isLoggedUser(note.author) - val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author) - - Popup(onDismissRequest = onDismiss, alignment = Alignment.Center) { - Card( - modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape), - shape = cardShape, - colors = CardDefaults.cardColors(containerColor = backgroundColor), - ) { - Column(modifier = Modifier.width(IntrinsicSize.Min)) { - Row(modifier = Modifier.height(IntrinsicSize.Min)) { - NoteQuickActionItem( - icon = Icons.Default.ContentCopy, - label = stringResource(R.string.quick_action_copy_text), - ) { - accountViewModel.decrypt(note) { - clipboardManager.setText(AnnotatedString(it)) - showToast(R.string.copied_note_text_to_clipboard) - } - - onDismiss() - } - VerticalDivider(primaryLight) - NoteQuickActionItem( - Icons.Default.AlternateEmail, - stringResource(R.string.quick_action_copy_user_id), - ) { - scope.launch(Dispatchers.IO) { - clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")) - showToast(R.string.copied_user_id_to_clipboard) - onDismiss() - } - } - VerticalDivider(primaryLight) - NoteQuickActionItem( - Icons.Default.FormatQuote, - stringResource(R.string.quick_action_copy_note_id), - ) { - scope.launch(Dispatchers.IO) { - clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}")) - showToast(R.string.copied_note_id_to_clipboard) - onDismiss() - } - } - - if (!isOwnNote) { - VerticalDivider(primaryLight) - - NoteQuickActionItem( - Icons.Default.Block, - stringResource(R.string.quick_action_block), - ) { - if (accountViewModel.hideBlockAlertDialog) { - note.author?.let { accountViewModel.hide(it) } - onDismiss() - } else { - showBlockAlertDialog.value = true - } - } - } + val backgroundColor = + if (MaterialTheme.colorScheme.isLight) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.secondaryButtonBackground } - Divider( - color = primaryLight, - modifier = Modifier.fillMaxWidth().width(1.dp), - ) - Row(modifier = Modifier.height(IntrinsicSize.Min)) { - if (isOwnNote) { - NoteQuickActionItem( - Icons.Default.Delete, - stringResource(R.string.quick_action_delete), - ) { - if (accountViewModel.hideDeleteRequestDialog) { - accountViewModel.delete(note) - onDismiss() - } else { - showDeleteAlertDialog.value = true - } - } - } else if (isFollowingUser) { - NoteQuickActionItem( - Icons.Default.PersonRemove, - stringResource(R.string.quick_action_unfollow), - ) { - accountViewModel.unfollow(note.author!!) - onDismiss() - } - } else { - NoteQuickActionItem( - Icons.Default.PersonAdd, - stringResource(R.string.quick_action_follow), - ) { - accountViewModel.follow(note.author!!) - onDismiss() - } - } - VerticalDivider(primaryLight) - NoteQuickActionItem( - icon = ImageVector.vectorResource(id = R.drawable.relays), - label = stringResource(R.string.broadcast), - ) { - scope.launch(Dispatchers.IO) { - accountViewModel.broadcast(note) - // showSelectTextDialog = true - onDismiss() - } - } - VerticalDivider(primaryLight) - NoteQuickActionItem( - icon = Icons.Default.Share, - label = stringResource(R.string.quick_action_share), - ) { - val sendIntent = - Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - externalLinkForNote(note), - ) - putExtra( - Intent.EXTRA_TITLE, - context.getString(R.string.quick_action_share_browser_link), - ) - } - - val shareIntent = - Intent.createChooser( - sendIntent, - context.getString(R.string.quick_action_share), - ) - ContextCompat.startActivity(context, shareIntent, null) - onDismiss() - } - - if (!isOwnNote) { - VerticalDivider(primaryLight) - - NoteQuickActionItem( - Icons.Default.Report, - stringResource(R.string.quick_action_report), - ) { - showReportDialog.value = true - } - } + val showToast = { stringResource: Int -> + scope.launch { + Toast.makeText( + context, + context.getString(stringResource), + Toast.LENGTH_SHORT, + ) + .show() + } + } + + val isOwnNote = accountViewModel.isLoggedUser(note.author) + val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author) + + Popup(onDismissRequest = onDismiss, alignment = Alignment.Center) { + Card( + modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape), + shape = cardShape, + colors = CardDefaults.cardColors(containerColor = backgroundColor), + ) { + Column(modifier = Modifier.width(IntrinsicSize.Min)) { + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + NoteQuickActionItem( + icon = Icons.Default.ContentCopy, + label = stringResource(R.string.quick_action_copy_text), + ) { + accountViewModel.decrypt(note) { + clipboardManager.setText(AnnotatedString(it)) + showToast(R.string.copied_note_text_to_clipboard) + } + + onDismiss() + } + VerticalDivider(primaryLight) + NoteQuickActionItem( + Icons.Default.AlternateEmail, + stringResource(R.string.quick_action_copy_user_id), + ) { + scope.launch(Dispatchers.IO) { + clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")) + showToast(R.string.copied_user_id_to_clipboard) + onDismiss() + } + } + VerticalDivider(primaryLight) + NoteQuickActionItem( + Icons.Default.FormatQuote, + stringResource(R.string.quick_action_copy_note_id), + ) { + scope.launch(Dispatchers.IO) { + clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}")) + showToast(R.string.copied_note_id_to_clipboard) + onDismiss() + } + } + + if (!isOwnNote) { + VerticalDivider(primaryLight) + + NoteQuickActionItem( + Icons.Default.Block, + stringResource(R.string.quick_action_block), + ) { + if (accountViewModel.hideBlockAlertDialog) { + note.author?.let { accountViewModel.hide(it) } + onDismiss() + } else { + showBlockAlertDialog.value = true + } + } + } + } + Divider( + color = primaryLight, + modifier = Modifier.fillMaxWidth().width(1.dp), + ) + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + if (isOwnNote) { + NoteQuickActionItem( + Icons.Default.Delete, + stringResource(R.string.quick_action_delete), + ) { + if (accountViewModel.hideDeleteRequestDialog) { + accountViewModel.delete(note) + onDismiss() + } else { + showDeleteAlertDialog.value = true + } + } + } else if (isFollowingUser) { + NoteQuickActionItem( + Icons.Default.PersonRemove, + stringResource(R.string.quick_action_unfollow), + ) { + accountViewModel.unfollow(note.author!!) + onDismiss() + } + } else { + NoteQuickActionItem( + Icons.Default.PersonAdd, + stringResource(R.string.quick_action_follow), + ) { + accountViewModel.follow(note.author!!) + onDismiss() + } + } + + VerticalDivider(primaryLight) + NoteQuickActionItem( + icon = ImageVector.vectorResource(id = R.drawable.relays), + label = stringResource(R.string.broadcast), + ) { + scope.launch(Dispatchers.IO) { + accountViewModel.broadcast(note) + // showSelectTextDialog = true + onDismiss() + } + } + VerticalDivider(primaryLight) + NoteQuickActionItem( + icon = Icons.Default.Share, + label = stringResource(R.string.quick_action_share), + ) { + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + externalLinkForNote(note), + ) + putExtra( + Intent.EXTRA_TITLE, + context.getString(R.string.quick_action_share_browser_link), + ) + } + + val shareIntent = + Intent.createChooser( + sendIntent, + context.getString(R.string.quick_action_share), + ) + ContextCompat.startActivity(context, shareIntent, null) + onDismiss() + } + + if (!isOwnNote) { + VerticalDivider(primaryLight) + + NoteQuickActionItem( + Icons.Default.Report, + stringResource(R.string.quick_action_report), + ) { + showReportDialog.value = true + } + } + } + } } - } } - } } @Composable fun NoteQuickActionItem( - icon: ImageVector, - label: String, - onClick: () -> Unit, + icon: ImageVector, + label: String, + onClick: () -> Unit, ) { - Column( - modifier = Modifier.size(70.dp).clickable { onClick() }, - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(24.dp).padding(bottom = 5.dp), - tint = Color.White, - ) - Text(text = label, fontSize = 12.sp, color = Color.White, textAlign = TextAlign.Center) - } + Column( + modifier = Modifier.size(70.dp).clickable { onClick() }, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp).padding(bottom = 5.dp), + tint = Color.White, + ) + Text(text = label, fontSize = 12.sp, color = Color.White, textAlign = TextAlign.Center) + } } @Composable fun DeleteAlertDialog( - note: Note, - accountViewModel: AccountViewModel, - onDismiss: () -> Unit, + note: Note, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, ) { - QuickActionAlertDialog( - title = stringResource(R.string.quick_action_request_deletion_alert_title), - textContent = stringResource(R.string.quick_action_request_deletion_alert_body), - buttonIcon = Icons.Default.Delete, - buttonText = stringResource(R.string.quick_action_delete_dialog_btn), - onClickDoOnce = { - accountViewModel.delete(note) - onDismiss() - }, - onClickDontShowAgain = { - accountViewModel.delete(note) - accountViewModel.dontShowDeleteRequestDialog() - onDismiss() - }, - onDismiss = onDismiss, - ) + QuickActionAlertDialog( + title = stringResource(R.string.quick_action_request_deletion_alert_title), + textContent = stringResource(R.string.quick_action_request_deletion_alert_body), + buttonIcon = Icons.Default.Delete, + buttonText = stringResource(R.string.quick_action_delete_dialog_btn), + onClickDoOnce = { + accountViewModel.delete(note) + onDismiss() + }, + onClickDontShowAgain = { + accountViewModel.delete(note) + accountViewModel.dontShowDeleteRequestDialog() + onDismiss() + }, + onDismiss = onDismiss, + ) } @Composable private fun BlockAlertDialog( - note: Note, - accountViewModel: AccountViewModel, - onDismiss: () -> Unit, -) = - QuickActionAlertDialog( + note: Note, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) = QuickActionAlertDialog( title = stringResource(R.string.report_dialog_block_hide_user_btn), textContent = stringResource(R.string.report_dialog_blocking_a_user), buttonIcon = Icons.Default.Block, buttonText = stringResource(R.string.quick_action_block_dialog_btn), buttonColors = - ButtonDefaults.buttonColors( - containerColor = WarningColor, - contentColor = Color.White, - ), + ButtonDefaults.buttonColors( + containerColor = WarningColor, + contentColor = Color.White, + ), onClickDoOnce = { - note.author?.let { accountViewModel.hide(it) } - onDismiss() + note.author?.let { accountViewModel.hide(it) } + onDismiss() }, onClickDontShowAgain = { - note.author?.let { accountViewModel.hide(it) } - accountViewModel.dontShowBlockAlertDialog() - onDismiss() + note.author?.let { accountViewModel.hide(it) } + accountViewModel.dontShowBlockAlertDialog() + onDismiss() }, onDismiss = onDismiss, - ) +) @Composable fun QuickActionAlertDialog( - title: String, - textContent: String, - buttonIcon: ImageVector, - buttonText: String, - buttonColors: ButtonColors = ButtonDefaults.buttonColors(), - onClickDoOnce: () -> Unit, - onClickDontShowAgain: () -> Unit, - onDismiss: () -> Unit, + title: String, + textContent: String, + buttonIcon: ImageVector, + buttonText: String, + buttonColors: ButtonColors = ButtonDefaults.buttonColors(), + onClickDoOnce: () -> Unit, + onClickDontShowAgain: () -> Unit, + onDismiss: () -> Unit, ) { - QuickActionAlertDialog( - title = title, - textContent = textContent, - icon = { - Icon( - imageVector = buttonIcon, - contentDescription = null, - ) - }, - buttonText = buttonText, - buttonColors = buttonColors, - onClickDoOnce = onClickDoOnce, - onClickDontShowAgain = onClickDontShowAgain, - onDismiss = onDismiss, - ) + QuickActionAlertDialog( + title = title, + textContent = textContent, + icon = { + Icon( + imageVector = buttonIcon, + contentDescription = null, + ) + }, + buttonText = buttonText, + buttonColors = buttonColors, + onClickDoOnce = onClickDoOnce, + onClickDontShowAgain = onClickDontShowAgain, + onDismiss = onDismiss, + ) } @Composable fun QuickActionAlertDialog( - title: String, - textContent: String, - buttonIconResource: Int, - buttonText: String, - buttonColors: ButtonColors = ButtonDefaults.buttonColors(), - onClickDoOnce: () -> Unit, - onClickDontShowAgain: () -> Unit, - onDismiss: () -> Unit, + title: String, + textContent: String, + buttonIconResource: Int, + buttonText: String, + buttonColors: ButtonColors = ButtonDefaults.buttonColors(), + onClickDoOnce: () -> Unit, + onClickDontShowAgain: () -> Unit, + onDismiss: () -> Unit, ) { - QuickActionAlertDialog( - title = title, - textContent = textContent, - icon = { - Icon( - painter = painterResource(buttonIconResource), - contentDescription = null, - ) - }, - buttonText = buttonText, - buttonColors = buttonColors, - onClickDoOnce = onClickDoOnce, - onClickDontShowAgain = onClickDontShowAgain, - onDismiss = onDismiss, - ) + QuickActionAlertDialog( + title = title, + textContent = textContent, + icon = { + Icon( + painter = painterResource(buttonIconResource), + contentDescription = null, + ) + }, + buttonText = buttonText, + buttonColors = buttonColors, + onClickDoOnce = onClickDoOnce, + onClickDontShowAgain = onClickDontShowAgain, + onDismiss = onDismiss, + ) } @Composable fun QuickActionAlertDialog( - title: String, - textContent: String, - icon: @Composable () -> Unit, - buttonText: String, - buttonColors: ButtonColors = ButtonDefaults.buttonColors(), - onClickDoOnce: () -> Unit, - onClickDontShowAgain: () -> Unit, - onDismiss: () -> Unit, + title: String, + textContent: String, + icon: @Composable () -> Unit, + buttonText: String, + buttonColors: ButtonColors = ButtonDefaults.buttonColors(), + onClickDoOnce: () -> Unit, + onClickDontShowAgain: () -> Unit, + onDismiss: () -> Unit, ) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(title) }, - text = { Text(textContent) }, - confirmButton = { - Row( - modifier = Modifier.padding(all = 8.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - TextButton(onClick = onClickDontShowAgain) { - Text(stringResource(R.string.quick_action_dont_show_again_button)) - } - Button( - onClick = onClickDoOnce, - colors = buttonColors, - contentPadding = PaddingValues(horizontal = 16.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - icon() - Spacer(Modifier.width(8.dp)) - Text(buttonText) - } - } - } - }, - ) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Text(textContent) }, + confirmButton = { + Row( + modifier = Modifier.padding(all = 8.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextButton(onClick = onClickDontShowAgain) { + Text(stringResource(R.string.quick_action_dont_show_again_button)) + } + Button( + onClick = onClickDoOnce, + colors = buttonColors, + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + icon() + Spacer(Modifier.width(8.dp)) + Text(buttonText) + } + } + } + }, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index 837eb710f..bd604d56f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -87,483 +87,483 @@ import com.vitorpamplona.quartz.events.EmptyTagList import com.vitorpamplona.quartz.events.ImmutableListOfLists import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.toImmutableListOfLists -import kotlin.math.roundToInt import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlin.math.roundToInt @Composable fun PollNote( - baseNote: Note, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val pollViewModel: PollNoteViewModel = viewModel(key = "PollNoteViewModel") + val pollViewModel: PollNoteViewModel = viewModel(key = "PollNoteViewModel") - pollViewModel.load(accountViewModel.account, baseNote) + pollViewModel.load(accountViewModel.account, baseNote) - PollNote( - baseNote = baseNote, - pollViewModel = pollViewModel, - canPreview = canPreview, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) + PollNote( + baseNote = baseNote, + pollViewModel = pollViewModel, + canPreview = canPreview, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) } @Composable fun PollNote( - baseNote: Note, - pollViewModel: PollNoteViewModel, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + pollViewModel: PollNoteViewModel, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - WatchZapsAndUpdateTallies(baseNote, pollViewModel) + WatchZapsAndUpdateTallies(baseNote, pollViewModel) - val tallies by pollViewModel.tallies.collectAsStateWithLifecycle() + val tallies by pollViewModel.tallies.collectAsStateWithLifecycle() - tallies.forEach { poll_op -> - OptionNote( - poll_op, - pollViewModel, - baseNote, - accountViewModel, - canPreview, - backgroundColor, - nav, - ) - } + tallies.forEach { poll_op -> + OptionNote( + poll_op, + pollViewModel, + baseNote, + accountViewModel, + canPreview, + backgroundColor, + nav, + ) + } } @Composable private fun WatchZapsAndUpdateTallies( - baseNote: Note, - pollViewModel: PollNoteViewModel, + baseNote: Note, + pollViewModel: PollNoteViewModel, ) { - val zapsState by baseNote.live().zaps.observeAsState() + val zapsState by baseNote.live().zaps.observeAsState() - LaunchedEffect(key1 = zapsState) { pollViewModel.refreshTallies() } + LaunchedEffect(key1 = zapsState) { pollViewModel.refreshTallies() } } @Composable private fun OptionNote( - poolOption: PollOption, - pollViewModel: PollNoteViewModel, - baseNote: Note, - accountViewModel: AccountViewModel, - canPreview: Boolean, - backgroundColor: MutableState, - nav: (String) -> Unit, + poolOption: PollOption, + pollViewModel: PollNoteViewModel, + baseNote: Note, + accountViewModel: AccountViewModel, + canPreview: Boolean, + backgroundColor: MutableState, + nav: (String) -> Unit, ) { - val tags = remember(baseNote) { baseNote.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + val tags = remember(baseNote) { baseNote.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 3.dp), - ) { - if (!pollViewModel.canZap()) { - val color = - if (poolOption.consensusThreadhold) { - Color.Green.copy(alpha = 0.32f) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 3.dp), + ) { + if (!pollViewModel.canZap()) { + val color = + if (poolOption.consensusThreadhold) { + Color.Green.copy(alpha = 0.32f) + } else { + MaterialTheme.colorScheme.mediumImportanceLink + } + + ZapVote( + baseNote, + poolOption, + pollViewModel = pollViewModel, + nonClickablePrepend = { + RenderOptionAfterVote( + poolOption.descriptor, + poolOption.tally.toFloat(), + color, + canPreview, + tags, + backgroundColor, + accountViewModel, + nav, + ) + }, + clickablePrepend = {}, + accountViewModel = accountViewModel, + nav = nav, + ) } else { - MaterialTheme.colorScheme.mediumImportanceLink + ZapVote( + baseNote, + poolOption, + pollViewModel = pollViewModel, + nonClickablePrepend = {}, + clickablePrepend = { + RenderOptionBeforeVote( + poolOption.descriptor, + canPreview, + tags, + backgroundColor, + accountViewModel, + nav, + ) + }, + accountViewModel = accountViewModel, + nav = nav, + ) } - - ZapVote( - baseNote, - poolOption, - pollViewModel = pollViewModel, - nonClickablePrepend = { - RenderOptionAfterVote( - poolOption.descriptor, - poolOption.tally.toFloat(), - color, - canPreview, - tags, - backgroundColor, - accountViewModel, - nav, - ) - }, - clickablePrepend = {}, - accountViewModel = accountViewModel, - nav = nav, - ) - } else { - ZapVote( - baseNote, - poolOption, - pollViewModel = pollViewModel, - nonClickablePrepend = {}, - clickablePrepend = { - RenderOptionBeforeVote( - poolOption.descriptor, - canPreview, - tags, - backgroundColor, - accountViewModel, - nav, - ) - }, - accountViewModel = accountViewModel, - nav = nav, - ) } - } } @Composable private fun RenderOptionAfterVote( - description: String, - totalRatio: Float, - color: Color, - canPreview: Boolean, - tags: ImmutableListOfLists, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + description: String, + totalRatio: Float, + color: Color, + canPreview: Boolean, + tags: ImmutableListOfLists, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val totalPercentage = remember(totalRatio) { "${(totalRatio * 100).roundToInt()}%" } + val totalPercentage = remember(totalRatio) { "${(totalRatio * 100).roundToInt()}%" } - Box( - Modifier.fillMaxWidth(0.75f) - .clip(shape = QuoteBorder) - .border( - 2.dp, - color, - QuoteBorder, - ), - ) { - LinearProgressIndicator( - modifier = Modifier.matchParentSize(), - color = color, - progress = totalRatio, - ) - - Row( - verticalAlignment = Alignment.CenterVertically, + Box( + Modifier.fillMaxWidth(0.75f) + .clip(shape = QuoteBorder) + .border( + 2.dp, + color, + QuoteBorder, + ), ) { - Column( - horizontalAlignment = Alignment.End, - modifier = remember { Modifier.padding(horizontal = 10.dp).width(40.dp) }, - ) { - Text( - text = totalPercentage, - fontWeight = FontWeight.Bold, + LinearProgressIndicator( + modifier = Modifier.matchParentSize(), + color = color, + progress = totalRatio, ) - } - Column( - modifier = remember { Modifier.fillMaxWidth().padding(15.dp) }, - ) { - TranslatableRichTextViewer( - description, - canPreview, - remember { Modifier }, - tags, - backgroundColor, - accountViewModel, - nav, - ) - } + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + horizontalAlignment = Alignment.End, + modifier = remember { Modifier.padding(horizontal = 10.dp).width(40.dp) }, + ) { + Text( + text = totalPercentage, + fontWeight = FontWeight.Bold, + ) + } + + Column( + modifier = remember { Modifier.fillMaxWidth().padding(15.dp) }, + ) { + TranslatableRichTextViewer( + description, + canPreview, + remember { Modifier }, + tags, + backgroundColor, + accountViewModel, + nav, + ) + } + } } - } } @Composable private fun RenderOptionBeforeVote( - description: String, - canPreview: Boolean, - tags: ImmutableListOfLists, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + description: String, + canPreview: Boolean, + tags: ImmutableListOfLists, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Box( - Modifier.fillMaxWidth(0.75f) - .clip(shape = QuoteBorder) - .border( - 2.dp, - MaterialTheme.colorScheme.primary, - QuoteBorder, - ), - ) { - TranslatableRichTextViewer( - description, - canPreview, - remember { Modifier.padding(15.dp) }, - tags, - backgroundColor, - accountViewModel, - nav, - ) - } + Box( + Modifier.fillMaxWidth(0.75f) + .clip(shape = QuoteBorder) + .border( + 2.dp, + MaterialTheme.colorScheme.primary, + QuoteBorder, + ), + ) { + TranslatableRichTextViewer( + description, + canPreview, + remember { Modifier.padding(15.dp) }, + tags, + backgroundColor, + accountViewModel, + nav, + ) + } } @Composable @OptIn(ExperimentalFoundationApi::class) fun ZapVote( - baseNote: Note, - poolOption: PollOption, - modifier: Modifier = Modifier, - pollViewModel: PollNoteViewModel, - nonClickablePrepend: @Composable () -> Unit, - clickablePrepend: @Composable () -> Unit, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + poolOption: PollOption, + modifier: Modifier = Modifier, + pollViewModel: PollNoteViewModel, + nonClickablePrepend: @Composable () -> Unit, + clickablePrepend: @Composable () -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val isLoggedUser by remember { derivedStateOf { accountViewModel.isLoggedUser(baseNote.author) } } + val isLoggedUser by remember { derivedStateOf { accountViewModel.isLoggedUser(baseNote.author) } } - var wantsToZap by remember { mutableStateOf(false) } - var wantsToPay by remember { - mutableStateOf>( - persistentListOf(), - ) - } - - var zappingProgress by remember { mutableStateOf(0f) } - var showErrorMessageDialog by remember { mutableStateOf(null) } - - val context = LocalContext.current - val scope = rememberCoroutineScope() - - nonClickablePrepend() - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier.combinedClickable( - role = Role.Button, - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false, radius = 24.dp), - onClick = { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_send_zaps, - ) - } else if (pollViewModel.isPollClosed()) { - accountViewModel.toast( - R.string.poll_unable_to_vote, - R.string.poll_is_closed_explainer, - ) - } else if (isLoggedUser) { - accountViewModel.toast( - R.string.poll_unable_to_vote, - R.string.poll_author_no_vote, - ) - } else if (pollViewModel.isVoteAmountAtomic() && poolOption.zappedByLoggedIn) { - // only allow one vote per option when min==max, i.e. atomic vote amount specified - accountViewModel.toast( - R.string.poll_unable_to_vote, - R.string.one_vote_per_user_on_atomic_votes, - ) - return@combinedClickable - } else if ( - accountViewModel.account.zapAmountChoices.size == 1 && - pollViewModel.isValidInputVoteAmount( - accountViewModel.account.zapAmountChoices.first(), - ) - ) { - accountViewModel.zap( - baseNote, - accountViewModel.account.zapAmountChoices.first() * 1000, - poolOption.option, - "", - context, - onError = { title, message -> - zappingProgress = 0f - showErrorMessageDialog = StringToastMsg(title, message) - }, - onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } }, - onPayViaIntent = {}, - zapType = accountViewModel.account.defaultZapType, - ) - } else { - wantsToZap = true - } - }, - ), - ) { - if (wantsToZap) { - FilteredZapAmountChoicePopup( - baseNote, - accountViewModel, - pollViewModel, - poolOption.option, - onDismiss = { - wantsToZap = false - zappingProgress = 0f - }, - onChangeAmount = { wantsToZap = false }, - onError = { title, message -> - showErrorMessageDialog = StringToastMsg(title, message) - zappingProgress = 0f - }, - onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } }, - onPayViaIntent = { wantsToPay = it }, - ) - } - - if (wantsToPay.isNotEmpty()) { - PayViaIntentDialog( - payingInvoices = wantsToPay, - accountViewModel = accountViewModel, - onClose = { wantsToPay = persistentListOf() }, - onError = { - wantsToPay = persistentListOf() - scope.launch { - zappingProgress = 0f - showErrorMessageDialog = - StringToastMsg( - context.getString(R.string.error_dialog_zap_error), - it, - ) - } - }, - ) - } - - showErrorMessageDialog?.let { toast -> - ErrorMessageDialog( - title = toast.title, - textContent = toast.msg, - onClickStartMessage = { - baseNote.author?.let { nav(routeToMessage(it, toast.msg, accountViewModel)) } - }, - onDismiss = { showErrorMessageDialog = null }, - ) - } - - clickablePrepend() - - if (poolOption.zappedByLoggedIn) { - zappingProgress = 1f - Icon( - imageVector = Icons.Default.Bolt, - contentDescription = stringResource(R.string.zaps), - modifier = Modifier.size(20.dp), - tint = BitcoinOrange, - ) - } else { - if (zappingProgress < 0.1 || zappingProgress > 0.99) { - Icon( - imageVector = Icons.Outlined.Bolt, - contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.placeholderText, + var wantsToZap by remember { mutableStateOf(false) } + var wantsToPay by remember { + mutableStateOf>( + persistentListOf(), ) - } else { - Spacer(Modifier.width(3.dp)) - CircularProgressIndicator( - progress = zappingProgress, - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp, - ) - } } - } - // only show tallies after a user has zapped note - if (!pollViewModel.canZap()) { - val amountStr = remember(poolOption.zappedValue) { showAmount(poolOption.zappedValue) } - Text( - text = amountStr, - fontSize = Font14SP, - color = MaterialTheme.colorScheme.placeholderText, - modifier = modifier, - ) - } + var zappingProgress by remember { mutableStateOf(0f) } + var showErrorMessageDialog by remember { mutableStateOf(null) } + + val context = LocalContext.current + val scope = rememberCoroutineScope() + + nonClickablePrepend() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.combinedClickable( + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false, radius = 24.dp), + onClick = { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_send_zaps, + ) + } else if (pollViewModel.isPollClosed()) { + accountViewModel.toast( + R.string.poll_unable_to_vote, + R.string.poll_is_closed_explainer, + ) + } else if (isLoggedUser) { + accountViewModel.toast( + R.string.poll_unable_to_vote, + R.string.poll_author_no_vote, + ) + } else if (pollViewModel.isVoteAmountAtomic() && poolOption.zappedByLoggedIn) { + // only allow one vote per option when min==max, i.e. atomic vote amount specified + accountViewModel.toast( + R.string.poll_unable_to_vote, + R.string.one_vote_per_user_on_atomic_votes, + ) + return@combinedClickable + } else if ( + accountViewModel.account.zapAmountChoices.size == 1 && + pollViewModel.isValidInputVoteAmount( + accountViewModel.account.zapAmountChoices.first(), + ) + ) { + accountViewModel.zap( + baseNote, + accountViewModel.account.zapAmountChoices.first() * 1000, + poolOption.option, + "", + context, + onError = { title, message -> + zappingProgress = 0f + showErrorMessageDialog = StringToastMsg(title, message) + }, + onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } }, + onPayViaIntent = {}, + zapType = accountViewModel.account.defaultZapType, + ) + } else { + wantsToZap = true + } + }, + ), + ) { + if (wantsToZap) { + FilteredZapAmountChoicePopup( + baseNote, + accountViewModel, + pollViewModel, + poolOption.option, + onDismiss = { + wantsToZap = false + zappingProgress = 0f + }, + onChangeAmount = { wantsToZap = false }, + onError = { title, message -> + showErrorMessageDialog = StringToastMsg(title, message) + zappingProgress = 0f + }, + onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } }, + onPayViaIntent = { wantsToPay = it }, + ) + } + + if (wantsToPay.isNotEmpty()) { + PayViaIntentDialog( + payingInvoices = wantsToPay, + accountViewModel = accountViewModel, + onClose = { wantsToPay = persistentListOf() }, + onError = { + wantsToPay = persistentListOf() + scope.launch { + zappingProgress = 0f + showErrorMessageDialog = + StringToastMsg( + context.getString(R.string.error_dialog_zap_error), + it, + ) + } + }, + ) + } + + showErrorMessageDialog?.let { toast -> + ErrorMessageDialog( + title = toast.title, + textContent = toast.msg, + onClickStartMessage = { + baseNote.author?.let { nav(routeToMessage(it, toast.msg, accountViewModel)) } + }, + onDismiss = { showErrorMessageDialog = null }, + ) + } + + clickablePrepend() + + if (poolOption.zappedByLoggedIn) { + zappingProgress = 1f + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(20.dp), + tint = BitcoinOrange, + ) + } else { + if (zappingProgress < 0.1 || zappingProgress > 0.99) { + Icon( + imageVector = Icons.Outlined.Bolt, + contentDescription = stringResource(id = R.string.zaps), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) + } else { + Spacer(Modifier.width(3.dp)) + CircularProgressIndicator( + progress = zappingProgress, + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + ) + } + } + } + + // only show tallies after a user has zapped note + if (!pollViewModel.canZap()) { + val amountStr = remember(poolOption.zappedValue) { showAmount(poolOption.zappedValue) } + Text( + text = amountStr, + fontSize = Font14SP, + color = MaterialTheme.colorScheme.placeholderText, + modifier = modifier, + ) + } } @OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) @Composable fun FilteredZapAmountChoicePopup( - baseNote: Note, - accountViewModel: AccountViewModel, - pollViewModel: PollNoteViewModel, - pollOption: Int, - onDismiss: () -> Unit, - onChangeAmount: () -> Unit, - onError: (title: String, text: String) -> Unit, - onProgress: (percent: Float) -> Unit, - onPayViaIntent: (ImmutableList) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + pollViewModel: PollNoteViewModel, + pollOption: Int, + onDismiss: () -> Unit, + onChangeAmount: () -> Unit, + onError: (title: String, text: String) -> Unit, + onProgress: (percent: Float) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit, ) { - val context = LocalContext.current + val context = LocalContext.current - val accountState by accountViewModel.accountLiveData.observeAsState() - val defaultZapType by - remember(accountState) { - derivedStateOf { accountState?.account?.defaultZapType ?: LnZapEvent.ZapType.PRIVATE } - } - - val zapMessage = "" - - val sortedOptions = - remember(accountState) { pollViewModel.createZapOptionsThatMatchThePollingParameters() } - - Popup( - alignment = Alignment.BottomCenter, - offset = IntOffset(0, -100), - onDismissRequest = { onDismiss() }, - ) { - FlowRow(horizontalArrangement = Arrangement.Center) { - sortedOptions.forEach { amountInSats -> - val zapAmount = remember { "โšก ${showAmount(amountInSats.toBigDecimal().setScale(1))}" } - - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { - accountViewModel.zap( - baseNote, - amountInSats * 1000, - pollOption, - zapMessage, - context, - onError, - onProgress, - onPayViaIntent, - defaultZapType, - ) - onDismiss() - }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text( - text = zapAmount, - color = Color.White, - textAlign = TextAlign.Center, - modifier = - Modifier.combinedClickable( - onClick = { - accountViewModel.zap( - baseNote, - amountInSats * 1000, - pollOption, - zapMessage, - context, - onError, - onProgress, - onPayViaIntent, - defaultZapType, - ) - onDismiss() - }, - onLongClick = { onChangeAmount() }, - ), - ) + val accountState by accountViewModel.accountLiveData.observeAsState() + val defaultZapType by + remember(accountState) { + derivedStateOf { accountState?.account?.defaultZapType ?: LnZapEvent.ZapType.PRIVATE } + } + + val zapMessage = "" + + val sortedOptions = + remember(accountState) { pollViewModel.createZapOptionsThatMatchThePollingParameters() } + + Popup( + alignment = Alignment.BottomCenter, + offset = IntOffset(0, -100), + onDismissRequest = { onDismiss() }, + ) { + FlowRow(horizontalArrangement = Arrangement.Center) { + sortedOptions.forEach { amountInSats -> + val zapAmount = remember { "โšก ${showAmount(amountInSats.toBigDecimal().setScale(1))}" } + + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { + accountViewModel.zap( + baseNote, + amountInSats * 1000, + pollOption, + zapMessage, + context, + onError, + onProgress, + onPayViaIntent, + defaultZapType, + ) + onDismiss() + }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text( + text = zapAmount, + color = Color.White, + textAlign = TextAlign.Center, + modifier = + Modifier.combinedClickable( + onClick = { + accountViewModel.zap( + baseNote, + amountInSats * 1000, + pollOption, + zapMessage, + context, + onError, + onProgress, + onPayViaIntent, + defaultZapType, + ) + onDismiss() + }, + onLongClick = { onChangeAmount() }, + ), + ) + } + } } - } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt index d28e003cf..fa9fceae8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt @@ -35,247 +35,246 @@ import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.VALUE_MAXIMUM import com.vitorpamplona.quartz.events.VALUE_MINIMUM import com.vitorpamplona.quartz.utils.TimeUtils -import java.math.BigDecimal -import java.math.RoundingMode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import java.math.BigDecimal +import java.math.RoundingMode @Immutable data class PollOption( - val option: Int, - val descriptor: String, - val zappedValue: BigDecimal, - val tally: BigDecimal, - val consensusThreadhold: Boolean, - val zappedByLoggedIn: Boolean, + val option: Int, + val descriptor: String, + val zappedValue: BigDecimal, + val tally: BigDecimal, + val consensusThreadhold: Boolean, + val zappedByLoggedIn: Boolean, ) @Stable class PollNoteViewModel : ViewModel() { - private var account: Account? = null - private var pollNote: Note? = null + private var account: Account? = null + private var pollNote: Note? = null - private var pollEvent: PollNoteEvent? = null - private var pollOptions: Map? = null - private var valueMaximum: Long? = null - private var valueMinimum: Long? = null - private var valueMaximumBD: BigDecimal? = null - private var valueMinimumBD: BigDecimal? = null + private var pollEvent: PollNoteEvent? = null + private var pollOptions: Map? = null + private var valueMaximum: Long? = null + private var valueMinimum: Long? = null + private var valueMaximumBD: BigDecimal? = null + private var valueMinimumBD: BigDecimal? = null - private var closedAt: Long? = null - private var consensusThreshold: BigDecimal? = null + private var closedAt: Long? = null + private var consensusThreshold: BigDecimal? = null - private var totalZapped: BigDecimal = BigDecimal.ZERO - private var wasZappedByLoggedInAccount: Boolean = false + private var totalZapped: BigDecimal = BigDecimal.ZERO + private var wasZappedByLoggedInAccount: Boolean = false - private val _tallies = MutableStateFlow>(emptyList()) - val tallies = _tallies.asStateFlow() + private val _tallies = MutableStateFlow>(emptyList()) + val tallies = _tallies.asStateFlow() - fun load( - acc: Account, - note: Note?, - ) { - account = acc - pollNote = note - pollEvent = pollNote?.event as PollNoteEvent - pollOptions = pollEvent?.pollOptions() - valueMaximum = pollEvent?.getTagLong(VALUE_MAXIMUM) - valueMinimum = pollEvent?.getTagLong(VALUE_MINIMUM) - valueMinimumBD = valueMinimum?.let { BigDecimal(it) } - valueMaximumBD = valueMaximum?.let { BigDecimal(it) } - consensusThreshold = - pollEvent?.getTagLong(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal() - closedAt = pollEvent?.getTagLong(CLOSED_AT) - } + fun load( + acc: Account, + note: Note?, + ) { + account = acc + pollNote = note + pollEvent = pollNote?.event as PollNoteEvent + pollOptions = pollEvent?.pollOptions() + valueMaximum = pollEvent?.getTagLong(VALUE_MAXIMUM) + valueMinimum = pollEvent?.getTagLong(VALUE_MINIMUM) + valueMinimumBD = valueMinimum?.let { BigDecimal(it) } + valueMaximumBD = valueMaximum?.let { BigDecimal(it) } + consensusThreshold = + pollEvent?.getTagLong(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal() + closedAt = pollEvent?.getTagLong(CLOSED_AT) + } - fun refreshTallies() { - viewModelScope.launch(Dispatchers.Default) { - totalZapped = totalZapped() - wasZappedByLoggedInAccount = false - account?.calculateIfNoteWasZappedByAccount(pollNote) { wasZappedByLoggedInAccount = true } + fun refreshTallies() { + viewModelScope.launch(Dispatchers.Default) { + totalZapped = totalZapped() + wasZappedByLoggedInAccount = false + account?.calculateIfNoteWasZappedByAccount(pollNote) { wasZappedByLoggedInAccount = true } - val newOptions = - pollOptions?.keys?.map { option -> - val zappedInOption = zappedPollOptionAmount(option) + val newOptions = + pollOptions?.keys?.map { option -> + val zappedInOption = zappedPollOptionAmount(option) - val myTally = - if (totalZapped.compareTo(BigDecimal.ZERO) > 0) { - zappedInOption.divide(totalZapped, 2, RoundingMode.HALF_UP) - } else { - BigDecimal.ZERO + val myTally = + if (totalZapped.compareTo(BigDecimal.ZERO) > 0) { + zappedInOption.divide(totalZapped, 2, RoundingMode.HALF_UP) + } else { + BigDecimal.ZERO + } + + val cachedZappedByLoggedIn = + account?.userProfile()?.let { it1 -> cachedIsPollOptionZappedBy(option, it1) } ?: false + + val consensus = consensusThreshold != null && myTally >= consensusThreshold!! + + PollOption( + option, + pollOptions?.get(option) ?: "", + zappedInOption, + myTally, + consensus, + cachedZappedByLoggedIn, + ) + } + + _tallies.emit( + newOptions ?: emptyList(), + ) + } + } + + fun canZap(): Boolean { + val account = account ?: return false + val note = pollNote ?: return false + return account.userProfile() != note.author && !wasZappedByLoggedInAccount + } + + fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum + + fun isPollClosed(): Boolean = + closedAt?.let { // allow 2 minute leeway for zap to propagate + pollNote?.createdAt()?.plus(it * (86400 + 120))!! < TimeUtils.now() + } == true + + fun voteAmountPlaceHolderText(sats: String): String = + if (valueMinimum == null && valueMaximum == null) { + sats + } else if (valueMinimum == null) { + "1โ€”$valueMaximum $sats" + } else if (valueMaximum == null) { + ">$valueMinimum $sats" + } else { + "$valueMinimumโ€”$valueMaximum $sats" + } + + fun inputVoteAmountLong(textAmount: String) = + if (textAmount.isEmpty()) { + null + } else { + try { + textAmount.toLong() + } catch (e: Exception) { + null } - - val cachedZappedByLoggedIn = - account?.userProfile()?.let { it1 -> cachedIsPollOptionZappedBy(option, it1) } ?: false - - val consensus = consensusThreshold != null && myTally >= consensusThreshold!! - - PollOption( - option, - pollOptions?.get(option) ?: "", - zappedInOption, - myTally, - consensus, - cachedZappedByLoggedIn, - ) } - _tallies.emit( - newOptions ?: emptyList(), - ) - } - } - - fun canZap(): Boolean { - val account = account ?: return false - val note = pollNote ?: return false - return account.userProfile() != note.author && !wasZappedByLoggedInAccount - } - - fun isVoteAmountAtomic() = - valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum - - fun isPollClosed(): Boolean = - closedAt?.let { // allow 2 minute leeway for zap to propagate - pollNote?.createdAt()?.plus(it * (86400 + 120))!! < TimeUtils.now() - } == true - - fun voteAmountPlaceHolderText(sats: String): String = - if (valueMinimum == null && valueMaximum == null) { - sats - } else if (valueMinimum == null) { - "1โ€”$valueMaximum $sats" - } else if (valueMaximum == null) { - ">$valueMinimum $sats" - } else { - "$valueMinimumโ€”$valueMaximum $sats" - } - - fun inputVoteAmountLong(textAmount: String) = - if (textAmount.isEmpty()) { - null - } else { - try { - textAmount.toLong() - } catch (e: Exception) { - null - } - } - - fun isValidInputVoteAmount(amount: BigDecimal?): Boolean { - if (amount == null) { - return false - } else if (valueMinimum == null && valueMaximum == null) { - if (amount > BigDecimal.ZERO) { - return true - } - } else if (valueMinimum == null) { - if (amount > BigDecimal.ZERO && amount <= valueMaximumBD!!) { - return true - } - } else if (valueMaximum == null) { - if (amount >= valueMinimumBD!!) { - return true - } - } else { - if ((valueMinimumBD!! <= amount) && (amount <= valueMaximumBD!!)) { - return true - } - } - return false - } - - fun isValidInputVoteAmount(amount: Long?): Boolean { - if (amount == null) { - return false - } else if (valueMinimum == null && valueMaximum == null) { - if (amount > 0) { - return true - } - } else if (valueMinimum == null) { - if (amount > 0 && amount <= valueMaximum!!) { - return true - } - } else if (valueMaximum == null) { - if (amount >= valueMinimum!!) { - return true - } - } else { - if ((valueMinimum!! <= amount) && (amount <= valueMaximum!!)) { - return true - } - } - return false - } - - fun isPollOptionZappedBy( - option: Int, - user: User, - onWasZappedByAuthor: () -> Unit, - ) { - pollNote?.isZappedBy(option, user, account!!, onWasZappedByAuthor) - } - - fun cachedIsPollOptionZappedBy( - option: Int, - user: User, - ): Boolean { - return pollNote!!.zaps.any { - val zapEvent = it.value?.event as? LnZapEvent - val privateZapAuthor = (it.key.event as? LnZapRequestEvent)?.cachedPrivateZap() - zapEvent?.zappedPollOption() == option && - (it.key.author?.pubkeyHex == user.pubkeyHex || privateZapAuthor?.pubKey == user.pubkeyHex) - } - } - - private fun zappedPollOptionAmount(option: Int): BigDecimal { - return pollNote?.zaps?.values?.sumOf { - val event = it?.event as? LnZapEvent - val zapAmount = event?.amount ?: BigDecimal.ZERO - val isValidAmount = isValidInputVoteAmount(event?.amount) - - if (isValidAmount && event?.zappedPollOption() == option) { - zapAmount - } else { - BigDecimal.ZERO - } - } - ?: BigDecimal.ZERO - } - - private fun totalZapped(): BigDecimal { - return pollNote?.zaps?.values?.sumOf { - val zapEvent = (it?.event as? LnZapEvent) - val zapAmount = zapEvent?.amount ?: BigDecimal.ZERO - val isValidAmount = isValidInputVoteAmount(zapEvent?.amount) - - if (isValidAmount && zapEvent?.zappedPollOption() != null) { - zapAmount - } else { - BigDecimal.ZERO - } - } - ?: BigDecimal.ZERO - } - - fun createZapOptionsThatMatchThePollingParameters(): List { - val options = - account?.zapAmountChoices?.filter { isValidInputVoteAmount(it) }?.toMutableList() - ?: mutableListOf() - if (options.isEmpty()) { - valueMinimum?.let { minimum -> - valueMaximum?.let { maximum -> - if (minimum != maximum) { - options.add(((minimum + maximum) / 2).toLong()) - } + fun isValidInputVoteAmount(amount: BigDecimal?): Boolean { + if (amount == null) { + return false + } else if (valueMinimum == null && valueMaximum == null) { + if (amount > BigDecimal.ZERO) { + return true + } + } else if (valueMinimum == null) { + if (amount > BigDecimal.ZERO && amount <= valueMaximumBD!!) { + return true + } + } else if (valueMaximum == null) { + if (amount >= valueMinimumBD!!) { + return true + } + } else { + if ((valueMinimumBD!! <= amount) && (amount <= valueMaximumBD!!)) { + return true + } } - } + return false } - valueMinimum?.let { options.add(it) } - valueMaximum?.let { options.add(it) } - return options.toSet().sorted() - } + fun isValidInputVoteAmount(amount: Long?): Boolean { + if (amount == null) { + return false + } else if (valueMinimum == null && valueMaximum == null) { + if (amount > 0) { + return true + } + } else if (valueMinimum == null) { + if (amount > 0 && amount <= valueMaximum!!) { + return true + } + } else if (valueMaximum == null) { + if (amount >= valueMinimum!!) { + return true + } + } else { + if ((valueMinimum!! <= amount) && (amount <= valueMaximum!!)) { + return true + } + } + return false + } + + fun isPollOptionZappedBy( + option: Int, + user: User, + onWasZappedByAuthor: () -> Unit, + ) { + pollNote?.isZappedBy(option, user, account!!, onWasZappedByAuthor) + } + + fun cachedIsPollOptionZappedBy( + option: Int, + user: User, + ): Boolean { + return pollNote!!.zaps.any { + val zapEvent = it.value?.event as? LnZapEvent + val privateZapAuthor = (it.key.event as? LnZapRequestEvent)?.cachedPrivateZap() + zapEvent?.zappedPollOption() == option && + (it.key.author?.pubkeyHex == user.pubkeyHex || privateZapAuthor?.pubKey == user.pubkeyHex) + } + } + + private fun zappedPollOptionAmount(option: Int): BigDecimal { + return pollNote?.zaps?.values?.sumOf { + val event = it?.event as? LnZapEvent + val zapAmount = event?.amount ?: BigDecimal.ZERO + val isValidAmount = isValidInputVoteAmount(event?.amount) + + if (isValidAmount && event?.zappedPollOption() == option) { + zapAmount + } else { + BigDecimal.ZERO + } + } + ?: BigDecimal.ZERO + } + + private fun totalZapped(): BigDecimal { + return pollNote?.zaps?.values?.sumOf { + val zapEvent = (it?.event as? LnZapEvent) + val zapAmount = zapEvent?.amount ?: BigDecimal.ZERO + val isValidAmount = isValidInputVoteAmount(zapEvent?.amount) + + if (isValidAmount && zapEvent?.zappedPollOption() != null) { + zapAmount + } else { + BigDecimal.ZERO + } + } + ?: BigDecimal.ZERO + } + + fun createZapOptionsThatMatchThePollingParameters(): List { + val options = + account?.zapAmountChoices?.filter { isValidInputVoteAmount(it) }?.toMutableList() + ?: mutableListOf() + if (options.isEmpty()) { + valueMinimum?.let { minimum -> + valueMaximum?.let { maximum -> + if (minimum != maximum) { + options.add(((minimum + maximum) / 2).toLong()) + } + } + } + } + valueMinimum?.let { options.add(it) } + valueMaximum?.let { options.add(it) } + + return options.toSet().sorted() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt index ae68b9680..98e25dc39 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt @@ -24,14 +24,14 @@ import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.toHexKey fun ByteArray.toShortenHex(): String { - return toHexKey().toShortenHex() + return toHexKey().toShortenHex() } fun String.toShortenHex(): String { - if (length <= 16) return this - return replaceRange(8, length - 8, ":") + if (length <= 16) return this + return replaceRange(8, length - 8, ":") } fun HexKey.toDisplayHexKey(): String { - return this.toShortenHex() + return this.toShortenHex() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index ebfec3ca8..dfee68fc7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -128,10 +128,6 @@ import com.vitorpamplona.amethyst.ui.theme.TinyBorders import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderTextColorFilter -import java.math.BigDecimal -import java.math.RoundingMode -import java.text.DecimalFormat -import kotlin.math.roundToInt import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf @@ -140,1279 +136,1285 @@ import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat +import kotlin.math.roundToInt @Composable fun ReactionsRow( - baseNote: Note, - showReactionDetail: Boolean, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + showReactionDetail: Boolean, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val wantsToSeeReactions = remember(baseNote) { mutableStateOf(false) } + val wantsToSeeReactions = remember(baseNote) { mutableStateOf(false) } - Spacer(modifier = HalfDoubleVertSpacer) - - InnerReactionRow(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel, nav) - - Spacer(modifier = HalfDoubleVertSpacer) - - LoadAndDisplayZapraiser(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel) - - if (showReactionDetail && wantsToSeeReactions.value) { - ReactionDetailGallery(baseNote, nav, accountViewModel) Spacer(modifier = HalfDoubleVertSpacer) - } + + InnerReactionRow(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel, nav) + + Spacer(modifier = HalfDoubleVertSpacer) + + LoadAndDisplayZapraiser(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel) + + if (showReactionDetail && wantsToSeeReactions.value) { + ReactionDetailGallery(baseNote, nav, accountViewModel) + Spacer(modifier = HalfDoubleVertSpacer) + } } @Composable private fun InnerReactionRow( - baseNote: Note, - showReactionDetail: Boolean, - wantsToSeeReactions: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + showReactionDetail: Boolean, + wantsToSeeReactions: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - GenericInnerReactionRow( - showReactionDetail = showReactionDetail, - one = { - WatchReactionsZapsBoostsAndDisplayIfExists(baseNote) { - RenderShowIndividualReactionsButton(wantsToSeeReactions) - } - }, - two = { - ReplyReactionWithDialog( - baseNote, - MaterialTheme.colorScheme.placeholderText, - accountViewModel, - nav, - ) - }, - three = { - BoostWithDialog( - baseNote, - MaterialTheme.colorScheme.placeholderText, - accountViewModel, - nav, - ) - }, - four = { - LikeReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav) - }, - five = { - ZapReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav) - }, - six = { - ViewCountReaction( - note = baseNote, - grayTint = MaterialTheme.colorScheme.placeholderText, - viewCountColorFilter = MaterialTheme.colorScheme.placeholderTextColorFilter, - ) - }, - ) + GenericInnerReactionRow( + showReactionDetail = showReactionDetail, + one = { + WatchReactionsZapsBoostsAndDisplayIfExists(baseNote) { + RenderShowIndividualReactionsButton(wantsToSeeReactions) + } + }, + two = { + ReplyReactionWithDialog( + baseNote, + MaterialTheme.colorScheme.placeholderText, + accountViewModel, + nav, + ) + }, + three = { + BoostWithDialog( + baseNote, + MaterialTheme.colorScheme.placeholderText, + accountViewModel, + nav, + ) + }, + four = { + LikeReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav) + }, + five = { + ZapReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav) + }, + six = { + ViewCountReaction( + note = baseNote, + grayTint = MaterialTheme.colorScheme.placeholderText, + viewCountColorFilter = MaterialTheme.colorScheme.placeholderTextColorFilter, + ) + }, + ) } @Composable private fun GenericInnerReactionRow( - showReactionDetail: Boolean, - one: @Composable () -> Unit, - two: @Composable () -> Unit, - three: @Composable () -> Unit, - four: @Composable () -> Unit, - five: @Composable () -> Unit, - six: @Composable () -> Unit, + showReactionDetail: Boolean, + one: @Composable () -> Unit, + two: @Composable () -> Unit, + three: @Composable () -> Unit, + four: @Composable () -> Unit, + five: @Composable () -> Unit, + six: @Composable () -> Unit, ) { - Row(verticalAlignment = CenterVertically, modifier = ReactionRowHeight) { - val fullWeight = remember { Modifier.weight(1f) } + Row(verticalAlignment = CenterVertically, modifier = ReactionRowHeight) { + val fullWeight = remember { Modifier.weight(1f) } - if (showReactionDetail) { - Row( - verticalAlignment = CenterVertically, - modifier = remember { ReactionRowExpandButton.then(fullWeight) }, - ) { - one() - } + if (showReactionDetail) { + Row( + verticalAlignment = CenterVertically, + modifier = remember { ReactionRowExpandButton.then(fullWeight) }, + ) { + one() + } + } + + Row(verticalAlignment = CenterVertically, modifier = fullWeight) { two() } + + Row(verticalAlignment = CenterVertically, modifier = fullWeight) { three() } + + Row(verticalAlignment = CenterVertically, modifier = fullWeight) { four() } + + Row(verticalAlignment = CenterVertically, modifier = fullWeight) { five() } + + Row(verticalAlignment = CenterVertically, modifier = fullWeight) { six() } } - - Row(verticalAlignment = CenterVertically, modifier = fullWeight) { two() } - - Row(verticalAlignment = CenterVertically, modifier = fullWeight) { three() } - - Row(verticalAlignment = CenterVertically, modifier = fullWeight) { four() } - - Row(verticalAlignment = CenterVertically, modifier = fullWeight) { five() } - - Row(verticalAlignment = CenterVertically, modifier = fullWeight) { six() } - } } @Composable private fun LoadAndDisplayZapraiser( - baseNote: Note, - showReactionDetail: Boolean, - wantsToSeeReactions: MutableState, - accountViewModel: AccountViewModel, + baseNote: Note, + showReactionDetail: Boolean, + wantsToSeeReactions: MutableState, + accountViewModel: AccountViewModel, ) { - val zapraiserAmount by - remember(baseNote) { derivedStateOf { baseNote.event?.zapraiserAmount() ?: 0 } } + val zapraiserAmount by + remember(baseNote) { derivedStateOf { baseNote.event?.zapraiserAmount() ?: 0 } } - if (zapraiserAmount > 0) { - Box( - modifier = - remember { - ReactionRowZapraiserSize.padding(start = if (showReactionDetail) Size75dp else Size0dp) - }, - contentAlignment = CenterStart, - ) { - RenderZapRaiser(baseNote, zapraiserAmount, wantsToSeeReactions.value, accountViewModel) + if (zapraiserAmount > 0) { + Box( + modifier = + remember { + ReactionRowZapraiserSize.padding(start = if (showReactionDetail) Size75dp else Size0dp) + }, + contentAlignment = CenterStart, + ) { + RenderZapRaiser(baseNote, zapraiserAmount, wantsToSeeReactions.value, accountViewModel) + } } - } } @Immutable data class ZapraiserStatus(val progress: Float, val left: String) @Composable fun RenderZapRaiser( - baseNote: Note, - zapraiserAmount: Long, - details: Boolean, - accountViewModel: AccountViewModel, + baseNote: Note, + zapraiserAmount: Long, + details: Boolean, + accountViewModel: AccountViewModel, ) { - val zapsState by baseNote.live().zaps.observeAsState() + val zapsState by baseNote.live().zaps.observeAsState() - var zapraiserStatus by remember { mutableStateOf(ZapraiserStatus(0F, "$zapraiserAmount")) } + var zapraiserStatus by remember { mutableStateOf(ZapraiserStatus(0F, "$zapraiserAmount")) } - LaunchedEffect(key1 = zapsState) { - zapsState?.note?.let { - accountViewModel.calculateZapraiser(baseNote) { newStatus -> - if (zapraiserStatus != newStatus) { - zapraiserStatus = newStatus + LaunchedEffect(key1 = zapsState) { + zapsState?.note?.let { + accountViewModel.calculateZapraiser(baseNote) { newStatus -> + if (zapraiserStatus != newStatus) { + zapraiserStatus = newStatus + } + } } - } - } - } - - val color = - if (zapraiserStatus.progress > 0.99) { - DarkerGreen - } else { - MaterialTheme.colorScheme.mediumImportanceLink } - LinearProgressIndicator( - modifier = remember(details) { Modifier.fillMaxWidth().height(if (details) 24.dp else 4.dp) }, - color = color, - progress = zapraiserStatus.progress, - ) - - if (details) { - Box( - contentAlignment = Center, - modifier = TinyBorders, - ) { - val totalPercentage by - remember(zapraiserStatus) { - derivedStateOf { "${(zapraiserStatus.progress * 100).roundToInt()}%" } + val color = + if (zapraiserStatus.progress > 0.99) { + DarkerGreen + } else { + MaterialTheme.colorScheme.mediumImportanceLink } - Text( - text = - stringResource(id = R.string.sats_to_complete, totalPercentage, zapraiserStatus.left), - modifier = NoSoTinyBorders, - color = MaterialTheme.colorScheme.placeholderText, - fontSize = Font14SP, - maxLines = 1, - ) + LinearProgressIndicator( + modifier = remember(details) { Modifier.fillMaxWidth().height(if (details) 24.dp else 4.dp) }, + color = color, + progress = zapraiserStatus.progress, + ) + + if (details) { + Box( + contentAlignment = Center, + modifier = TinyBorders, + ) { + val totalPercentage by + remember(zapraiserStatus) { + derivedStateOf { "${(zapraiserStatus.progress * 100).roundToInt()}%" } + } + + Text( + text = + stringResource(id = R.string.sats_to_complete, totalPercentage, zapraiserStatus.left), + modifier = NoSoTinyBorders, + color = MaterialTheme.colorScheme.placeholderText, + fontSize = Font14SP, + maxLines = 1, + ) + } } - } } @Composable private fun WatchReactionsZapsBoostsAndDisplayIfExists( - baseNote: Note, - content: @Composable () -> Unit, + baseNote: Note, + content: @Composable () -> Unit, ) { - val hasReactions by - baseNote - .live() - .hasReactions - .observeAsState( - baseNote.zaps.isNotEmpty() || - baseNote.boosts.isNotEmpty() || - baseNote.reactions.isNotEmpty(), - ) + val hasReactions by + baseNote + .live() + .hasReactions + .observeAsState( + baseNote.zaps.isNotEmpty() || + baseNote.boosts.isNotEmpty() || + baseNote.reactions.isNotEmpty(), + ) - if (hasReactions) { - content() - } + if (hasReactions) { + content() + } } fun LiveData.combineWith( - liveData1: LiveData, - block: (T?, K?) -> R, + liveData1: LiveData, + block: (T?, K?) -> R, ): LiveData { - val result = MediatorLiveData() - result.addSource(this) { result.value = block(this.value, liveData1.value) } - result.addSource(liveData1) { result.value = block(this.value, liveData1.value) } - return result + val result = MediatorLiveData() + result.addSource(this) { result.value = block(this.value, liveData1.value) } + result.addSource(liveData1) { result.value = block(this.value, liveData1.value) } + return result } fun LiveData.combineWith( - liveData1: LiveData, - liveData2: LiveData

, - block: (T?, K?, P?) -> R, + liveData1: LiveData, + liveData2: LiveData

, + block: (T?, K?, P?) -> R, ): LiveData { - val result = MediatorLiveData() - result.addSource(this) { result.value = block(this.value, liveData1.value, liveData2.value) } - result.addSource(liveData1) { result.value = block(this.value, liveData1.value, liveData2.value) } - result.addSource(liveData2) { result.value = block(this.value, liveData1.value, liveData2.value) } - return result + val result = MediatorLiveData() + result.addSource(this) { result.value = block(this.value, liveData1.value, liveData2.value) } + result.addSource(liveData1) { result.value = block(this.value, liveData1.value, liveData2.value) } + result.addSource(liveData2) { result.value = block(this.value, liveData1.value, liveData2.value) } + return result } @Composable private fun RenderShowIndividualReactionsButton(wantsToSeeReactions: MutableState) { - IconButton( - onClick = { wantsToSeeReactions.value = !wantsToSeeReactions.value }, - modifier = Size20Modifier, - ) { - Crossfade( - targetState = wantsToSeeReactions.value, - label = "RenderShowIndividualReactionsButton", + IconButton( + onClick = { wantsToSeeReactions.value = !wantsToSeeReactions.value }, + modifier = Size20Modifier, ) { - if (it) { - ExpandLessIcon(modifier = Size22Modifier) - } else { - ExpandMoreIcon(modifier = Size22Modifier) - } + Crossfade( + targetState = wantsToSeeReactions.value, + label = "RenderShowIndividualReactionsButton", + ) { + if (it) { + ExpandLessIcon(modifier = Size22Modifier) + } else { + ExpandMoreIcon(modifier = Size22Modifier) + } + } } - } } @Composable private fun ReactionDetailGallery( - baseNote: Note, - nav: (String) -> Unit, - accountViewModel: AccountViewModel, + baseNote: Note, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - val defaultBackgroundColor = MaterialTheme.colorScheme.background - val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } + val defaultBackgroundColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } - val hasReactions by - baseNote - .live() - .hasReactions - .observeAsState( - baseNote.zaps.isNotEmpty() || - baseNote.boosts.isNotEmpty() || - baseNote.reactions.isNotEmpty(), - ) + val hasReactions by + baseNote + .live() + .hasReactions + .observeAsState( + baseNote.zaps.isNotEmpty() || + baseNote.boosts.isNotEmpty() || + baseNote.reactions.isNotEmpty(), + ) - if (hasReactions) { - Row( - verticalAlignment = CenterVertically, - modifier = Modifier.padding(start = 10.dp, top = 5.dp), - ) { - Column { - WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel) - WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel) - WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel) - } + if (hasReactions) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(start = 10.dp, top = 5.dp), + ) { + Column { + WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel) + WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel) + WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel) + } + } } - } } @Composable private fun WatchBoostsAndRenderGallery( - baseNote: Note, - nav: (String) -> Unit, - accountViewModel: AccountViewModel, + baseNote: Note, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - val boostsEvents by baseNote.live().boosts.observeAsState() + val boostsEvents by baseNote.live().boosts.observeAsState() - boostsEvents?.let { - if (it.note.boosts.isNotEmpty()) { - RenderBoostGallery( - it, - nav, - accountViewModel, - ) + boostsEvents?.let { + if (it.note.boosts.isNotEmpty()) { + RenderBoostGallery( + it, + nav, + accountViewModel, + ) + } } - } } @Composable private fun WatchReactionsAndRenderGallery( - baseNote: Note, - nav: (String) -> Unit, - accountViewModel: AccountViewModel, + baseNote: Note, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - val reactionsState by baseNote.live().reactions.observeAsState() - val reactionEvents by - remember(reactionsState) { derivedStateOf { baseNote.reactions.toImmutableMap() } } + val reactionsState by baseNote.live().reactions.observeAsState() + val reactionEvents by + remember(reactionsState) { derivedStateOf { baseNote.reactions.toImmutableMap() } } - if (reactionEvents.isNotEmpty()) { - reactionEvents.forEach { - val reactions = remember(it.value) { it.value.toImmutableList() } - RenderLikeGallery( - it.key, - reactions, - nav, - accountViewModel, - ) + if (reactionEvents.isNotEmpty()) { + reactionEvents.forEach { + val reactions = remember(it.value) { it.value.toImmutableList() } + RenderLikeGallery( + it.key, + reactions, + nav, + accountViewModel, + ) + } } - } } @Composable private fun WatchZapAndRenderGallery( - baseNote: Note, - backgroundColor: MutableState, - nav: (String) -> Unit, - accountViewModel: AccountViewModel, + baseNote: Note, + backgroundColor: MutableState, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - val zapsState by baseNote.live().zaps.observeAsState() + val zapsState by baseNote.live().zaps.observeAsState() - var zapEvents by - remember(zapsState) { - mutableStateOf( - accountViewModel.cachedDecryptAmountMessageInGroup(baseNote), - ) + var zapEvents by + remember(zapsState) { + mutableStateOf( + accountViewModel.cachedDecryptAmountMessageInGroup(baseNote), + ) + } + + LaunchedEffect(key1 = zapsState) { + accountViewModel.decryptAmountMessageInGroup(baseNote) { zapEvents = it } } - LaunchedEffect(key1 = zapsState) { - accountViewModel.decryptAmountMessageInGroup(baseNote) { zapEvents = it } - } - - if (zapEvents.isNotEmpty()) { - RenderZapGallery( - zapEvents, - backgroundColor, - nav, - accountViewModel, - ) - } + if (zapEvents.isNotEmpty()) { + RenderZapGallery( + zapEvents, + backgroundColor, + nav, + accountViewModel, + ) + } } @Composable private fun BoostWithDialog( - baseNote: Note, - grayTint: Color, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + grayTint: Color, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var wantsToQuote by remember { mutableStateOf(null) } + var wantsToQuote by remember { mutableStateOf(null) } - if (wantsToQuote != null) { - NewPostView( - onClose = { wantsToQuote = null }, - baseReplyTo = null, - quote = wantsToQuote, - accountViewModel = accountViewModel, - nav = nav, - ) - } + if (wantsToQuote != null) { + NewPostView( + onClose = { wantsToQuote = null }, + baseReplyTo = null, + quote = wantsToQuote, + accountViewModel = accountViewModel, + nav = nav, + ) + } - BoostReaction(baseNote, grayTint, accountViewModel) { wantsToQuote = baseNote } + BoostReaction(baseNote, grayTint, accountViewModel) { wantsToQuote = baseNote } } @Composable private fun ReplyReactionWithDialog( - baseNote: Note, - grayTint: Color, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + grayTint: Color, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var wantsToReplyTo by remember { mutableStateOf(null) } + var wantsToReplyTo by remember { mutableStateOf(null) } - if (wantsToReplyTo != null) { - NewPostView( - onClose = { wantsToReplyTo = null }, - baseReplyTo = wantsToReplyTo, - quote = null, - accountViewModel = accountViewModel, - nav = nav, - ) - } + if (wantsToReplyTo != null) { + NewPostView( + onClose = { wantsToReplyTo = null }, + baseReplyTo = wantsToReplyTo, + quote = null, + accountViewModel = accountViewModel, + nav = nav, + ) + } - ReplyReaction(baseNote, grayTint, accountViewModel) { wantsToReplyTo = baseNote } + ReplyReaction(baseNote, grayTint, accountViewModel) { wantsToReplyTo = baseNote } } @Composable fun ReplyReaction( - baseNote: Note, - grayTint: Color, - accountViewModel: AccountViewModel, - showCounter: Boolean = true, - iconSizeModifier: Modifier = Size17Modifier, - onPress: () -> Unit, + baseNote: Note, + grayTint: Color, + accountViewModel: AccountViewModel, + showCounter: Boolean = true, + iconSizeModifier: Modifier = Size17Modifier, + onPress: () -> Unit, ) { - IconButton( - modifier = iconSizeModifier, - onClick = { - if (accountViewModel.isWriteable()) { - onPress() - } else { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_reply, - ) - } - }, - ) { - CommentIcon(iconSizeModifier, grayTint) - } + IconButton( + modifier = iconSizeModifier, + onClick = { + if (accountViewModel.isWriteable()) { + onPress() + } else { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_reply, + ) + } + }, + ) { + CommentIcon(iconSizeModifier, grayTint) + } - if (showCounter) { - ReplyCounter(baseNote, grayTint) - } + if (showCounter) { + ReplyCounter(baseNote, grayTint) + } } @Composable fun ReplyCounter( - baseNote: Note, - textColor: Color, + baseNote: Note, + textColor: Color, ) { - val repliesState by baseNote.live().replyCount.observeAsState(baseNote.replies.size) + val repliesState by baseNote.live().replyCount.observeAsState(baseNote.replies.size) - SlidingAnimationCount(repliesState, textColor) + SlidingAnimationCount(repliesState, textColor) } @Composable private fun SlidingAnimationCount( - baseCount: Int, - textColor: Color, + baseCount: Int, + textColor: Color, ) { - AnimatedContent( - targetState = baseCount, - transitionSpec = AnimatedContentTransitionScope::transitionSpec, - label = "SlidingAnimationCount", - ) { count -> - TextCount(count, textColor) - } + AnimatedContent( + targetState = baseCount, + transitionSpec = AnimatedContentTransitionScope::transitionSpec, + label = "SlidingAnimationCount", + ) { count -> + TextCount(count, textColor) + } } @OptIn(ExperimentalAnimationApi::class) private fun AnimatedContentTransitionScope.transitionSpec(): ContentTransform { - return slideAnimation + return slideAnimation } @ExperimentalAnimationApi val slideAnimation: ContentTransform = - (slideInVertically(animationSpec = tween(durationMillis = 100)) { height -> height } + - fadeIn( - animationSpec = tween(durationMillis = 100), - )) - .togetherWith( - slideOutVertically(animationSpec = tween(durationMillis = 100)) { height -> -height } + - fadeOut( - animationSpec = tween(durationMillis = 100), - ), + ( + slideInVertically(animationSpec = tween(durationMillis = 100)) { height -> height } + + fadeIn( + animationSpec = tween(durationMillis = 100), + ) ) + .togetherWith( + slideOutVertically(animationSpec = tween(durationMillis = 100)) { height -> -height } + + fadeOut( + animationSpec = tween(durationMillis = 100), + ), + ) @Composable fun TextCount( - count: Int, - textColor: Color, + count: Int, + textColor: Color, ) { - Text( - text = showCount(count), - fontSize = Font14SP, - color = textColor, - modifier = HalfStartPadding, - maxLines = 1, - ) + Text( + text = showCount(count), + fontSize = Font14SP, + color = textColor, + modifier = HalfStartPadding, + maxLines = 1, + ) } @Composable private fun SlidingAnimationAmount( - amount: MutableState, - textColor: Color, + amount: MutableState, + textColor: Color, ) { - AnimatedContent( - targetState = amount.value, - transitionSpec = AnimatedContentTransitionScope::transitionSpec, - label = "SlidingAnimationAmount", - ) { count -> - Text( - text = count, - fontSize = Font14SP, - color = textColor, - maxLines = 1, - ) - } + AnimatedContent( + targetState = amount.value, + transitionSpec = AnimatedContentTransitionScope::transitionSpec, + label = "SlidingAnimationAmount", + ) { count -> + Text( + text = count, + fontSize = Font14SP, + color = textColor, + maxLines = 1, + ) + } } @Composable fun BoostReaction( - baseNote: Note, - grayTint: Color, - accountViewModel: AccountViewModel, - iconSizeModifier: Modifier = Size20Modifier, - iconSize: Dp = Size20dp, - onQuotePress: () -> Unit, + baseNote: Note, + grayTint: Color, + accountViewModel: AccountViewModel, + iconSizeModifier: Modifier = Size20Modifier, + iconSize: Dp = Size20dp, + onQuotePress: () -> Unit, ) { - var wantsToBoost by remember { mutableStateOf(false) } + var wantsToBoost by remember { mutableStateOf(false) } - IconButton( - modifier = iconSizeModifier, - onClick = { accountViewModel.tryBoost(baseNote) { wantsToBoost = true } }, - ) { - ObserveBoostIcon(baseNote, accountViewModel) { hasBoosted -> - RepostedIcon(iconSizeModifier, if (hasBoosted) Color.Unspecified else grayTint) + IconButton( + modifier = iconSizeModifier, + onClick = { accountViewModel.tryBoost(baseNote) { wantsToBoost = true } }, + ) { + ObserveBoostIcon(baseNote, accountViewModel) { hasBoosted -> + RepostedIcon(iconSizeModifier, if (hasBoosted) Color.Unspecified else grayTint) + } + + if (wantsToBoost) { + BoostTypeChoicePopup( + baseNote, + iconSize, + accountViewModel, + onDismiss = { wantsToBoost = false }, + onQuote = { + wantsToBoost = false + onQuotePress() + }, + onRepost = { accountViewModel.boost(baseNote) }, + ) + } } - if (wantsToBoost) { - BoostTypeChoicePopup( - baseNote, - iconSize, - accountViewModel, - onDismiss = { wantsToBoost = false }, - onQuote = { - wantsToBoost = false - onQuotePress() - }, - onRepost = { accountViewModel.boost(baseNote) }, - ) - } - } - - BoostText(baseNote, grayTint) + BoostText(baseNote, grayTint) } @Composable fun ObserveBoostIcon( - baseNote: Note, - accountViewModel: AccountViewModel, - inner: @Composable (Boolean) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + inner: @Composable (Boolean) -> Unit, ) { - val hasBoosted by - remember(baseNote) { - baseNote - .live() - .boosts - .map { it.note.isBoostedBy(accountViewModel.userProfile()) } - .distinctUntilChanged() - } - .observeAsState( - baseNote.isBoostedBy(accountViewModel.userProfile()), - ) + val hasBoosted by + remember(baseNote) { + baseNote + .live() + .boosts + .map { it.note.isBoostedBy(accountViewModel.userProfile()) } + .distinctUntilChanged() + } + .observeAsState( + baseNote.isBoostedBy(accountViewModel.userProfile()), + ) - inner(hasBoosted) + inner(hasBoosted) } @Composable fun BoostText( - baseNote: Note, - grayTint: Color, + baseNote: Note, + grayTint: Color, ) { - val boostState by baseNote.live().boostCount.observeAsState(baseNote.boosts.size) + val boostState by baseNote.live().boostCount.observeAsState(baseNote.boosts.size) - SlidingAnimationCount(boostState, grayTint) + SlidingAnimationCount(boostState, grayTint) } @OptIn(ExperimentalFoundationApi::class) @Composable fun LikeReaction( - baseNote: Note, - grayTint: Color, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - iconSize: Dp = Size20dp, - heartSizeModifier: Modifier = Size16Modifier, - iconFontSize: TextUnit = Font14SP, + baseNote: Note, + grayTint: Color, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + iconSize: Dp = Size20dp, + heartSizeModifier: Modifier = Size16Modifier, + iconFontSize: TextUnit = Font14SP, ) { - var wantsToChangeReactionSymbol by remember { mutableStateOf(false) } - var wantsToReact by remember { mutableStateOf(false) } + var wantsToChangeReactionSymbol by remember { mutableStateOf(false) } + var wantsToReact by remember { mutableStateOf(false) } - Box( - contentAlignment = Center, - modifier = - Modifier.size(iconSize) - .combinedClickable( - role = Role.Button, - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false, radius = Size24dp), - onClick = { - likeClick( - accountViewModel, - onMultipleChoices = { wantsToReact = true }, - onWantsToSignReaction = { accountViewModel.reactToOrDelete(baseNote) }, - ) - }, - onLongClick = { wantsToChangeReactionSymbol = true }, - ), - ) { - ObserveLikeIcon(baseNote, accountViewModel) { reactionType -> - Crossfade(targetState = reactionType.value, label = "LikeIcon") { - if (it != null) { - RenderReactionType(it, heartSizeModifier, iconFontSize) - } else { - LikeIcon(heartSizeModifier, grayTint) + Box( + contentAlignment = Center, + modifier = + Modifier.size(iconSize) + .combinedClickable( + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false, radius = Size24dp), + onClick = { + likeClick( + accountViewModel, + onMultipleChoices = { wantsToReact = true }, + onWantsToSignReaction = { accountViewModel.reactToOrDelete(baseNote) }, + ) + }, + onLongClick = { wantsToChangeReactionSymbol = true }, + ), + ) { + ObserveLikeIcon(baseNote, accountViewModel) { reactionType -> + Crossfade(targetState = reactionType.value, label = "LikeIcon") { + if (it != null) { + RenderReactionType(it, heartSizeModifier, iconFontSize) + } else { + LikeIcon(heartSizeModifier, grayTint) + } + } + } + + if (wantsToChangeReactionSymbol) { + UpdateReactionTypeDialog( + { wantsToChangeReactionSymbol = false }, + accountViewModel = accountViewModel, + nav, + ) + } + + if (wantsToReact) { + ReactionChoicePopup( + baseNote, + iconSize, + accountViewModel, + onDismiss = { wantsToReact = false }, + onChangeAmount = { + wantsToReact = false + wantsToChangeReactionSymbol = true + }, + ) } - } } - if (wantsToChangeReactionSymbol) { - UpdateReactionTypeDialog( - { wantsToChangeReactionSymbol = false }, - accountViewModel = accountViewModel, - nav, - ) - } - - if (wantsToReact) { - ReactionChoicePopup( - baseNote, - iconSize, - accountViewModel, - onDismiss = { wantsToReact = false }, - onChangeAmount = { - wantsToReact = false - wantsToChangeReactionSymbol = true - }, - ) - } - } - - ObserveLikeText(baseNote) { reactionCount -> SlidingAnimationCount(reactionCount, grayTint) } + ObserveLikeText(baseNote) { reactionCount -> SlidingAnimationCount(reactionCount, grayTint) } } @Composable fun ObserveLikeIcon( - baseNote: Note, - accountViewModel: AccountViewModel, - inner: @Composable (MutableState) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + inner: @Composable (MutableState) -> Unit, ) { - val reactionType = remember(baseNote) { mutableStateOf(null) } + val reactionType = remember(baseNote) { mutableStateOf(null) } - val reactionsState by baseNote.live().reactions.observeAsState() + val reactionsState by baseNote.live().reactions.observeAsState() - LaunchedEffect(key1 = reactionsState) { - accountViewModel.loadReactionTo(reactionsState?.note) { newReactionType -> - if (reactionType.value != newReactionType) { - reactionType.value = newReactionType - } + LaunchedEffect(key1 = reactionsState) { + accountViewModel.loadReactionTo(reactionsState?.note) { newReactionType -> + if (reactionType.value != newReactionType) { + reactionType.value = newReactionType + } + } } - } - inner(reactionType) + inner(reactionType) } @Composable private fun RenderReactionType( - reactionType: String, - iconSizeModifier: Modifier = Size20Modifier, - iconFontSize: TextUnit, + reactionType: String, + iconSizeModifier: Modifier = Size20Modifier, + iconFontSize: TextUnit, ) { - if (reactionType.isNotEmpty() && reactionType[0] == ':') { - val renderable = - remember(reactionType) { - listOf( - ImageUrlType(reactionType.removePrefix(":").substringAfter(":")), - ) - .toImmutableList() - } + if (reactionType.isNotEmpty() && reactionType[0] == ':') { + val renderable = + remember(reactionType) { + listOf( + ImageUrlType(reactionType.removePrefix(":").substringAfter(":")), + ) + .toImmutableList() + } - InLineIconRenderer( - renderable, - style = SpanStyle(color = Color.White), - fontSize = iconFontSize, - maxLines = 1, - ) - } else { - when (reactionType) { - "+" -> LikedIcon(iconSizeModifier) - "-" -> Text(text = "\uD83D\uDC4E", fontSize = iconFontSize) - else -> Text(text = reactionType, fontSize = iconFontSize) + InLineIconRenderer( + renderable, + style = SpanStyle(color = Color.White), + fontSize = iconFontSize, + maxLines = 1, + ) + } else { + when (reactionType) { + "+" -> LikedIcon(iconSizeModifier) + "-" -> Text(text = "\uD83D\uDC4E", fontSize = iconFontSize) + else -> Text(text = reactionType, fontSize = iconFontSize) + } } - } } @Composable fun ObserveLikeText( - baseNote: Note, - inner: @Composable (Int) -> Unit, + baseNote: Note, + inner: @Composable (Int) -> Unit, ) { - val reactionCount by baseNote.live().reactionCount.observeAsState(0) + val reactionCount by baseNote.live().reactionCount.observeAsState(0) - inner(reactionCount) + inner(reactionCount) } private fun likeClick( - accountViewModel: AccountViewModel, - onMultipleChoices: () -> Unit, - onWantsToSignReaction: () -> Unit, + accountViewModel: AccountViewModel, + onMultipleChoices: () -> Unit, + onWantsToSignReaction: () -> Unit, ) { - if (accountViewModel.account.reactionChoices.isEmpty()) { - accountViewModel.toast( - R.string.no_reactions_setup, - R.string.no_reaction_type_setup_long_press_to_change, - ) - } else if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_like_posts, - ) - } else if (accountViewModel.account.reactionChoices.size == 1) { - onWantsToSignReaction() - } else if (accountViewModel.account.reactionChoices.size > 1) { - onMultipleChoices() - } + if (accountViewModel.account.reactionChoices.isEmpty()) { + accountViewModel.toast( + R.string.no_reactions_setup, + R.string.no_reaction_type_setup_long_press_to_change, + ) + } else if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_like_posts, + ) + } else if (accountViewModel.account.reactionChoices.size == 1) { + onWantsToSignReaction() + } else if (accountViewModel.account.reactionChoices.size > 1) { + onMultipleChoices() + } } @Composable @OptIn(ExperimentalFoundationApi::class) fun ZapReaction( - baseNote: Note, - grayTint: Color, - accountViewModel: AccountViewModel, - iconSize: Dp = Size20dp, - iconSizeModifier: Modifier = Size20Modifier, - animationSize: Dp = 14.dp, - nav: (String) -> Unit, + baseNote: Note, + grayTint: Color, + accountViewModel: AccountViewModel, + iconSize: Dp = Size20dp, + iconSizeModifier: Modifier = Size20Modifier, + animationSize: Dp = 14.dp, + nav: (String) -> Unit, ) { - var wantsToZap by remember { mutableStateOf(false) } - var wantsToChangeZapAmount by remember { mutableStateOf(false) } - var wantsToSetCustomZap by remember { mutableStateOf(false) } - var showErrorMessageDialog by remember { mutableStateOf(null) } - var wantsToPay by - remember(baseNote) { - mutableStateOf>( - persistentListOf(), - ) - } - - val context = LocalContext.current - val scope = rememberCoroutineScope() - - var zappingProgress by remember { mutableFloatStateOf(0f) } - - Row( - verticalAlignment = CenterVertically, - modifier = - iconSizeModifier.combinedClickable( - role = Role.Button, - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false, radius = Size24dp), - onClick = { - zapClick( - baseNote, - accountViewModel, - context, - onZappingProgress = { progress: Float -> scope.launch { zappingProgress = progress } }, - onMultipleChoices = { wantsToZap = true }, - onError = { _, message -> - scope.launch { - zappingProgress = 0f - showErrorMessageDialog = message - } - }, - onPayViaIntent = { wantsToPay = it }, - ) - }, - onLongClick = { wantsToChangeZapAmount = true }, - onDoubleClick = { wantsToSetCustomZap = true }, - ), - ) { - if (wantsToZap) { - ZapAmountChoicePopup( - baseNote = baseNote, - iconSize = iconSize, - accountViewModel = accountViewModel, - onDismiss = { - wantsToZap = false - zappingProgress = 0f - }, - onChangeAmount = { - wantsToZap = false - wantsToChangeZapAmount = true - }, - onError = { _, message -> - scope.launch { - zappingProgress = 0f - showErrorMessageDialog = message - } - }, - onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } }, - onPayViaIntent = { wantsToPay = it }, - ) - } - - if (showErrorMessageDialog != null) { - ErrorMessageDialog( - title = stringResource(id = R.string.error_dialog_zap_error), - textContent = showErrorMessageDialog ?: "", - onClickStartMessage = { - baseNote.author?.let { - scope.launch(Dispatchers.IO) { - val route = routeToMessage(it, showErrorMessageDialog, accountViewModel) - nav(route) - } - } - }, - onDismiss = { showErrorMessageDialog = null }, - ) - } - - if (wantsToChangeZapAmount) { - UpdateZapAmountDialog( - onClose = { wantsToChangeZapAmount = false }, - accountViewModel = accountViewModel, - ) - } - - if (wantsToPay.isNotEmpty()) { - PayViaIntentDialog( - payingInvoices = wantsToPay, - accountViewModel = accountViewModel, - onClose = { wantsToPay = persistentListOf() }, - onError = { - wantsToPay = persistentListOf() - scope.launch { - zappingProgress = 0f - showErrorMessageDialog = it - } - }, - ) - } - - if (wantsToSetCustomZap) { - ZapCustomDialog( - onClose = { wantsToSetCustomZap = false }, - onError = { _, message -> - scope.launch { - zappingProgress = 0f - showErrorMessageDialog = message - } - }, - onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } }, - onPayViaIntent = { wantsToPay = it }, - accountViewModel = accountViewModel, - baseNote = baseNote, - ) - } - - if (zappingProgress > 0.00001 && zappingProgress < 0.99999) { - Spacer(ModifierWidth3dp) - - CircularProgressIndicator( - progress = - animateFloatAsState( - targetValue = zappingProgress, - animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, - label = "ZapIconIndicator", + var wantsToZap by remember { mutableStateOf(false) } + var wantsToChangeZapAmount by remember { mutableStateOf(false) } + var wantsToSetCustomZap by remember { mutableStateOf(false) } + var showErrorMessageDialog by remember { mutableStateOf(null) } + var wantsToPay by + remember(baseNote) { + mutableStateOf>( + persistentListOf(), ) - .value, - modifier = remember { Modifier.size(animationSize) }, - strokeWidth = 2.dp, - ) - } else { - ObserveZapIcon( - baseNote, - accountViewModel, - ) { wasZappedByLoggedInUser -> - Crossfade(targetState = wasZappedByLoggedInUser.value, label = "ZapIcon") { - if (it) { - ZappedIcon(iconSizeModifier) - } else { - ZapIcon(iconSizeModifier, grayTint) - } } - } - } - } - ObserveZapAmountText(baseNote, accountViewModel) { zapAmountTxt -> - SlidingAnimationAmount(zapAmountTxt, grayTint) - } + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var zappingProgress by remember { mutableFloatStateOf(0f) } + + Row( + verticalAlignment = CenterVertically, + modifier = + iconSizeModifier.combinedClickable( + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false, radius = Size24dp), + onClick = { + zapClick( + baseNote, + accountViewModel, + context, + onZappingProgress = { progress: Float -> scope.launch { zappingProgress = progress } }, + onMultipleChoices = { wantsToZap = true }, + onError = { _, message -> + scope.launch { + zappingProgress = 0f + showErrorMessageDialog = message + } + }, + onPayViaIntent = { wantsToPay = it }, + ) + }, + onLongClick = { wantsToChangeZapAmount = true }, + onDoubleClick = { wantsToSetCustomZap = true }, + ), + ) { + if (wantsToZap) { + ZapAmountChoicePopup( + baseNote = baseNote, + iconSize = iconSize, + accountViewModel = accountViewModel, + onDismiss = { + wantsToZap = false + zappingProgress = 0f + }, + onChangeAmount = { + wantsToZap = false + wantsToChangeZapAmount = true + }, + onError = { _, message -> + scope.launch { + zappingProgress = 0f + showErrorMessageDialog = message + } + }, + onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } }, + onPayViaIntent = { wantsToPay = it }, + ) + } + + if (showErrorMessageDialog != null) { + ErrorMessageDialog( + title = stringResource(id = R.string.error_dialog_zap_error), + textContent = showErrorMessageDialog ?: "", + onClickStartMessage = { + baseNote.author?.let { + scope.launch(Dispatchers.IO) { + val route = routeToMessage(it, showErrorMessageDialog, accountViewModel) + nav(route) + } + } + }, + onDismiss = { showErrorMessageDialog = null }, + ) + } + + if (wantsToChangeZapAmount) { + UpdateZapAmountDialog( + onClose = { wantsToChangeZapAmount = false }, + accountViewModel = accountViewModel, + ) + } + + if (wantsToPay.isNotEmpty()) { + PayViaIntentDialog( + payingInvoices = wantsToPay, + accountViewModel = accountViewModel, + onClose = { wantsToPay = persistentListOf() }, + onError = { + wantsToPay = persistentListOf() + scope.launch { + zappingProgress = 0f + showErrorMessageDialog = it + } + }, + ) + } + + if (wantsToSetCustomZap) { + ZapCustomDialog( + onClose = { wantsToSetCustomZap = false }, + onError = { _, message -> + scope.launch { + zappingProgress = 0f + showErrorMessageDialog = message + } + }, + onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } }, + onPayViaIntent = { wantsToPay = it }, + accountViewModel = accountViewModel, + baseNote = baseNote, + ) + } + + if (zappingProgress > 0.00001 && zappingProgress < 0.99999) { + Spacer(ModifierWidth3dp) + + CircularProgressIndicator( + progress = + animateFloatAsState( + targetValue = zappingProgress, + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + label = "ZapIconIndicator", + ) + .value, + modifier = remember { Modifier.size(animationSize) }, + strokeWidth = 2.dp, + ) + } else { + ObserveZapIcon( + baseNote, + accountViewModel, + ) { wasZappedByLoggedInUser -> + Crossfade(targetState = wasZappedByLoggedInUser.value, label = "ZapIcon") { + if (it) { + ZappedIcon(iconSizeModifier) + } else { + ZapIcon(iconSizeModifier, grayTint) + } + } + } + } + } + + ObserveZapAmountText(baseNote, accountViewModel) { zapAmountTxt -> + SlidingAnimationAmount(zapAmountTxt, grayTint) + } } private fun zapClick( - baseNote: Note, - accountViewModel: AccountViewModel, - context: Context, - onZappingProgress: (Float) -> Unit, - onMultipleChoices: () -> Unit, - onError: (String, String) -> Unit, - onPayViaIntent: (ImmutableList) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + context: Context, + onZappingProgress: (Float) -> Unit, + onMultipleChoices: () -> Unit, + onError: (String, String) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit, ) { - if (accountViewModel.account.zapAmountChoices.isEmpty()) { - accountViewModel.toast( - context.getString(R.string.error_dialog_zap_error), - context.getString(R.string.no_zap_amount_setup_long_press_to_change), - ) - } else if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - context.getString(R.string.error_dialog_zap_error), - context.getString(R.string.login_with_a_private_key_to_be_able_to_send_zaps), - ) - } else if (accountViewModel.account.zapAmountChoices.size == 1) { - accountViewModel.zap( - baseNote, - accountViewModel.account.zapAmountChoices.first() * 1000, - null, - "", - context, - onError = onError, - onProgress = { onZappingProgress(it) }, - zapType = accountViewModel.account.defaultZapType, - onPayViaIntent = onPayViaIntent, - ) - } else if (accountViewModel.account.zapAmountChoices.size > 1) { - onMultipleChoices() - } + if (accountViewModel.account.zapAmountChoices.isEmpty()) { + accountViewModel.toast( + context.getString(R.string.error_dialog_zap_error), + context.getString(R.string.no_zap_amount_setup_long_press_to_change), + ) + } else if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + context.getString(R.string.error_dialog_zap_error), + context.getString(R.string.login_with_a_private_key_to_be_able_to_send_zaps), + ) + } else if (accountViewModel.account.zapAmountChoices.size == 1) { + accountViewModel.zap( + baseNote, + accountViewModel.account.zapAmountChoices.first() * 1000, + null, + "", + context, + onError = onError, + onProgress = { onZappingProgress(it) }, + zapType = accountViewModel.account.defaultZapType, + onPayViaIntent = onPayViaIntent, + ) + } else if (accountViewModel.account.zapAmountChoices.size > 1) { + onMultipleChoices() + } } @Composable private fun ObserveZapIcon( - baseNote: Note, - accountViewModel: AccountViewModel, - inner: @Composable (MutableState) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + inner: @Composable (MutableState) -> Unit, ) { - val wasZappedByLoggedInUser = remember { mutableStateOf(false) } + val wasZappedByLoggedInUser = remember { mutableStateOf(false) } - if (!wasZappedByLoggedInUser.value) { - val zapsState by baseNote.live().zaps.observeAsState() + if (!wasZappedByLoggedInUser.value) { + val zapsState by baseNote.live().zaps.observeAsState() - LaunchedEffect(key1 = zapsState) { - accountViewModel.calculateIfNoteWasZappedByAccount(baseNote) { newWasZapped -> - if (wasZappedByLoggedInUser.value != newWasZapped) { - wasZappedByLoggedInUser.value = newWasZapped + LaunchedEffect(key1 = zapsState) { + accountViewModel.calculateIfNoteWasZappedByAccount(baseNote) { newWasZapped -> + if (wasZappedByLoggedInUser.value != newWasZapped) { + wasZappedByLoggedInUser.value = newWasZapped + } + } } - } } - } - inner(wasZappedByLoggedInUser) + inner(wasZappedByLoggedInUser) } @Composable private fun ObserveZapAmountText( - baseNote: Note, - accountViewModel: AccountViewModel, - inner: @Composable (MutableState) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + inner: @Composable (MutableState) -> Unit, ) { - val zapAmountTxt = remember(baseNote) { mutableStateOf(showAmount(baseNote.zapsAmount)) } - val zapsState by baseNote.live().zaps.observeAsState() + val zapAmountTxt = remember(baseNote) { mutableStateOf(showAmount(baseNote.zapsAmount)) } + val zapsState by baseNote.live().zaps.observeAsState() - LaunchedEffect(key1 = zapsState) { - accountViewModel.calculateZapAmount(baseNote) { newZapAmount -> - if (zapAmountTxt.value != newZapAmount) { - zapAmountTxt.value = newZapAmount - } + LaunchedEffect(key1 = zapsState) { + accountViewModel.calculateZapAmount(baseNote) { newZapAmount -> + if (zapAmountTxt.value != newZapAmount) { + zapAmountTxt.value = newZapAmount + } + } } - } - inner(zapAmountTxt) + inner(zapAmountTxt) } @Composable fun ViewCountReaction( - note: Note, - grayTint: Color, - barChartModifier: Modifier = Size19Modifier, - numberSizeModifier: Modifier = Height24dpModifier, - viewCountColorFilter: ColorFilter, + note: Note, + grayTint: Color, + barChartModifier: Modifier = Size19Modifier, + numberSizeModifier: Modifier = Height24dpModifier, + viewCountColorFilter: ColorFilter, ) { - ViewCountIcon(barChartModifier, grayTint) - DrawViewCount(note, numberSizeModifier, viewCountColorFilter) + ViewCountIcon(barChartModifier, grayTint) + DrawViewCount(note, numberSizeModifier, viewCountColorFilter) } @Composable private fun DrawViewCount( - note: Note, - iconModifier: Modifier = Modifier, - viewCountColorFilter: ColorFilter, + note: Note, + iconModifier: Modifier = Modifier, + viewCountColorFilter: ColorFilter, ) { - val context = LocalContext.current + val context = LocalContext.current - AsyncImage( - model = - remember(note) { - ImageRequest.Builder(context) - .data("https://counter.amethyst.social/${note.idHex}.svg?label=+&color=00000000") - .diskCachePolicy(CachePolicy.DISABLED) - .memoryCachePolicy(CachePolicy.ENABLED) - .build() - }, - contentDescription = context.getString(R.string.view_count), - modifier = iconModifier, - colorFilter = viewCountColorFilter, - ) + AsyncImage( + model = + remember(note) { + ImageRequest.Builder(context) + .data("https://counter.amethyst.social/${note.idHex}.svg?label=+&color=00000000") + .diskCachePolicy(CachePolicy.DISABLED) + .memoryCachePolicy(CachePolicy.ENABLED) + .build() + }, + contentDescription = context.getString(R.string.view_count), + modifier = iconModifier, + colorFilter = viewCountColorFilter, + ) } @OptIn(ExperimentalLayoutApi::class) @Composable private fun BoostTypeChoicePopup( - baseNote: Note, - iconSize: Dp, - accountViewModel: AccountViewModel, - onDismiss: () -> Unit, - onQuote: () -> Unit, - onRepost: () -> Unit, + baseNote: Note, + iconSize: Dp, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, + onQuote: () -> Unit, + onRepost: () -> Unit, ) { - val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() } + val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() } - Popup( - alignment = Alignment.BottomCenter, - offset = IntOffset(0, iconSizePx), - onDismissRequest = { onDismiss() }, - ) { - FlowRow { - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { - if (accountViewModel.isWriteable()) { - accountViewModel.boost(baseNote) - onDismiss() - } else { - onRepost() - onDismiss() - } - }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text(stringResource(R.string.boost), color = Color.White, textAlign = TextAlign.Center) - } + Popup( + alignment = Alignment.BottomCenter, + offset = IntOffset(0, iconSizePx), + onDismissRequest = { onDismiss() }, + ) { + FlowRow { + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { + if (accountViewModel.isWriteable()) { + accountViewModel.boost(baseNote) + onDismiss() + } else { + onRepost() + onDismiss() + } + }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(stringResource(R.string.boost), color = Color.White, textAlign = TextAlign.Center) + } - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = onQuote, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text(stringResource(R.string.quote), color = Color.White, textAlign = TextAlign.Center) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = onQuote, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(stringResource(R.string.quote), color = Color.White, textAlign = TextAlign.Center) + } + } } - } } @OptIn(ExperimentalLayoutApi::class) @Composable fun ReactionChoicePopup( - baseNote: Note, - iconSize: Dp, - accountViewModel: AccountViewModel, - onDismiss: () -> Unit, - onChangeAmount: () -> Unit, + baseNote: Note, + iconSize: Dp, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, + onChangeAmount: () -> Unit, ) { - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return - val toRemove = remember { baseNote.reactedBy(account.userProfile()).toImmutableSet() } + val toRemove = remember { baseNote.reactedBy(account.userProfile()).toImmutableSet() } - val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() } + val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() } - Popup( - alignment = Alignment.BottomCenter, - offset = IntOffset(0, iconSizePx), - onDismissRequest = { onDismiss() }, - ) { - FlowRow(horizontalArrangement = Arrangement.Center) { - account.reactionChoices.forEach { reactionType -> - ActionableReactionButton( - baseNote, - reactionType, - accountViewModel, - onDismiss, - onChangeAmount, - toRemove, - ) - } + Popup( + alignment = Alignment.BottomCenter, + offset = IntOffset(0, iconSizePx), + onDismissRequest = { onDismiss() }, + ) { + FlowRow(horizontalArrangement = Arrangement.Center) { + account.reactionChoices.forEach { reactionType -> + ActionableReactionButton( + baseNote, + reactionType, + accountViewModel, + onDismiss, + onChangeAmount, + toRemove, + ) + } + } } - } } @Composable @OptIn(ExperimentalFoundationApi::class) private fun ActionableReactionButton( - baseNote: Note, - reactionType: String, - accountViewModel: AccountViewModel, - onDismiss: () -> Unit, - onChangeAmount: () -> Unit, - toRemove: ImmutableSet, + baseNote: Note, + reactionType: String, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, + onChangeAmount: () -> Unit, + toRemove: ImmutableSet, ) { - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { - accountViewModel.reactToOrDelete( - baseNote, - reactionType, - ) - onDismiss() - }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - val thisModifier = - remember(reactionType) { - Modifier.combinedClickable( - onClick = { + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { accountViewModel.reactToOrDelete( - baseNote, - reactionType, + baseNote, + reactionType, ) onDismiss() - }, - onLongClick = { onChangeAmount() }, - ) - } + }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + val thisModifier = + remember(reactionType) { + Modifier.combinedClickable( + onClick = { + accountViewModel.reactToOrDelete( + baseNote, + reactionType, + ) + onDismiss() + }, + onLongClick = { onChangeAmount() }, + ) + } - val removeSymbol = - remember(reactionType) { - if (reactionType in toRemove) { - " โœ–" + val removeSymbol = + remember(reactionType) { + if (reactionType in toRemove) { + " โœ–" + } else { + "" + } + } + + if (reactionType.startsWith(":")) { + val noStartColon = reactionType.removePrefix(":") + val url = noStartColon.substringAfter(":") + + val renderable = + listOf( + ImageUrlType(url), + TextType(removeSymbol), + ) + .toImmutableList() + + InLineIconRenderer( + renderable, + style = SpanStyle(color = Color.White), + maxLines = 1, + ) } else { - "" + when (reactionType) { + "+" -> { + Icon( + painter = painterResource(R.drawable.ic_liked), + null, + modifier = remember { thisModifier.size(16.dp) }, + tint = Color.White, + ) + Text( + text = removeSymbol, + color = Color.White, + textAlign = TextAlign.Center, + modifier = thisModifier, + ) + } + "-" -> + Text( + text = "\uD83D\uDC4E$removeSymbol", + color = Color.White, + textAlign = TextAlign.Center, + modifier = thisModifier, + ) + else -> + Text( + "$reactionType$removeSymbol", + color = Color.White, + textAlign = TextAlign.Center, + modifier = thisModifier, + ) + } } - } - - if (reactionType.startsWith(":")) { - val noStartColon = reactionType.removePrefix(":") - val url = noStartColon.substringAfter(":") - - val renderable = - listOf( - ImageUrlType(url), - TextType(removeSymbol), - ) - .toImmutableList() - - InLineIconRenderer( - renderable, - style = SpanStyle(color = Color.White), - maxLines = 1, - ) - } else { - when (reactionType) { - "+" -> { - Icon( - painter = painterResource(R.drawable.ic_liked), - null, - modifier = remember { thisModifier.size(16.dp) }, - tint = Color.White, - ) - Text( - text = removeSymbol, - color = Color.White, - textAlign = TextAlign.Center, - modifier = thisModifier, - ) - } - "-" -> - Text( - text = "\uD83D\uDC4E$removeSymbol", - color = Color.White, - textAlign = TextAlign.Center, - modifier = thisModifier, - ) - else -> - Text( - "$reactionType$removeSymbol", - color = Color.White, - textAlign = TextAlign.Center, - modifier = thisModifier, - ) - } } - } } @OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) @Composable fun ZapAmountChoicePopup( - baseNote: Note, - accountViewModel: AccountViewModel, - iconSize: Dp, - onDismiss: () -> Unit, - onChangeAmount: () -> Unit, - onError: (title: String, text: String) -> Unit, - onProgress: (percent: Float) -> Unit, - onPayViaIntent: (ImmutableList) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + iconSize: Dp, + onDismiss: () -> Unit, + onChangeAmount: () -> Unit, + onError: (title: String, text: String) -> Unit, + onProgress: (percent: Float) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit, ) { - val context = LocalContext.current - val zapMessage = "" + val context = LocalContext.current + val zapMessage = "" - val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() } + val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() } - Popup( - alignment = Alignment.BottomCenter, - offset = IntOffset(0, iconSizePx), - onDismissRequest = { onDismiss() }, - ) { - FlowRow(horizontalArrangement = Arrangement.Center) { - accountViewModel.account.zapAmountChoices.forEach { amountInSats -> - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { - accountViewModel.zap( - baseNote, - amountInSats * 1000, - null, - zapMessage, - context, - onError, - onProgress, - onPayViaIntent, - accountViewModel.account.defaultZapType, - ) - onDismiss() - }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text( - "โšก ${showAmount(amountInSats.toBigDecimal().setScale(1))}", - color = Color.White, - textAlign = TextAlign.Center, - modifier = - Modifier.combinedClickable( - onClick = { - accountViewModel.zap( - baseNote, - amountInSats * 1000, - null, - zapMessage, - context, - onError, - onProgress, - onPayViaIntent, - accountViewModel.account.defaultZapType, - ) - onDismiss() - }, - onLongClick = { onChangeAmount() }, - ), - ) + Popup( + alignment = Alignment.BottomCenter, + offset = IntOffset(0, iconSizePx), + onDismissRequest = { onDismiss() }, + ) { + FlowRow(horizontalArrangement = Arrangement.Center) { + accountViewModel.account.zapAmountChoices.forEach { amountInSats -> + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { + accountViewModel.zap( + baseNote, + amountInSats * 1000, + null, + zapMessage, + context, + onError, + onProgress, + onPayViaIntent, + accountViewModel.account.defaultZapType, + ) + onDismiss() + }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text( + "โšก ${showAmount(amountInSats.toBigDecimal().setScale(1))}", + color = Color.White, + textAlign = TextAlign.Center, + modifier = + Modifier.combinedClickable( + onClick = { + accountViewModel.zap( + baseNote, + amountInSats * 1000, + null, + zapMessage, + context, + onError, + onProgress, + onPayViaIntent, + accountViewModel.account.defaultZapType, + ) + onDismiss() + }, + onLongClick = { onChangeAmount() }, + ), + ) + } + } } - } } - } } fun showCount(count: Int?): String { - if (count == null) return "" - if (count == 0) return "" + if (count == null) return "" + if (count == 0) return "" - return when { - count >= 1000000000 -> "${(count / 1000000000f).roundToInt()}G" - count >= 1000000 -> "${(count / 1000000f).roundToInt()}M" - count >= 10000 -> "${(count / 1000f).roundToInt()}k" - else -> "$count" - } + return when { + count >= 1000000000 -> "${(count / 1000000000f).roundToInt()}G" + count >= 1000000 -> "${(count / 1000000f).roundToInt()}M" + count >= 10000 -> "${(count / 1000f).roundToInt()}k" + else -> "$count" + } } val OneGiga = BigDecimal(1000000000) @@ -1426,13 +1428,13 @@ var dfK: DecimalFormat = DecimalFormat("#.0k") var dfN: DecimalFormat = DecimalFormat("#") fun showAmount(amount: BigDecimal?): String { - if (amount == null) return "" - if (amount.abs() < BigDecimal(0.01)) return "" + if (amount == null) return "" + if (amount.abs() < BigDecimal(0.01)) return "" - return when { - amount >= OneGiga -> dfG.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) - amount >= OneMega -> dfM.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) - amount >= TenKilo -> dfK.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) - else -> dfN.format(amount) - } + return when { + amount >= OneGiga -> dfG.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) + amount >= OneMega -> dfM.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) + amount >= TenKilo -> dfK.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) + else -> dfN.format(amount) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt index 7af10dd18..ec9d50417 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt @@ -55,114 +55,114 @@ import java.time.format.DateTimeFormatter @Composable fun RelayCompose( - relay: RelayInfo, - accountViewModel: AccountViewModel, - onAddRelay: () -> Unit, - onRemoveRelay: () -> Unit, + relay: RelayInfo, + accountViewModel: AccountViewModel, + onAddRelay: () -> Unit, + onRemoveRelay: () -> Unit, ) { - val context = LocalContext.current + val context = LocalContext.current - Column { - Row( - modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp), - ) { - Column( - modifier = Modifier.padding(start = 10.dp).weight(1f), - ) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Text( - relay.url.trim().removePrefix("wss://"), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Column { + Row( + modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp), + ) { + Column( + modifier = Modifier.padding(start = 10.dp).weight(1f), + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + relay.url.trim().removePrefix("wss://"), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) - val lastTime by - remember(relay.lastEvent) { - derivedStateOf { timeAgo(relay.lastEvent, context = context) } + val lastTime by + remember(relay.lastEvent) { + derivedStateOf { timeAgo(relay.lastEvent, context = context) } + } + + Text( + text = lastTime, + maxLines = 1, + ) + } + + Text( + "${relay.counter} ${stringResource(R.string.posts_received)}", + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } - Text( - text = lastTime, - maxLines = 1, - ) + Column(modifier = Modifier.padding(start = 10.dp)) { + RelayOptions(accountViewModel, relay, onAddRelay, onRemoveRelay) + } } - Text( - "${relay.counter} ${stringResource(R.string.posts_received)}", - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = DividerThickness, ) - } - - Column(modifier = Modifier.padding(start = 10.dp)) { - RelayOptions(accountViewModel, relay, onAddRelay, onRemoveRelay) - } } - - Divider( - modifier = Modifier.padding(top = 10.dp), - thickness = DividerThickness, - ) - } } @Composable private fun RelayOptions( - accountViewModel: AccountViewModel, - relay: RelayInfo, - onAddRelay: () -> Unit, - onRemoveRelay: () -> Unit, + accountViewModel: AccountViewModel, + relay: RelayInfo, + onAddRelay: () -> Unit, + onRemoveRelay: () -> Unit, ) { - val userState by accountViewModel.userRelays.observeAsState() + val userState by accountViewModel.userRelays.observeAsState() - val isNotUsingRelay = - remember(userState) { - accountViewModel.account.activeRelays()?.none { it.url == relay.url } == true + val isNotUsingRelay = + remember(userState) { + accountViewModel.account.activeRelays()?.none { it.url == relay.url } == true + } + + if (isNotUsingRelay) { + AddRelayButton(onAddRelay) + } else { + RemoveRelayButton(onRemoveRelay) } - - if (isNotUsingRelay) { - AddRelayButton(onAddRelay) - } else { - RemoveRelayButton(onRemoveRelay) - } } @Composable fun AddRelayButton(onClick: () -> Unit) { - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = onClick, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - contentPadding = ButtonPadding, - ) { - Text(text = stringResource(id = R.string.add), color = Color.White) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = onClick, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(id = R.string.add), color = Color.White) + } } @Composable fun RemoveRelayButton(onClick: () -> Unit) { - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = onClick, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - contentPadding = ButtonPadding, - ) { - Text(text = stringResource(R.string.remove), color = Color.White) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = onClick, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(R.string.remove), color = Color.White) + } } fun formattedDateTime(timestamp: Long): String { - return Instant.ofEpochSecond(timestamp) - .atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ofPattern("MMM d, uuuu hh:mm a")) + return Instant.ofEpochSecond(timestamp) + .atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("MMM d, uuuu hh:mm a")) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt index d58b3fcfe..3fdc8e644 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt @@ -48,49 +48,49 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @OptIn(ExperimentalLayoutApi::class) @Composable fun RelayBadges( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var expanded by remember { mutableStateOf(false) } + var expanded by remember { mutableStateOf(false) } - val relayList by baseNote.live().relayInfo.observeAsState(baseNote.relays) + val relayList by baseNote.live().relayInfo.observeAsState(baseNote.relays) - Spacer(DoubleVertSpacer) + Spacer(DoubleVertSpacer) - // FlowRow Seems to be a lot faster than LazyVerticalGrid - FlowRow { - if (expanded) { - relayList?.forEach { RenderRelay(it, accountViewModel, nav) } - } else { - relayList?.getOrNull(0)?.let { RenderRelay(it, accountViewModel, nav) } - relayList?.getOrNull(1)?.let { RenderRelay(it, accountViewModel, nav) } - relayList?.getOrNull(2)?.let { RenderRelay(it, accountViewModel, nav) } + // FlowRow Seems to be a lot faster than LazyVerticalGrid + FlowRow { + if (expanded) { + relayList?.forEach { RenderRelay(it, accountViewModel, nav) } + } else { + relayList?.getOrNull(0)?.let { RenderRelay(it, accountViewModel, nav) } + relayList?.getOrNull(1)?.let { RenderRelay(it, accountViewModel, nav) } + relayList?.getOrNull(2)?.let { RenderRelay(it, accountViewModel, nav) } + } } - } - if (relayList.size > 3 && !expanded) { - ShowMoreRelaysButton { expanded = true } - } + if (relayList.size > 3 && !expanded) { + ShowMoreRelaysButton { expanded = true } + } } @Composable private fun ShowMoreRelaysButton(onClick: () -> Unit) { - Row( - modifier = ShowMoreRelaysButtonBoxModifer, - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.Top, - ) { - IconButton( - modifier = ShowMoreRelaysButtonIconButtonModifier, - onClick = onClick, + Row( + modifier = ShowMoreRelaysButtonBoxModifer, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.Top, ) { - Icon( - imageVector = Icons.Default.ExpandMore, - null, - modifier = ShowMoreRelaysButtonIconModifier, - tint = MaterialTheme.colorScheme.placeholderText, - ) + IconButton( + modifier = ShowMoreRelaysButtonIconButtonModifier, + onClick = onClick, + ) { + Icon( + imageVector = Icons.Default.ExpandMore, + null, + modifier = ShowMoreRelaysButtonIconModifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt index 1cb3a10a3..9891828db 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt @@ -65,166 +65,167 @@ import com.vitorpamplona.amethyst.ui.theme.relayIconModifier @Composable public fun RelayBadgesHorizontal( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val expanded = remember { mutableStateOf(false) } + val expanded = remember { mutableStateOf(false) } - RenderRelayList(baseNote, expanded, accountViewModel, nav) + RenderRelayList(baseNote, expanded, accountViewModel, nav) - RenderExpandButton(baseNote, expanded) { ChatRelayExpandButton { expanded.value = true } } + RenderExpandButton(baseNote, expanded) { ChatRelayExpandButton { expanded.value = true } } } @OptIn(ExperimentalLayoutApi::class) @Composable fun RenderRelayList( - baseNote: Note, - expanded: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + expanded: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteRelays by baseNote.live().relayInfo.observeAsState(baseNote.relays) + val noteRelays by baseNote.live().relayInfo.observeAsState(baseNote.relays) - FlowRow(StdStartPadding, verticalArrangement = Arrangement.Center) { - if (expanded.value) { - noteRelays?.forEach { RenderRelay(it, accountViewModel, nav) } - } else { - noteRelays?.getOrNull(0)?.let { RenderRelay(it, accountViewModel, nav) } - noteRelays?.getOrNull(1)?.let { RenderRelay(it, accountViewModel, nav) } - noteRelays?.getOrNull(2)?.let { RenderRelay(it, accountViewModel, nav) } + FlowRow(StdStartPadding, verticalArrangement = Arrangement.Center) { + if (expanded.value) { + noteRelays?.forEach { RenderRelay(it, accountViewModel, nav) } + } else { + noteRelays?.getOrNull(0)?.let { RenderRelay(it, accountViewModel, nav) } + noteRelays?.getOrNull(1)?.let { RenderRelay(it, accountViewModel, nav) } + noteRelays?.getOrNull(2)?.let { RenderRelay(it, accountViewModel, nav) } + } } - } } @Composable fun RenderExpandButton( - baseNote: Note, - expanded: MutableState, - content: @Composable () -> Unit, + baseNote: Note, + expanded: MutableState, + content: @Composable () -> Unit, ) { - val showExpandButton by - baseNote.live().relays.map { it.note.relays.size > 3 }.observeAsState(baseNote.relays.size > 3) + val showExpandButton by + baseNote.live().relays.map { it.note.relays.size > 3 }.observeAsState(baseNote.relays.size > 3) - if (showExpandButton && !expanded.value) { - content() - } + if (showExpandButton && !expanded.value) { + content() + } } @Composable fun ChatRelayExpandButton(onClick: () -> Unit) { - IconButton( - modifier = Size15Modifier, - onClick = onClick, - ) { - Icon( - imageVector = Icons.Default.ChevronRight, - null, - modifier = Size15Modifier, - tint = MaterialTheme.colorScheme.placeholderText, - ) - } + IconButton( + modifier = Size15Modifier, + onClick = onClick, + ) { + Icon( + imageVector = Icons.Default.ChevronRight, + null, + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + } } @Composable fun RenderRelay( - relay: RelayBriefInfoCache.RelayBriefInfo, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + relay: RelayBriefInfoCache.RelayBriefInfo, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var relayInfo: RelayInformation? by remember { mutableStateOf(null) } + var relayInfo: RelayInformation? by remember { mutableStateOf(null) } - if (relayInfo != null) { - RelayInformationDialog( - onClose = { relayInfo = null }, - relayInfo = relayInfo!!, - relayBriefInfo = relay, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - val context = LocalContext.current - - val interactionSource = remember { MutableInteractionSource() } - val ripple = rememberRipple(bounded = false, radius = Size15dp) - - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } - - val clickableModifier = - remember(relay) { - Modifier.padding(1.dp) - .size(Size15dp) - .clickable( - role = Role.Button, - interactionSource = interactionSource, - indication = ripple, - onClick = { - accountViewModel.retrieveRelayDocument( - relay.url, - onInfo = { relayInfo = it }, - onError = { url, errorCode, exceptionMessage -> - val msg = - when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> - context.getString( - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> - context.getString( - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> - context.getString( - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> - context.getString( - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - } - - accountViewModel.toast( - context.getString(R.string.unable_to_download_relay_document), - msg, - ) - }, - ) - }, + if (relayInfo != null) { + RelayInformationDialog( + onClose = { relayInfo = null }, + relayInfo = relayInfo!!, + relayBriefInfo = relay, + accountViewModel = accountViewModel, + nav = nav, ) } - Box( - modifier = clickableModifier, - contentAlignment = Alignment.Center, - ) { - RenderRelayIcon(relay.displayUrl, relay.favIcon, automaticallyShowProfilePicture) - } + val context = LocalContext.current + + val interactionSource = remember { MutableInteractionSource() } + val ripple = rememberRipple(bounded = false, radius = Size15dp) + + val automaticallyShowProfilePicture = + remember { + accountViewModel.settings.showProfilePictures.value + } + + val clickableModifier = + remember(relay) { + Modifier.padding(1.dp) + .size(Size15dp) + .clickable( + role = Role.Button, + interactionSource = interactionSource, + indication = ripple, + onClick = { + accountViewModel.retrieveRelayDocument( + relay.url, + onInfo = { relayInfo = it }, + onError = { url, errorCode, exceptionMessage -> + val msg = + when (errorCode) { + Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + } + + accountViewModel.toast( + context.getString(R.string.unable_to_download_relay_document), + msg, + ) + }, + ) + }, + ) + } + + Box( + modifier = clickableModifier, + contentAlignment = Alignment.Center, + ) { + RenderRelayIcon(relay.displayUrl, relay.favIcon, automaticallyShowProfilePicture) + } } @Composable fun RenderRelayIcon( - displayUrl: String, - iconUrl: String, - loadProfilePicture: Boolean, - iconModifier: Modifier = MaterialTheme.colorScheme.relayIconModifier, + displayUrl: String, + iconUrl: String, + loadProfilePicture: Boolean, + iconModifier: Modifier = MaterialTheme.colorScheme.relayIconModifier, ) { - RobohashFallbackAsyncImage( - robot = displayUrl, - model = iconUrl, - contentDescription = stringResource(id = R.string.relay_icon), - colorFilter = RelayIconFilter, - modifier = iconModifier, - loadProfilePicture = loadProfilePicture, - ) + RobohashFallbackAsyncImage( + robot = displayUrl, + model = iconUrl, + contentDescription = stringResource(id = R.string.relay_icon), + colorFilter = RelayIconFilter, + modifier = iconModifier, + loadProfilePicture = loadProfilePicture, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt index e31a1ac8e..f3c095cea 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt @@ -55,190 +55,190 @@ import kotlinx.coroutines.launch @Composable fun ReplyInformation( - replyTo: ImmutableList?, - mentions: ImmutableList, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + replyTo: ImmutableList?, + mentions: ImmutableList, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var sortedMentions by remember { mutableStateOf?>(null) } + var sortedMentions by remember { mutableStateOf?>(null) } - LaunchedEffect(Unit) { - launch(Dispatchers.IO) { - sortedMentions = - mentions - .mapNotNull { LocalCache.checkGetOrCreateUser(it) } - .toSet() - .sortedBy { !accountViewModel.account.userProfile().isFollowingCached(it) } - .toImmutableList() + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + sortedMentions = + mentions + .mapNotNull { LocalCache.checkGetOrCreateUser(it) } + .toSet() + .sortedBy { !accountViewModel.account.userProfile().isFollowingCached(it) } + .toImmutableList() + } } - } - if (sortedMentions != null) { - ReplyInformation(replyTo, sortedMentions) { nav("User/${it.pubkeyHex}") } - } + if (sortedMentions != null) { + ReplyInformation(replyTo, sortedMentions) { nav("User/${it.pubkeyHex}") } + } } @OptIn(ExperimentalLayoutApi::class) @Composable private fun ReplyInformation( - replyTo: ImmutableList?, - sortedMentions: ImmutableList?, - prefix: String = "", - onUserTagClick: (User) -> Unit, + replyTo: ImmutableList?, + sortedMentions: ImmutableList?, + prefix: String = "", + onUserTagClick: (User) -> Unit, ) { - var expanded by remember { mutableStateOf((sortedMentions?.size ?: 0) <= 2) } + var expanded by remember { mutableStateOf((sortedMentions?.size ?: 0) <= 2) } - FlowRow { - if (sortedMentions != null && sortedMentions.isNotEmpty()) { - if (replyTo != null && replyTo.isNotEmpty()) { - val repliesToDisplay = if (expanded) sortedMentions else sortedMentions.take(2) + FlowRow { + if (sortedMentions != null && sortedMentions.isNotEmpty()) { + if (replyTo != null && replyTo.isNotEmpty()) { + val repliesToDisplay = if (expanded) sortedMentions else sortedMentions.take(2) - Text( - stringResource(R.string.replying_to), - fontSize = 13.sp, - color = MaterialTheme.colorScheme.placeholderText, - ) - - repliesToDisplay.forEachIndexed { idx, user -> - ReplyInfoMention(user, prefix, onUserTagClick) - - if (expanded) { - if (idx < repliesToDisplay.size - 2) { - Text( - ", ", - fontSize = 13.sp, - color = MaterialTheme.colorScheme.placeholderText, - ) - } else if (idx < repliesToDisplay.size - 1) { - Text( - stringResource(R.string.and), - fontSize = 13.sp, - color = MaterialTheme.colorScheme.placeholderText, - ) - } - } else { - if (idx < repliesToDisplay.size - 1) { - Text( - ", ", - fontSize = 13.sp, - color = MaterialTheme.colorScheme.placeholderText, - ) - } else if (idx < repliesToDisplay.size) { - Text( - stringResource(R.string.and), - fontSize = 13.sp, - color = MaterialTheme.colorScheme.placeholderText, - ) - - ClickableText( - AnnotatedString("${sortedMentions.size - 2}"), - style = - LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.lessImportantLink, + Text( + stringResource(R.string.replying_to), fontSize = 13.sp, - ), - onClick = { expanded = true }, - ) + color = MaterialTheme.colorScheme.placeholderText, + ) - Text( - " ${stringResource(R.string.others)}", - fontSize = 13.sp, - color = MaterialTheme.colorScheme.placeholderText, - ) + repliesToDisplay.forEachIndexed { idx, user -> + ReplyInfoMention(user, prefix, onUserTagClick) + + if (expanded) { + if (idx < repliesToDisplay.size - 2) { + Text( + ", ", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.placeholderText, + ) + } else if (idx < repliesToDisplay.size - 1) { + Text( + stringResource(R.string.and), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + } else { + if (idx < repliesToDisplay.size - 1) { + Text( + ", ", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.placeholderText, + ) + } else if (idx < repliesToDisplay.size) { + Text( + stringResource(R.string.and), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.placeholderText, + ) + + ClickableText( + AnnotatedString("${sortedMentions.size - 2}"), + style = + LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.lessImportantLink, + fontSize = 13.sp, + ), + onClick = { expanded = true }, + ) + + Text( + " ${stringResource(R.string.others)}", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + } + } } - } } - } } - } } @Composable fun ReplyInformationChannel( - replyTo: ImmutableList?, - mentions: ImmutableList, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + replyTo: ImmutableList?, + mentions: ImmutableList, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var sortedMentions by remember { mutableStateOf>(persistentListOf()) } + var sortedMentions by remember { mutableStateOf>(persistentListOf()) } - LaunchedEffect(Unit) { - accountViewModel.loadMentions(mentions) { newSortedMentions -> - if (newSortedMentions != sortedMentions) { - sortedMentions = newSortedMentions - } + LaunchedEffect(Unit) { + accountViewModel.loadMentions(mentions) { newSortedMentions -> + if (newSortedMentions != sortedMentions) { + sortedMentions = newSortedMentions + } + } } - } - if (sortedMentions.isNotEmpty()) { - ReplyInformationChannel( - replyTo, - sortedMentions, - onUserTagClick = { nav("User/${it.pubkeyHex}") }, - ) - Spacer(modifier = StdVertSpacer) - } + if (sortedMentions.isNotEmpty()) { + ReplyInformationChannel( + replyTo, + sortedMentions, + onUserTagClick = { nav("User/${it.pubkeyHex}") }, + ) + Spacer(modifier = StdVertSpacer) + } } @OptIn(ExperimentalLayoutApi::class) @Composable fun ReplyInformationChannel( - replyTo: ImmutableList?, - mentions: ImmutableList?, - prefix: String = "", - onUserTagClick: (User) -> Unit, + replyTo: ImmutableList?, + mentions: ImmutableList?, + prefix: String = "", + onUserTagClick: (User) -> Unit, ) { - FlowRow { - if (mentions != null && mentions.isNotEmpty()) { - if (replyTo != null && replyTo.isNotEmpty()) { - Text( - stringResource(id = R.string.replying_to), - fontSize = 13.sp, - color = MaterialTheme.colorScheme.placeholderText, - ) + FlowRow { + if (mentions != null && mentions.isNotEmpty()) { + if (replyTo != null && replyTo.isNotEmpty()) { + Text( + stringResource(id = R.string.replying_to), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.placeholderText, + ) - mentions.forEachIndexed { idx, user -> - ReplyInfoMention(user, prefix, onUserTagClick) + mentions.forEachIndexed { idx, user -> + ReplyInfoMention(user, prefix, onUserTagClick) - if (idx < mentions.size - 2) { - Text( - ", ", - fontSize = 13.sp, - color = MaterialTheme.colorScheme.placeholderText, - ) - } else if (idx < mentions.size - 1) { - Text( - " ${stringResource(id = R.string.and)} ", - fontSize = 13.sp, - color = MaterialTheme.colorScheme.placeholderText, - ) - } + if (idx < mentions.size - 2) { + Text( + ", ", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.placeholderText, + ) + } else if (idx < mentions.size - 1) { + Text( + " ${stringResource(id = R.string.and)} ", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + } + } } - } } - } } @Composable private fun ReplyInfoMention( - user: User, - prefix: String, - onUserTagClick: (User) -> Unit, + user: User, + prefix: String, + onUserTagClick: (User) -> Unit, ) { - val innerUserState by user.live().metadata.observeAsState() + val innerUserState by user.live().metadata.observeAsState() - CreateClickableTextWithEmoji( - clickablePart = - remember(innerUserState) { "$prefix${innerUserState?.user?.toBestDisplayName()}" }, - tags = - remember(innerUserState) { - innerUserState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() - }, - style = - LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.lessImportantLink, - fontSize = 13.sp, - ), - onClick = { onUserTagClick(user) }, - ) + CreateClickableTextWithEmoji( + clickablePart = + remember(innerUserState) { "$prefix${innerUserState?.user?.toBestDisplayName()}" }, + tags = + remember(innerUserState) { + innerUserState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() + }, + style = + LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.lessImportantLink, + fontSize = 13.sp, + ), + onClick = { onUserTagClick(user) }, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt index 4a0dca4f0..a415a0768 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt @@ -32,62 +32,62 @@ var yearFormatter = SimpleDateFormat(" โ€ข MMM dd, yyyy", locale) var monthFormatter = SimpleDateFormat(" โ€ข MMM dd", locale) fun timeAgo( - time: Long?, - context: Context, + time: Long?, + context: Context, ): String { - if (time == null) return " " - if (time == 0L) return " โ€ข ${context.getString(R.string.never)}" + if (time == null) return " " + if (time == 0L) return " โ€ข ${context.getString(R.string.never)}" - val timeDifference = TimeUtils.now() - time + val timeDifference = TimeUtils.now() - time - return if (timeDifference > TimeUtils.ONE_YEAR) { - // Dec 12, 2022 + return if (timeDifference > TimeUtils.ONE_YEAR) { + // Dec 12, 2022 - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(" โ€ข MMM dd, yyyy", locale) - monthFormatter = SimpleDateFormat(" โ€ข MMM dd", locale) + if (locale != Locale.getDefault()) { + locale = Locale.getDefault() + yearFormatter = SimpleDateFormat(" โ€ข MMM dd, yyyy", locale) + monthFormatter = SimpleDateFormat(" โ€ข MMM dd", locale) + } + + yearFormatter.format(time * 1000) + } else if (timeDifference > TimeUtils.ONE_MONTH) { + // Dec 12 + if (locale != Locale.getDefault()) { + locale = Locale.getDefault() + yearFormatter = SimpleDateFormat(" โ€ข MMM dd, yyyy", locale) + monthFormatter = SimpleDateFormat(" โ€ข MMM dd", locale) + } + + monthFormatter.format(time * 1000) + } else if (timeDifference > TimeUtils.ONE_DAY) { + // 2 days + " โ€ข " + (timeDifference / TimeUtils.ONE_DAY).toString() + context.getString(R.string.d) + } else if (timeDifference > TimeUtils.ONE_HOUR) { + " โ€ข " + (timeDifference / TimeUtils.ONE_HOUR).toString() + context.getString(R.string.h) + } else if (timeDifference > TimeUtils.ONE_MINUTE) { + " โ€ข " + (timeDifference / TimeUtils.ONE_MINUTE).toString() + context.getString(R.string.m) + } else { + " โ€ข " + context.getString(R.string.now) } - - yearFormatter.format(time * 1000) - } else if (timeDifference > TimeUtils.ONE_MONTH) { - // Dec 12 - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(" โ€ข MMM dd, yyyy", locale) - monthFormatter = SimpleDateFormat(" โ€ข MMM dd", locale) - } - - monthFormatter.format(time * 1000) - } else if (timeDifference > TimeUtils.ONE_DAY) { - // 2 days - " โ€ข " + (timeDifference / TimeUtils.ONE_DAY).toString() + context.getString(R.string.d) - } else if (timeDifference > TimeUtils.ONE_HOUR) { - " โ€ข " + (timeDifference / TimeUtils.ONE_HOUR).toString() + context.getString(R.string.h) - } else if (timeDifference > TimeUtils.ONE_MINUTE) { - " โ€ข " + (timeDifference / TimeUtils.ONE_MINUTE).toString() + context.getString(R.string.m) - } else { - " โ€ข " + context.getString(R.string.now) - } } fun timeAgoShort( - mills: Long?, - stringForNow: String, + mills: Long?, + stringForNow: String, ): String { - if (mills == null) return " " + if (mills == null) return " " - var humanReadable = - DateUtils.getRelativeTimeSpanString( - mills * 1000, - System.currentTimeMillis(), - DateUtils.MINUTE_IN_MILLIS, - DateUtils.FORMAT_ABBREV_ALL, - ) - .toString() - if (humanReadable.startsWith("In") || humanReadable.startsWith("0")) { - humanReadable = stringForNow - } + var humanReadable = + DateUtils.getRelativeTimeSpanString( + mills * 1000, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_ALL, + ) + .toString() + if (humanReadable.startsWith("In") || humanReadable.startsWith("0")) { + humanReadable = stringForNow + } - return humanReadable + return humanReadable } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt index 21ce28481..cf07e8e91 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt @@ -95,317 +95,315 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch class UpdateReactionTypeViewModel(val account: Account) : ViewModel() { - var nextChoice by mutableStateOf(TextFieldValue("")) - var reactionSet by mutableStateOf(listOf()) + var nextChoice by mutableStateOf(TextFieldValue("")) + var reactionSet by mutableStateOf(listOf()) - fun load() { - this.reactionSet = account.reactionChoices - } - - fun toListOfChoices(commaSeparatedAmounts: String): List { - return commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } - } - - fun addChoice() { - val newValue = nextChoice.text.trim().firstFullChar() - reactionSet = reactionSet + newValue - - nextChoice = TextFieldValue("") - } - - fun addChoice(customEmoji: EmojiUrl) { - reactionSet = reactionSet + (customEmoji.encode()) - } - - fun removeChoice(reaction: String) { - reactionSet = reactionSet - reaction - } - - fun sendPost() { - account.changeReactionTypes(reactionSet) - nextChoice = TextFieldValue("") - } - - fun cancel() { - nextChoice = TextFieldValue("") - } - - fun hasChanged(): Boolean { - return reactionSet != account.reactionChoices - } - - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): UpdateReactionTypeViewModel { - return UpdateReactionTypeViewModel(account) as UpdateReactionTypeViewModel + fun load() { + this.reactionSet = account.reactionChoices + } + + fun toListOfChoices(commaSeparatedAmounts: String): List { + return commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } + } + + fun addChoice() { + val newValue = nextChoice.text.trim().firstFullChar() + reactionSet = reactionSet + newValue + + nextChoice = TextFieldValue("") + } + + fun addChoice(customEmoji: EmojiUrl) { + reactionSet = reactionSet + (customEmoji.encode()) + } + + fun removeChoice(reaction: String) { + reactionSet = reactionSet - reaction + } + + fun sendPost() { + account.changeReactionTypes(reactionSet) + nextChoice = TextFieldValue("") + } + + fun cancel() { + nextChoice = TextFieldValue("") + } + + fun hasChanged(): Boolean { + return reactionSet != account.reactionChoices + } + + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): UpdateReactionTypeViewModel { + return UpdateReactionTypeViewModel(account) as UpdateReactionTypeViewModel + } } - } } @OptIn(ExperimentalLayoutApi::class) @Composable fun UpdateReactionTypeDialog( - onClose: () -> Unit, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + onClose: () -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val postViewModel: UpdateReactionTypeViewModel = - viewModel( - key = "UpdateReactionTypeViewModel", - factory = UpdateReactionTypeViewModel.Factory(accountViewModel.account), - ) + val postViewModel: UpdateReactionTypeViewModel = + viewModel( + key = "UpdateReactionTypeViewModel", + factory = UpdateReactionTypeViewModel.Factory(accountViewModel.account), + ) - LaunchedEffect(accountViewModel) { postViewModel.load() } + LaunchedEffect(accountViewModel) { postViewModel.load() } - Dialog( - onDismissRequest = { onClose() }, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - decorFitsSystemWindows = false, - ), - ) { - Surface( - modifier = Modifier.fillMaxWidth(), + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false, + ), ) { - Column( - modifier = Modifier.padding(10.dp).imePadding(), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + Surface( + modifier = Modifier.fillMaxWidth(), ) { - CloseButton( - onPress = { - postViewModel.cancel() - onClose() - }, - ) - - SaveButton( - onPost = { - postViewModel.sendPost() - onClose() - }, - isActive = postViewModel.hasChanged(), - ) - } - - Spacer(modifier = Modifier.height(10.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier.verticalScroll(rememberScrollState()), - ) { - Row(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.animateContentSize()) { - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - postViewModel.reactionSet.forEach { reactionType -> - RenderReactionOption(reactionType, postViewModel) - } - } - } - } - - Spacer(modifier = Modifier.height(10.dp)) - - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically, + Column( + modifier = Modifier.padding(10.dp).imePadding(), ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.new_reaction_symbol)) }, - value = postViewModel.nextChoice, - onValueChange = { postViewModel.nextChoice = it }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Text, - ), - placeholder = { - Text( - text = "\uD83D\uDCAF, \uD83C\uDF89, \uD83D\uDC4E", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - singleLine = true, - modifier = Modifier.padding(end = 10.dp).weight(1f), - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton( + onPress = { + postViewModel.cancel() + onClose() + }, + ) - Button( - onClick = { postViewModel.addChoice() }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text(text = stringResource(R.string.add), color = Color.White) - } + SaveButton( + onPost = { + postViewModel.sendPost() + onClose() + }, + isActive = postViewModel.hasChanged(), + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + Row(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.animateContentSize()) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + postViewModel.reactionSet.forEach { reactionType -> + RenderReactionOption(reactionType, postViewModel) + } + } + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.new_reaction_symbol)) }, + value = postViewModel.nextChoice, + onValueChange = { postViewModel.nextChoice = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Text, + ), + placeholder = { + Text( + text = "\uD83D\uDCAF, \uD83C\uDF89, \uD83D\uDC4E", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + modifier = Modifier.padding(end = 10.dp).weight(1f), + ) + + Button( + onClick = { postViewModel.addChoice() }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(text = stringResource(R.string.add), color = Color.White) + } + } + } + } + + EmojiSelector( + accountViewModel = accountViewModel, + nav = nav, + ) { + postViewModel.addChoice(it) + } } - } } - - EmojiSelector( - accountViewModel = accountViewModel, - nav = nav, - ) { - postViewModel.addChoice(it) - } - } } - } } @Composable private fun RenderReactionOption( - reactionType: String, - postViewModel: UpdateReactionTypeViewModel, + reactionType: String, + postViewModel: UpdateReactionTypeViewModel, ) { - Button( - modifier = Modifier.padding(horizontal = 3.dp), - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - onClick = { postViewModel.removeChoice(reactionType) }, - contentPadding = PaddingValues(horizontal = 5.dp), - ) { - if (reactionType.startsWith(":")) { - val noStartColon = reactionType.removePrefix(":") - val url = noStartColon.substringAfter(":") + Button( + modifier = Modifier.padding(horizontal = 3.dp), + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + onClick = { postViewModel.removeChoice(reactionType) }, + contentPadding = PaddingValues(horizontal = 5.dp), + ) { + if (reactionType.startsWith(":")) { + val noStartColon = reactionType.removePrefix(":") + val url = noStartColon.substringAfter(":") - val renderable = - listOf( - ImageUrlType(url), - TextType(" โœ–"), - ) - .toImmutableList() + val renderable = + listOf( + ImageUrlType(url), + TextType(" โœ–"), + ) + .toImmutableList() - InLineIconRenderer( - renderable, - style = SpanStyle(color = Color.White), - maxLines = 1, - ) - } else { - when (reactionType) { - "+" -> { - Icon( - painter = painterResource(R.drawable.ic_liked), - null, - modifier = remember { Modifier.size(16.dp) }, - tint = Color.White, - ) - Text( - text = " โœ–", - color = Color.White, - textAlign = TextAlign.Center, - ) + InLineIconRenderer( + renderable, + style = SpanStyle(color = Color.White), + maxLines = 1, + ) + } else { + when (reactionType) { + "+" -> { + Icon( + painter = painterResource(R.drawable.ic_liked), + null, + modifier = remember { Modifier.size(16.dp) }, + tint = Color.White, + ) + Text( + text = " โœ–", + color = Color.White, + textAlign = TextAlign.Center, + ) + } + "-" -> + Text( + text = "\uD83D\uDC4E โœ–", + color = Color.White, + textAlign = TextAlign.Center, + ) + else -> + Text( + text = "$reactionType โœ–", + color = Color.White, + textAlign = TextAlign.Center, + ) + } } - "-" -> - Text( - text = "\uD83D\uDC4E โœ–", - color = Color.White, - textAlign = TextAlign.Center, - ) - else -> - Text( - text = "$reactionType โœ–", - color = Color.White, - textAlign = TextAlign.Center, - ) - } } - } } @Composable private fun EmojiSelector( - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - onClick: ((EmojiUrl) -> Unit)? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onClick: ((EmojiUrl) -> Unit)? = null, ) { - LoadAddressableNote( - aTag = - ATag( - EmojiPackSelectionEvent.KIND, - accountViewModel.userProfile().pubkeyHex, - "", - null, - ), - accountViewModel, - ) { emptyNote -> - emptyNote?.let { usersEmojiList -> - val collections by - usersEmojiList - .live() - .metadata - .map { (it.note.event as? EmojiPackSelectionEvent)?.taggedAddresses()?.toImmutableList() } - .distinctUntilChanged() - .observeAsState( - (usersEmojiList.event as? EmojiPackSelectionEvent) - ?.taggedAddresses() - ?.toImmutableList(), - ) + LoadAddressableNote( + aTag = + ATag( + EmojiPackSelectionEvent.KIND, + accountViewModel.userProfile().pubkeyHex, + "", + null, + ), + accountViewModel, + ) { emptyNote -> + emptyNote?.let { usersEmojiList -> + val collections by + usersEmojiList + .live() + .metadata + .map { (it.note.event as? EmojiPackSelectionEvent)?.taggedAddresses()?.toImmutableList() } + .distinctUntilChanged() + .observeAsState( + (usersEmojiList.event as? EmojiPackSelectionEvent) + ?.taggedAddresses() + ?.toImmutableList(), + ) - collections?.let { EmojiCollectionGallery(it, accountViewModel, nav, onClick) } + collections?.let { EmojiCollectionGallery(it, accountViewModel, nav, onClick) } + } } - } } @Composable fun EmojiCollectionGallery( - emojiCollections: ImmutableList, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - onClick: ((EmojiUrl) -> Unit)? = null, + emojiCollections: ImmutableList, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onClick: ((EmojiUrl) -> Unit)? = null, ) { - val color = MaterialTheme.colorScheme.background - val bgColor = remember { mutableStateOf(color) } + val color = MaterialTheme.colorScheme.background + val bgColor = remember { mutableStateOf(color) } - val listState = rememberLazyListState() + val listState = rememberLazyListState() - LazyColumn( - state = listState, - ) { - itemsIndexed(emojiCollections, key = { _, item -> item.toTag() }) { _, item -> - LoadAddressableNote(aTag = item, accountViewModel) { - it?.let { WatchAndRenderNote(it, bgColor, accountViewModel, nav, onClick) } - } + LazyColumn( + state = listState, + ) { + itemsIndexed(emojiCollections, key = { _, item -> item.toTag() }) { _, item -> + LoadAddressableNote(aTag = item, accountViewModel) { + it?.let { WatchAndRenderNote(it, bgColor, accountViewModel, nav, onClick) } + } + } } - } } @Composable private fun WatchAndRenderNote( - emojiPack: AddressableNote, - bgColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - onClick: ((EmojiUrl) -> Unit)?, + emojiPack: AddressableNote, + bgColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onClick: ((EmojiUrl) -> Unit)?, ) { - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - Column( - Modifier.fillMaxWidth().clickable { - scope.launch { routeFor(emojiPack, accountViewModel.userProfile())?.let { nav(it) } } - }, - ) { - RenderEmojiPack( - baseNote = emojiPack, - actionable = false, - backgroundColor = bgColor, - accountViewModel = accountViewModel, - onClick = onClick, - ) - } + Column( + Modifier.fillMaxWidth().clickable { + scope.launch { routeFor(emojiPack, accountViewModel.userProfile())?.let { nav(it) } } + }, + ) { + RenderEmojiPack( + baseNote = emojiPack, + actionable = false, + backgroundColor = bgColor, + accountViewModel = accountViewModel, + onClick = onClick, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index b48621479..bcd0ce310 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -105,326 +105,327 @@ import com.vitorpamplona.quartz.events.LnZapEvent import kotlinx.collections.immutable.toImmutableList class UpdateZapAmountViewModel(val account: Account) : ViewModel() { - var nextAmount by mutableStateOf(TextFieldValue("")) - var amountSet by mutableStateOf(listOf()) - var walletConnectRelay by mutableStateOf(TextFieldValue("")) - var walletConnectPubkey by mutableStateOf(TextFieldValue("")) - var walletConnectSecret by mutableStateOf(TextFieldValue("")) - var selectedZapType by mutableStateOf(LnZapEvent.ZapType.PRIVATE) + var nextAmount by mutableStateOf(TextFieldValue("")) + var amountSet by mutableStateOf(listOf()) + var walletConnectRelay by mutableStateOf(TextFieldValue("")) + var walletConnectPubkey by mutableStateOf(TextFieldValue("")) + var walletConnectSecret by mutableStateOf(TextFieldValue("")) + var selectedZapType by mutableStateOf(LnZapEvent.ZapType.PRIVATE) - fun load() { - this.amountSet = account.zapAmountChoices - this.walletConnectPubkey = - account.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("") - this.walletConnectRelay = - account.zapPaymentRequest?.relayUri?.let { TextFieldValue(it) } ?: TextFieldValue("") - this.walletConnectSecret = - account.zapPaymentRequest?.secret?.let { TextFieldValue(it) } ?: TextFieldValue("") - this.selectedZapType = account.defaultZapType - } - - fun toListOfAmounts(commaSeparatedAmounts: String): List { - return commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } - } - - fun addAmount() { - val newValue = nextAmount.text.trim().toLongOrNull() - if (newValue != null) { - amountSet = amountSet + newValue + fun load() { + this.amountSet = account.zapAmountChoices + this.walletConnectPubkey = + account.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("") + this.walletConnectRelay = + account.zapPaymentRequest?.relayUri?.let { TextFieldValue(it) } ?: TextFieldValue("") + this.walletConnectSecret = + account.zapPaymentRequest?.secret?.let { TextFieldValue(it) } ?: TextFieldValue("") + this.selectedZapType = account.defaultZapType } - nextAmount = TextFieldValue("") - } + fun toListOfAmounts(commaSeparatedAmounts: String): List { + return commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } + } - fun removeAmount(amount: Long) { - amountSet = amountSet - amount - } - - fun sendPost() { - account?.changeZapAmounts(amountSet) - account?.changeDefaultZapType(selectedZapType) - - if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) { - val pubkeyHex = - try { - decodePublicKey(walletConnectPubkey.text.trim()).toHexKey() - } catch (e: Exception) { - null + fun addAmount() { + val newValue = nextAmount.text.trim().toLongOrNull() + if (newValue != null) { + amountSet = amountSet + newValue } - val relayUrl = - walletConnectRelay.text - .ifBlank { null } - ?.let { - var addedWSS = - if (!it.startsWith("wss://") && !it.startsWith("ws://")) "wss://$it" else it - if (addedWSS.endsWith("/")) addedWSS = addedWSS.dropLast(1) + nextAmount = TextFieldValue("") + } - addedWSS - } + fun removeAmount(amount: Long) { + amountSet = amountSet - amount + } - val unverifiedPrivKey = walletConnectSecret.text.ifBlank { null } - val privKeyHex = - try { - unverifiedPrivKey?.let { decodePublicKey(it).toHexKey() } - } catch (e: Exception) { - null + fun sendPost() { + account?.changeZapAmounts(amountSet) + account?.changeDefaultZapType(selectedZapType) + + if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) { + val pubkeyHex = + try { + decodePublicKey(walletConnectPubkey.text.trim()).toHexKey() + } catch (e: Exception) { + null + } + + val relayUrl = + walletConnectRelay.text + .ifBlank { null } + ?.let { + var addedWSS = + if (!it.startsWith("wss://") && !it.startsWith("ws://")) "wss://$it" else it + if (addedWSS.endsWith("/")) addedWSS = addedWSS.dropLast(1) + + addedWSS + } + + val unverifiedPrivKey = walletConnectSecret.text.ifBlank { null } + val privKeyHex = + try { + unverifiedPrivKey?.let { decodePublicKey(it).toHexKey() } + } catch (e: Exception) { + null + } + + if (pubkeyHex != null) { + account?.changeZapPaymentRequest( + Nip47URI( + pubkeyHex, + relayUrl, + privKeyHex, + ), + ) + } else { + account?.changeZapPaymentRequest(null) + } + } else { + account?.changeZapPaymentRequest(null) } - if (pubkeyHex != null) { - account?.changeZapPaymentRequest( - Nip47URI( - pubkeyHex, - relayUrl, - privKeyHex, - ), + nextAmount = TextFieldValue("") + } + + fun cancel() { + nextAmount = TextFieldValue("") + } + + fun hasChanged(): Boolean { + return ( + selectedZapType != account?.defaultZapType || + amountSet != account?.zapAmountChoices || + walletConnectPubkey.text != (account?.zapPaymentRequest?.pubKeyHex ?: "") || + walletConnectRelay.text != (account?.zapPaymentRequest?.relayUri ?: "") || + walletConnectSecret.text != (account?.zapPaymentRequest?.secret ?: "") ) - } else { - account?.changeZapPaymentRequest(null) - } - } else { - account?.changeZapPaymentRequest(null) } - nextAmount = TextFieldValue("") - } - - fun cancel() { - nextAmount = TextFieldValue("") - } - - fun hasChanged(): Boolean { - return (selectedZapType != account?.defaultZapType || - amountSet != account?.zapAmountChoices || - walletConnectPubkey.text != (account?.zapPaymentRequest?.pubKeyHex ?: "") || - walletConnectRelay.text != (account?.zapPaymentRequest?.relayUri ?: "") || - walletConnectSecret.text != (account?.zapPaymentRequest?.secret ?: "")) - } - - fun updateNIP47(uri: String) { - val contact = Nip47WalletConnectParser.parse(uri) - if (contact != null) { - walletConnectPubkey = TextFieldValue(contact.pubKeyHex) - walletConnectRelay = TextFieldValue(contact.relayUri ?: "") - walletConnectSecret = TextFieldValue(contact.secret ?: "") + fun updateNIP47(uri: String) { + val contact = Nip47WalletConnectParser.parse(uri) + if (contact != null) { + walletConnectPubkey = TextFieldValue(contact.pubKeyHex) + walletConnectRelay = TextFieldValue(contact.relayUri ?: "") + walletConnectSecret = TextFieldValue(contact.secret ?: "") + } } - } - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): UpdateZapAmountViewModel { - return UpdateZapAmountViewModel(account) as UpdateZapAmountViewModel + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): UpdateZapAmountViewModel { + return UpdateZapAmountViewModel(account) as UpdateZapAmountViewModel + } } - } } @OptIn(ExperimentalLayoutApi::class) @Composable fun UpdateZapAmountDialog( - onClose: () -> Unit, - nip47uri: String? = null, - accountViewModel: AccountViewModel, + onClose: () -> Unit, + nip47uri: String? = null, + accountViewModel: AccountViewModel, ) { - val context = LocalContext.current - val scope = rememberCoroutineScope() + val context = LocalContext.current + val scope = rememberCoroutineScope() - val postViewModel: UpdateZapAmountViewModel = - viewModel( - key = "UpdateZapAmountViewModel", - factory = UpdateZapAmountViewModel.Factory(accountViewModel.account), - ) + val postViewModel: UpdateZapAmountViewModel = + viewModel( + key = "UpdateZapAmountViewModel", + factory = UpdateZapAmountViewModel.Factory(accountViewModel.account), + ) - val uri = LocalUriHandler.current + val uri = LocalUriHandler.current - val zapTypes = - listOf( - Triple( - LnZapEvent.ZapType.PUBLIC, - stringResource(id = R.string.zap_type_public), - stringResource(id = R.string.zap_type_public_explainer), - ), - Triple( - LnZapEvent.ZapType.PRIVATE, - stringResource(id = R.string.zap_type_private), - stringResource(id = R.string.zap_type_private_explainer), - ), - Triple( - LnZapEvent.ZapType.ANONYMOUS, - stringResource(id = R.string.zap_type_anonymous), - stringResource(id = R.string.zap_type_anonymous_explainer), - ), - Triple( - LnZapEvent.ZapType.NONZAP, - stringResource(id = R.string.zap_type_nonzap), - stringResource(id = R.string.zap_type_nonzap_explainer), - ), - ) + val zapTypes = + listOf( + Triple( + LnZapEvent.ZapType.PUBLIC, + stringResource(id = R.string.zap_type_public), + stringResource(id = R.string.zap_type_public_explainer), + ), + Triple( + LnZapEvent.ZapType.PRIVATE, + stringResource(id = R.string.zap_type_private), + stringResource(id = R.string.zap_type_private_explainer), + ), + Triple( + LnZapEvent.ZapType.ANONYMOUS, + stringResource(id = R.string.zap_type_anonymous), + stringResource(id = R.string.zap_type_anonymous_explainer), + ), + Triple( + LnZapEvent.ZapType.NONZAP, + stringResource(id = R.string.zap_type_nonzap), + stringResource(id = R.string.zap_type_nonzap_explainer), + ), + ) - val zapOptions = remember { - zapTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() - } - - LaunchedEffect(accountViewModel, nip47uri) { - postViewModel.load() - if (nip47uri != null) { - try { - postViewModel.updateNIP47(nip47uri) - } catch (e: IllegalArgumentException) { - if (e.message != null) { - accountViewModel.toast( - context.getString(R.string.error_parsing_nip47_title), - context.getString(R.string.error_parsing_nip47, nip47uri, e.message!!), - ) - } else { - accountViewModel.toast( - context.getString(R.string.error_parsing_nip47_title), - context.getString(R.string.error_parsing_nip47_no_error, nip47uri), - ) + val zapOptions = + remember { + zapTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() + } + + LaunchedEffect(accountViewModel, nip47uri) { + postViewModel.load() + if (nip47uri != null) { + try { + postViewModel.updateNIP47(nip47uri) + } catch (e: IllegalArgumentException) { + if (e.message != null) { + accountViewModel.toast( + context.getString(R.string.error_parsing_nip47_title), + context.getString(R.string.error_parsing_nip47, nip47uri, e.message!!), + ) + } else { + accountViewModel.toast( + context.getString(R.string.error_parsing_nip47_title), + context.getString(R.string.error_parsing_nip47_no_error, nip47uri), + ) + } + } } - } } - } - Dialog( - onDismissRequest = { onClose() }, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - decorFitsSystemWindows = false, - ), - ) { - Surface( - modifier = Modifier.fillMaxWidth(), + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false, + ), ) { - Column(modifier = Modifier.padding(10.dp).imePadding()) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + Surface( + modifier = Modifier.fillMaxWidth(), ) { - CloseButton( - onPress = { - postViewModel.cancel() - onClose() - }, - ) - - SaveButton( - onPost = { - postViewModel.sendPost() - onClose() - }, - isActive = postViewModel.hasChanged(), - ) - } - - Spacer(modifier = Modifier.height(10.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier.verticalScroll(rememberScrollState()), - ) { - Row(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.animateContentSize()) { - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, + Column(modifier = Modifier.padding(10.dp).imePadding()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - postViewModel.amountSet.forEach { amountInSats -> - Button( - modifier = Modifier.padding(horizontal = 3.dp), - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - onClick = { postViewModel.removeAmount(amountInSats) }, + CloseButton( + onPress = { + postViewModel.cancel() + onClose() + }, + ) + + SaveButton( + onPost = { + postViewModel.sendPost() + onClose() + }, + isActive = postViewModel.hasChanged(), + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), ) { - Text( - "โšก ${ + Row(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.animateContentSize()) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + postViewModel.amountSet.forEach { amountInSats -> + Button( + modifier = Modifier.padding(horizontal = 3.dp), + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + onClick = { postViewModel.removeAmount(amountInSats) }, + ) { + Text( + "โšก ${ showAmount( amountInSats.toBigDecimal().setScale(1), ) } โœ–", - color = Color.White, - textAlign = TextAlign.Center, - ) - } - } - } - } - } + color = Color.White, + textAlign = TextAlign.Center, + ) + } + } + } + } + } - Spacer(modifier = Modifier.height(10.dp)) + Spacer(modifier = Modifier.height(10.dp)) - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.new_amount_in_sats)) }, - value = postViewModel.nextAmount, - onValueChange = { postViewModel.nextAmount = it }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Number, - ), - placeholder = { - Text( - text = "100, 1000, 5000", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - singleLine = true, - modifier = Modifier.padding(end = 10.dp).weight(1f), - ) + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.new_amount_in_sats)) }, + value = postViewModel.nextAmount, + onValueChange = { postViewModel.nextAmount = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Number, + ), + placeholder = { + Text( + text = "100, 1000, 5000", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + modifier = Modifier.padding(end = 10.dp).weight(1f), + ) - Button( - onClick = { postViewModel.addAmount() }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text(text = stringResource(R.string.add), color = Color.White) - } - } + Button( + onClick = { postViewModel.addAmount() }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(text = stringResource(R.string.add), color = Color.White) + } + } - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - TextSpinner( - label = stringResource(id = R.string.zap_type_explainer), - placeholder = - zapTypes.filter { it.first == accountViewModel.defaultZapType() }.first().second, - options = zapOptions, - onSelect = { postViewModel.selectedZapType = zapTypes[it].first }, - modifier = Modifier.weight(1f).padding(end = 5.dp), - ) - } + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextSpinner( + label = stringResource(id = R.string.zap_type_explainer), + placeholder = + zapTypes.filter { it.first == accountViewModel.defaultZapType() }.first().second, + options = zapOptions, + onSelect = { postViewModel.selectedZapType = zapTypes[it].first }, + modifier = Modifier.weight(1f).padding(end = 5.dp), + ) + } - Divider( - modifier = Modifier.padding(vertical = 10.dp), - thickness = DividerThickness, - ) + Divider( + modifier = Modifier.padding(vertical = 10.dp), + thickness = DividerThickness, + ) - var qrScanning by remember { mutableStateOf(false) } + var qrScanning by remember { mutableStateOf(false) } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - stringResource(id = R.string.wallet_connect_service), - Modifier.weight(1f), - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(id = R.string.wallet_connect_service), + Modifier.weight(1f), + ) /* TODO: Find a way to open this in the PWA IconButton(onClick = { @@ -439,273 +440,273 @@ fun UpdateZapAmountDialog( ) }*/ - IconButton( - onClick = { - onClose() - runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") } - }, - ) { - Icon( - painter = painterResource(R.drawable.alby), - null, - modifier = Modifier.size(24.dp), - tint = Color.Unspecified, - ) - } + IconButton( + onClick = { + onClose() + runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") } + }, + ) { + Icon( + painter = painterResource(R.drawable.alby), + null, + modifier = Modifier.size(24.dp), + tint = Color.Unspecified, + ) + } - IconButton(onClick = { qrScanning = true }) { - Icon( - painter = painterResource(R.drawable.ic_qrcode), - null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } - } + IconButton(onClick = { qrScanning = true }) { + Icon( + painter = painterResource(R.drawable.ic_qrcode), + null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - stringResource(id = R.string.wallet_connect_service_explainer), - Modifier.weight(1f), - color = MaterialTheme.colorScheme.placeholderText, - fontSize = Font14SP, - ) - } + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(id = R.string.wallet_connect_service_explainer), + Modifier.weight(1f), + color = MaterialTheme.colorScheme.placeholderText, + fontSize = Font14SP, + ) + } - if (qrScanning) { - SimpleQrCodeScanner { - qrScanning = false - if (!it.isNullOrEmpty()) { - try { - postViewModel.updateNIP47(it) - } catch (e: IllegalArgumentException) { - if (e.message != null) { - accountViewModel.toast( - context.getString(R.string.error_parsing_nip47_title), - context.getString(R.string.error_parsing_nip47, it, e.message!!), - ) - } else { - accountViewModel.toast( - context.getString(R.string.error_parsing_nip47_title), - context.getString(R.string.error_parsing_nip47_no_error, it), - ) + if (qrScanning) { + SimpleQrCodeScanner { + qrScanning = false + if (!it.isNullOrEmpty()) { + try { + postViewModel.updateNIP47(it) + } catch (e: IllegalArgumentException) { + if (e.message != null) { + accountViewModel.toast( + context.getString(R.string.error_parsing_nip47_title), + context.getString(R.string.error_parsing_nip47, it, e.message!!), + ) + } else { + accountViewModel.toast( + context.getString(R.string.error_parsing_nip47_title), + context.getString(R.string.error_parsing_nip47_no_error, it), + ) + } + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.wallet_connect_service_pubkey)) }, + value = postViewModel.walletConnectPubkey, + onValueChange = { postViewModel.walletConnectPubkey = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + ), + placeholder = { + Text( + text = "npub, hex", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.wallet_connect_service_relay)) }, + modifier = Modifier.weight(1f), + value = postViewModel.walletConnectRelay, + onValueChange = { postViewModel.walletConnectRelay = it }, + placeholder = { + Text( + text = "wss://relay.server.com", + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + }, + singleLine = true, + ) + } + + var showPassword by remember { mutableStateOf(false) } + + val context = LocalContext.current + + val keyguardLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + result: ActivityResult -> + if (result.resultCode == Activity.RESULT_OK) { + showPassword = true + } + } + + val authTitle = stringResource(id = R.string.wallet_connect_service_show_secret) + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.wallet_connect_service_secret)) }, + modifier = Modifier.weight(1f), + value = postViewModel.walletConnectSecret, + onValueChange = { postViewModel.walletConnectSecret = it }, + keyboardOptions = + KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + placeholder = { + Text( + text = stringResource(R.string.wallet_connect_service_secret_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + IconButton( + onClick = { + if (!showPassword) { + authenticate( + title = authTitle, + context = context, + keyguardLauncher = keyguardLauncher, + onApproved = { showPassword = true }, + onError = { title, message -> accountViewModel.toast(title, message) }, + ) + } else { + showPassword = false + } + }, + ) { + Icon( + imageVector = + if (showPassword) { + Icons.Outlined.VisibilityOff + } else { + Icons.Outlined.Visibility + }, + contentDescription = + if (showPassword) { + stringResource(R.string.show_password) + } else { + stringResource( + R.string.hide_password, + ) + }, + ) + } + }, + visualTransformation = + if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + ) + } } - } } - } } - - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.wallet_connect_service_pubkey)) }, - value = postViewModel.walletConnectPubkey, - onValueChange = { postViewModel.walletConnectPubkey = it }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - ), - placeholder = { - Text( - text = "npub, hex", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - singleLine = true, - modifier = Modifier.weight(1f), - ) - } - - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.wallet_connect_service_relay)) }, - modifier = Modifier.weight(1f), - value = postViewModel.walletConnectRelay, - onValueChange = { postViewModel.walletConnectRelay = it }, - placeholder = { - Text( - text = "wss://relay.server.com", - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - ) - }, - singleLine = true, - ) - } - - var showPassword by remember { mutableStateOf(false) } - - val context = LocalContext.current - - val keyguardLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { - result: ActivityResult -> - if (result.resultCode == Activity.RESULT_OK) { - showPassword = true - } - } - - val authTitle = stringResource(id = R.string.wallet_connect_service_show_secret) - - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.wallet_connect_service_secret)) }, - modifier = Modifier.weight(1f), - value = postViewModel.walletConnectSecret, - onValueChange = { postViewModel.walletConnectSecret = it }, - keyboardOptions = - KeyboardOptions( - autoCorrect = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, - ), - placeholder = { - Text( - text = stringResource(R.string.wallet_connect_service_secret_placeholder), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - trailingIcon = { - IconButton( - onClick = { - if (!showPassword) { - authenticate( - title = authTitle, - context = context, - keyguardLauncher = keyguardLauncher, - onApproved = { showPassword = true }, - onError = { title, message -> accountViewModel.toast(title, message) }, - ) - } else { - showPassword = false - } - }, - ) { - Icon( - imageVector = - if (showPassword) { - Icons.Outlined.VisibilityOff - } else { - Icons.Outlined.Visibility - }, - contentDescription = - if (showPassword) { - stringResource(R.string.show_password) - } else { - stringResource( - R.string.hide_password, - ) - }, - ) - } - }, - visualTransformation = - if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), - ) - } - } } - } } - } } fun authenticate( - title: String, - context: Context, - keyguardLauncher: ManagedActivityResultLauncher, - onApproved: () -> Unit, - onError: (String, String) -> Unit, + title: String, + context: Context, + keyguardLauncher: ManagedActivityResultLauncher, + onApproved: () -> Unit, + onError: (String, String) -> Unit, ) { - val fragmentContext = context.getFragmentActivity()!! - val keyguardManager = - fragmentContext.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + val fragmentContext = context.getFragmentActivity()!! + val keyguardManager = + fragmentContext.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager - if (!keyguardManager.isDeviceSecure) { - onApproved() - return - } + if (!keyguardManager.isDeviceSecure) { + onApproved() + return + } - @Suppress("DEPRECATION") - fun keyguardPrompt() { - val intent = - keyguardManager.createConfirmDeviceCredentialIntent( - context.getString(R.string.app_name_release), - title, - ) + @Suppress("DEPRECATION") + fun keyguardPrompt() { + val intent = + keyguardManager.createConfirmDeviceCredentialIntent( + context.getString(R.string.app_name_release), + title, + ) - keyguardLauncher.launch(intent) - } + keyguardLauncher.launch(intent) + } - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) { - keyguardPrompt() - return - } + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) { + keyguardPrompt() + return + } - val biometricManager = BiometricManager.from(context) - val authenticators = - BiometricManager.Authenticators.BIOMETRIC_STRONG or - BiometricManager.Authenticators.DEVICE_CREDENTIAL + val biometricManager = BiometricManager.from(context) + val authenticators = + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL - val promptInfo = - BiometricPrompt.PromptInfo.Builder() - .setTitle(context.getString(R.string.app_name_release)) - .setSubtitle(title) - .setAllowedAuthenticators(authenticators) - .build() + val promptInfo = + BiometricPrompt.PromptInfo.Builder() + .setTitle(context.getString(R.string.app_name_release)) + .setSubtitle(title) + .setAllowedAuthenticators(authenticators) + .build() - val biometricPrompt = - BiometricPrompt( - fragmentContext, - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationError( - errorCode: Int, - errString: CharSequence, - ) { - super.onAuthenticationError(errorCode, errString) + val biometricPrompt = + BiometricPrompt( + fragmentContext, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError( + errorCode: Int, + errString: CharSequence, + ) { + super.onAuthenticationError(errorCode, errString) - when (errorCode) { - BiometricPrompt.ERROR_NEGATIVE_BUTTON -> keyguardPrompt() - BiometricPrompt.ERROR_LOCKOUT -> keyguardPrompt() - else -> - onError( - context.getString(R.string.biometric_authentication_failed), - context.getString( - R.string.biometric_authentication_failed_explainer_with_error, - errString, - ), - ) - } - } + when (errorCode) { + BiometricPrompt.ERROR_NEGATIVE_BUTTON -> keyguardPrompt() + BiometricPrompt.ERROR_LOCKOUT -> keyguardPrompt() + else -> + onError( + context.getString(R.string.biometric_authentication_failed), + context.getString( + R.string.biometric_authentication_failed_explainer_with_error, + errString, + ), + ) + } + } - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - onError( - context.getString(R.string.biometric_authentication_failed), - context.getString(R.string.biometric_authentication_failed_explainer), - ) - } + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + onError( + context.getString(R.string.biometric_authentication_failed), + context.getString(R.string.biometric_authentication_failed_explainer), + ) + } - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - super.onAuthenticationSucceeded(result) - onApproved() - } - }, - ) + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + onApproved() + } + }, + ) - when (biometricManager.canAuthenticate(authenticators)) { - BiometricManager.BIOMETRIC_SUCCESS -> biometricPrompt.authenticate(promptInfo) - else -> keyguardPrompt() - } + when (biometricManager.canAuthenticate(authenticators)) { + BiometricManager.BIOMETRIC_SUCCESS -> biometricPrompt.authenticate(promptInfo) + else -> keyguardPrompt() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt index cc051a971..0622fadd0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt @@ -38,39 +38,39 @@ import com.vitorpamplona.amethyst.ui.theme.StdPadding @Composable fun UserCompose( - baseUser: User, - overallModifier: Modifier = StdPadding, - showDiviser: Boolean = true, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseUser: User, + overallModifier: Modifier = StdPadding, + showDiviser: Boolean = true, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Column( - modifier = - Modifier.clickable( - onClick = { nav("User/${baseUser.pubkeyHex}") }, - ), - ) { - Row( - modifier = overallModifier, - verticalAlignment = Alignment.CenterVertically, + Column( + modifier = + Modifier.clickable( + onClick = { nav("User/${baseUser.pubkeyHex}") }, + ), ) { - UserPicture(baseUser, Size55dp, accountViewModel = accountViewModel, nav = nav) + Row( + modifier = overallModifier, + verticalAlignment = Alignment.CenterVertically, + ) { + UserPicture(baseUser, Size55dp, accountViewModel = accountViewModel, nav = nav) - Column(modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }) { - Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser) } + Column(modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }) { + Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser) } - AboutDisplay(baseUser) - } + AboutDisplay(baseUser) + } - Column(modifier = remember { Modifier.padding(start = 10.dp) }) { - UserActionOptions(baseUser, accountViewModel) - } + Column(modifier = remember { Modifier.padding(start = 10.dp) }) { + UserActionOptions(baseUser, accountViewModel) + } + } + + if (showDiviser) { + Divider( + thickness = DividerThickness, + ) + } } - - if (showDiviser) { - Divider( - thickness = DividerThickness, - ) - } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index 9eab528ff..ac9efe810 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -77,606 +77,610 @@ import kotlinx.coroutines.launch @Composable fun NoteAuthorPicture( - baseNote: Note, - nav: (String) -> Unit, - accountViewModel: AccountViewModel, - size: Dp, - pictureModifier: Modifier = Modifier, + baseNote: Note, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, + size: Dp, + pictureModifier: Modifier = Modifier, ) { - NoteAuthorPicture(baseNote, size, accountViewModel, pictureModifier) { - nav("User/${it.pubkeyHex}") - } + NoteAuthorPicture(baseNote, size, accountViewModel, pictureModifier) { + nav("User/${it.pubkeyHex}") + } } @Composable fun NoteAuthorPicture( - baseNote: Note, - size: Dp, - accountViewModel: AccountViewModel, - modifier: Modifier = Modifier, - onClick: ((User) -> Unit)? = null, + baseNote: Note, + size: Dp, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, + onClick: ((User) -> Unit)? = null, ) { - val author by baseNote.live().authorChanges.observeAsState(baseNote.author) + val author by baseNote.live().authorChanges.observeAsState(baseNote.author) - Crossfade(targetState = author, label = "NoteAuthorPicture") { - if (it == null) { - DisplayBlankAuthor(size, modifier) - } else { - ClickableUserPicture(it, size, accountViewModel, modifier, onClick) + Crossfade(targetState = author, label = "NoteAuthorPicture") { + if (it == null) { + DisplayBlankAuthor(size, modifier) + } else { + ClickableUserPicture(it, size, accountViewModel, modifier, onClick) + } } - } } @Composable fun DisplayBlankAuthor( - size: Dp, - modifier: Modifier = Modifier, + size: Dp, + modifier: Modifier = Modifier, ) { - val backgroundColor = MaterialTheme.colorScheme.background + val backgroundColor = MaterialTheme.colorScheme.background - val nullModifier = remember { - modifier.size(size).clip(shape = CircleShape).background(backgroundColor) - } + val nullModifier = + remember { + modifier.size(size).clip(shape = CircleShape).background(backgroundColor) + } - RobohashAsyncImage( - robot = "authornotfound", - contentDescription = stringResource(R.string.unknown_author), - modifier = nullModifier, - ) + RobohashAsyncImage( + robot = "authornotfound", + contentDescription = stringResource(R.string.unknown_author), + modifier = nullModifier, + ) } @Composable fun UserPicture( - userHex: String, - size: Dp, - pictureModifier: Modifier = Modifier, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + userHex: String, + size: Dp, + pictureModifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LoadUser(baseUserHex = userHex, accountViewModel) { - if (it != null) { - UserPicture( - user = it, - size = size, - pictureModifier = pictureModifier, - accountViewModel = accountViewModel, - nav = nav, - ) - } else { - DisplayBlankAuthor( - size, - pictureModifier, - ) + LoadUser(baseUserHex = userHex, accountViewModel) { + if (it != null) { + UserPicture( + user = it, + size = size, + pictureModifier = pictureModifier, + accountViewModel = accountViewModel, + nav = nav, + ) + } else { + DisplayBlankAuthor( + size, + pictureModifier, + ) + } } - } } @Composable fun UserPicture( - user: User, - size: Dp, - pictureModifier: Modifier = Modifier, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + user: User, + size: Dp, + pictureModifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - ClickableUserPicture( - baseUser = user, - size = size, - accountViewModel = accountViewModel, - modifier = pictureModifier, - onClick = { nav("User/${user.pubkeyHex}") }, - ) + ClickableUserPicture( + baseUser = user, + size = size, + accountViewModel = accountViewModel, + modifier = pictureModifier, + onClick = { nav("User/${user.pubkeyHex}") }, + ) } @OptIn(ExperimentalFoundationApi::class) @Composable fun ClickableUserPicture( - baseUser: User, - size: Dp, - accountViewModel: AccountViewModel, - modifier: Modifier = Modifier, - onClick: ((User) -> Unit)? = null, - onLongClick: ((User) -> Unit)? = null, + baseUser: User, + size: Dp, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, + onClick: ((User) -> Unit)? = null, + onLongClick: ((User) -> Unit)? = null, ) { - val interactionSource = remember { MutableInteractionSource() } - val ripple = rememberRipple(bounded = false, radius = size) + val interactionSource = remember { MutableInteractionSource() } + val ripple = rememberRipple(bounded = false, radius = size) - // BaseUser is the same reference as accountState.user - val myModifier = remember { - if (onClick != null && onLongClick != null) { - Modifier.size(size) - .combinedClickable( - onClick = { onClick(baseUser) }, - onLongClick = { onLongClick(baseUser) }, - role = Role.Button, - interactionSource = interactionSource, - indication = ripple, - ) - } else if (onClick != null) { - Modifier.size(size) - .clickable( - onClick = { onClick(baseUser) }, - role = Role.Button, - interactionSource = interactionSource, - indication = ripple, - ) - } else { - Modifier.size(size) + // BaseUser is the same reference as accountState.user + val myModifier = + remember { + if (onClick != null && onLongClick != null) { + Modifier.size(size) + .combinedClickable( + onClick = { onClick(baseUser) }, + onLongClick = { onLongClick(baseUser) }, + role = Role.Button, + interactionSource = interactionSource, + indication = ripple, + ) + } else if (onClick != null) { + Modifier.size(size) + .clickable( + onClick = { onClick(baseUser) }, + role = Role.Button, + interactionSource = interactionSource, + indication = ripple, + ) + } else { + Modifier.size(size) + } + } + + Box(modifier = myModifier, contentAlignment = Alignment.TopEnd) { + BaseUserPicture(baseUser, size, accountViewModel, modifier) } - } - - Box(modifier = myModifier, contentAlignment = Alignment.TopEnd) { - BaseUserPicture(baseUser, size, accountViewModel, modifier) - } } @Composable fun NonClickableUserPictures( - users: ImmutableSet, - size: Dp, - accountViewModel: AccountViewModel, + users: ImmutableSet, + size: Dp, + accountViewModel: AccountViewModel, ) { - val myBoxModifier = remember { Modifier.size(size) } + val myBoxModifier = remember { Modifier.size(size) } - Box(myBoxModifier, contentAlignment = Alignment.TopEnd) { - val userList = remember(users) { users.toList() } + Box(myBoxModifier, contentAlignment = Alignment.TopEnd) { + val userList = remember(users) { users.toList() } - when (userList.size) { - 0 -> {} - 1 -> - LoadUser(baseUserHex = userList[0], accountViewModel) { - it?.let { BaseUserPicture(it, size, accountViewModel, outerModifier = Modifier) } + when (userList.size) { + 0 -> {} + 1 -> + LoadUser(baseUserHex = userList[0], accountViewModel) { + it?.let { BaseUserPicture(it, size, accountViewModel, outerModifier = Modifier) } + } + 2 -> { + LoadUser(baseUserHex = userList[0], accountViewModel) { + it?.let { + BaseUserPicture( + it, + size.div(1.5f), + accountViewModel, + outerModifier = Modifier.align(Alignment.CenterStart), + ) + } + } + LoadUser(baseUserHex = userList[1], accountViewModel) { + it?.let { + BaseUserPicture( + it, + size.div(1.5f), + accountViewModel, + outerModifier = Modifier.align(Alignment.CenterEnd), + ) + } + } + } + 3 -> { + LoadUser(baseUserHex = userList[0], accountViewModel) { + it?.let { + BaseUserPicture( + it, + size.div(1.8f), + accountViewModel, + outerModifier = Modifier.align(Alignment.BottomStart), + ) + } + } + LoadUser(baseUserHex = userList[1], accountViewModel) { + it?.let { + BaseUserPicture( + it, + size.div(1.8f), + accountViewModel, + outerModifier = Modifier.align(Alignment.TopCenter), + ) + } + } + LoadUser(baseUserHex = userList[2], accountViewModel) { + it?.let { + BaseUserPicture( + it, + size.div(1.8f), + accountViewModel, + outerModifier = Modifier.align(Alignment.BottomEnd), + ) + } + } + } + else -> { + LoadUser(baseUserHex = userList[0], accountViewModel) { + it?.let { + BaseUserPicture( + it, + size.div(2f), + accountViewModel, + outerModifier = Modifier.align(Alignment.BottomStart), + ) + } + } + LoadUser(baseUserHex = userList[1], accountViewModel) { + it?.let { + BaseUserPicture( + it, + size.div(2f), + accountViewModel, + outerModifier = Modifier.align(Alignment.TopStart), + ) + } + } + LoadUser(baseUserHex = userList[2], accountViewModel) { + it?.let { + BaseUserPicture( + it, + size.div(2f), + accountViewModel, + outerModifier = Modifier.align(Alignment.BottomEnd), + ) + } + } + LoadUser(baseUserHex = userList[3], accountViewModel) { + it?.let { + BaseUserPicture( + it, + size.div(2f), + accountViewModel, + outerModifier = Modifier.align(Alignment.TopEnd), + ) + } + } + } } - 2 -> { - LoadUser(baseUserHex = userList[0], accountViewModel) { - it?.let { - BaseUserPicture( - it, - size.div(1.5f), - accountViewModel, - outerModifier = Modifier.align(Alignment.CenterStart), - ) - } - } - LoadUser(baseUserHex = userList[1], accountViewModel) { - it?.let { - BaseUserPicture( - it, - size.div(1.5f), - accountViewModel, - outerModifier = Modifier.align(Alignment.CenterEnd), - ) - } - } - } - 3 -> { - LoadUser(baseUserHex = userList[0], accountViewModel) { - it?.let { - BaseUserPicture( - it, - size.div(1.8f), - accountViewModel, - outerModifier = Modifier.align(Alignment.BottomStart), - ) - } - } - LoadUser(baseUserHex = userList[1], accountViewModel) { - it?.let { - BaseUserPicture( - it, - size.div(1.8f), - accountViewModel, - outerModifier = Modifier.align(Alignment.TopCenter), - ) - } - } - LoadUser(baseUserHex = userList[2], accountViewModel) { - it?.let { - BaseUserPicture( - it, - size.div(1.8f), - accountViewModel, - outerModifier = Modifier.align(Alignment.BottomEnd), - ) - } - } - } - else -> { - LoadUser(baseUserHex = userList[0], accountViewModel) { - it?.let { - BaseUserPicture( - it, - size.div(2f), - accountViewModel, - outerModifier = Modifier.align(Alignment.BottomStart), - ) - } - } - LoadUser(baseUserHex = userList[1], accountViewModel) { - it?.let { - BaseUserPicture( - it, - size.div(2f), - accountViewModel, - outerModifier = Modifier.align(Alignment.TopStart), - ) - } - } - LoadUser(baseUserHex = userList[2], accountViewModel) { - it?.let { - BaseUserPicture( - it, - size.div(2f), - accountViewModel, - outerModifier = Modifier.align(Alignment.BottomEnd), - ) - } - } - LoadUser(baseUserHex = userList[3], accountViewModel) { - it?.let { - BaseUserPicture( - it, - size.div(2f), - accountViewModel, - outerModifier = Modifier.align(Alignment.TopEnd), - ) - } - } - } } - } } @Composable fun BaseUserPicture( - baseUser: User, - size: Dp, - accountViewModel: AccountViewModel, - innerModifier: Modifier = Modifier, - outerModifier: Modifier = remember { Modifier.size(size) }, + baseUser: User, + size: Dp, + accountViewModel: AccountViewModel, + innerModifier: Modifier = Modifier, + outerModifier: Modifier = remember { Modifier.size(size) }, ) { - val myIconSize by remember(size) { derivedStateOf { size.div(3.5f) } } + val myIconSize by remember(size) { derivedStateOf { size.div(3.5f) } } - Box(outerModifier, contentAlignment = Alignment.TopEnd) { - LoadUserProfilePicture(baseUser) { userProfilePicture -> - InnerUserPicture( - userHex = baseUser.pubkeyHex, - userPicture = userProfilePicture, - size = size, - modifier = innerModifier, - accountViewModel = accountViewModel, - ) + Box(outerModifier, contentAlignment = Alignment.TopEnd) { + LoadUserProfilePicture(baseUser) { userProfilePicture -> + InnerUserPicture( + userHex = baseUser.pubkeyHex, + userPicture = userProfilePicture, + size = size, + modifier = innerModifier, + accountViewModel = accountViewModel, + ) + } + + ObserveAndDisplayFollowingMark(baseUser.pubkeyHex, myIconSize, accountViewModel) } - - ObserveAndDisplayFollowingMark(baseUser.pubkeyHex, myIconSize, accountViewModel) - } } @Composable fun LoadUserProfilePicture( - baseUser: User, - innerContent: @Composable (String?) -> Unit, + baseUser: User, + innerContent: @Composable (String?) -> Unit, ) { - val userProfile by baseUser.live().profilePictureChanges.observeAsState(baseUser.profilePicture()) + val userProfile by baseUser.live().profilePictureChanges.observeAsState(baseUser.profilePicture()) - innerContent(userProfile) + innerContent(userProfile) } @Composable fun InnerUserPicture( - userHex: String, - userPicture: String?, - size: Dp, - modifier: Modifier, - accountViewModel: AccountViewModel, + userHex: String, + userPicture: String?, + size: Dp, + modifier: Modifier, + accountViewModel: AccountViewModel, ) { - val backgroundColor = MaterialTheme.colorScheme.background - val myImageModifier = remember { - modifier.size(size).clip(shape = CircleShape).background(backgroundColor) - } + val backgroundColor = MaterialTheme.colorScheme.background + val myImageModifier = + remember { + modifier.size(size).clip(shape = CircleShape).background(backgroundColor) + } - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } + val automaticallyShowProfilePicture = + remember { + accountViewModel.settings.showProfilePictures.value + } - RobohashFallbackAsyncImage( - robot = userHex, - model = userPicture, - contentDescription = stringResource(id = R.string.profile_image), - modifier = myImageModifier, - contentScale = ContentScale.Crop, - loadProfilePicture = automaticallyShowProfilePicture, - ) + RobohashFallbackAsyncImage( + robot = userHex, + model = userPicture, + contentDescription = stringResource(id = R.string.profile_image), + modifier = myImageModifier, + contentScale = ContentScale.Crop, + loadProfilePicture = automaticallyShowProfilePicture, + ) } @Composable fun ObserveAndDisplayFollowingMark( - userHex: String, - iconSize: Dp, - accountViewModel: AccountViewModel, + userHex: String, + iconSize: Dp, + accountViewModel: AccountViewModel, ) { - WatchUserFollows(userHex, accountViewModel) { newFollowingState -> - AnimatedVisibility( - visible = newFollowingState, - enter = remember { fadeIn() }, - exit = remember { fadeOut() }, - ) { - FollowingIcon(iconSize) + WatchUserFollows(userHex, accountViewModel) { newFollowingState -> + AnimatedVisibility( + visible = newFollowingState, + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + FollowingIcon(iconSize) + } } - } } @Composable fun WatchUserFollows( - userHex: String, - accountViewModel: AccountViewModel, - onFollowChanges: @Composable (Boolean) -> Unit, + userHex: String, + accountViewModel: AccountViewModel, + onFollowChanges: @Composable (Boolean) -> Unit, ) { - val showFollowingMark by - remember { - accountViewModel.userFollows - .map { - it.user.isFollowingCached(userHex) || - (userHex == accountViewModel.account.userProfile().pubkeyHex) - } - .distinctUntilChanged() - } - .observeAsState( - accountViewModel.account.userProfile().isFollowingCached(userHex) || - (userHex == accountViewModel.account.userProfile().pubkeyHex), - ) + val showFollowingMark by + remember { + accountViewModel.userFollows + .map { + it.user.isFollowingCached(userHex) || + (userHex == accountViewModel.account.userProfile().pubkeyHex) + } + .distinctUntilChanged() + } + .observeAsState( + accountViewModel.account.userProfile().isFollowingCached(userHex) || + (userHex == accountViewModel.account.userProfile().pubkeyHex), + ) - onFollowChanges(showFollowingMark) + onFollowChanges(showFollowingMark) } @Immutable data class DropDownParams( - val isFollowingAuthor: Boolean, - val isPrivateBookmarkNote: Boolean, - val isPublicBookmarkNote: Boolean, - val isLoggedUser: Boolean, - val isSensitive: Boolean, - val showSensitiveContent: Boolean?, + val isFollowingAuthor: Boolean, + val isPrivateBookmarkNote: Boolean, + val isPublicBookmarkNote: Boolean, + val isLoggedUser: Boolean, + val isSensitive: Boolean, + val showSensitiveContent: Boolean?, ) @Composable fun NoteDropDownMenu( - note: Note, - popupExpanded: MutableState, - accountViewModel: AccountViewModel, + note: Note, + popupExpanded: MutableState, + accountViewModel: AccountViewModel, ) { - var reportDialogShowing by remember { mutableStateOf(false) } + var reportDialogShowing by remember { mutableStateOf(false) } - var state by remember { - mutableStateOf( - DropDownParams( - isFollowingAuthor = false, - isPrivateBookmarkNote = false, - isPublicBookmarkNote = false, - isLoggedUser = false, - isSensitive = false, - showSensitiveContent = null, - ), - ) - } - - val onDismiss = remember(popupExpanded) { { popupExpanded.value = false } } - - DropdownMenu( - expanded = popupExpanded.value, - onDismissRequest = onDismiss, - ) { - val clipboardManager = LocalClipboardManager.current - val appContext = LocalContext.current.applicationContext - val actContext = LocalContext.current - - WatchBookmarksFollowsAndAccount(note, accountViewModel) { newState -> - if (state != newState) { - state = newState - } + var state by remember { + mutableStateOf( + DropDownParams( + isFollowingAuthor = false, + isPrivateBookmarkNote = false, + isPublicBookmarkNote = false, + isLoggedUser = false, + isSensitive = false, + showSensitiveContent = null, + ), + ) } - val scope = rememberCoroutineScope() + val onDismiss = remember(popupExpanded) { { popupExpanded.value = false } } - if (!state.isFollowingAuthor) { - DropdownMenuItem( - text = { Text(stringResource(R.string.follow)) }, - onClick = { - val author = note.author ?: return@DropdownMenuItem - accountViewModel.follow(author) - onDismiss() - }, - ) - Divider() - } - DropdownMenuItem( - text = { Text(stringResource(R.string.copy_text)) }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.decrypt(note) { clipboardManager.setText(AnnotatedString(it)) } - onDismiss() + DropdownMenu( + expanded = popupExpanded.value, + onDismissRequest = onDismiss, + ) { + val clipboardManager = LocalClipboardManager.current + val appContext = LocalContext.current.applicationContext + val actContext = LocalContext.current + + WatchBookmarksFollowsAndAccount(note, accountViewModel) { newState -> + if (state != newState) { + state = newState + } } - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.copy_user_pubkey)) }, - onClick = { - scope.launch(Dispatchers.IO) { - clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")) - onDismiss() - } - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.copy_note_id)) }, - onClick = { - scope.launch(Dispatchers.IO) { - clipboardManager.setText(AnnotatedString("nostr:" + note.toNEvent())) - onDismiss() - } - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.quick_action_share)) }, - onClick = { - val sendIntent = - Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - externalLinkForNote(note), + + val scope = rememberCoroutineScope() + + if (!state.isFollowingAuthor) { + DropdownMenuItem( + text = { Text(stringResource(R.string.follow)) }, + onClick = { + val author = note.author ?: return@DropdownMenuItem + accountViewModel.follow(author) + onDismiss() + }, ) - putExtra( - Intent.EXTRA_TITLE, - actContext.getString(R.string.quick_action_share_browser_link), - ) - } - - val shareIntent = - Intent.createChooser(sendIntent, appContext.getString(R.string.quick_action_share)) - ContextCompat.startActivity(actContext, shareIntent, null) - onDismiss() - }, - ) - Divider() - DropdownMenuItem( - text = { Text(stringResource(R.string.broadcast)) }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.broadcast(note) - onDismiss() + Divider() } - }, - ) - Divider() - if (state.isPrivateBookmarkNote) { - DropdownMenuItem( - text = { Text(stringResource(R.string.remove_from_private_bookmarks)) }, - onClick = { - accountViewModel.removePrivateBookmark(note) - onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringResource(R.string.add_to_private_bookmarks)) }, - onClick = { - accountViewModel.addPrivateBookmark(note) - onDismiss() - }, - ) - } - if (state.isPublicBookmarkNote) { - DropdownMenuItem( - text = { Text(stringResource(R.string.remove_from_public_bookmarks)) }, - onClick = { - accountViewModel.removePublicBookmark(note) - onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringResource(R.string.add_to_public_bookmarks)) }, - onClick = { - accountViewModel.addPublicBookmark(note) - onDismiss() - }, - ) - } - Divider() - if (state.showSensitiveContent == null || state.showSensitiveContent == true) { - DropdownMenuItem( - text = { Text(stringResource(R.string.content_warning_hide_all_sensitive_content)) }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.hideSensitiveContent() - onDismiss() - } - }, - ) - } - if (state.showSensitiveContent == null || state.showSensitiveContent == false) { - DropdownMenuItem( - text = { Text(stringResource(R.string.content_warning_show_all_sensitive_content)) }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.disableContentWarnings() - onDismiss() - } - }, - ) - } - if (state.showSensitiveContent != null) { - DropdownMenuItem( - text = { Text(stringResource(R.string.content_warning_see_warnings)) }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.seeContentWarnings() - onDismiss() - } - }, - ) - } - Divider() - if (state.isLoggedUser) { - DropdownMenuItem( - text = { Text(stringResource(R.string.request_deletion)) }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.delete(note) - onDismiss() - } - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringResource(R.string.block_report)) }, - onClick = { reportDialogShowing = true }, - ) - } - } + DropdownMenuItem( + text = { Text(stringResource(R.string.copy_text)) }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.decrypt(note) { clipboardManager.setText(AnnotatedString(it)) } + onDismiss() + } + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.copy_user_pubkey)) }, + onClick = { + scope.launch(Dispatchers.IO) { + clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")) + onDismiss() + } + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.copy_note_id)) }, + onClick = { + scope.launch(Dispatchers.IO) { + clipboardManager.setText(AnnotatedString("nostr:" + note.toNEvent())) + onDismiss() + } + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.quick_action_share)) }, + onClick = { + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + externalLinkForNote(note), + ) + putExtra( + Intent.EXTRA_TITLE, + actContext.getString(R.string.quick_action_share_browser_link), + ) + } - if (reportDialogShowing) { - ReportNoteDialog(note = note, accountViewModel = accountViewModel) { - reportDialogShowing = false - onDismiss() + val shareIntent = + Intent.createChooser(sendIntent, appContext.getString(R.string.quick_action_share)) + ContextCompat.startActivity(actContext, shareIntent, null) + onDismiss() + }, + ) + Divider() + DropdownMenuItem( + text = { Text(stringResource(R.string.broadcast)) }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.broadcast(note) + onDismiss() + } + }, + ) + Divider() + if (state.isPrivateBookmarkNote) { + DropdownMenuItem( + text = { Text(stringResource(R.string.remove_from_private_bookmarks)) }, + onClick = { + accountViewModel.removePrivateBookmark(note) + onDismiss() + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.add_to_private_bookmarks)) }, + onClick = { + accountViewModel.addPrivateBookmark(note) + onDismiss() + }, + ) + } + if (state.isPublicBookmarkNote) { + DropdownMenuItem( + text = { Text(stringResource(R.string.remove_from_public_bookmarks)) }, + onClick = { + accountViewModel.removePublicBookmark(note) + onDismiss() + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.add_to_public_bookmarks)) }, + onClick = { + accountViewModel.addPublicBookmark(note) + onDismiss() + }, + ) + } + Divider() + if (state.showSensitiveContent == null || state.showSensitiveContent == true) { + DropdownMenuItem( + text = { Text(stringResource(R.string.content_warning_hide_all_sensitive_content)) }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.hideSensitiveContent() + onDismiss() + } + }, + ) + } + if (state.showSensitiveContent == null || state.showSensitiveContent == false) { + DropdownMenuItem( + text = { Text(stringResource(R.string.content_warning_show_all_sensitive_content)) }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.disableContentWarnings() + onDismiss() + } + }, + ) + } + if (state.showSensitiveContent != null) { + DropdownMenuItem( + text = { Text(stringResource(R.string.content_warning_see_warnings)) }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.seeContentWarnings() + onDismiss() + } + }, + ) + } + Divider() + if (state.isLoggedUser) { + DropdownMenuItem( + text = { Text(stringResource(R.string.request_deletion)) }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.delete(note) + onDismiss() + } + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.block_report)) }, + onClick = { reportDialogShowing = true }, + ) + } + } + + if (reportDialogShowing) { + ReportNoteDialog(note = note, accountViewModel = accountViewModel) { + reportDialogShowing = false + onDismiss() + } } - } } @Composable fun WatchBookmarksFollowsAndAccount( - note: Note, - accountViewModel: AccountViewModel, - onNew: (DropDownParams) -> Unit, + note: Note, + accountViewModel: AccountViewModel, + onNew: (DropDownParams) -> Unit, ) { - val followState by accountViewModel.userProfile().live().follows.observeAsState() - val bookmarkState by accountViewModel.userProfile().live().bookmarks.observeAsState() - val showSensitiveContent by - accountViewModel.showSensitiveContentChanges.observeAsState( - accountViewModel.account.showSensitiveContent, - ) + val followState by accountViewModel.userProfile().live().follows.observeAsState() + val bookmarkState by accountViewModel.userProfile().live().bookmarks.observeAsState() + val showSensitiveContent by + accountViewModel.showSensitiveContentChanges.observeAsState( + accountViewModel.account.showSensitiveContent, + ) - LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = showSensitiveContent) { - launch(Dispatchers.IO) { - accountViewModel.isInPrivateBookmarks(note) { - val newState = - DropDownParams( - isFollowingAuthor = accountViewModel.isFollowing(note.author), - isPrivateBookmarkNote = it, - isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note), - isLoggedUser = accountViewModel.isLoggedUser(note.author), - isSensitive = note.event?.isSensitive() ?: false, - showSensitiveContent = showSensitiveContent, - ) + LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = showSensitiveContent) { + launch(Dispatchers.IO) { + accountViewModel.isInPrivateBookmarks(note) { + val newState = + DropDownParams( + isFollowingAuthor = accountViewModel.isFollowing(note.author), + isPrivateBookmarkNote = it, + isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note), + isLoggedUser = accountViewModel.isLoggedUser(note.author), + isSensitive = note.event?.isSensitive() ?: false, + showSensitiveContent = showSensitiveContent, + ) - launch(Dispatchers.Main) { - onNew( - newState, - ) + launch(Dispatchers.Main) { + onNew( + newState, + ) + } + } } - } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt index db4a5d560..06514959c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt @@ -73,11 +73,6 @@ import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.TextNoteEvent -import java.math.BigDecimal -import java.time.Instant -import java.time.LocalDateTime -import java.time.ZoneId -import java.time.format.DateTimeFormatter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -85,401 +80,404 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import java.math.BigDecimal +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter @Composable fun UserReactionsRow( - model: UserReactionsViewModel, - onClick: () -> Unit, + model: UserReactionsViewModel, + onClick: () -> Unit, ) { - Row( - verticalAlignment = CenterVertically, - modifier = Modifier.clickable(onClick = onClick).padding(10.dp), - ) { - Row(verticalAlignment = CenterVertically, modifier = Modifier.width(68.dp)) { - Text( - text = stringResource(id = R.string.today), - fontWeight = FontWeight.Bold, - ) + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.clickable(onClick = onClick).padding(10.dp), + ) { + Row(verticalAlignment = CenterVertically, modifier = Modifier.width(68.dp)) { + Text( + text = stringResource(id = R.string.today), + fontWeight = FontWeight.Bold, + ) - Icon( - imageVector = Icons.Default.ExpandMore, - null, - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.placeholderText, - ) - } + Icon( + imageVector = Icons.Default.ExpandMore, + null, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + } - Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { - UserReplyModel(model) - } + Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { + UserReplyModel(model) + } - Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { - UserBoostModel(model) - } + Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { + UserBoostModel(model) + } - Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { - UserReactionModel(model) - } + Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { + UserReactionModel(model) + } - Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { - UserZapModel(model) + Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { + UserZapModel(model) + } } - } } @Composable private fun UserZapModel(model: UserReactionsViewModel) { - Icon( - imageVector = Icons.Default.Bolt, - contentDescription = stringResource(R.string.zaps), - modifier = Size24Modifier, - tint = BitcoinOrange, - ) + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.zaps), + modifier = Size24Modifier, + tint = BitcoinOrange, + ) - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(8.dp)) - UserZapReaction(model) + UserZapReaction(model) } @Composable private fun UserReactionModel(model: UserReactionsViewModel) { - Icon( - painter = painterResource(R.drawable.ic_liked), - null, - modifier = Size20Modifier, - tint = Color.Unspecified, - ) + Icon( + painter = painterResource(R.drawable.ic_liked), + null, + modifier = Size20Modifier, + tint = Color.Unspecified, + ) - Spacer(modifier = StdHorzSpacer) + Spacer(modifier = StdHorzSpacer) - UserLikeReaction(model) + UserLikeReaction(model) } @Composable private fun UserBoostModel(model: UserReactionsViewModel) { - Icon( - painter = painterResource(R.drawable.ic_retweeted), - null, - modifier = Size24Modifier, - tint = Color.Unspecified, - ) + Icon( + painter = painterResource(R.drawable.ic_retweeted), + null, + modifier = Size24Modifier, + tint = Color.Unspecified, + ) - Spacer(modifier = StdHorzSpacer) + Spacer(modifier = StdHorzSpacer) - UserBoostReaction(model) + UserBoostReaction(model) } @Composable private fun UserReplyModel(model: UserReactionsViewModel) { - Icon( - painter = painterResource(R.drawable.ic_comment), - null, - modifier = Size20Modifier, - tint = RoyalBlue, - ) + Icon( + painter = painterResource(R.drawable.ic_comment), + null, + modifier = Size20Modifier, + tint = RoyalBlue, + ) - Spacer(modifier = StdHorzSpacer) + Spacer(modifier = StdHorzSpacer) - UserReplyReaction(model) + UserReplyReaction(model) } @Stable class UserReactionsViewModel(val account: Account) : ViewModel() { - val user: User = account.userProfile() + val user: User = account.userProfile() - private var _reactions = MutableStateFlow>(emptyMap()) - private var _boosts = MutableStateFlow>(emptyMap()) - private var _zaps = MutableStateFlow>(emptyMap()) - private var _replies = MutableStateFlow>(emptyMap()) + private var _reactions = MutableStateFlow>(emptyMap()) + private var _boosts = MutableStateFlow>(emptyMap()) + private var _zaps = MutableStateFlow>(emptyMap()) + private var _replies = MutableStateFlow>(emptyMap()) - private var _chartModel = MutableStateFlow?>(null) - private var _axisLabels = MutableStateFlow>(emptyList()) + private var _chartModel = MutableStateFlow?>(null) + private var _axisLabels = MutableStateFlow>(emptyList()) - val reactions = _reactions.asStateFlow() - val boosts = _boosts.asStateFlow() - val zaps = _zaps.asStateFlow() - val replies = _replies.asStateFlow() + val reactions = _reactions.asStateFlow() + val boosts = _boosts.asStateFlow() + val zaps = _zaps.asStateFlow() + val replies = _replies.asStateFlow() - val chartModel = _chartModel.asStateFlow() - val axisLabels = _axisLabels.asStateFlow() + val chartModel = _chartModel.asStateFlow() + val axisLabels = _axisLabels.asStateFlow() - private var takenIntoAccount = setOf() - private val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat() + private var takenIntoAccount = setOf() + private val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat() - val todaysReplyCount = _replies.map { showCount(it[today()]) }.distinctUntilChanged() - val todaysBoostCount = _boosts.map { showCount(it[today()]) }.distinctUntilChanged() - val todaysReactionCount = _reactions.map { showCount(it[today()]) }.distinctUntilChanged() - val todaysZapAmount = _zaps.map { showAmountAxis(it[today()]) }.distinctUntilChanged() + val todaysReplyCount = _replies.map { showCount(it[today()]) }.distinctUntilChanged() + val todaysBoostCount = _boosts.map { showCount(it[today()]) }.distinctUntilChanged() + val todaysReactionCount = _reactions.map { showCount(it[today()]) }.distinctUntilChanged() + val todaysZapAmount = _zaps.map { showAmountAxis(it[today()]) }.distinctUntilChanged() - var shouldShowDecimalsInAxis = false + var shouldShowDecimalsInAxis = false - fun formatDate(createAt: Long): String { - return sdf.format( - Instant.ofEpochSecond(createAt).atZone(ZoneId.systemDefault()).toLocalDateTime(), - ) - } - - fun today() = sdf.format(LocalDateTime.now()) - - private suspend fun initializeSuspend() { - checkNotInMainThread() - - val currentUser = user.pubkeyHex - - val reactions = mutableMapOf() - val boosts = mutableMapOf() - val zaps = mutableMapOf() - val replies = mutableMapOf() - val takenIntoAccount = mutableSetOf() - - LocalCache.notes.values.forEach { - val noteEvent = it.event - if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) { - if (noteEvent is ReactionEvent) { - if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { - val netDate = formatDate(noteEvent.createdAt) - reactions[netDate] = (reactions[netDate] ?: 0) + 1 - takenIntoAccount.add(noteEvent.id()) - } - } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { - if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey() != currentUser) { - val netDate = formatDate(noteEvent.createdAt()) - boosts[netDate] = (boosts[netDate] ?: 0) + 1 - takenIntoAccount.add(noteEvent.id()) - } - } else if (noteEvent is LnZapEvent) { - if ( - noteEvent.isTaggedUser(currentUser) - ) { // the user might be sending his own receipts noteEvent.pubKey != currentUser - val netDate = formatDate(noteEvent.createdAt) - zaps[netDate] = - (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO) - takenIntoAccount.add(noteEvent.id()) - } - } else if (noteEvent is TextNoteEvent) { - if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { - val netDate = formatDate(noteEvent.createdAt) - replies[netDate] = (replies[netDate] ?: 0) + 1 - takenIntoAccount.add(noteEvent.id()) - } - } - } - } - - this.takenIntoAccount = takenIntoAccount - this._reactions.emit(reactions) - this._replies.emit(replies) - this._zaps.emit(zaps) - this._boosts.emit(boosts) - - refreshChartModel() - } - - suspend fun addToStatsSuspend(newNotes: Set) { - checkNotInMainThread() - - val currentUser = user.pubkeyHex - - val reactions = this._reactions.value.toMutableMap() - val boosts = this._boosts.value.toMutableMap() - val zaps = this._zaps.value.toMutableMap() - val replies = this._replies.value.toMutableMap() - val takenIntoAccount = this.takenIntoAccount.toMutableSet() - var hasNewElements = false - - newNotes.forEach { - val noteEvent = it.event - if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) { - if (noteEvent is ReactionEvent) { - if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { - val netDate = formatDate(noteEvent.createdAt) - reactions[netDate] = (reactions[netDate] ?: 0) + 1 - takenIntoAccount.add(noteEvent.id()) - hasNewElements = true - } - } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { - if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey() != currentUser) { - val netDate = formatDate(noteEvent.createdAt()) - boosts[netDate] = (boosts[netDate] ?: 0) + 1 - takenIntoAccount.add(noteEvent.id()) - hasNewElements = true - } - } else if (noteEvent is LnZapEvent) { - if ( - noteEvent.isTaggedUser(currentUser) - ) { // && noteEvent.pubKey != currentUser User might be sending his own receipts - val netDate = formatDate(noteEvent.createdAt) - zaps[netDate] = - (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO) - takenIntoAccount.add(noteEvent.id()) - hasNewElements = true - } - } else if (noteEvent is TextNoteEvent) { - if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { - val netDate = formatDate(noteEvent.createdAt) - replies[netDate] = (replies[netDate] ?: 0) + 1 - takenIntoAccount.add(noteEvent.id()) - hasNewElements = true - } - } - } - } - - if (hasNewElements) { - this.takenIntoAccount = takenIntoAccount - this._reactions.emit(reactions) - this._replies.emit(replies) - this._zaps.emit(zaps) - this._boosts.emit(boosts) - - refreshChartModel() - } - } - - private suspend fun refreshChartModel() { - checkNotInMainThread() - - val day = 24 * 60 * 60L - val now = LocalDateTime.now() - val displayAxisFormatter = DateTimeFormatter.ofPattern("EEE") - - val dataAxisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { sdf.format(now.minusSeconds(day * it)) } - - val listOfCountCurves = - listOf( - dataAxisLabels.mapIndexed { index, dateStr -> - entryOf(index, _replies.value[dateStr]?.toFloat() ?: 0f) - }, - dataAxisLabels.mapIndexed { index, dateStr -> - entryOf(index, _boosts.value[dateStr]?.toFloat() ?: 0f) - }, - dataAxisLabels.mapIndexed { index, dateStr -> - entryOf(index, _reactions.value[dateStr]?.toFloat() ?: 0f) - }, - ) - - val listOfValueCurves = - listOf( - dataAxisLabels.mapIndexed { index, dateStr -> - entryOf(index, _zaps.value[dateStr]?.toFloat() ?: 0f) - }, - ) - - val chartEntryModelProducer1 = ChartEntryModelProducer(listOfCountCurves).getModel() - val chartEntryModelProducer2 = ChartEntryModelProducer(listOfValueCurves).getModel() - - chartEntryModelProducer1?.let { chart1 -> - chartEntryModelProducer2?.let { chart2 -> - this.shouldShowDecimalsInAxis = shouldShowDecimals(chart2.minY, chart2.maxY) - - this._axisLabels.emit( - listOf(6, 5, 4, 3, 2, 1, 0).map { - displayAxisFormatter.format(now.minusSeconds(day * it)) - }, + fun formatDate(createAt: Long): String { + return sdf.format( + Instant.ofEpochSecond(createAt).atZone(ZoneId.systemDefault()).toLocalDateTime(), ) - this._chartModel.emit(chart1.plus(chart2)) - } - } - } - - // determine if the min max are so close that they render to the same number. - fun shouldShowDecimals( - min: Float, - max: Float, - ): Boolean { - val step = (max - min) / 8 - - var previous = showAmountAxis(min.toBigDecimal()) - for (i in 1..7) { - val current = showAmountAxis((min + (i * step)).toBigDecimal()) - if (previous == current) { - return true - } - previous = current } - return false - } + fun today() = sdf.format(LocalDateTime.now()) - var collectorJob: Job? = null + private suspend fun initializeSuspend() { + checkNotInMainThread() - init { - Log.d("Init", "User Reactions Row") - viewModelScope.launch(Dispatchers.IO) { - initializeSuspend() + val currentUser = user.pubkeyHex - collectorJob = - viewModelScope.launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { newNotes -> - checkNotInMainThread() + val reactions = mutableMapOf() + val boosts = mutableMapOf() + val zaps = mutableMapOf() + val replies = mutableMapOf() + val takenIntoAccount = mutableSetOf() - invalidateInsertData(newNotes) - } + LocalCache.notes.values.forEach { + val noteEvent = it.event + if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) { + if (noteEvent is ReactionEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + reactions[netDate] = (reactions[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + } + } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey() != currentUser) { + val netDate = formatDate(noteEvent.createdAt()) + boosts[netDate] = (boosts[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + } + } else if (noteEvent is LnZapEvent) { + if ( + noteEvent.isTaggedUser(currentUser) + ) { // the user might be sending his own receipts noteEvent.pubKey != currentUser + val netDate = formatDate(noteEvent.createdAt) + zaps[netDate] = + (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO) + takenIntoAccount.add(noteEvent.id()) + } + } else if (noteEvent is TextNoteEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + replies[netDate] = (replies[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + } + } + } + } + + this.takenIntoAccount = takenIntoAccount + this._reactions.emit(reactions) + this._replies.emit(replies) + this._zaps.emit(zaps) + this._boosts.emit(boosts) + + refreshChartModel() + } + + suspend fun addToStatsSuspend(newNotes: Set) { + checkNotInMainThread() + + val currentUser = user.pubkeyHex + + val reactions = this._reactions.value.toMutableMap() + val boosts = this._boosts.value.toMutableMap() + val zaps = this._zaps.value.toMutableMap() + val replies = this._replies.value.toMutableMap() + val takenIntoAccount = this.takenIntoAccount.toMutableSet() + var hasNewElements = false + + newNotes.forEach { + val noteEvent = it.event + if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) { + if (noteEvent is ReactionEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + reactions[netDate] = (reactions[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey() != currentUser) { + val netDate = formatDate(noteEvent.createdAt()) + boosts[netDate] = (boosts[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } else if (noteEvent is LnZapEvent) { + if ( + noteEvent.isTaggedUser(currentUser) + ) { // && noteEvent.pubKey != currentUser User might be sending his own receipts + val netDate = formatDate(noteEvent.createdAt) + zaps[netDate] = + (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO) + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } else if (noteEvent is TextNoteEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + replies[netDate] = (replies[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } + } + } + + if (hasNewElements) { + this.takenIntoAccount = takenIntoAccount + this._reactions.emit(reactions) + this._replies.emit(replies) + this._zaps.emit(zaps) + this._boosts.emit(boosts) + + refreshChartModel() } } - } - private val bundlerInsert = BundledInsert>(250, Dispatchers.IO) + private suspend fun refreshChartModel() { + checkNotInMainThread() - fun invalidateInsertData(newItems: Set) { - bundlerInsert.invalidateList(newItems) { addToStatsSuspend(it.flatten().toSet()) } - } + val day = 24 * 60 * 60L + val now = LocalDateTime.now() + val displayAxisFormatter = DateTimeFormatter.ofPattern("EEE") - override fun onCleared() { - collectorJob?.cancel() - bundlerInsert.cancel() - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - super.onCleared() - } + val dataAxisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { sdf.format(now.minusSeconds(day * it)) } - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): UserReactionsViewModel { - return UserReactionsViewModel(account) as UserReactionsViewModel + val listOfCountCurves = + listOf( + dataAxisLabels.mapIndexed { index, dateStr -> + entryOf(index, _replies.value[dateStr]?.toFloat() ?: 0f) + }, + dataAxisLabels.mapIndexed { index, dateStr -> + entryOf(index, _boosts.value[dateStr]?.toFloat() ?: 0f) + }, + dataAxisLabels.mapIndexed { index, dateStr -> + entryOf(index, _reactions.value[dateStr]?.toFloat() ?: 0f) + }, + ) + + val listOfValueCurves = + listOf( + dataAxisLabels.mapIndexed { index, dateStr -> + entryOf(index, _zaps.value[dateStr]?.toFloat() ?: 0f) + }, + ) + + val chartEntryModelProducer1 = ChartEntryModelProducer(listOfCountCurves).getModel() + val chartEntryModelProducer2 = ChartEntryModelProducer(listOfValueCurves).getModel() + + chartEntryModelProducer1?.let { chart1 -> + chartEntryModelProducer2?.let { chart2 -> + this.shouldShowDecimalsInAxis = shouldShowDecimals(chart2.minY, chart2.maxY) + + this._axisLabels.emit( + listOf(6, 5, 4, 3, 2, 1, 0).map { + displayAxisFormatter.format(now.minusSeconds(day * it)) + }, + ) + this._chartModel.emit(chart1.plus(chart2)) + } + } + } + + // determine if the min max are so close that they render to the same number. + fun shouldShowDecimals( + min: Float, + max: Float, + ): Boolean { + val step = (max - min) / 8 + + var previous = showAmountAxis(min.toBigDecimal()) + for (i in 1..7) { + val current = showAmountAxis((min + (i * step)).toBigDecimal()) + if (previous == current) { + return true + } + previous = current + } + + return false + } + + var collectorJob: Job? = null + + init { + Log.d("Init", "User Reactions Row") + viewModelScope.launch(Dispatchers.IO) { + initializeSuspend() + + collectorJob = + viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + checkNotInMainThread() + + invalidateInsertData(newNotes) + } + } + } + } + + private val bundlerInsert = BundledInsert>(250, Dispatchers.IO) + + fun invalidateInsertData(newItems: Set) { + bundlerInsert.invalidateList(newItems) { addToStatsSuspend(it.flatten().toSet()) } + } + + override fun onCleared() { + collectorJob?.cancel() + bundlerInsert.cancel() + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + super.onCleared() + } + + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): UserReactionsViewModel { + return UserReactionsViewModel(account) as UserReactionsViewModel + } } - } } @Composable fun UserReplyReaction(model: UserReactionsViewModel) { - val showCounts by model.todaysReplyCount.collectAsStateWithLifecycle("") + val showCounts by model.todaysReplyCount.collectAsStateWithLifecycle("") - Text( - showCounts, - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - ) + Text( + showCounts, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) } @Composable fun UserBoostReaction(model: UserReactionsViewModel) { - val boosts by model.todaysBoostCount.collectAsStateWithLifecycle("") + val boosts by model.todaysBoostCount.collectAsStateWithLifecycle("") - Text( - boosts, - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - ) + Text( + boosts, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) } @Composable fun UserLikeReaction(model: UserReactionsViewModel) { - val reactions by model.todaysReactionCount.collectAsStateWithLifecycle("") + val reactions by model.todaysReactionCount.collectAsStateWithLifecycle("") - Text( - text = reactions, - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - ) + Text( + text = reactions, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) } @Composable fun UserZapReaction(model: UserReactionsViewModel) { - val amount by model.todaysZapAmount.collectAsStateWithLifecycle("") - Text( - amount, - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - ) + val amount by model.todaysZapAmount.collectAsStateWithLifecycle("") + Text( + amount, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index 9e3dcebca..85ce58f12 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -52,140 +52,140 @@ import com.vitorpamplona.quartz.events.ImmutableListOfLists @Composable fun NoteUsernameDisplay( - baseNote: Note, - weight: Modifier = Modifier, - showPlayButton: Boolean = true, - textColor: Color = Color.Unspecified, + baseNote: Note, + weight: Modifier = Modifier, + showPlayButton: Boolean = true, + textColor: Color = Color.Unspecified, ) { - val authorState by baseNote.live().authorChanges.observeAsState(baseNote.author) + val authorState by baseNote.live().authorChanges.observeAsState(baseNote.author) - Crossfade(targetState = authorState, modifier = weight, label = "NoteUsernameDisplay") { - it?.let { UsernameDisplay(it, weight, showPlayButton, textColor = textColor) } - } + Crossfade(targetState = authorState, modifier = weight, label = "NoteUsernameDisplay") { + it?.let { UsernameDisplay(it, weight, showPlayButton, textColor = textColor) } + } } @Composable fun UsernameDisplay( - baseUser: User, - weight: Modifier = Modifier, - showPlayButton: Boolean = true, - fontWeight: FontWeight = FontWeight.Bold, - textColor: Color = Color.Unspecified, + baseUser: User, + weight: Modifier = Modifier, + showPlayButton: Boolean = true, + fontWeight: FontWeight = FontWeight.Bold, + textColor: Color = Color.Unspecified, ) { - val npubDisplay by remember { derivedStateOf { baseUser.pubkeyDisplayHex() } } + val npubDisplay by remember { derivedStateOf { baseUser.pubkeyDisplayHex() } } - val userMetadata by baseUser.live().userMetadataInfo.observeAsState(baseUser.info) + val userMetadata by baseUser.live().userMetadataInfo.observeAsState(baseUser.info) - Crossfade(targetState = userMetadata, modifier = weight, label = "UsernameDisplay") { - if (it != null) { - UserNameDisplay( - it.bestUsername(), - it.bestDisplayName(), - npubDisplay, - it.tags, - weight, - showPlayButton, - fontWeight, - textColor, - ) - } else { - NPubDisplay(npubDisplay, weight, fontWeight, textColor) + Crossfade(targetState = userMetadata, modifier = weight, label = "UsernameDisplay") { + if (it != null) { + UserNameDisplay( + it.bestUsername(), + it.bestDisplayName(), + npubDisplay, + it.tags, + weight, + showPlayButton, + fontWeight, + textColor, + ) + } else { + NPubDisplay(npubDisplay, weight, fontWeight, textColor) + } } - } } @Composable private fun UserNameDisplay( - bestUserName: String?, - bestDisplayName: String?, - npubDisplay: String, - tags: ImmutableListOfLists?, - modifier: Modifier, - showPlayButton: Boolean = true, - fontWeight: FontWeight = FontWeight.Bold, - textColor: Color = Color.Unspecified, + bestUserName: String?, + bestDisplayName: String?, + npubDisplay: String, + tags: ImmutableListOfLists?, + modifier: Modifier, + showPlayButton: Boolean = true, + fontWeight: FontWeight = FontWeight.Bold, + textColor: Color = Color.Unspecified, ) { - if (bestUserName != null && bestDisplayName != null && bestDisplayName != bestUserName) { - UserAndUsernameDisplay( - bestDisplayName.trim(), - tags, - bestUserName.trim(), - modifier, - showPlayButton, - fontWeight, - textColor, - ) - } else if (bestDisplayName != null) { - UserDisplay(bestDisplayName.trim(), tags, modifier, showPlayButton, fontWeight, textColor) - } else if (bestUserName != null) { - UserDisplay(bestUserName.trim(), tags, modifier, showPlayButton, fontWeight, textColor) - } else { - NPubDisplay(npubDisplay, modifier, fontWeight, textColor) - } + if (bestUserName != null && bestDisplayName != null && bestDisplayName != bestUserName) { + UserAndUsernameDisplay( + bestDisplayName.trim(), + tags, + bestUserName.trim(), + modifier, + showPlayButton, + fontWeight, + textColor, + ) + } else if (bestDisplayName != null) { + UserDisplay(bestDisplayName.trim(), tags, modifier, showPlayButton, fontWeight, textColor) + } else if (bestUserName != null) { + UserDisplay(bestUserName.trim(), tags, modifier, showPlayButton, fontWeight, textColor) + } else { + NPubDisplay(npubDisplay, modifier, fontWeight, textColor) + } } @Composable fun NPubDisplay( - npubDisplay: String, - modifier: Modifier, - fontWeight: FontWeight = FontWeight.Bold, - textColor: Color = Color.Unspecified, + npubDisplay: String, + modifier: Modifier, + fontWeight: FontWeight = FontWeight.Bold, + textColor: Color = Color.Unspecified, ) { - Text( - text = npubDisplay, - fontWeight = fontWeight, - modifier = modifier, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = textColor, - ) + Text( + text = npubDisplay, + fontWeight = fontWeight, + modifier = modifier, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = textColor, + ) } @Composable private fun UserDisplay( - bestDisplayName: String, - tags: ImmutableListOfLists?, - modifier: Modifier, - showPlayButton: Boolean = true, - fontWeight: FontWeight = FontWeight.Bold, - textColor: Color = Color.Unspecified, + bestDisplayName: String, + tags: ImmutableListOfLists?, + modifier: Modifier, + showPlayButton: Boolean = true, + fontWeight: FontWeight = FontWeight.Bold, + textColor: Color = Color.Unspecified, ) { - Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { - CreateTextWithEmoji( - text = bestDisplayName, - tags = tags, - fontWeight = fontWeight, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = modifier, - color = textColor, - ) - if (showPlayButton) { - Spacer(StdHorzSpacer) - DrawPlayName(bestDisplayName) + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + CreateTextWithEmoji( + text = bestDisplayName, + tags = tags, + fontWeight = fontWeight, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + color = textColor, + ) + if (showPlayButton) { + Spacer(StdHorzSpacer) + DrawPlayName(bestDisplayName) + } } - } } @Composable private fun UserAndUsernameDisplay( - bestDisplayName: String, - tags: ImmutableListOfLists?, - bestUserName: String, - modifier: Modifier, - showPlayButton: Boolean = true, - fontWeight: FontWeight = FontWeight.Bold, - textColor: Color = Color.Unspecified, + bestDisplayName: String, + tags: ImmutableListOfLists?, + bestUserName: String, + modifier: Modifier, + showPlayButton: Boolean = true, + fontWeight: FontWeight = FontWeight.Bold, + textColor: Color = Color.Unspecified, ) { - Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { - CreateTextWithEmoji( - text = bestDisplayName, - tags = tags, - fontWeight = fontWeight, - maxLines = 1, - modifier = modifier, - color = textColor, - ) + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + CreateTextWithEmoji( + text = bestDisplayName, + tags = tags, + fontWeight = fontWeight, + maxLines = 1, + modifier = modifier, + color = textColor, + ) /* CreateTextWithEmoji( text = remember { "@$bestUserName" }, @@ -195,37 +195,37 @@ private fun UserAndUsernameDisplay( overflow = TextOverflow.Ellipsis, )*/ - if (showPlayButton) { - Spacer(StdHorzSpacer) - DrawPlayName(bestDisplayName) + if (showPlayButton) { + Spacer(StdHorzSpacer) + DrawPlayName(bestDisplayName) + } } - } } @Composable fun DrawPlayName(name: String) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current - DrawPlayNameIcon { speak(name, context, lifecycleOwner) } + DrawPlayNameIcon { speak(name, context, lifecycleOwner) } } @Composable fun DrawPlayNameIcon(onClick: () -> Unit) { - IconButton(onClick = onClick, modifier = StdButtonSizeModifier) { - PlayIcon(modifier = StdButtonSizeModifier, tint = MaterialTheme.colorScheme.placeholderText) - } + IconButton(onClick = onClick, modifier = StdButtonSizeModifier) { + PlayIcon(modifier = StdButtonSizeModifier, tint = MaterialTheme.colorScheme.placeholderText) + } } private fun speak( - message: String, - context: Context, - owner: LifecycleOwner, + message: String, + context: Context, + owner: LifecycleOwner, ) { - TextToSpeechHelper.getInstance(context) - .registerLifecycle(owner) - .speak(message) - .highlight() - .onDone { Log.d("TextToSpeak", "speak: done") } - .onError { Log.d("TextToSpeak", "speak error: $it") } + TextToSpeechHelper.getInstance(context) + .registerLifecycle(owner) + .speak(message) + .highlight() + .onDone { Log.d("TextToSpeak", "speak: done") } + .onError { Log.d("TextToSpeak", "speak error: $it") } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 5fd290a8b..15b5cec1c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -90,389 +90,390 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList class ZapOptionstViewModel : ViewModel() { - private var account: Account? = null + private var account: Account? = null - var customAmount by mutableStateOf(TextFieldValue("21")) - var customMessage by mutableStateOf(TextFieldValue("")) + var customAmount by mutableStateOf(TextFieldValue("21")) + var customMessage by mutableStateOf(TextFieldValue("")) - fun load(account: Account) { - this.account = account - } - - fun canSend(): Boolean { - return value() != null - } - - fun value(): Long? { - return try { - customAmount.text.trim().toLongOrNull() - } catch (e: Exception) { - null + fun load(account: Account) { + this.account = account } - } - fun cancel() {} + fun canSend(): Boolean { + return value() != null + } + + fun value(): Long? { + return try { + customAmount.text.trim().toLongOrNull() + } catch (e: Exception) { + null + } + } + + fun cancel() {} } @Composable fun ZapCustomDialog( - onClose: () -> Unit, - onError: (title: String, text: String) -> Unit, - onProgress: (percent: Float) -> Unit, - onPayViaIntent: (ImmutableList) -> Unit, - accountViewModel: AccountViewModel, - baseNote: Note, + onClose: () -> Unit, + onError: (title: String, text: String) -> Unit, + onProgress: (percent: Float) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit, + accountViewModel: AccountViewModel, + baseNote: Note, ) { - val context = LocalContext.current - val postViewModel: ZapOptionstViewModel = viewModel() + val context = LocalContext.current + val postViewModel: ZapOptionstViewModel = viewModel() - LaunchedEffect(accountViewModel) { postViewModel.load(accountViewModel.account) } + LaunchedEffect(accountViewModel) { postViewModel.load(accountViewModel.account) } - val zapTypes = - listOf( - Triple( - LnZapEvent.ZapType.PUBLIC, - stringResource(id = R.string.zap_type_public), - stringResource(id = R.string.zap_type_public_explainer), - ), - Triple( - LnZapEvent.ZapType.PRIVATE, - stringResource(id = R.string.zap_type_private), - stringResource(id = R.string.zap_type_private_explainer), - ), - Triple( - LnZapEvent.ZapType.ANONYMOUS, - stringResource(id = R.string.zap_type_anonymous), - stringResource(id = R.string.zap_type_anonymous_explainer), - ), - Triple( - LnZapEvent.ZapType.NONZAP, - stringResource(id = R.string.zap_type_nonzap), - stringResource(id = R.string.zap_type_nonzap_explainer), - ), - ) + val zapTypes = + listOf( + Triple( + LnZapEvent.ZapType.PUBLIC, + stringResource(id = R.string.zap_type_public), + stringResource(id = R.string.zap_type_public_explainer), + ), + Triple( + LnZapEvent.ZapType.PRIVATE, + stringResource(id = R.string.zap_type_private), + stringResource(id = R.string.zap_type_private_explainer), + ), + Triple( + LnZapEvent.ZapType.ANONYMOUS, + stringResource(id = R.string.zap_type_anonymous), + stringResource(id = R.string.zap_type_anonymous_explainer), + ), + Triple( + LnZapEvent.ZapType.NONZAP, + stringResource(id = R.string.zap_type_nonzap), + stringResource(id = R.string.zap_type_nonzap_explainer), + ), + ) - val zapOptions = remember { - zapTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() - } - var selectedZapType by - remember(accountViewModel) { mutableStateOf(accountViewModel.account.defaultZapType) } - - Dialog( - onDismissRequest = { onClose() }, - properties = - DialogProperties( - dismissOnClickOutside = false, - usePlatformDefaultWidth = false, - ), - ) { - Surface { - Column(modifier = Modifier.padding(10.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CloseButton( - onPress = { - postViewModel.cancel() - onClose() - }, - ) - - ZapButton( - isActive = postViewModel.canSend(), - ) { - accountViewModel.zap( - baseNote, - postViewModel.value()!! * 1000L, - null, - postViewModel.customMessage.text, - context, - onError = onError, - onProgress = onProgress, - onPayViaIntent = onPayViaIntent, - zapType = selectedZapType, - ) - onClose() - } + val zapOptions = + remember { + zapTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() } + var selectedZapType by + remember(accountViewModel) { mutableStateOf(accountViewModel.account.defaultZapType) } - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - // stringResource(R.string.new_amount_in_sats - label = { Text(text = stringResource(id = R.string.amount_in_sats)) }, - value = postViewModel.customAmount, - onValueChange = { postViewModel.customAmount = it }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Number, - ), - placeholder = { - Text( - text = "100, 1000, 5000", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - singleLine = true, - modifier = Modifier.padding(end = 5.dp).weight(1f), - ) + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + dismissOnClickOutside = false, + usePlatformDefaultWidth = false, + ), + ) { + Surface { + Column(modifier = Modifier.padding(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton( + onPress = { + postViewModel.cancel() + onClose() + }, + ) - TextSpinner( - label = stringResource(id = R.string.zap_type), - placeholder = - zapTypes - .filter { it.first == accountViewModel.account.defaultZapType } - .first() - .second, - options = zapOptions, - onSelect = { selectedZapType = zapTypes[it].first }, - modifier = Modifier.weight(1f).padding(end = 5.dp), - ) + ZapButton( + isActive = postViewModel.canSend(), + ) { + accountViewModel.zap( + baseNote, + postViewModel.value()!! * 1000L, + null, + postViewModel.customMessage.text, + context, + onError = onError, + onProgress = onProgress, + onPayViaIntent = onPayViaIntent, + zapType = selectedZapType, + ) + onClose() + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + // stringResource(R.string.new_amount_in_sats + label = { Text(text = stringResource(id = R.string.amount_in_sats)) }, + value = postViewModel.customAmount, + onValueChange = { postViewModel.customAmount = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Number, + ), + placeholder = { + Text( + text = "100, 1000, 5000", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + modifier = Modifier.padding(end = 5.dp).weight(1f), + ) + + TextSpinner( + label = stringResource(id = R.string.zap_type), + placeholder = + zapTypes + .filter { it.first == accountViewModel.account.defaultZapType } + .first() + .second, + options = zapOptions, + onSelect = { selectedZapType = zapTypes[it].first }, + modifier = Modifier.weight(1f).padding(end = 5.dp), + ) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + // stringResource(R.string.new_amount_in_sats + label = { + if ( + selectedZapType == LnZapEvent.ZapType.PUBLIC || + selectedZapType == LnZapEvent.ZapType.ANONYMOUS + ) { + Text(text = stringResource(id = R.string.custom_zaps_add_a_message)) + } else if (selectedZapType == LnZapEvent.ZapType.PRIVATE) { + Text(text = stringResource(id = R.string.custom_zaps_add_a_message_private)) + } else if (selectedZapType == LnZapEvent.ZapType.NONZAP) { + Text(text = stringResource(id = R.string.custom_zaps_add_a_message_nonzap)) + } + }, + value = postViewModel.customMessage, + onValueChange = { postViewModel.customMessage = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Text, + ), + placeholder = { + Text( + text = stringResource(id = R.string.custom_zaps_add_a_message_example), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + modifier = Modifier.padding(end = 5.dp).weight(1f), + ) + } + } } - - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - // stringResource(R.string.new_amount_in_sats - label = { - if ( - selectedZapType == LnZapEvent.ZapType.PUBLIC || - selectedZapType == LnZapEvent.ZapType.ANONYMOUS - ) { - Text(text = stringResource(id = R.string.custom_zaps_add_a_message)) - } else if (selectedZapType == LnZapEvent.ZapType.PRIVATE) { - Text(text = stringResource(id = R.string.custom_zaps_add_a_message_private)) - } else if (selectedZapType == LnZapEvent.ZapType.NONZAP) { - Text(text = stringResource(id = R.string.custom_zaps_add_a_message_nonzap)) - } - }, - value = postViewModel.customMessage, - onValueChange = { postViewModel.customMessage = it }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Text, - ), - placeholder = { - Text( - text = stringResource(id = R.string.custom_zaps_add_a_message_example), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - singleLine = true, - modifier = Modifier.padding(end = 5.dp).weight(1f), - ) - } - } } - } } @Composable fun ZapButton( - isActive: Boolean, - onPost: () -> Unit, + isActive: Boolean, + onPost: () -> Unit, ) { - Button( - onClick = { onPost() }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = if (isActive) MaterialTheme.colorScheme.primary else Color.Gray, - ), - ) { - Text(text = "โšกZap ", color = Color.White) - } + Button( + onClick = { onPost() }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = if (isActive) MaterialTheme.colorScheme.primary else Color.Gray, + ), + ) { + Text(text = "โšกZap ", color = Color.White) + } } @Composable fun ErrorMessageDialog( - title: String, - textContent: String, - buttonColors: ButtonColors = ButtonDefaults.buttonColors(), - onClickStartMessage: (() -> Unit)? = null, - onDismiss: () -> Unit, + title: String, + textContent: String, + buttonColors: ButtonColors = ButtonDefaults.buttonColors(), + onClickStartMessage: (() -> Unit)? = null, + onDismiss: () -> Unit, ) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(title) }, - text = { SelectionContainer { Text(textContent) } }, - confirmButton = { - Row( - modifier = Modifier.padding(vertical = 8.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - onClickStartMessage?.let { - TextButton(onClick = onClickStartMessage) { - Icon( - painter = painterResource(R.drawable.ic_dm), - contentDescription = null, - ) - Spacer(StdHorzSpacer) - Text(stringResource(R.string.error_dialog_talk_to_user)) - } - } - Button( - onClick = onDismiss, - colors = buttonColors, - contentPadding = PaddingValues(horizontal = Size16dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Outlined.Done, - contentDescription = null, - ) - Spacer(StdHorzSpacer) - Text(stringResource(R.string.error_dialog_button_ok)) - } - } - } - }, - ) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { SelectionContainer { Text(textContent) } }, + confirmButton = { + Row( + modifier = Modifier.padding(vertical = 8.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + onClickStartMessage?.let { + TextButton(onClick = onClickStartMessage) { + Icon( + painter = painterResource(R.drawable.ic_dm), + contentDescription = null, + ) + Spacer(StdHorzSpacer) + Text(stringResource(R.string.error_dialog_talk_to_user)) + } + } + Button( + onClick = onDismiss, + colors = buttonColors, + contentPadding = PaddingValues(horizontal = Size16dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Done, + contentDescription = null, + ) + Spacer(StdHorzSpacer) + Text(stringResource(R.string.error_dialog_button_ok)) + } + } + } + }, + ) } @Composable fun PayViaIntentDialog( - payingInvoices: ImmutableList, - accountViewModel: AccountViewModel, - onClose: () -> Unit, - onError: (String) -> Unit, + payingInvoices: ImmutableList, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + onError: (String) -> Unit, ) { - val context = LocalContext.current + val context = LocalContext.current - if (payingInvoices.size == 1) { - payViaIntent(payingInvoices.first().invoice, context, onError) - onClose() - } else { - Dialog( - onDismissRequest = onClose, - properties = - DialogProperties( - dismissOnClickOutside = false, - usePlatformDefaultWidth = false, - ), - ) { - Surface { - Column(modifier = Modifier.padding(10.dp)) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CloseButton(onPress = onClose) - } + if (payingInvoices.size == 1) { + payViaIntent(payingInvoices.first().invoice, context, onError) + onClose() + } else { + Dialog( + onDismissRequest = onClose, + properties = + DialogProperties( + dismissOnClickOutside = false, + usePlatformDefaultWidth = false, + ), + ) { + Surface { + Column(modifier = Modifier.padding(10.dp)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton(onPress = onClose) + } - Spacer(modifier = DoubleVertSpacer) + Spacer(modifier = DoubleVertSpacer) - payingInvoices.forEachIndexed { index, it -> - val paid = remember { mutableStateOf(false) } + payingInvoices.forEachIndexed { index, it -> + val paid = remember { mutableStateOf(false) } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size10dp), - ) { - if (it.user != null) { - BaseUserPicture(it.user, Size55dp, accountViewModel = accountViewModel) - } else { - DisplayBlankAuthor(size = Size55dp) - } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size10dp), + ) { + if (it.user != null) { + BaseUserPicture(it.user, Size55dp, accountViewModel = accountViewModel) + } else { + DisplayBlankAuthor(size = Size55dp) + } - Spacer(modifier = DoubleHorzSpacer) + Spacer(modifier = DoubleHorzSpacer) - Column(modifier = Modifier.weight(1f)) { - if (it.user != null) { - UsernameDisplay(it.user, showPlayButton = false) - } else { - Text( - text = stringResource(id = R.string.wallet_number, index + 1), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - ) + Column(modifier = Modifier.weight(1f)) { + if (it.user != null) { + UsernameDisplay(it.user, showPlayButton = false) + } else { + Text( + text = stringResource(id = R.string.wallet_number, index + 1), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) + } + Row { + Text( + text = showAmount((it.amountMilliSats / 1000.0f).toBigDecimal()), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) + Spacer(modifier = StdHorzSpacer) + Text( + text = stringResource(id = R.string.sats), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) + } + } + + Spacer(modifier = DoubleHorzSpacer) + + PayButton(isActive = !paid.value) { + paid.value = true + + payViaIntent(it.invoice, context, onError) + } + } + } } - Row { - Text( - text = showAmount((it.amountMilliSats / 1000.0f).toBigDecimal()), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - ) - Spacer(modifier = StdHorzSpacer) - Text( - text = stringResource(id = R.string.sats), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - ) - } - } - - Spacer(modifier = DoubleHorzSpacer) - - PayButton(isActive = !paid.value) { - paid.value = true - - payViaIntent(it.invoice, context, onError) - } } - } } - } } - } } fun payViaIntent( - invoice: String, - context: Context, - onError: (String) -> Unit, + invoice: String, + context: Context, + onError: (String) -> Unit, ) { - try { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$invoice")) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$invoice")) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - ContextCompat.startActivity(context, intent, null) - } catch (e: Exception) { - if (e.message != null) { - onError(context.getString(R.string.no_wallet_found_with_error, e.message!!)) - } else { - onError(context.getString(R.string.no_wallet_found)) + ContextCompat.startActivity(context, intent, null) + } catch (e: Exception) { + if (e.message != null) { + onError(context.getString(R.string.no_wallet_found_with_error, e.message!!)) + } else { + onError(context.getString(R.string.no_wallet_found)) + } } - } } @Composable fun PayButton( - isActive: Boolean, - modifier: Modifier = Modifier, - onPost: () -> Unit = {}, + isActive: Boolean, + modifier: Modifier = Modifier, + onPost: () -> Unit = {}, ) { - Button( - modifier = modifier, - onClick = onPost, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = if (isActive) MaterialTheme.colorScheme.primary else Color.Gray, - ), - contentPadding = ZeroPadding, - ) { - if (isActive) { - Text(text = stringResource(R.string.pay), color = Color.White) - } else { - Text(text = stringResource(R.string.paid), color = Color.White) + Button( + modifier = modifier, + onClick = onPost, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = if (isActive) MaterialTheme.colorScheme.primary else Color.Gray, + ), + contentPadding = ZeroPadding, + ) { + if (isActive) { + Text(text = stringResource(R.string.pay), color = Color.White) + } else { + Text(text = stringResource(R.string.paid), color = Color.White) + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt index 98424b0be..21ef9ac84 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt @@ -63,176 +63,176 @@ import kotlinx.coroutines.launch @Composable fun ZapNoteCompose( - baseReqResponse: ZapReqResponse, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseReqResponse: ZapReqResponse, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val baseNoteRequest by baseReqResponse.zapRequest.live().metadata.observeAsState() + val baseNoteRequest by baseReqResponse.zapRequest.live().metadata.observeAsState() - var baseAuthor by remember { mutableStateOf(null) } + var baseAuthor by remember { mutableStateOf(null) } - LaunchedEffect(baseNoteRequest) { - baseNoteRequest?.note?.let { - accountViewModel.decryptAmountMessage(it, baseReqResponse.zapEvent) { baseAuthor = it?.user } + LaunchedEffect(baseNoteRequest) { + baseNoteRequest?.note?.let { + accountViewModel.decryptAmountMessage(it, baseReqResponse.zapEvent) { baseAuthor = it?.user } + } } - } - if (baseAuthor == null) { - BlankNote() - } else { - val route = remember(baseAuthor) { "User/${baseAuthor?.pubkeyHex}" } + if (baseAuthor == null) { + BlankNote() + } else { + val route = remember(baseAuthor) { "User/${baseAuthor?.pubkeyHex}" } - Column( - modifier = - Modifier.clickable( - onClick = { nav(route) }, - ), - verticalArrangement = Arrangement.Center, - ) { - baseAuthor?.let { RenderZapNote(it, baseReqResponse.zapEvent, nav, accountViewModel) } + Column( + modifier = + Modifier.clickable( + onClick = { nav(route) }, + ), + verticalArrangement = Arrangement.Center, + ) { + baseAuthor?.let { RenderZapNote(it, baseReqResponse.zapEvent, nav, accountViewModel) } - Divider( - modifier = Modifier.padding(top = 10.dp), - thickness = DividerThickness, - ) + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = DividerThickness, + ) + } } - } } @Composable private fun RenderZapNote( - baseAuthor: User, - zapNote: Note, - nav: (String) -> Unit, - accountViewModel: AccountViewModel, + baseAuthor: User, + zapNote: Note, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - Row( - modifier = - remember { - Modifier.padding( - start = 12.dp, - end = 12.dp, - top = 10.dp, - ) - }, - verticalAlignment = Alignment.CenterVertically, - ) { - UserPicture(baseAuthor, Size55dp, accountViewModel = accountViewModel, nav = nav) - - Column( - modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }, + Row( + modifier = + remember { + Modifier.padding( + start = 12.dp, + end = 12.dp, + top = 10.dp, + ) + }, + verticalAlignment = Alignment.CenterVertically, ) { - Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseAuthor) } - Row(verticalAlignment = Alignment.CenterVertically) { AboutDisplay(baseAuthor) } - } + UserPicture(baseAuthor, Size55dp, accountViewModel = accountViewModel, nav = nav) - Column( - modifier = remember { Modifier.padding(start = 10.dp) }, - verticalArrangement = Arrangement.Center, - ) { - ZapAmount(zapNote) - } + Column( + modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseAuthor) } + Row(verticalAlignment = Alignment.CenterVertically) { AboutDisplay(baseAuthor) } + } - Column(modifier = Modifier.padding(start = 10.dp)) { - UserActionOptions(baseAuthor, accountViewModel) + Column( + modifier = remember { Modifier.padding(start = 10.dp) }, + verticalArrangement = Arrangement.Center, + ) { + ZapAmount(zapNote) + } + + Column(modifier = Modifier.padding(start = 10.dp)) { + UserActionOptions(baseAuthor, accountViewModel) + } } - } } @Composable private fun ZapAmount(zapEventNote: Note) { - val noteState by zapEventNote.live().metadata.observeAsState() + val noteState by zapEventNote.live().metadata.observeAsState() - var zapAmount by remember { mutableStateOf(null) } + var zapAmount by remember { mutableStateOf(null) } - LaunchedEffect(key1 = noteState) { - launch(Dispatchers.IO) { - val newZapAmount = showAmountAxis((noteState?.note?.event as? LnZapEvent)?.amount) - if (zapAmount != newZapAmount) { - zapAmount = newZapAmount - } + LaunchedEffect(key1 = noteState) { + launch(Dispatchers.IO) { + val newZapAmount = showAmountAxis((noteState?.note?.event as? LnZapEvent)?.amount) + if (zapAmount != newZapAmount) { + zapAmount = newZapAmount + } + } } - } - zapAmount?.let { - Text( - text = it, - color = BitcoinOrange, - fontSize = 20.sp, - fontWeight = FontWeight.W500, - ) - } + zapAmount?.let { + Text( + text = it, + color = BitcoinOrange, + fontSize = 20.sp, + fontWeight = FontWeight.W500, + ) + } } @Composable fun UserActionOptions( - baseAuthor: User, - accountViewModel: AccountViewModel, + baseAuthor: User, + accountViewModel: AccountViewModel, ) { - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - WatchIsHiddenUser(baseAuthor, accountViewModel) { isHidden -> - if (isHidden) { - ShowUserButton { accountViewModel.show(baseAuthor) } - } else { - ShowFollowingOrUnfollowingButton(baseAuthor, accountViewModel) + WatchIsHiddenUser(baseAuthor, accountViewModel) { isHidden -> + if (isHidden) { + ShowUserButton { accountViewModel.show(baseAuthor) } + } else { + ShowFollowingOrUnfollowingButton(baseAuthor, accountViewModel) + } } - } } @Composable fun ShowFollowingOrUnfollowingButton( - baseAuthor: User, - accountViewModel: AccountViewModel, + baseAuthor: User, + accountViewModel: AccountViewModel, ) { - var isFollowing by remember { mutableStateOf(false) } - val accountFollowsState by accountViewModel.account.userProfile().live().follows.observeAsState() + var isFollowing by remember { mutableStateOf(false) } + val accountFollowsState by accountViewModel.account.userProfile().live().follows.observeAsState() - LaunchedEffect(key1 = accountFollowsState) { - launch(Dispatchers.Default) { - val newShowFollowingMark = accountFollowsState?.user?.isFollowing(baseAuthor) == true + LaunchedEffect(key1 = accountFollowsState) { + launch(Dispatchers.Default) { + val newShowFollowingMark = accountFollowsState?.user?.isFollowing(baseAuthor) == true - if (newShowFollowingMark != isFollowing) { - isFollowing = newShowFollowingMark - } + if (newShowFollowingMark != isFollowing) { + isFollowing = newShowFollowingMark + } + } } - } - if (isFollowing) { - UnfollowButton { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_unfollow, - ) - } else { - accountViewModel.unfollow(baseAuthor) - } + if (isFollowing) { + UnfollowButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_unfollow, + ) + } else { + accountViewModel.unfollow(baseAuthor) + } + } + } else { + FollowButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_follow, + ) + } else { + accountViewModel.follow(baseAuthor) + } + } } - } else { - FollowButton { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_follow, - ) - } else { - accountViewModel.follow(baseAuthor) - } - } - } } @Composable fun AboutDisplay(baseAuthor: User) { - val baseAuthorState by baseAuthor.live().metadata.observeAsState() - val userAboutMe by - remember(baseAuthorState) { derivedStateOf { baseAuthorState?.user?.info?.about ?: "" } } + val baseAuthorState by baseAuthor.live().metadata.observeAsState() + val userAboutMe by + remember(baseAuthorState) { derivedStateOf { baseAuthorState?.user?.info?.about ?: "" } } - Text( - userAboutMe, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Text( + userAboutMe, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt index d35c0140b..47ee1ccd0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt @@ -51,89 +51,89 @@ import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor @Composable fun ZapUserSetCompose( - zapSetCard: ZapUserSetCard, - isInnerNote: Boolean = false, - routeForLastRead: String, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + zapSetCard: ZapUserSetCard, + isInnerNote: Boolean = false, + routeForLastRead: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val defaultBackgroundColor = MaterialTheme.colorScheme.background - val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } - val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor + val defaultBackgroundColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } + val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor - LaunchedEffect(key1 = zapSetCard.createdAt()) { - accountViewModel.loadAndMarkAsRead(routeForLastRead, zapSetCard.createdAt) { isNew -> - val newBackgroundColor = - if (isNew) { - newItemColor.compositeOver(defaultBackgroundColor) - } else { - defaultBackgroundColor + LaunchedEffect(key1 = zapSetCard.createdAt()) { + accountViewModel.loadAndMarkAsRead(routeForLastRead, zapSetCard.createdAt) { isNew -> + val newBackgroundColor = + if (isNew) { + newItemColor.compositeOver(defaultBackgroundColor) + } else { + defaultBackgroundColor + } + + if (backgroundColor.value != newBackgroundColor) { + backgroundColor.value = newBackgroundColor + } } - - if (backgroundColor.value != newBackgroundColor) { - backgroundColor.value = newBackgroundColor - } } - } - Column( - modifier = - Modifier.background(backgroundColor.value).clickable { - nav("User/${zapSetCard.user.pubkeyHex}") - }, - ) { - Row( - modifier = - Modifier.padding( - start = if (!isInnerNote) 12.dp else 0.dp, - end = if (!isInnerNote) 12.dp else 0.dp, - top = 10.dp, - ), + Column( + modifier = + Modifier.background(backgroundColor.value).clickable { + nav("User/${zapSetCard.user.pubkeyHex}") + }, ) { - // Draws the like picture outside the boosted card. - if (!isInnerNote) { - Box( - modifier = Size55Modifier, - ) { - ZappedIcon( - remember { Modifier.size(Size25dp).align(Alignment.TopEnd) }, - ) - } - } - - Column(modifier = Modifier) { - Row(Modifier.fillMaxWidth()) { - MapZaps(zapSetCard.zapEvents, accountViewModel) { - AuthorGalleryZaps(it, backgroundColor, nav, accountViewModel) - } - } - - Spacer(DoubleVertSpacer) - Row( - Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp).fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.padding( + start = if (!isInnerNote) 12.dp else 0.dp, + end = if (!isInnerNote) 12.dp else 0.dp, + top = 10.dp, + ), ) { - UserPicture( - zapSetCard.user, - Size55dp, - accountViewModel = accountViewModel, - nav = nav, - ) + // Draws the like picture outside the boosted card. + if (!isInnerNote) { + Box( + modifier = Size55Modifier, + ) { + ZappedIcon( + remember { Modifier.size(Size25dp).align(Alignment.TopEnd) }, + ) + } + } - Column(modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }) { - Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(zapSetCard.user) } + Column(modifier = Modifier) { + Row(Modifier.fillMaxWidth()) { + MapZaps(zapSetCard.zapEvents, accountViewModel) { + AuthorGalleryZaps(it, backgroundColor, nav, accountViewModel) + } + } - AboutDisplay(zapSetCard.user) - } + Spacer(DoubleVertSpacer) + + Row( + Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + UserPicture( + zapSetCard.user, + Size55dp, + accountViewModel = accountViewModel, + nav = nav, + ) + + Column(modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }) { + Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(zapSetCard.user) } + + AboutDisplay(zapSetCard.user) + } + } + + Spacer(DoubleVertSpacer) + } } - Spacer(DoubleVertSpacer) - } + Divider( + thickness = DividerThickness, + ) } - - Divider( - thickness = DividerThickness, - ) - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeDrawer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeDrawer.kt index 009ce0c9f..a5f992666 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeDrawer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeDrawer.kt @@ -52,141 +52,141 @@ const val QR_MARGIN_PX = 100f @Preview @Composable fun QrCodeDrawerPreview() { - QrCodeDrawer("Test QR data") + QrCodeDrawer("Test QR data") } @Composable fun QrCodeDrawer( - contents: String, - modifier: Modifier = Modifier, + contents: String, + modifier: Modifier = Modifier, ) { - val qrCode = remember(contents) { createQrCode(contents = contents) } + val qrCode = remember(contents) { createQrCode(contents = contents) } - val foregroundColor = MaterialTheme.colorScheme.onSurface + val foregroundColor = MaterialTheme.colorScheme.onSurface - Box( - modifier = - modifier - .defaultMinSize(48.dp, 48.dp) - .aspectRatio(1f) - .background(MaterialTheme.colorScheme.background), - ) { - Canvas(modifier = Modifier.fillMaxSize()) { - // Calculate the height and width of each column/row - val rowHeight = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.height - val columnWidth = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.width + Box( + modifier = + modifier + .defaultMinSize(48.dp, 48.dp) + .aspectRatio(1f) + .background(MaterialTheme.colorScheme.background), + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + // Calculate the height and width of each column/row + val rowHeight = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.height + val columnWidth = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.width - // Draw all of the finder patterns required by the QR spec. Calculate the ratio - // of the number of rows/columns to the width and height - drawQrCodeFinders( - sideLength = size.width, - finderPatternSize = - Size( - width = columnWidth * FINDER_PATTERN_ROW_COUNT, - height = rowHeight * FINDER_PATTERN_ROW_COUNT, - ), - color = foregroundColor, - ) + // Draw all of the finder patterns required by the QR spec. Calculate the ratio + // of the number of rows/columns to the width and height + drawQrCodeFinders( + sideLength = size.width, + finderPatternSize = + Size( + width = columnWidth * FINDER_PATTERN_ROW_COUNT, + height = rowHeight * FINDER_PATTERN_ROW_COUNT, + ), + color = foregroundColor, + ) - // Draw data bits (encoded data part) - drawAllQrCodeDataBits( - bytes = qrCode.matrix, - size = - Size( - width = columnWidth, - height = rowHeight, - ), - color = foregroundColor, - ) + // Draw data bits (encoded data part) + drawAllQrCodeDataBits( + bytes = qrCode.matrix, + size = + Size( + width = columnWidth, + height = rowHeight, + ), + color = foregroundColor, + ) + } } - } } private typealias Coordinate = Pair private fun createQrCode(contents: String): QRCode { - require(contents.isNotEmpty()) + require(contents.isNotEmpty()) - return Encoder.encode( - contents, - ErrorCorrectionLevel.Q, - mapOf( - EncodeHintType.CHARACTER_SET to "UTF-8", - EncodeHintType.MARGIN to QR_MARGIN_PX, - EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q, - ), - ) + return Encoder.encode( + contents, + ErrorCorrectionLevel.Q, + mapOf( + EncodeHintType.CHARACTER_SET to "UTF-8", + EncodeHintType.MARGIN to QR_MARGIN_PX, + EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q, + ), + ) } fun newPath(withPath: Path.() -> Unit) = - Path().apply { - fillType = PathFillType.EvenOdd - withPath(this) - } + Path().apply { + fillType = PathFillType.EvenOdd + withPath(this) + } fun DrawScope.drawAllQrCodeDataBits( - bytes: ByteMatrix, - size: Size, - color: Color, + bytes: ByteMatrix, + size: Size, + color: Color, ) { - setOf( - // data bits between top left finder pattern and top right finder pattern. - Pair( - first = Coordinate(first = FINDER_PATTERN_ROW_COUNT, second = 0), - second = - Coordinate( - first = (bytes.width - FINDER_PATTERN_ROW_COUNT), - second = FINDER_PATTERN_ROW_COUNT, - ), - ), - // data bits below top left finder pattern and above bottom left finder pattern. - Pair( - first = Coordinate(first = 0, second = FINDER_PATTERN_ROW_COUNT), - second = - Coordinate( - first = bytes.width, - second = bytes.height - FINDER_PATTERN_ROW_COUNT, - ), - ), - // data bits to the right of the bottom left finder pattern. - Pair( - first = - Coordinate( - first = FINDER_PATTERN_ROW_COUNT, - second = (bytes.height - FINDER_PATTERN_ROW_COUNT), - ), - second = - Coordinate( - first = bytes.width, - second = bytes.height, - ), - ), + setOf( + // data bits between top left finder pattern and top right finder pattern. + Pair( + first = Coordinate(first = FINDER_PATTERN_ROW_COUNT, second = 0), + second = + Coordinate( + first = (bytes.width - FINDER_PATTERN_ROW_COUNT), + second = FINDER_PATTERN_ROW_COUNT, + ), + ), + // data bits below top left finder pattern and above bottom left finder pattern. + Pair( + first = Coordinate(first = 0, second = FINDER_PATTERN_ROW_COUNT), + second = + Coordinate( + first = bytes.width, + second = bytes.height - FINDER_PATTERN_ROW_COUNT, + ), + ), + // data bits to the right of the bottom left finder pattern. + Pair( + first = + Coordinate( + first = FINDER_PATTERN_ROW_COUNT, + second = (bytes.height - FINDER_PATTERN_ROW_COUNT), + ), + second = + Coordinate( + first = bytes.width, + second = bytes.height, + ), + ), ) - .forEach { section -> - for (y in section.first.second until section.second.second) { - for (x in section.first.first until section.second.first) { - if (bytes[x, y] == 1.toByte()) { - drawPath( - color = color, - path = - newPath { - addRect( - rect = - Rect( - offset = - Offset( - x = QR_MARGIN_PX + x * size.width, - y = QR_MARGIN_PX + y * size.height, - ), - size = size, - ), - ) - }, - ) - } + .forEach { section -> + for (y in section.first.second until section.second.second) { + for (x in section.first.first until section.second.first) { + if (bytes[x, y] == 1.toByte()) { + drawPath( + color = color, + path = + newPath { + addRect( + rect = + Rect( + offset = + Offset( + x = QR_MARGIN_PX + x * size.width, + y = QR_MARGIN_PX + y * size.height, + ), + size = size, + ), + ) + }, + ) + } + } + } } - } - } } const val FINDER_PATTERN_ROW_COUNT = 7 @@ -205,87 +205,87 @@ private const val INTERIOR_BACKGROUND_EXTERIOR_SHAPE_CORNER_RADIUS = 0.5f * @param finderPatternSize [Size] of each finder patten, based on the QR code spec */ internal fun DrawScope.drawQrCodeFinders( - sideLength: Float, - finderPatternSize: Size, - color: Color, + sideLength: Float, + finderPatternSize: Size, + color: Color, ) { - setOf( - // Draw top left finder pattern. - Offset(x = QR_MARGIN_PX, y = QR_MARGIN_PX), - // Draw top right finder pattern. - Offset(x = sideLength - (QR_MARGIN_PX + finderPatternSize.width), y = QR_MARGIN_PX), - // Draw bottom finder pattern. - Offset(x = QR_MARGIN_PX, y = sideLength - (QR_MARGIN_PX + finderPatternSize.height)), + setOf( + // Draw top left finder pattern. + Offset(x = QR_MARGIN_PX, y = QR_MARGIN_PX), + // Draw top right finder pattern. + Offset(x = sideLength - (QR_MARGIN_PX + finderPatternSize.width), y = QR_MARGIN_PX), + // Draw bottom finder pattern. + Offset(x = QR_MARGIN_PX, y = sideLength - (QR_MARGIN_PX + finderPatternSize.height)), ) - .forEach { offset -> - drawQrCodeFinder( - topLeft = offset, - finderPatternSize = finderPatternSize, - cornerRadius = CornerRadius.Zero, - color = color, - ) - } + .forEach { offset -> + drawQrCodeFinder( + topLeft = offset, + finderPatternSize = finderPatternSize, + cornerRadius = CornerRadius.Zero, + color = color, + ) + } } /** This func is responsible for drawing a single finder pattern, for a QR code */ private fun DrawScope.drawQrCodeFinder( - topLeft: Offset, - finderPatternSize: Size, - cornerRadius: CornerRadius, - color: Color, + topLeft: Offset, + finderPatternSize: Size, + cornerRadius: CornerRadius, + color: Color, ) { - drawPath( - color = color, - path = - newPath { - // Draw the outer rectangle for the finder pattern. - addRoundRect( - roundRect = - RoundRect( - rect = - Rect( - offset = topLeft, - size = finderPatternSize, - ), - cornerRadius = cornerRadius, - ), - ) + drawPath( + color = color, + path = + newPath { + // Draw the outer rectangle for the finder pattern. + addRoundRect( + roundRect = + RoundRect( + rect = + Rect( + offset = topLeft, + size = finderPatternSize, + ), + cornerRadius = cornerRadius, + ), + ) - // Draw background for the finder pattern interior (this keeps the arc ratio consistent). - val innerBackgroundOffset = - Offset( - x = finderPatternSize.width * INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO, - y = finderPatternSize.height * INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO, - ) - addRoundRect( - roundRect = - RoundRect( - rect = - Rect( - offset = topLeft + innerBackgroundOffset, - size = finderPatternSize * INTERIOR_BACKGROUND_EXTERIOR_SHAPE_RATIO, - ), - cornerRadius = cornerRadius * INTERIOR_BACKGROUND_EXTERIOR_SHAPE_CORNER_RADIUS, - ), - ) + // Draw background for the finder pattern interior (this keeps the arc ratio consistent). + val innerBackgroundOffset = + Offset( + x = finderPatternSize.width * INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO, + y = finderPatternSize.height * INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO, + ) + addRoundRect( + roundRect = + RoundRect( + rect = + Rect( + offset = topLeft + innerBackgroundOffset, + size = finderPatternSize * INTERIOR_BACKGROUND_EXTERIOR_SHAPE_RATIO, + ), + cornerRadius = cornerRadius * INTERIOR_BACKGROUND_EXTERIOR_SHAPE_CORNER_RADIUS, + ), + ) - // Draw the inner rectangle for the finder pattern. - val innerRectOffset = - Offset( - x = finderPatternSize.width * INTERIOR_EXTERIOR_OFFSET_RATIO, - y = finderPatternSize.height * INTERIOR_EXTERIOR_OFFSET_RATIO, - ) - addRoundRect( - roundRect = - RoundRect( - rect = - Rect( - offset = topLeft + innerRectOffset, - size = finderPatternSize * INTERIOR_EXTERIOR_SHAPE_RATIO, - ), - cornerRadius = cornerRadius * INTERIOR_EXTERIOR_SHAPE_CORNER_RADIUS, - ), - ) - }, - ) + // Draw the inner rectangle for the finder pattern. + val innerRectOffset = + Offset( + x = finderPatternSize.width * INTERIOR_EXTERIOR_OFFSET_RATIO, + y = finderPatternSize.height * INTERIOR_EXTERIOR_OFFSET_RATIO, + ) + addRoundRect( + roundRect = + RoundRect( + rect = + Rect( + offset = topLeft + innerRectOffset, + size = finderPatternSize * INTERIOR_EXTERIOR_SHAPE_RATIO, + ), + cornerRadius = cornerRadius * INTERIOR_EXTERIOR_SHAPE_CORNER_RADIUS, + ), + ) + }, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt index 9e4a79cc2..fec70d41c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt @@ -33,53 +33,53 @@ import com.vitorpamplona.quartz.encoders.Nip19 @Composable fun NIP19QrCodeScanner(onScan: (String?) -> Unit) { - SimpleQrCodeScanner { - try { - val nip19 = Nip19.uriToRoute(it) - val startingPage = - when (nip19?.type) { - Nip19.Type.USER -> "User/${nip19.hex}" - Nip19.Type.NOTE -> "Note/${nip19.hex}" - Nip19.Type.EVENT -> "Event/${nip19.hex}" - Nip19.Type.ADDRESS -> "Note/${nip19.hex}" - else -> null - } + SimpleQrCodeScanner { + try { + val nip19 = Nip19.uriToRoute(it) + val startingPage = + when (nip19?.type) { + Nip19.Type.USER -> "User/${nip19.hex}" + Nip19.Type.NOTE -> "Note/${nip19.hex}" + Nip19.Type.EVENT -> "Event/${nip19.hex}" + Nip19.Type.ADDRESS -> "Note/${nip19.hex}" + else -> null + } - if (startingPage != null) { - onScan(startingPage) - } else { - onScan(null) - } - } catch (e: Throwable) { - Log.e("NIP19 Scanner", "Error parsing $it", e) - // QR can be anything, do not throw errors. - onScan(null) + if (startingPage != null) { + onScan(startingPage) + } else { + onScan(null) + } + } catch (e: Throwable) { + Log.e("NIP19 Scanner", "Error parsing $it", e) + // QR can be anything, do not throw errors. + onScan(null) + } } - } } @Composable fun SimpleQrCodeScanner(onScan: (String?) -> Unit) { - val qrLauncher = - rememberLauncherForActivityResult(ScanContract()) { - if (it.contents != null) { - onScan(it.contents) - } else { - onScan(null) - } - } + val qrLauncher = + rememberLauncherForActivityResult(ScanContract()) { + if (it.contents != null) { + onScan(it.contents) + } else { + onScan(null) + } + } - val scanOptions = - ScanOptions().apply { - setDesiredBarcodeFormats(ScanOptions.QR_CODE) - setPrompt(stringResource(id = R.string.point_to_the_qr_code)) - setBeepEnabled(false) - setOrientationLocked(false) - addExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN) - } + val scanOptions = + ScanOptions().apply { + setDesiredBarcodeFormats(ScanOptions.QR_CODE) + setPrompt(stringResource(id = R.string.point_to_the_qr_code)) + setBeepEnabled(false) + setOrientationLocked(false) + addExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN) + } - DisposableEffect(Unit) { - qrLauncher.launch(scanOptions) - onDispose {} - } + DisposableEffect(Unit) { + qrLauncher.launch(scanOptions) + onDispose {} + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt index b10a76b52..ae4e6ac1c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt @@ -64,115 +64,115 @@ import com.vitorpamplona.quartz.events.toImmutableListOfLists @Preview @Composable fun ShowQRDialogPreview() { - val user = User("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") + val user = User("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") - user.info = - UserMetadata().apply { - name = "My Name" - picture = "Picture" - banner = "http://banner.com/test" - website = "http://mywebsite.com/test" - about = "This is the about me" - } + user.info = + UserMetadata().apply { + name = "My Name" + picture = "Picture" + banner = "http://banner.com/test" + website = "http://mywebsite.com/test" + about = "This is the about me" + } - ShowQRDialog( - user = user, - loadProfilePicture = false, - onScan = {}, - onClose = {}, - ) + ShowQRDialog( + user = user, + loadProfilePicture = false, + onScan = {}, + onClose = {}, + ) } @Composable fun ShowQRDialog( - user: User, - loadProfilePicture: Boolean, - onScan: (String) -> Unit, - onClose: () -> Unit, + user: User, + loadProfilePicture: Boolean, + onScan: (String) -> Unit, + onClose: () -> Unit, ) { - var presenting by remember { mutableStateOf(true) } + var presenting by remember { mutableStateOf(true) } - Dialog( - onDismissRequest = onClose, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Surface { - Column { - Row( - modifier = Modifier.padding(10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CloseButton(onPress = onClose) - } - - Column( - modifier = Modifier.fillMaxSize().padding(horizontal = 10.dp), - verticalArrangement = Arrangement.SpaceAround, - ) { - if (presenting) { + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface { Column { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth(), - ) { - RobohashFallbackAsyncImage( - robot = user.pubkeyHex, - model = user.profilePicture(), - contentDescription = stringResource(R.string.profile_image), - modifier = - Modifier.width(100.dp) - .height(100.dp) - .clip(shape = CircleShape) - .border(3.dp, MaterialTheme.colorScheme.background, CircleShape) - .background(MaterialTheme.colorScheme.background), - loadProfilePicture = loadProfilePicture, - ) - } - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth().padding(top = 5.dp), - ) { - CreateTextWithEmoji( - text = user.bestDisplayName() ?: user.bestUsername() ?: "", - tags = user.info?.latestMetadata?.tags?.toImmutableListOfLists(), - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - ) - } - } + Row( + modifier = Modifier.padding(10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton(onPress = onClose) + } - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth().padding(horizontal = Size35dp), - ) { - QrCodeDrawer("nostr:${user.pubkeyNpub()}") - } + Column( + modifier = Modifier.fillMaxSize().padding(horizontal = 10.dp), + verticalArrangement = Arrangement.SpaceAround, + ) { + if (presenting) { + Column { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + RobohashFallbackAsyncImage( + robot = user.pubkeyHex, + model = user.profilePicture(), + contentDescription = stringResource(R.string.profile_image), + modifier = + Modifier.width(100.dp) + .height(100.dp) + .clip(shape = CircleShape) + .border(3.dp, MaterialTheme.colorScheme.background, CircleShape) + .background(MaterialTheme.colorScheme.background), + loadProfilePicture = loadProfilePicture, + ) + } + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth().padding(top = 5.dp), + ) { + CreateTextWithEmoji( + text = user.bestDisplayName() ?: user.bestUsername() ?: "", + tags = user.info?.latestMetadata?.tags?.toImmutableListOfLists(), + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) + } + } - Row(modifier = Modifier.padding(horizontal = 30.dp)) { - Button( - onClick = { presenting = false }, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier.fillMaxWidth().height(50.dp), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text(text = stringResource(R.string.scan_qr)) - } + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth().padding(horizontal = Size35dp), + ) { + QrCodeDrawer("nostr:${user.pubkeyNpub()}") + } + + Row(modifier = Modifier.padding(horizontal = 30.dp)) { + Button( + onClick = { presenting = false }, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.fillMaxWidth().height(50.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(text = stringResource(R.string.scan_qr)) + } + } + } else { + NIP19QrCodeScanner { + if (it.isNullOrEmpty()) { + presenting = true + } else { + onScan(it) + } + } + } + } } - } else { - NIP19QrCodeScanner { - if (it.isNullOrEmpty()) { - presenting = true - } else { - onScan(it) - } - } - } } - } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt index 9676947e7..683fd4f86 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt @@ -56,121 +56,121 @@ import kotlinx.coroutines.launch @Composable fun AccountScreen( - accountStateViewModel: AccountStateViewModel, - sharedPreferencesViewModel: SharedPreferencesViewModel, + accountStateViewModel: AccountStateViewModel, + sharedPreferencesViewModel: SharedPreferencesViewModel, ) { - val accountState by accountStateViewModel.accountContent.collectAsStateWithLifecycle() + val accountState by accountStateViewModel.accountContent.collectAsStateWithLifecycle() - Crossfade( - targetState = accountState, - animationSpec = tween(durationMillis = 100), - label = "AccountState", - ) { state -> - when (state) { - is AccountState.Loading -> { - LoadingAccounts() - } - is AccountState.LoggedOff -> { - LoginPage(accountStateViewModel, isFirstLogin = true) - } - is AccountState.LoggedIn -> { - CompositionLocalProvider( - LocalViewModelStoreOwner provides state.currentViewModelStore, - ) { - LoggedInPage( - state.account, - accountStateViewModel, - sharedPreferencesViewModel, - ) + Crossfade( + targetState = accountState, + animationSpec = tween(durationMillis = 100), + label = "AccountState", + ) { state -> + when (state) { + is AccountState.Loading -> { + LoadingAccounts() + } + is AccountState.LoggedOff -> { + LoginPage(accountStateViewModel, isFirstLogin = true) + } + is AccountState.LoggedIn -> { + CompositionLocalProvider( + LocalViewModelStoreOwner provides state.currentViewModelStore, + ) { + LoggedInPage( + state.account, + accountStateViewModel, + sharedPreferencesViewModel, + ) + } + } + is AccountState.LoggedInViewOnly -> { + CompositionLocalProvider( + LocalViewModelStoreOwner provides state.currentViewModelStore, + ) { + LoggedInPage( + state.account, + accountStateViewModel, + sharedPreferencesViewModel, + ) + } + } } - } - is AccountState.LoggedInViewOnly -> { - CompositionLocalProvider( - LocalViewModelStoreOwner provides state.currentViewModelStore, - ) { - LoggedInPage( - state.account, - accountStateViewModel, - sharedPreferencesViewModel, - ) - } - } } - } } @Composable fun LoggedInPage( - account: Account, - accountStateViewModel: AccountStateViewModel, - sharedPreferencesViewModel: SharedPreferencesViewModel, + account: Account, + accountStateViewModel: AccountStateViewModel, + sharedPreferencesViewModel: SharedPreferencesViewModel, ) { - val accountViewModel: AccountViewModel = - viewModel( - key = "AccountViewModel", - factory = - AccountViewModel.Factory( - account, - sharedPreferencesViewModel.sharedPrefs, - ), - ) + val accountViewModel: AccountViewModel = + viewModel( + key = "AccountViewModel", + factory = + AccountViewModel.Factory( + account, + sharedPreferencesViewModel.sharedPrefs, + ), + ) - val activity = getActivity() as MainActivity - // Find a better way to associate these two. - accountViewModel.serviceManager = activity.serviceManager + val activity = getActivity() as MainActivity + // Find a better way to associate these two. + accountViewModel.serviceManager = activity.serviceManager - if (accountViewModel.account.signer is NostrSignerExternal) { - val launcher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - onResult = { result -> - if (result.resultCode != Activity.RESULT_OK) { - accountViewModel.toast( - R.string.sign_request_rejected, - R.string.sign_request_rejected_description, + if (accountViewModel.account.signer is NostrSignerExternal) { + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + onResult = { result -> + if (result.resultCode != Activity.RESULT_OK) { + accountViewModel.toast( + R.string.sign_request_rejected, + R.string.sign_request_rejected_description, + ) + } else { + result.data?.let { + accountViewModel.runOnIO { accountViewModel.account.signer.launcher.newResult(it) } + } + } + }, ) - } else { - result.data?.let { - accountViewModel.runOnIO { accountViewModel.account.signer.launcher.newResult(it) } - } - } - }, - ) - DisposableEffect(accountViewModel, accountViewModel.account, launcher, activity) { - accountViewModel.account.signer.launcher.registerLauncher( - launcher = { - try { - activity.prepareToLaunchSigner() - launcher.launch(it) - } catch (e: Exception) { - Log.e("Signer", "Error opening Signer app", e) - accountViewModel.toast( - R.string.error_opening_external_signer, - R.string.error_opening_external_signer_description, + DisposableEffect(accountViewModel, accountViewModel.account, launcher, activity) { + accountViewModel.account.signer.launcher.registerLauncher( + launcher = { + try { + activity.prepareToLaunchSigner() + launcher.launch(it) + } catch (e: Exception) { + Log.e("Signer", "Error opening Signer app", e) + accountViewModel.toast( + R.string.error_opening_external_signer, + R.string.error_opening_external_signer_description, + ) + } + }, + contentResolver = { Amethyst.instance.contentResolver }, ) - } - }, - contentResolver = { Amethyst.instance.contentResolver }, - ) - onDispose { accountViewModel.account.signer.launcher.clearLauncher() } + onDispose { accountViewModel.account.signer.launcher.clearLauncher() } + } } - } - MainScreen(accountViewModel, accountStateViewModel, sharedPreferencesViewModel) + MainScreen(accountViewModel, accountStateViewModel, sharedPreferencesViewModel) } class AccountCentricViewModelStore(val account: Account) : ViewModelStoreOwner { - override val viewModelStore = ViewModelStore() + override val viewModelStore = ViewModelStore() } @Composable fun LoadingAccounts() { - Column( - Modifier.fillMaxHeight().fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text(stringResource(R.string.loading_account)) - } + Column( + Modifier.fillMaxHeight().fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(stringResource(R.string.loading_account)) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt index 351737ff1..3d4e3d591 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt @@ -23,15 +23,15 @@ package com.vitorpamplona.amethyst.ui.screen import com.vitorpamplona.amethyst.model.Account sealed class AccountState { - object Loading : AccountState() + object Loading : AccountState() - object LoggedOff : AccountState() + object LoggedOff : AccountState() - class LoggedInViewOnly(val account: Account) : AccountState() { - val currentViewModelStore = AccountCentricViewModelStore(account) - } + class LoggedInViewOnly(val account: Account) : AccountState() { + val currentViewModelStore = AccountCentricViewModelStore(account) + } - class LoggedIn(val account: Account) : AccountState() { - val currentViewModelStore = AccountCentricViewModelStore(account) - } + class LoggedIn(val account: Account) : AccountState() { + val currentViewModelStore = AccountCentricViewModelStore(account) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 0832f1a02..19194f211 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -39,7 +39,6 @@ import com.vitorpamplona.quartz.encoders.toNpub import com.vitorpamplona.quartz.signers.ExternalSignerLauncher import com.vitorpamplona.quartz.signers.NostrSignerExternal import com.vitorpamplona.quartz.signers.NostrSignerInternal -import java.util.regex.Pattern import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -48,196 +47,196 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.regex.Pattern val EMAIL_PATTERN = Pattern.compile(".+@.+\\.[a-z]+") @Stable class AccountStateViewModel() : ViewModel() { - var serviceManager: ServiceManager? = null + var serviceManager: ServiceManager? = null - private val _accountContent = MutableStateFlow(AccountState.Loading) - val accountContent = _accountContent.asStateFlow() + private val _accountContent = MutableStateFlow(AccountState.Loading) + val accountContent = _accountContent.asStateFlow() - fun tryLoginExistingAccountAsync() { - // pulls account from storage. - viewModelScope.launch(Dispatchers.IO) { tryLoginExistingAccount() } - } - - private suspend fun tryLoginExistingAccount() = - withContext(Dispatchers.IO) { - LocalPreferences.loadCurrentAccountFromEncryptedStorage()?.let { startUI(it) } - ?: run { requestLoginUI() } + fun tryLoginExistingAccountAsync() { + // pulls account from storage. + viewModelScope.launch(Dispatchers.IO) { tryLoginExistingAccount() } } - private suspend fun requestLoginUI() { - _accountContent.update { AccountState.LoggedOff } - - viewModelScope.launch(Dispatchers.IO) { serviceManager?.pauseForGoodAndClearAccount() } - } - - suspend fun loginAndStartUI( - key: String, - useProxy: Boolean, - proxyPort: Int, - loginWithExternalSigner: Boolean = false, - packageName: String = "", - ) = - withContext(Dispatchers.IO) { - val parsed = Nip19.uriToRoute(key) - val pubKeyParsed = parsed?.hex?.hexToByteArray() - val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) - - if (loginWithExternalSigner && pubKeyParsed == null) { - throw Exception("Invalid key while trying to login with external signer") - } - - val account = - if (loginWithExternalSigner) { - val keyPair = KeyPair(pubKey = pubKeyParsed) - val localPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" } - Account( - keyPair, - proxy = proxy, - proxyPort = proxyPort, - signer = - NostrSignerExternal( - keyPair.pubKey.toHexKey(), - ExternalSignerLauncher(keyPair.pubKey.toNpub(), localPackageName), - ), - ) - } else if (key.startsWith("nsec")) { - val keyPair = KeyPair(privKey = key.bechToBytes()) - Account( - keyPair, - proxy = proxy, - proxyPort = proxyPort, - signer = NostrSignerInternal(keyPair), - ) - } else if (pubKeyParsed != null) { - val keyPair = KeyPair(pubKey = pubKeyParsed) - Account( - keyPair, - proxy = proxy, - proxyPort = proxyPort, - signer = NostrSignerInternal(keyPair), - ) - } else if (EMAIL_PATTERN.matcher(key).matches()) { - val keyPair = KeyPair() - // Evaluate NIP-5 - Account( - keyPair, - proxy = proxy, - proxyPort = proxyPort, - signer = NostrSignerInternal(keyPair), - ) - } else { - val keyPair = KeyPair(Hex.decode(key)) - Account( - keyPair, - proxy = proxy, - proxyPort = proxyPort, - signer = NostrSignerInternal(keyPair), - ) + private suspend fun tryLoginExistingAccount() = + withContext(Dispatchers.IO) { + LocalPreferences.loadCurrentAccountFromEncryptedStorage()?.let { startUI(it) } + ?: run { requestLoginUI() } } - LocalPreferences.updatePrefsForLogin(account) + private suspend fun requestLoginUI() { + _accountContent.update { AccountState.LoggedOff } - startUI(account) + viewModelScope.launch(Dispatchers.IO) { serviceManager?.pauseForGoodAndClearAccount() } } - suspend fun startUI(account: Account) = - withContext(Dispatchers.Main) { - if (account.isWriteable()) { - _accountContent.update { AccountState.LoggedIn(account) } - } else { - _accountContent.update { AccountState.LoggedInViewOnly(account) } - } + suspend fun loginAndStartUI( + key: String, + useProxy: Boolean, + proxyPort: Int, + loginWithExternalSigner: Boolean = false, + packageName: String = "", + ) = withContext(Dispatchers.IO) { + val parsed = Nip19.uriToRoute(key) + val pubKeyParsed = parsed?.hex?.hexToByteArray() + val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) - viewModelScope.launch(Dispatchers.IO) { + if (loginWithExternalSigner && pubKeyParsed == null) { + throw Exception("Invalid key while trying to login with external signer") + } + + val account = + if (loginWithExternalSigner) { + val keyPair = KeyPair(pubKey = pubKeyParsed) + val localPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" } + Account( + keyPair, + proxy = proxy, + proxyPort = proxyPort, + signer = + NostrSignerExternal( + keyPair.pubKey.toHexKey(), + ExternalSignerLauncher(keyPair.pubKey.toNpub(), localPackageName), + ), + ) + } else if (key.startsWith("nsec")) { + val keyPair = KeyPair(privKey = key.bechToBytes()) + Account( + keyPair, + proxy = proxy, + proxyPort = proxyPort, + signer = NostrSignerInternal(keyPair), + ) + } else if (pubKeyParsed != null) { + val keyPair = KeyPair(pubKey = pubKeyParsed) + Account( + keyPair, + proxy = proxy, + proxyPort = proxyPort, + signer = NostrSignerInternal(keyPair), + ) + } else if (EMAIL_PATTERN.matcher(key).matches()) { + val keyPair = KeyPair() + // Evaluate NIP-5 + Account( + keyPair, + proxy = proxy, + proxyPort = proxyPort, + signer = NostrSignerInternal(keyPair), + ) + } else { + val keyPair = KeyPair(Hex.decode(key)) + Account( + keyPair, + proxy = proxy, + proxyPort = proxyPort, + signer = NostrSignerInternal(keyPair), + ) + } + + LocalPreferences.updatePrefsForLogin(account) + + startUI(account) + } + + suspend fun startUI(account: Account) = withContext(Dispatchers.Main) { - // Prepares livedata objects on the main user. - account.userProfile().live() + if (account.isWriteable()) { + _accountContent.update { AccountState.LoggedIn(account) } + } else { + _accountContent.update { AccountState.LoggedInViewOnly(account) } + } + + viewModelScope.launch(Dispatchers.IO) { + withContext(Dispatchers.Main) { + // Prepares livedata objects on the main user. + account.userProfile().live() + } + serviceManager?.restartIfDifferentAccount(account) + } + + account.saveable.observeForever(saveListener) } - serviceManager?.restartIfDifferentAccount(account) - } - account.saveable.observeForever(saveListener) + @OptIn(DelicateCoroutinesApi::class) + private val saveListener: (com.vitorpamplona.amethyst.model.AccountState) -> Unit = { + GlobalScope.launch(Dispatchers.IO) { LocalPreferences.saveToEncryptedStorage(it.account) } } - @OptIn(DelicateCoroutinesApi::class) - private val saveListener: (com.vitorpamplona.amethyst.model.AccountState) -> Unit = { - GlobalScope.launch(Dispatchers.IO) { LocalPreferences.saveToEncryptedStorage(it.account) } - } - - private suspend fun prepareLogoutOrSwitch() = - withContext(Dispatchers.Main) { - when (val state = _accountContent.value) { - is AccountState.LoggedIn -> { - state.account.saveable.removeObserver(saveListener) - withContext(Dispatchers.IO) { state.currentViewModelStore.viewModelStore.clear() } + private suspend fun prepareLogoutOrSwitch() = + withContext(Dispatchers.Main) { + when (val state = _accountContent.value) { + is AccountState.LoggedIn -> { + state.account.saveable.removeObserver(saveListener) + withContext(Dispatchers.IO) { state.currentViewModelStore.viewModelStore.clear() } + } + is AccountState.LoggedInViewOnly -> { + state.account.saveable.removeObserver(saveListener) + withContext(Dispatchers.IO) { state.currentViewModelStore.viewModelStore.clear() } + } + else -> {} + } } - is AccountState.LoggedInViewOnly -> { - state.account.saveable.removeObserver(saveListener) - withContext(Dispatchers.IO) { state.currentViewModelStore.viewModelStore.clear() } + + fun login( + key: String, + useProxy: Boolean, + proxyPort: Int, + loginWithExternalSigner: Boolean = false, + packageName: String = "", + onError: () -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + try { + loginAndStartUI(key, useProxy, proxyPort, loginWithExternalSigner, packageName) + } catch (e: Exception) { + Log.e("Login", "Could not sign in", e) + onError() + } } - else -> {} - } } - fun login( - key: String, - useProxy: Boolean, - proxyPort: Int, - loginWithExternalSigner: Boolean = false, - packageName: String = "", - onError: () -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - try { - loginAndStartUI(key, useProxy, proxyPort, loginWithExternalSigner, packageName) - } catch (e: Exception) { - Log.e("Login", "Could not sign in", e) - onError() - } + fun newKey( + useProxy: Boolean, + proxyPort: Int, + ) { + viewModelScope.launch(Dispatchers.IO) { + val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) + val keyPair = KeyPair() + val account = + Account( + keyPair, + proxy = proxy, + proxyPort = proxyPort, + signer = NostrSignerInternal(keyPair), + ) + + account.follow(account.userProfile()) + + // saves to local preferences + LocalPreferences.updatePrefsForLogin(account) + startUI(account) + } } - } - fun newKey( - useProxy: Boolean, - proxyPort: Int, - ) { - viewModelScope.launch(Dispatchers.IO) { - val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) - val keyPair = KeyPair() - val account = - Account( - keyPair, - proxy = proxy, - proxyPort = proxyPort, - signer = NostrSignerInternal(keyPair), - ) - - account.follow(account.userProfile()) - - // saves to local preferences - LocalPreferences.updatePrefsForLogin(account) - startUI(account) + fun switchUser(accountInfo: AccountInfo) { + viewModelScope.launch(Dispatchers.IO) { + prepareLogoutOrSwitch() + LocalPreferences.switchToAccount(accountInfo) + tryLoginExistingAccount() + } } - } - fun switchUser(accountInfo: AccountInfo) { - viewModelScope.launch(Dispatchers.IO) { - prepareLogoutOrSwitch() - LocalPreferences.switchToAccount(accountInfo) - tryLoginExistingAccount() + fun logOff(accountInfo: AccountInfo) { + viewModelScope.launch(Dispatchers.IO) { + prepareLogoutOrSwitch() + LocalPreferences.updatePrefsForLogout(accountInfo) + tryLoginExistingAccount() + } } - } - - fun logOff(accountInfo: AccountInfo) { - viewModelScope.launch(Dispatchers.IO) { - prepareLogoutOrSwitch() - LocalPreferences.updatePrefsForLogout(accountInfo) - tryLoginExistingAccount() - } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt index 4782cd1f8..f23a78a73 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt @@ -33,97 +33,97 @@ import kotlinx.collections.immutable.toImmutableMap @Immutable abstract class Card() { - abstract fun createdAt(): Long + abstract fun createdAt(): Long - abstract fun id(): String + abstract fun id(): String } @Immutable class BadgeCard(val note: Note) : Card() { - override fun createdAt(): Long { - return note.createdAt() ?: 0 - } + override fun createdAt(): Long { + return note.createdAt() ?: 0 + } - override fun id() = note.idHex + override fun id() = note.idHex } @Immutable class NoteCard(val note: Note) : Card() { - override fun createdAt(): Long { - return note.createdAt() ?: 0 - } + override fun createdAt(): Long { + return note.createdAt() ?: 0 + } - override fun id() = note.idHex + override fun id() = note.idHex } @Immutable class ZapUserSetCard(val user: User, val zapEvents: ImmutableList) : Card() { - val createdAt = zapEvents.maxOf { it.createdAt() ?: 0 } + val createdAt = zapEvents.maxOf { it.createdAt() ?: 0 } - override fun createdAt(): Long { - return createdAt - } + override fun createdAt(): Long { + return createdAt + } - override fun id() = user.pubkeyHex + "U" + createdAt + override fun id() = user.pubkeyHex + "U" + createdAt } @Immutable class MultiSetCard( - val note: Note, - val boostEvents: ImmutableList, - val likeEvents: ImmutableList, - val zapEvents: ImmutableList, + val note: Note, + val boostEvents: ImmutableList, + val likeEvents: ImmutableList, + val zapEvents: ImmutableList, ) : Card() { - val maxCreatedAt = - maxOf( - zapEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0, - likeEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0, - boostEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0, - ) + val maxCreatedAt = + maxOf( + zapEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0, + likeEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0, + boostEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0, + ) - val minCreatedAt = - minOf( - zapEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE, - likeEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE, - boostEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE, - ) + val minCreatedAt = + minOf( + zapEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE, + likeEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE, + boostEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE, + ) - val likeEventsByType = - likeEvents - .groupBy { - it.event - ?.content() - ?.firstFullCharOrEmoji(ImmutableListOfLists(it.event?.tags() ?: emptyArray())) - ?: "+" - } - .mapValues { it.value.toImmutableList() } - .toImmutableMap() + val likeEventsByType = + likeEvents + .groupBy { + it.event + ?.content() + ?.firstFullCharOrEmoji(ImmutableListOfLists(it.event?.tags() ?: emptyArray())) + ?: "+" + } + .mapValues { it.value.toImmutableList() } + .toImmutableMap() - override fun createdAt(): Long { - return maxCreatedAt - } + override fun createdAt(): Long { + return maxCreatedAt + } - override fun id() = note.idHex + "X" + maxCreatedAt + "X" + minCreatedAt + override fun id() = note.idHex + "X" + maxCreatedAt + "X" + minCreatedAt } @Immutable class MessageSetCard(val note: Note) : Card() { - override fun createdAt(): Long { - return note.createdAt() ?: 0 - } + override fun createdAt(): Long { + return note.createdAt() ?: 0 + } - override fun id() = note.idHex + override fun id() = note.idHex } @Immutable sealed class CardFeedState { - @Immutable object Loading : CardFeedState() + @Immutable object Loading : CardFeedState() - @Stable - class Loaded(val feed: MutableState>, val showHidden: MutableState) : - CardFeedState() + @Stable + class Loaded(val feed: MutableState>, val showHidden: MutableState) : + CardFeedState() - @Immutable object Empty : CardFeedState() + @Immutable object Empty : CardFeedState() - @Immutable class FeedError(val errorMessage: String) : CardFeedState() + @Immutable class FeedError(val errorMessage: String) : CardFeedState() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt index 948f179e1..6232e04e4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt @@ -55,225 +55,225 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding @Composable fun RefresheableCardView( - viewModel: CardFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - routeForLastRead: String, - scrollStateKey: String? = null, - enablePullRefresh: Boolean = true, + viewModel: CardFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + routeForLastRead: String, + scrollStateKey: String? = null, + enablePullRefresh: Boolean = true, ) { - var refreshing by remember { mutableStateOf(false) } - val pullRefreshState = - rememberPullRefreshState( - refreshing, - onRefresh = { - refreshing = true - viewModel.invalidateData() - refreshing = false - }, - ) + var refreshing by remember { mutableStateOf(false) } + val pullRefreshState = + rememberPullRefreshState( + refreshing, + onRefresh = { + refreshing = true + viewModel.invalidateData() + refreshing = false + }, + ) - val modifier = - if (enablePullRefresh) { - Modifier.fillMaxSize().pullRefresh(pullRefreshState) - } else { - Modifier.fillMaxSize() + val modifier = + if (enablePullRefresh) { + Modifier.fillMaxSize().pullRefresh(pullRefreshState) + } else { + Modifier.fillMaxSize() + } + + Box(modifier) { + SaveableCardFeedState(viewModel, accountViewModel, nav, routeForLastRead, scrollStateKey) + + if (enablePullRefresh) { + PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) + } } - - Box(modifier) { - SaveableCardFeedState(viewModel, accountViewModel, nav, routeForLastRead, scrollStateKey) - - if (enablePullRefresh) { - PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) - } - } } @Composable private fun SaveableCardFeedState( - viewModel: CardFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - routeForLastRead: String, - scrollStateKey: String? = null, + viewModel: CardFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + routeForLastRead: String, + scrollStateKey: String? = null, ) { - val listState = - if (scrollStateKey != null) { - rememberForeverLazyListState(scrollStateKey) - } else { - rememberLazyListState() - } + val listState = + if (scrollStateKey != null) { + rememberForeverLazyListState(scrollStateKey) + } else { + rememberLazyListState() + } - WatchScrollToTop(viewModel, listState) + WatchScrollToTop(viewModel, listState) - RenderCardFeed(viewModel, accountViewModel, listState, nav, routeForLastRead) + RenderCardFeed(viewModel, accountViewModel, listState, nav, routeForLastRead) } @Composable private fun WatchScrollToTop( - viewModel: CardFeedViewModel, - listState: LazyListState, + viewModel: CardFeedViewModel, + listState: LazyListState, ) { - val scrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle() + val scrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle() - LaunchedEffect(scrollToTop) { - if (scrollToTop > 0 && viewModel.scrolltoTopPending) { - listState.scrollToItem(index = 0) - viewModel.sentToTop() + LaunchedEffect(scrollToTop) { + if (scrollToTop > 0 && viewModel.scrolltoTopPending) { + listState.scrollToItem(index = 0) + viewModel.sentToTop() + } } - } } @Composable fun RenderCardFeed( - viewModel: CardFeedViewModel, - accountViewModel: AccountViewModel, - listState: LazyListState, - nav: (String) -> Unit, - routeForLastRead: String, + viewModel: CardFeedViewModel, + accountViewModel: AccountViewModel, + listState: LazyListState, + nav: (String) -> Unit, + routeForLastRead: String, ) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - Crossfade( - modifier = Modifier.fillMaxSize(), - targetState = feedState, - animationSpec = tween(durationMillis = 100), - ) { state -> - when (state) { - is CardFeedState.Empty -> { - FeedEmpty { viewModel.invalidateData() } - } - is CardFeedState.FeedError -> { - FeedError(state.errorMessage) { viewModel.invalidateData() } - } - is CardFeedState.Loaded -> { - FeedLoaded( - state = state, - listState = listState, - routeForLastRead = routeForLastRead, - accountViewModel = accountViewModel, - nav = nav, - ) - } - CardFeedState.Loading -> { - LoadingFeed() - } + Crossfade( + modifier = Modifier.fillMaxSize(), + targetState = feedState, + animationSpec = tween(durationMillis = 100), + ) { state -> + when (state) { + is CardFeedState.Empty -> { + FeedEmpty { viewModel.invalidateData() } + } + is CardFeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + is CardFeedState.Loaded -> { + FeedLoaded( + state = state, + listState = listState, + routeForLastRead = routeForLastRead, + accountViewModel = accountViewModel, + nav = nav, + ) + } + CardFeedState.Loading -> { + LoadingFeed() + } + } } - } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun FeedLoaded( - state: CardFeedState.Loaded, - listState: LazyListState, - routeForLastRead: String, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + state: CardFeedState.Loaded, + listState: LazyListState, + routeForLastRead: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = FeedPadding, - state = listState, - ) { - itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item -> - val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() } + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item -> + val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() } - Row(defaultModifier) { - RenderCardItem( - item, - routeForLastRead, - showHidden = state.showHidden.value, - accountViewModel, - nav, - ) - } + Row(defaultModifier) { + RenderCardItem( + item, + routeForLastRead, + showHidden = state.showHidden.value, + accountViewModel, + nav, + ) + } + } } - } } @Composable private fun RenderCardItem( - item: Card, - routeForLastRead: String, - showHidden: Boolean, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + item: Card, + routeForLastRead: String, + showHidden: Boolean, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - when (item) { - is NoteCard -> - NoteCardCompose( - item, - isBoostedNote = false, - accountViewModel = accountViewModel, - showHidden = showHidden, - nav = nav, - routeForLastRead = routeForLastRead, - ) - is ZapUserSetCard -> - ZapUserSetCompose( - item, - isInnerNote = false, - accountViewModel = accountViewModel, - nav = nav, - routeForLastRead = routeForLastRead, - ) - is MultiSetCard -> - MultiSetCompose( - item, - accountViewModel = accountViewModel, - showHidden = showHidden, - nav = nav, - routeForLastRead = routeForLastRead, - ) - is BadgeCard -> - BadgeCompose( - item, - accountViewModel = accountViewModel, - showHidden = showHidden, - nav = nav, - routeForLastRead = routeForLastRead, - ) - is MessageSetCard -> - MessageSetCompose( - messageSetCard = item, - routeForLastRead = routeForLastRead, - showHidden = showHidden, - accountViewModel = accountViewModel, - nav = nav, - ) - } + when (item) { + is NoteCard -> + NoteCardCompose( + item, + isBoostedNote = false, + accountViewModel = accountViewModel, + showHidden = showHidden, + nav = nav, + routeForLastRead = routeForLastRead, + ) + is ZapUserSetCard -> + ZapUserSetCompose( + item, + isInnerNote = false, + accountViewModel = accountViewModel, + nav = nav, + routeForLastRead = routeForLastRead, + ) + is MultiSetCard -> + MultiSetCompose( + item, + accountViewModel = accountViewModel, + showHidden = showHidden, + nav = nav, + routeForLastRead = routeForLastRead, + ) + is BadgeCard -> + BadgeCompose( + item, + accountViewModel = accountViewModel, + showHidden = showHidden, + nav = nav, + routeForLastRead = routeForLastRead, + ) + is MessageSetCard -> + MessageSetCompose( + messageSetCard = item, + routeForLastRead = routeForLastRead, + showHidden = showHidden, + accountViewModel = accountViewModel, + nav = nav, + ) + } } @Composable fun NoteCardCompose( - baseNote: NoteCard, - routeForLastRead: String? = null, - modifier: Modifier = remember { Modifier }, - isBoostedNote: Boolean = false, - isQuotedNote: Boolean = false, - unPackReply: Boolean = true, - makeItShort: Boolean = false, - addMarginTop: Boolean = true, - showHidden: Boolean = false, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: NoteCard, + routeForLastRead: String? = null, + modifier: Modifier = remember { Modifier }, + isBoostedNote: Boolean = false, + isQuotedNote: Boolean = false, + unPackReply: Boolean = true, + makeItShort: Boolean = false, + addMarginTop: Boolean = true, + showHidden: Boolean = false, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val note = remember(baseNote) { baseNote.note } + val note = remember(baseNote) { baseNote.note } - NoteCompose( - baseNote = note, - routeForLastRead = routeForLastRead, - modifier = modifier, - isBoostedNote = isBoostedNote, - isQuotedNote = isQuotedNote, - unPackReply = unPackReply, - makeItShort = makeItShort, - addMarginTop = addMarginTop, - showHidden = showHidden, - parentBackgroundColor = parentBackgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) + NoteCompose( + baseNote = note, + routeForLastRead = routeForLastRead, + modifier = modifier, + isBoostedNote = isBoostedNote, + isQuotedNote = isQuotedNote, + unPackReply = unPackReply, + makeItShort = makeItShort, + addMarginTop = addMarginTop, + showHidden = showHidden, + parentBackgroundColor = parentBackgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt index 2b934246d..3bc23190d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt @@ -46,10 +46,6 @@ import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.RepostEvent -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import kotlin.time.measureTimedValue import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers @@ -58,414 +54,416 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlin.time.measureTimedValue @Stable class NotificationViewModel(val account: Account) : - CardFeedViewModel(NotificationFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NotificationViewModel { - return NotificationViewModel(account) as NotificationViewModel + CardFeedViewModel(NotificationFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NotificationViewModel { + return NotificationViewModel(account) as NotificationViewModel + } } - } } @Stable open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { - private val _feedContent = MutableStateFlow(CardFeedState.Loading) - val feedContent = _feedContent.asStateFlow() + private val _feedContent = MutableStateFlow(CardFeedState.Loading) + val feedContent = _feedContent.asStateFlow() - // Simple counter that changes when it needs to invalidate everything - private val _scrollToTop = MutableStateFlow(0) - val scrollToTop = _scrollToTop.asStateFlow() - var scrolltoTopPending = false + // Simple counter that changes when it needs to invalidate everything + private val _scrollToTop = MutableStateFlow(0) + val scrollToTop = _scrollToTop.asStateFlow() + var scrolltoTopPending = false - private var lastFeedKey: String? = null + private var lastFeedKey: String? = null - fun sendToTop() { - if (scrolltoTopPending) return + fun sendToTop() { + if (scrolltoTopPending) return - scrolltoTopPending = true - viewModelScope.launch(Dispatchers.IO) { _scrollToTop.emit(_scrollToTop.value + 1) } - } - - suspend fun sentToTop() { - scrolltoTopPending = false - } - - private var lastAccount: Account? = null - private var lastNotes: Set? = null - - fun refresh() { - viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } - } - - @Synchronized - private fun refreshSuspended() { - checkNotInMainThread() - - val notes = localFilter.feed() - lastFeedKey = localFilter.feedKey() - - val thisAccount = (localFilter as? NotificationFeedFilter)?.account - val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null - - val oldNotesState = _feedContent.value - if (lastNotesCopy != null && oldNotesState is CardFeedState.Loaded) { - val newCards = convertToCard(notes.minus(lastNotesCopy)) - if (newCards.isNotEmpty()) { - lastNotes = notes.toSet() - lastAccount = (localFilter as? NotificationFeedFilter)?.account - - val updatedCards = - (oldNotesState.feed.value + newCards) - .distinctBy { it.id() } - .sortedWith(compareBy({ it.createdAt() }, { it.id() })) - .reversed() - .take(localFilter.limit()) - .toImmutableList() - - if (!equalImmutableLists(oldNotesState.feed.value, updatedCards)) { - updateFeed(updatedCards) - } - } - } else { - lastNotes = notes.toSet() - lastAccount = (localFilter as? NotificationFeedFilter)?.account - - val cards = - convertToCard(notes) - .sortedWith(compareBy({ it.createdAt() }, { it.id() })) - .reversed() - .take(localFilter.limit()) - .toImmutableList() - - updateFeed(cards) + scrolltoTopPending = true + viewModelScope.launch(Dispatchers.IO) { _scrollToTop.emit(_scrollToTop.value + 1) } } - } - private fun convertToCard(notes: Collection): List { - checkNotInMainThread() + suspend fun sentToTop() { + scrolltoTopPending = false + } - val reactionsPerEvent = mutableMapOf>() - notes - .filter { it.event is ReactionEvent } - .forEach { - val reactedPost = - it.replyTo?.lastOrNull { - it.event !is ChannelMetadataEvent && it.event !is ChannelCreateEvent - } - if (reactedPost != null) { - reactionsPerEvent.getOrPut(reactedPost, { mutableListOf() }).add(it) - } - } + private var lastAccount: Account? = null + private var lastNotes: Set? = null - // val reactionCards = reactionsPerEvent.map { LikeSetCard(it.key, it.value) } - val zapsPerUser = mutableMapOf>() - val zapsPerEvent = mutableMapOf>() - notes - .filter { it.event is LnZapEvent } - .forEach { zapEvent -> - val zappedPost = zapEvent.replyTo?.lastOrNull() - if (zappedPost != null) { - val zapRequest = zappedPost.zaps.filter { it.value == zapEvent }.keys.firstOrNull() - if (zapRequest != null) { - // var newZapRequestEvent = LocalCache.checkPrivateZap(zapRequest.event as Event) - // zapRequest.event = newZapRequestEvent - zapsPerEvent - .getOrPut(zappedPost, { mutableListOf() }) - .add(CombinedZap(zapRequest, zapEvent)) - } + fun refresh() { + viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } + } + + @Synchronized + private fun refreshSuspended() { + checkNotInMainThread() + + val notes = localFilter.feed() + lastFeedKey = localFilter.feedKey() + + val thisAccount = (localFilter as? NotificationFeedFilter)?.account + val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null + + val oldNotesState = _feedContent.value + if (lastNotesCopy != null && oldNotesState is CardFeedState.Loaded) { + val newCards = convertToCard(notes.minus(lastNotesCopy)) + if (newCards.isNotEmpty()) { + lastNotes = notes.toSet() + lastAccount = (localFilter as? NotificationFeedFilter)?.account + + val updatedCards = + (oldNotesState.feed.value + newCards) + .distinctBy { it.id() } + .sortedWith(compareBy({ it.createdAt() }, { it.id() })) + .reversed() + .take(localFilter.limit()) + .toImmutableList() + + if (!equalImmutableLists(oldNotesState.feed.value, updatedCards)) { + updateFeed(updatedCards) + } + } } else { - val event = (zapEvent.event as LnZapEvent) - val author = - event.zappedAuthor().firstNotNullOfOrNull { - LocalCache.users[it] // don't create user if it doesn't exist - } - if (author != null) { - val zapRequest = author.zaps.filter { it.value == zapEvent }.keys.firstOrNull() - if (zapRequest != null) { - zapsPerUser - .getOrPut(author, { mutableListOf() }) - .add(CombinedZap(zapRequest, zapEvent)) - } - } + lastNotes = notes.toSet() + lastAccount = (localFilter as? NotificationFeedFilter)?.account + + val cards = + convertToCard(notes) + .sortedWith(compareBy({ it.createdAt() }, { it.id() })) + .reversed() + .take(localFilter.limit()) + .toImmutableList() + + updateFeed(cards) } - } - - val boostsPerEvent = mutableMapOf>() - notes - .filter { it.event is RepostEvent || it.event is GenericRepostEvent } - .forEach { - val boostedPost = - it.replyTo?.lastOrNull { - it.event !is ChannelMetadataEvent && it.event !is ChannelCreateEvent - } - if (boostedPost != null) { - boostsPerEvent.getOrPut(boostedPost, { mutableListOf() }).add(it) - } - } - - val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat() - - val allBaseNotes = zapsPerEvent.keys + boostsPerEvent.keys + reactionsPerEvent.keys - val multiCards = - allBaseNotes - .map { baseNote -> - val boostsInCard = boostsPerEvent[baseNote] ?: emptyList() - val reactionsInCard = reactionsPerEvent[baseNote] ?: emptyList() - val zapsInCard = zapsPerEvent[baseNote] ?: emptyList() - - val singleList = - (boostsInCard + zapsInCard.map { it.response } + reactionsInCard).groupBy { - sdf.format( - Instant.ofEpochSecond(it.createdAt() ?: 0) - .atZone(ZoneId.systemDefault()) - .toLocalDateTime(), - ) - } - - val days = singleList.keys.sortedBy { it } - - days - .mapNotNull { - val sortedList = - singleList - .get(it) - ?.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - ?.reversed() - - sortedList?.chunked(30)?.map { chunk -> - MultiSetCard( - baseNote, - boostsInCard.filter { it in chunk }.toImmutableList(), - reactionsInCard.filter { it in chunk }.toImmutableList(), - zapsInCard.filter { it.response in chunk }.toImmutableList(), - ) - } - } - .flatten() - } - .flatten() - - val userZaps = - zapsPerUser - .map { user -> - val byDay = - user.value.groupBy { - sdf.format( - Instant.ofEpochSecond(it.createdAt() ?: 0) - .atZone(ZoneId.systemDefault()) - .toLocalDateTime(), - ) - } - - byDay.values.map { - ZapUserSetCard( - user.key, - it - .sortedWith(compareBy({ it.createdAt() }, { it.idHex() })) - .reversed() - .toImmutableList(), - ) - } - } - .flatten() - - val textNoteCards = - notes - .filter { - it.event !is ReactionEvent && - it.event !is RepostEvent && - it.event !is GenericRepostEvent && - it.event !is LnZapEvent - } - .map { - if (it.event is PrivateDmEvent || it.event is ChatMessageEvent) { - MessageSetCard(it) - } else if (it.event is BadgeAwardEvent) { - BadgeCard(it) - } else { - NoteCard(it) - } - } - - return (multiCards + textNoteCards + userZaps) - .sortedWith(compareBy({ it.createdAt() }, { it.id() })) - .reversed() - } - - private fun updateFeed(notes: ImmutableList) { - viewModelScope.launch(Dispatchers.Main) { - val currentState = _feedContent.value - - if (notes.isEmpty()) { - _feedContent.update { CardFeedState.Empty } - } else if (currentState is CardFeedState.Loaded) { - currentState.showHidden.value = localFilter.showHiddenKey() - currentState.feed.value = notes - } else { - _feedContent.update { - CardFeedState.Loaded(mutableStateOf(notes), mutableStateOf(localFilter.showHiddenKey())) - } - } } - } - private fun refreshFromOldState(newItems: Set) { - val oldNotesState = _feedContent.value + private fun convertToCard(notes: Collection): List { + checkNotInMainThread() - val thisAccount = (localFilter as? NotificationFeedFilter)?.account - val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null + val reactionsPerEvent = mutableMapOf>() + notes + .filter { it.event is ReactionEvent } + .forEach { + val reactedPost = + it.replyTo?.lastOrNull { + it.event !is ChannelMetadataEvent && it.event !is ChannelCreateEvent + } + if (reactedPost != null) { + reactionsPerEvent.getOrPut(reactedPost, { mutableListOf() }).add(it) + } + } - if ( - lastNotesCopy != null && - localFilter is AdditiveFeedFilter && - oldNotesState is CardFeedState.Loaded && - lastFeedKey == localFilter.feedKey() - ) { - val filteredNewList = localFilter.applyFilter(newItems) + // val reactionCards = reactionsPerEvent.map { LikeSetCard(it.key, it.value) } + val zapsPerUser = mutableMapOf>() + val zapsPerEvent = mutableMapOf>() + notes + .filter { it.event is LnZapEvent } + .forEach { zapEvent -> + val zappedPost = zapEvent.replyTo?.lastOrNull() + if (zappedPost != null) { + val zapRequest = zappedPost.zaps.filter { it.value == zapEvent }.keys.firstOrNull() + if (zapRequest != null) { + // var newZapRequestEvent = LocalCache.checkPrivateZap(zapRequest.event as Event) + // zapRequest.event = newZapRequestEvent + zapsPerEvent + .getOrPut(zappedPost, { mutableListOf() }) + .add(CombinedZap(zapRequest, zapEvent)) + } + } else { + val event = (zapEvent.event as LnZapEvent) + val author = + event.zappedAuthor().firstNotNullOfOrNull { + LocalCache.users[it] // don't create user if it doesn't exist + } + if (author != null) { + val zapRequest = author.zaps.filter { it.value == zapEvent }.keys.firstOrNull() + if (zapRequest != null) { + zapsPerUser + .getOrPut(author, { mutableListOf() }) + .add(CombinedZap(zapRequest, zapEvent)) + } + } + } + } - if (filteredNewList.isEmpty()) return + val boostsPerEvent = mutableMapOf>() + notes + .filter { it.event is RepostEvent || it.event is GenericRepostEvent } + .forEach { + val boostedPost = + it.replyTo?.lastOrNull { + it.event !is ChannelMetadataEvent && it.event !is ChannelCreateEvent + } + if (boostedPost != null) { + boostsPerEvent.getOrPut(boostedPost, { mutableListOf() }).add(it) + } + } - val actuallyNew = filteredNewList.minus(lastNotesCopy) + val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat() - if (actuallyNew.isEmpty()) return + val allBaseNotes = zapsPerEvent.keys + boostsPerEvent.keys + reactionsPerEvent.keys + val multiCards = + allBaseNotes + .map { baseNote -> + val boostsInCard = boostsPerEvent[baseNote] ?: emptyList() + val reactionsInCard = reactionsPerEvent[baseNote] ?: emptyList() + val zapsInCard = zapsPerEvent[baseNote] ?: emptyList() - val newCards = convertToCard(actuallyNew) + val singleList = + (boostsInCard + zapsInCard.map { it.response } + reactionsInCard).groupBy { + sdf.format( + Instant.ofEpochSecond(it.createdAt() ?: 0) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(), + ) + } - if (newCards.isNotEmpty()) { - lastNotes = lastNotesCopy + actuallyNew - lastAccount = (localFilter as? NotificationFeedFilter)?.account + val days = singleList.keys.sortedBy { it } - val updatedCards = - (oldNotesState.feed.value + newCards) - .distinctBy { it.id() } + days + .mapNotNull { + val sortedList = + singleList + .get(it) + ?.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + ?.reversed() + + sortedList?.chunked(30)?.map { chunk -> + MultiSetCard( + baseNote, + boostsInCard.filter { it in chunk }.toImmutableList(), + reactionsInCard.filter { it in chunk }.toImmutableList(), + zapsInCard.filter { it.response in chunk }.toImmutableList(), + ) + } + } + .flatten() + } + .flatten() + + val userZaps = + zapsPerUser + .map { user -> + val byDay = + user.value.groupBy { + sdf.format( + Instant.ofEpochSecond(it.createdAt() ?: 0) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(), + ) + } + + byDay.values.map { + ZapUserSetCard( + user.key, + it + .sortedWith(compareBy({ it.createdAt() }, { it.idHex() })) + .reversed() + .toImmutableList(), + ) + } + } + .flatten() + + val textNoteCards = + notes + .filter { + it.event !is ReactionEvent && + it.event !is RepostEvent && + it.event !is GenericRepostEvent && + it.event !is LnZapEvent + } + .map { + if (it.event is PrivateDmEvent || it.event is ChatMessageEvent) { + MessageSetCard(it) + } else if (it.event is BadgeAwardEvent) { + BadgeCard(it) + } else { + NoteCard(it) + } + } + + return (multiCards + textNoteCards + userZaps) .sortedWith(compareBy({ it.createdAt() }, { it.id() })) .reversed() - .take(localFilter.limit()) - .toImmutableList() + } - if (!equalImmutableLists(oldNotesState.feed.value, updatedCards)) { - updateFeed(updatedCards) + private fun updateFeed(notes: ImmutableList) { + viewModelScope.launch(Dispatchers.Main) { + val currentState = _feedContent.value + + if (notes.isEmpty()) { + _feedContent.update { CardFeedState.Empty } + } else if (currentState is CardFeedState.Loaded) { + currentState.showHidden.value = localFilter.showHiddenKey() + currentState.feed.value = notes + } else { + _feedContent.update { + CardFeedState.Loaded(mutableStateOf(notes), mutableStateOf(localFilter.showHiddenKey())) + } + } } - } - } else { - // Refresh Everything - refreshSuspended() } - } - private val bundler = BundledUpdate(1000, Dispatchers.IO) - private val bundlerInsert = BundledInsert>(1000, Dispatchers.IO) + private fun refreshFromOldState(newItems: Set) { + val oldNotesState = _feedContent.value - fun invalidateData(ignoreIfDoing: Boolean = false) { - bundler.invalidate(ignoreIfDoing) { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - val (value, elapsed) = measureTimedValue { refreshSuspended() } - Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed") - } - } + val thisAccount = (localFilter as? NotificationFeedFilter)?.account + val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null - fun invalidateDataAndSendToTop() { - clear() - bundler.invalidate(false) { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - val (value, elapsed) = - measureTimedValue { - refreshSuspended() - sendToTop() - } - Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed") - } - } + if ( + lastNotesCopy != null && + localFilter is AdditiveFeedFilter && + oldNotesState is CardFeedState.Loaded && + lastFeedKey == localFilter.feedKey() + ) { + val filteredNewList = localFilter.applyFilter(newItems) - fun checkKeysInvalidateDataAndSendToTop() { - if (lastFeedKey != localFilter.feedKey()) { - clear() - bundler.invalidate(false) { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - val (value, elapsed) = - measureTimedValue { - refreshSuspended() - sendToTop() - } - Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed") - } - } - } + if (filteredNewList.isEmpty()) return - fun invalidateInsertData(newItems: Set) { - bundlerInsert.invalidateList(newItems) { - val newObjects = it.flatten().toSet() - val (value, elapsed) = - measureTimedValue { - if (newObjects.isNotEmpty()) { - refreshFromOldState(newObjects) - } - } - Log.d( - "Time", - "${this.javaClass.simpleName} Card additive update $elapsed. ${newObjects.size}", - ) - } - } + val actuallyNew = filteredNewList.minus(lastNotesCopy) - var collectorJob: Job? = null + if (actuallyNew.isEmpty()) return - init { - Log.d("Init", "${this.javaClass.simpleName}") - collectorJob = - viewModelScope.launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { newNotes -> - checkNotInMainThread() + val newCards = convertToCard(actuallyNew) - if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) { - invalidateInsertData(newNotes) - } else { + if (newCards.isNotEmpty()) { + lastNotes = lastNotesCopy + actuallyNew + lastAccount = (localFilter as? NotificationFeedFilter)?.account + + val updatedCards = + (oldNotesState.feed.value + newCards) + .distinctBy { it.id() } + .sortedWith(compareBy({ it.createdAt() }, { it.id() })) + .reversed() + .take(localFilter.limit()) + .toImmutableList() + + if (!equalImmutableLists(oldNotesState.feed.value, updatedCards)) { + updateFeed(updatedCards) + } + } + } else { // Refresh Everything - invalidateData() - } + refreshSuspended() } - } - } + } - fun clear() { - lastAccount = null - lastNotes = null - } + private val bundler = BundledUpdate(1000, Dispatchers.IO) + private val bundlerInsert = BundledInsert>(1000, Dispatchers.IO) - override fun onCleared() { - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - clear() - bundlerInsert.cancel() - bundler.cancel() - collectorJob?.cancel() - super.onCleared() - } + fun invalidateData(ignoreIfDoing: Boolean = false) { + bundler.invalidate(ignoreIfDoing) { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + val (value, elapsed) = measureTimedValue { refreshSuspended() } + Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed") + } + } + + fun invalidateDataAndSendToTop() { + clear() + bundler.invalidate(false) { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + val (value, elapsed) = + measureTimedValue { + refreshSuspended() + sendToTop() + } + Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed") + } + } + + fun checkKeysInvalidateDataAndSendToTop() { + if (lastFeedKey != localFilter.feedKey()) { + clear() + bundler.invalidate(false) { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + val (value, elapsed) = + measureTimedValue { + refreshSuspended() + sendToTop() + } + Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed") + } + } + } + + fun invalidateInsertData(newItems: Set) { + bundlerInsert.invalidateList(newItems) { + val newObjects = it.flatten().toSet() + val (value, elapsed) = + measureTimedValue { + if (newObjects.isNotEmpty()) { + refreshFromOldState(newObjects) + } + } + Log.d( + "Time", + "${this.javaClass.simpleName} Card additive update $elapsed. ${newObjects.size}", + ) + } + } + + var collectorJob: Job? = null + + init { + Log.d("Init", "${this.javaClass.simpleName}") + collectorJob = + viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + checkNotInMainThread() + + if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) { + invalidateInsertData(newNotes) + } else { + // Refresh Everything + invalidateData() + } + } + } + } + + fun clear() { + lastAccount = null + lastNotes = null + } + + override fun onCleared() { + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + clear() + bundlerInsert.cancel() + bundler.cancel() + collectorJob?.cancel() + super.onCleared() + } } fun equalImmutableLists( - list1: ImmutableList, - list2: ImmutableList, + list1: ImmutableList, + list2: ImmutableList, ): Boolean { - if (list1 === list2) return true - if (list1.size != list2.size) return false - for (i in 0 until list1.size) { - if (list1[i] !== list2[i]) { - return false + if (list1 === list2) return true + if (list1.size != list2.size) return false + for (i in 0 until list1.size) { + if (list1[i] !== list2[i]) { + return false + } } - } - return true + return true } @Immutable data class CombinedZap(val request: Note, val response: Note) { - fun createdAt() = response.createdAt() + fun createdAt() = response.createdAt() - fun idHex() = response.idHex + fun idHex() = response.idHex } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt index 0758a24f1..bca0cb9af 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt @@ -46,121 +46,121 @@ import com.vitorpamplona.amethyst.ui.theme.HalfPadding @Composable fun RefreshingChatroomFeedView( - viewModel: FeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - routeForLastRead: String, - onWantsToReply: (Note) -> Unit, - scrollStateKey: String? = null, - enablePullRefresh: Boolean = true, + viewModel: FeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + routeForLastRead: String, + onWantsToReply: (Note) -> Unit, + scrollStateKey: String? = null, + enablePullRefresh: Boolean = true, ) { - RefresheableView(viewModel, enablePullRefresh) { - SaveableFeedState(viewModel, scrollStateKey) { listState -> - RenderChatroomFeedView( - viewModel, - accountViewModel, - listState, - nav, - routeForLastRead, - onWantsToReply, - ) + RefresheableView(viewModel, enablePullRefresh) { + SaveableFeedState(viewModel, scrollStateKey) { listState -> + RenderChatroomFeedView( + viewModel, + accountViewModel, + listState, + nav, + routeForLastRead, + onWantsToReply, + ) + } } - } } @Composable fun RenderChatroomFeedView( - viewModel: FeedViewModel, - accountViewModel: AccountViewModel, - listState: LazyListState, - nav: (String) -> Unit, - routeForLastRead: String, - onWantsToReply: (Note) -> Unit, + viewModel: FeedViewModel, + accountViewModel: AccountViewModel, + listState: LazyListState, + nav: (String) -> Unit, + routeForLastRead: String, + onWantsToReply: (Note) -> Unit, ) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> - when (state) { - is FeedState.Empty -> { - FeedEmpty { viewModel.invalidateData() } - } - is FeedState.FeedError -> { - FeedError(state.errorMessage) { viewModel.invalidateData() } - } - is FeedState.Loaded -> { - ChatroomFeedLoaded( - state, - accountViewModel, - listState, - nav, - routeForLastRead, - onWantsToReply, - ) - } - is FeedState.Loading -> { - LoadingFeed() - } + Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty { viewModel.invalidateData() } + } + is FeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + is FeedState.Loaded -> { + ChatroomFeedLoaded( + state, + accountViewModel, + listState, + nav, + routeForLastRead, + onWantsToReply, + ) + } + is FeedState.Loading -> { + LoadingFeed() + } + } } - } } @Composable fun ChatroomFeedLoaded( - state: FeedState.Loaded, - accountViewModel: AccountViewModel, - listState: LazyListState, - nav: (String) -> Unit, - routeForLastRead: String, - onWantsToReply: (Note) -> Unit, + state: FeedState.Loaded, + accountViewModel: AccountViewModel, + listState: LazyListState, + nav: (String) -> Unit, + routeForLastRead: String, + onWantsToReply: (Note) -> Unit, ) { - LaunchedEffect(state.feed.value.firstOrNull()) { - if (listState.firstVisibleItemIndex <= 1) { - listState.animateScrollToItem(0) + LaunchedEffect(state.feed.value.firstOrNull()) { + if (listState.firstVisibleItemIndex <= 1) { + listState.animateScrollToItem(0) + } } - } - LazyColumn( - contentPadding = FeedPadding, - modifier = Modifier.fillMaxSize(), - reverseLayout = true, - state = listState, - ) { - itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> - ChatroomMessageCompose( - baseNote = item, - routeForLastRead = routeForLastRead, - accountViewModel = accountViewModel, - nav = nav, - onWantsToReply = onWantsToReply, - ) - NewSubject(item) + LazyColumn( + contentPadding = FeedPadding, + modifier = Modifier.fillMaxSize(), + reverseLayout = true, + state = listState, + ) { + itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> + ChatroomMessageCompose( + baseNote = item, + routeForLastRead = routeForLastRead, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = onWantsToReply, + ) + NewSubject(item) + } } - } } @Composable fun NewSubject(note: Note) { - val subject = remember(note) { note.event?.subject() } + val subject = remember(note) { note.event?.subject() } - if (subject != null) { - NewSubject(newSubject = subject) - } + if (subject != null) { + NewSubject(newSubject = subject) + } } @Composable fun NewSubject(newSubject: String) { - Row(verticalAlignment = Alignment.CenterVertically) { - Divider( - modifier = Modifier.weight(1f), - ) - Text( - text = newSubject, - fontWeight = FontWeight.Bold, - fontSize = Font14SP, - modifier = HalfPadding, - ) - Divider( - modifier = Modifier.weight(1f), - ) - } + Row(verticalAlignment = Alignment.CenterVertically) { + Divider( + modifier = Modifier.weight(1f), + ) + Text( + text = newSubject, + fontWeight = FontWeight.Bold, + fontSize = Font14SP, + modifier = HalfPadding, + ) + Divider( + modifier = Modifier.weight(1f), + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt index 0388a35e2..5c1309b32 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt @@ -40,75 +40,75 @@ import kotlin.time.ExperimentalTime @Composable fun ChatroomListFeedView( - viewModel: FeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - markAsRead: MutableState, + viewModel: FeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + markAsRead: MutableState, ) { - RefresheableView(viewModel, true) { CrossFadeState(viewModel, accountViewModel, nav, markAsRead) } + RefresheableView(viewModel, true) { CrossFadeState(viewModel, accountViewModel, nav, markAsRead) } } @Composable private fun CrossFadeState( - viewModel: FeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - markAsRead: MutableState, + viewModel: FeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + markAsRead: MutableState, ) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - Crossfade( - targetState = feedState, - animationSpec = tween(durationMillis = 100), - ) { state -> - when (state) { - is FeedState.Empty -> { - FeedEmpty { viewModel.invalidateData() } - } - is FeedState.FeedError -> { - FeedError(state.errorMessage) { viewModel.invalidateData() } - } - is FeedState.Loaded -> { - FeedLoaded(state, accountViewModel, nav, markAsRead) - } - FeedState.Loading -> { - LoadingFeed() - } + Crossfade( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + ) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty { viewModel.invalidateData() } + } + is FeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + is FeedState.Loaded -> { + FeedLoaded(state, accountViewModel, nav, markAsRead) + } + FeedState.Loading -> { + LoadingFeed() + } + } } - } } @OptIn(ExperimentalTime::class) @Composable private fun FeedLoaded( - state: FeedState.Loaded, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - markAsRead: MutableState, + state: FeedState.Loaded, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + markAsRead: MutableState, ) { - val listState = rememberLazyListState() + val listState = rememberLazyListState() - LaunchedEffect(key1 = markAsRead.value) { - if (markAsRead.value) { - accountViewModel.markAllAsRead(state.feed.value) { markAsRead.value = false } + LaunchedEffect(key1 = markAsRead.value) { + if (markAsRead.value) { + accountViewModel.markAllAsRead(state.feed.value) { markAsRead.value = false } + } } - } - LazyColumn( - contentPadding = FeedPadding, - state = listState, - ) { - itemsIndexed( - state.feed.value, - key = { index, item -> if (index == 0) index else item.idHex }, - ) { _, item -> - Row(Modifier.fillMaxWidth()) { - ChatroomHeaderCompose( - item, - accountViewModel = accountViewModel, - nav = nav, - ) - } + LazyColumn( + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed( + state.feed.value, + key = { index, item -> if (index == 0) index else item.idHex }, + ) { _, item -> + Row(Modifier.fillMaxWidth()) { + ChatroomHeaderCompose( + item, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt index 9bb9c06b9..bae5ad4e2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt @@ -25,12 +25,12 @@ import com.vitorpamplona.amethyst.model.Note import kotlinx.collections.immutable.ImmutableList sealed class FeedState { - object Loading : FeedState() + object Loading : FeedState() - class Loaded(val feed: MutableState>, val showHidden: MutableState) : - FeedState() + class Loaded(val feed: MutableState>, val showHidden: MutableState) : + FeedState() - object Empty : FeedState() + object Empty : FeedState() - class FeedError(val errorMessage: String) : FeedState() + class FeedError(val errorMessage: String) : FeedState() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index f12d9002e..c1e25758e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -60,239 +60,240 @@ import kotlin.time.ExperimentalTime @Composable fun RefresheableFeedView( - viewModel: FeedViewModel, - routeForLastRead: String?, - enablePullRefresh: Boolean = true, - scrollStateKey: String? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + viewModel: FeedViewModel, + routeForLastRead: String?, + enablePullRefresh: Boolean = true, + scrollStateKey: String? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - RefresheableView(viewModel, enablePullRefresh) { - SaveableFeedState(viewModel, routeForLastRead, scrollStateKey, accountViewModel, nav) - } + RefresheableView(viewModel, enablePullRefresh) { + SaveableFeedState(viewModel, routeForLastRead, scrollStateKey, accountViewModel, nav) + } } @Composable fun RefresheableView( - viewModel: InvalidatableViewModel, - enablePullRefresh: Boolean = true, - content: @Composable () -> Unit, + viewModel: InvalidatableViewModel, + enablePullRefresh: Boolean = true, + content: @Composable () -> Unit, ) { - var refreshing by remember { mutableStateOf(false) } - val refresh = { - refreshing = true - viewModel.invalidateData() - refreshing = false - } - val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - - val modifier = remember { - if (enablePullRefresh) { - Modifier.fillMaxSize().pullRefresh(pullRefreshState) - } else { - Modifier.fillMaxSize() + var refreshing by remember { mutableStateOf(false) } + val refresh = { + refreshing = true + viewModel.invalidateData() + refreshing = false } - } + val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - Box(modifier) { - content() + val modifier = + remember { + if (enablePullRefresh) { + Modifier.fillMaxSize().pullRefresh(pullRefreshState) + } else { + Modifier.fillMaxSize() + } + } - if (enablePullRefresh) { - PullRefreshIndicator( - refreshing = refreshing, - state = pullRefreshState, - modifier = remember { Modifier.align(Alignment.TopCenter) }, - ) + Box(modifier) { + content() + + if (enablePullRefresh) { + PullRefreshIndicator( + refreshing = refreshing, + state = pullRefreshState, + modifier = remember { Modifier.align(Alignment.TopCenter) }, + ) + } } - } } @Composable private fun SaveableFeedState( - viewModel: FeedViewModel, - routeForLastRead: String?, - scrollStateKey: String? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + viewModel: FeedViewModel, + routeForLastRead: String?, + scrollStateKey: String? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - SaveableFeedState(viewModel, scrollStateKey) { listState -> - RenderFeed(viewModel, accountViewModel, listState, nav, routeForLastRead) - } + SaveableFeedState(viewModel, scrollStateKey) { listState -> + RenderFeed(viewModel, accountViewModel, listState, nav, routeForLastRead) + } } @Composable fun SaveableFeedState( - viewModel: FeedViewModel, - scrollStateKey: String? = null, - content: @Composable (LazyListState) -> Unit, + viewModel: FeedViewModel, + scrollStateKey: String? = null, + content: @Composable (LazyListState) -> Unit, ) { - val listState = - if (scrollStateKey != null) { - rememberForeverLazyListState(scrollStateKey) - } else { - rememberLazyListState() - } + val listState = + if (scrollStateKey != null) { + rememberForeverLazyListState(scrollStateKey) + } else { + rememberLazyListState() + } - WatchScrollToTop(viewModel, listState) + WatchScrollToTop(viewModel, listState) - content(listState) + content(listState) } @Composable fun SaveableGridFeedState( - viewModel: FeedViewModel, - scrollStateKey: String? = null, - content: @Composable (LazyGridState) -> Unit, + viewModel: FeedViewModel, + scrollStateKey: String? = null, + content: @Composable (LazyGridState) -> Unit, ) { - val gridState = - if (scrollStateKey != null) { - rememberForeverLazyGridState(scrollStateKey) - } else { - rememberLazyGridState() - } + val gridState = + if (scrollStateKey != null) { + rememberForeverLazyGridState(scrollStateKey) + } else { + rememberLazyGridState() + } - WatchScrollToTop(viewModel, gridState) + WatchScrollToTop(viewModel, gridState) - content(gridState) + content(gridState) } @Composable private fun RenderFeed( - viewModel: FeedViewModel, - accountViewModel: AccountViewModel, - listState: LazyListState, - nav: (String) -> Unit, - routeForLastRead: String?, + viewModel: FeedViewModel, + accountViewModel: AccountViewModel, + listState: LazyListState, + nav: (String) -> Unit, + routeForLastRead: String?, ) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - Crossfade( - targetState = feedState, - animationSpec = tween(durationMillis = 100), - ) { state -> - when (state) { - is FeedState.Empty -> { - FeedEmpty { viewModel.invalidateData() } - } - is FeedState.FeedError -> { - FeedError(state.errorMessage) { viewModel.invalidateData() } - } - is FeedState.Loaded -> { - FeedLoaded( - state = state, - listState = listState, - routeForLastRead = routeForLastRead, - accountViewModel = accountViewModel, - nav = nav, - ) - } - is FeedState.Loading -> { - LoadingFeed() - } + Crossfade( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + ) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty { viewModel.invalidateData() } + } + is FeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + is FeedState.Loaded -> { + FeedLoaded( + state = state, + listState = listState, + routeForLastRead = routeForLastRead, + accountViewModel = accountViewModel, + nav = nav, + ) + } + is FeedState.Loading -> { + LoadingFeed() + } + } } - } } @Composable private fun WatchScrollToTop( - viewModel: FeedViewModel, - listState: LazyListState, + viewModel: FeedViewModel, + listState: LazyListState, ) { - val scrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle() + val scrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle() - LaunchedEffect(scrollToTop) { - if (scrollToTop > 0 && viewModel.scrolltoTopPending) { - listState.scrollToItem(index = 0) - viewModel.sentToTop() + LaunchedEffect(scrollToTop) { + if (scrollToTop > 0 && viewModel.scrolltoTopPending) { + listState.scrollToItem(index = 0) + viewModel.sentToTop() + } } - } } @Composable private fun WatchScrollToTop( - viewModel: FeedViewModel, - listState: LazyGridState, + viewModel: FeedViewModel, + listState: LazyGridState, ) { - val scrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle() + val scrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle() - LaunchedEffect(scrollToTop) { - if (scrollToTop > 0 && viewModel.scrolltoTopPending) { - listState.scrollToItem(index = 0) - viewModel.sentToTop() + LaunchedEffect(scrollToTop) { + if (scrollToTop > 0 && viewModel.scrolltoTopPending) { + listState.scrollToItem(index = 0) + viewModel.sentToTop() + } } - } } @OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class) @Composable private fun FeedLoaded( - state: FeedState.Loaded, - listState: LazyListState, - routeForLastRead: String?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + state: FeedState.Loaded, + listState: LazyListState, + routeForLastRead: String?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LazyColumn( - contentPadding = FeedPadding, - state = listState, - ) { - itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> - val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() } + LazyColumn( + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> + val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() } - Row(defaultModifier) { - NoteCompose( - item, - routeForLastRead = routeForLastRead, - modifier = Modifier, - isBoostedNote = false, - showHidden = state.showHidden.value, - accountViewModel = accountViewModel, - nav = nav, - ) - } + Row(defaultModifier) { + NoteCompose( + item, + routeForLastRead = routeForLastRead, + modifier = Modifier, + isBoostedNote = false, + showHidden = state.showHidden.value, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } } - } } @Composable fun LoadingFeed() { - Column( - Modifier.fillMaxHeight().fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text(stringResource(R.string.loading_feed)) - } + Column( + Modifier.fillMaxHeight().fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(stringResource(R.string.loading_feed)) + } } @Composable fun FeedError( - errorMessage: String, - onRefresh: () -> Unit, + errorMessage: String, + onRefresh: () -> Unit, ) { - Column( - Modifier.fillMaxHeight().fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text("${stringResource(R.string.error_loading_replies)} $errorMessage") - Button( - modifier = Modifier.align(Alignment.CenterHorizontally), - onClick = onRefresh, + Column( + Modifier.fillMaxHeight().fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, ) { - Text(text = stringResource(R.string.try_again)) + Text("${stringResource(R.string.error_loading_replies)} $errorMessage") + Button( + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = onRefresh, + ) { + Text(text = stringResource(R.string.try_again)) + } } - } } @Composable fun FeedEmpty(onRefresh: () -> Unit) { - Column( - Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text(stringResource(R.string.feed_is_empty)) - OutlinedButton(onClick = onRefresh) { Text(text = stringResource(R.string.refresh)) } - } + Column( + Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(stringResource(R.string.feed_is_empty)) + OutlinedButton(onClick = onRefresh) { Text(text = stringResource(R.string.refresh)) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index e1d8d61b8..c7d6340b6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -70,406 +70,362 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class NostrChannelFeedViewModel(val channel: Channel, val account: Account) : - FeedViewModel(ChannelFeedFilter(channel, account)) { - class Factory(val channel: Channel, val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrChannelFeedViewModel { - return NostrChannelFeedViewModel(channel, account) as NostrChannelFeedViewModel + FeedViewModel(ChannelFeedFilter(channel, account)) { + class Factory(val channel: Channel, val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrChannelFeedViewModel { + return NostrChannelFeedViewModel(channel, account) as NostrChannelFeedViewModel + } } - } } class NostrChatroomFeedViewModel(val user: ChatroomKey, val account: Account) : - FeedViewModel(ChatroomFeedFilter(user, account)) { - class Factory(val user: ChatroomKey, val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrChatRoomFeedViewModel { - return NostrChatroomFeedViewModel(user, account) as NostrChatRoomFeedViewModel + FeedViewModel(ChatroomFeedFilter(user, account)) { + class Factory(val user: ChatroomKey, val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrChatRoomFeedViewModel { + return NostrChatroomFeedViewModel(user, account) as NostrChatRoomFeedViewModel + } } - } } @Stable class NostrVideoFeedViewModel(val account: Account) : FeedViewModel(VideoFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrVideoFeedViewModel { - return NostrVideoFeedViewModel(account) as NostrVideoFeedViewModel + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrVideoFeedViewModel { + return NostrVideoFeedViewModel(account) as NostrVideoFeedViewModel + } } - } } class NostrDiscoverMarketplaceFeedViewModel(val account: Account) : - FeedViewModel( - DiscoverMarketplaceFeedFilter(account), - ) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrDiscoverMarketplaceFeedViewModel { - return NostrDiscoverMarketplaceFeedViewModel(account) as NostrDiscoverMarketplaceFeedViewModel + FeedViewModel( + DiscoverMarketplaceFeedFilter(account), + ) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrDiscoverMarketplaceFeedViewModel { + return NostrDiscoverMarketplaceFeedViewModel(account) as NostrDiscoverMarketplaceFeedViewModel + } } - } } class NostrDiscoverLiveFeedViewModel(val account: Account) : - FeedViewModel(DiscoverLiveFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrDiscoverLiveFeedViewModel { - return NostrDiscoverLiveFeedViewModel(account) as NostrDiscoverLiveFeedViewModel + FeedViewModel(DiscoverLiveFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrDiscoverLiveFeedViewModel { + return NostrDiscoverLiveFeedViewModel(account) as NostrDiscoverLiveFeedViewModel + } } - } } class NostrDiscoverCommunityFeedViewModel(val account: Account) : - FeedViewModel(DiscoverCommunityFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrDiscoverCommunityFeedViewModel { - return NostrDiscoverCommunityFeedViewModel(account) as NostrDiscoverCommunityFeedViewModel + FeedViewModel(DiscoverCommunityFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrDiscoverCommunityFeedViewModel { + return NostrDiscoverCommunityFeedViewModel(account) as NostrDiscoverCommunityFeedViewModel + } } - } } class NostrDiscoverChatFeedViewModel(val account: Account) : - FeedViewModel(DiscoverChatFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrDiscoverChatFeedViewModel { - return NostrDiscoverChatFeedViewModel(account) as NostrDiscoverChatFeedViewModel + FeedViewModel(DiscoverChatFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrDiscoverChatFeedViewModel { + return NostrDiscoverChatFeedViewModel(account) as NostrDiscoverChatFeedViewModel + } } - } } class NostrThreadFeedViewModel(account: Account, noteId: String) : - FeedViewModel(ThreadFeedFilter(account, noteId)) { - class Factory(val account: Account, val noteId: String) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrThreadFeedViewModel { - return NostrThreadFeedViewModel(account, noteId) as NostrThreadFeedViewModel + FeedViewModel(ThreadFeedFilter(account, noteId)) { + class Factory(val account: Account, val noteId: String) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrThreadFeedViewModel { + return NostrThreadFeedViewModel(account, noteId) as NostrThreadFeedViewModel + } } - } } class NostrUserProfileNewThreadsFeedViewModel(val user: User, val account: Account) : - FeedViewModel(UserProfileNewThreadFeedFilter(user, account)) { - class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrUserProfileNewThreadsFeedViewModel { - return NostrUserProfileNewThreadsFeedViewModel(user, account) - as NostrUserProfileNewThreadsFeedViewModel + FeedViewModel(UserProfileNewThreadFeedFilter(user, account)) { + class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrUserProfileNewThreadsFeedViewModel { + return NostrUserProfileNewThreadsFeedViewModel(user, account) + as NostrUserProfileNewThreadsFeedViewModel + } } - } } class NostrUserProfileConversationsFeedViewModel(val user: User, val account: Account) : - FeedViewModel(UserProfileConversationsFeedFilter(user, account)) { - class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrUserProfileConversationsFeedViewModel { - return NostrUserProfileConversationsFeedViewModel(user, account) - as NostrUserProfileConversationsFeedViewModel + FeedViewModel(UserProfileConversationsFeedFilter(user, account)) { + class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrUserProfileConversationsFeedViewModel { + return NostrUserProfileConversationsFeedViewModel(user, account) + as NostrUserProfileConversationsFeedViewModel + } } - } } class NostrHashtagFeedViewModel(val hashtag: String, val account: Account) : - FeedViewModel(HashtagFeedFilter(hashtag, account)) { - class Factory(val hashtag: String, val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrHashtagFeedViewModel { - return NostrHashtagFeedViewModel(hashtag, account) as NostrHashtagFeedViewModel + FeedViewModel(HashtagFeedFilter(hashtag, account)) { + class Factory(val hashtag: String, val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrHashtagFeedViewModel { + return NostrHashtagFeedViewModel(hashtag, account) as NostrHashtagFeedViewModel + } } - } } class NostrGeoHashFeedViewModel(val geohash: String, val account: Account) : - FeedViewModel(GeoHashFeedFilter(geohash, account)) { - class Factory(val geohash: String, val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrGeoHashFeedViewModel { - return NostrGeoHashFeedViewModel(geohash, account) as NostrGeoHashFeedViewModel + FeedViewModel(GeoHashFeedFilter(geohash, account)) { + class Factory(val geohash: String, val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrGeoHashFeedViewModel { + return NostrGeoHashFeedViewModel(geohash, account) as NostrGeoHashFeedViewModel + } } - } } class NostrCommunityFeedViewModel(val note: AddressableNote, val account: Account) : - FeedViewModel(CommunityFeedFilter(note, account)) { - class Factory(val note: AddressableNote, val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrCommunityFeedViewModel { - return NostrCommunityFeedViewModel(note, account) as NostrCommunityFeedViewModel + FeedViewModel(CommunityFeedFilter(note, account)) { + class Factory(val note: AddressableNote, val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrCommunityFeedViewModel { + return NostrCommunityFeedViewModel(note, account) as NostrCommunityFeedViewModel + } } - } } class NostrUserProfileReportFeedViewModel(val user: User) : - FeedViewModel(UserProfileReportsFeedFilter(user)) { - class Factory(val user: User) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrUserProfileReportFeedViewModel { - return NostrUserProfileReportFeedViewModel(user) as NostrUserProfileReportFeedViewModel + FeedViewModel(UserProfileReportsFeedFilter(user)) { + class Factory(val user: User) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrUserProfileReportFeedViewModel { + return NostrUserProfileReportFeedViewModel(user) as NostrUserProfileReportFeedViewModel + } } - } } class NostrUserProfileBookmarksFeedViewModel(val user: User, val account: Account) : - FeedViewModel(UserProfileBookmarksFeedFilter(user, account)) { - class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrUserProfileBookmarksFeedViewModel { - return NostrUserProfileBookmarksFeedViewModel(user, account) - as NostrUserProfileBookmarksFeedViewModel + FeedViewModel(UserProfileBookmarksFeedFilter(user, account)) { + class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrUserProfileBookmarksFeedViewModel { + return NostrUserProfileBookmarksFeedViewModel(user, account) + as NostrUserProfileBookmarksFeedViewModel + } } - } } class NostrChatroomListKnownFeedViewModel(val account: Account) : - FeedViewModel(ChatroomListKnownFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrChatroomListKnownFeedViewModel { - return NostrChatroomListKnownFeedViewModel(account) as NostrChatroomListKnownFeedViewModel + FeedViewModel(ChatroomListKnownFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrChatroomListKnownFeedViewModel { + return NostrChatroomListKnownFeedViewModel(account) as NostrChatroomListKnownFeedViewModel + } } - } } class NostrChatroomListNewFeedViewModel(val account: Account) : - FeedViewModel(ChatroomListNewFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrChatroomListNewFeedViewModel { - return NostrChatroomListNewFeedViewModel(account) as NostrChatroomListNewFeedViewModel + FeedViewModel(ChatroomListNewFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrChatroomListNewFeedViewModel { + return NostrChatroomListNewFeedViewModel(account) as NostrChatroomListNewFeedViewModel + } } - } } @Stable class NostrHomeFeedViewModel(val account: Account) : - FeedViewModel(HomeNewThreadFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrHomeFeedViewModel { - return NostrHomeFeedViewModel(account) as NostrHomeFeedViewModel + FeedViewModel(HomeNewThreadFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrHomeFeedViewModel { + return NostrHomeFeedViewModel(account) as NostrHomeFeedViewModel + } } - } } @Stable class NostrHomeRepliesFeedViewModel(val account: Account) : - FeedViewModel(HomeConversationsFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrHomeRepliesFeedViewModel { - return NostrHomeRepliesFeedViewModel(account) as NostrHomeRepliesFeedViewModel + FeedViewModel(HomeConversationsFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrHomeRepliesFeedViewModel { + return NostrHomeRepliesFeedViewModel(account) as NostrHomeRepliesFeedViewModel + } } - } } @Stable class NostrBookmarkPublicFeedViewModel(val account: Account) : - FeedViewModel(BookmarkPublicFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrBookmarkPublicFeedViewModel { - return NostrBookmarkPublicFeedViewModel(account) as NostrBookmarkPublicFeedViewModel + FeedViewModel(BookmarkPublicFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrBookmarkPublicFeedViewModel { + return NostrBookmarkPublicFeedViewModel(account) as NostrBookmarkPublicFeedViewModel + } } - } } @Stable class NostrBookmarkPrivateFeedViewModel(val account: Account) : - FeedViewModel(BookmarkPrivateFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrBookmarkPrivateFeedViewModel { - return NostrBookmarkPrivateFeedViewModel(account) as NostrBookmarkPrivateFeedViewModel + FeedViewModel(BookmarkPrivateFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrBookmarkPrivateFeedViewModel { + return NostrBookmarkPrivateFeedViewModel(account) as NostrBookmarkPrivateFeedViewModel + } } - } } class NostrUserAppRecommendationsFeedViewModel(val user: User) : - FeedViewModel(UserProfileAppRecommendationsFeedFilter(user)) { - class Factory(val user: User) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrUserAppRecommendationsFeedViewModel { - return NostrUserAppRecommendationsFeedViewModel(user) - as NostrUserAppRecommendationsFeedViewModel + FeedViewModel(UserProfileAppRecommendationsFeedFilter(user)) { + class Factory(val user: User) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrUserAppRecommendationsFeedViewModel { + return NostrUserAppRecommendationsFeedViewModel(user) + as NostrUserAppRecommendationsFeedViewModel + } } - } } @Stable abstract class FeedViewModel(val localFilter: FeedFilter) : - ViewModel(), InvalidatableViewModel { - private val _feedContent = MutableStateFlow(FeedState.Loading) - val feedContent = _feedContent.asStateFlow() + ViewModel(), InvalidatableViewModel { + private val _feedContent = MutableStateFlow(FeedState.Loading) + val feedContent = _feedContent.asStateFlow() - // Simple counter that changes when it needs to invalidate everything - private val _scrollToTop = MutableStateFlow(0) - val scrollToTop = _scrollToTop.asStateFlow() - var scrolltoTopPending = false + // Simple counter that changes when it needs to invalidate everything + private val _scrollToTop = MutableStateFlow(0) + val scrollToTop = _scrollToTop.asStateFlow() + var scrolltoTopPending = false - private var lastFeedKey: String? = null + private var lastFeedKey: String? = null - fun sendToTop() { - if (scrolltoTopPending) return + fun sendToTop() { + if (scrolltoTopPending) return - scrolltoTopPending = true - viewModelScope.launch(Dispatchers.IO) { _scrollToTop.emit(_scrollToTop.value + 1) } - } - - suspend fun sentToTop() { - scrolltoTopPending = false - } - - private fun refresh() { - viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } - } - - fun refreshSuspended() { - checkNotInMainThread() - - lastFeedKey = localFilter.feedKey() - val notes = localFilter.loadTop().distinctBy { it.idHex }.toImmutableList() - - val oldNotesState = _feedContent.value - if (oldNotesState is FeedState.Loaded) { - if (!equalImmutableLists(notes, oldNotesState.feed.value)) { - updateFeed(notes) - } - } else { - updateFeed(notes) + scrolltoTopPending = true + viewModelScope.launch(Dispatchers.IO) { _scrollToTop.emit(_scrollToTop.value + 1) } } - } - private fun updateFeed(notes: ImmutableList) { - viewModelScope.launch(Dispatchers.Main) { - val currentState = _feedContent.value - if (notes.isEmpty()) { - _feedContent.update { FeedState.Empty } - } else if (currentState is FeedState.Loaded) { - // updates the current list - currentState.showHidden.value = localFilter.showHiddenKey() - currentState.feed.value = notes - } else { - _feedContent.update { - FeedState.Loaded(mutableStateOf(notes), mutableStateOf(localFilter.showHiddenKey())) + suspend fun sentToTop() { + scrolltoTopPending = false + } + + private fun refresh() { + viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } + } + + fun refreshSuspended() { + checkNotInMainThread() + + lastFeedKey = localFilter.feedKey() + val notes = localFilter.loadTop().distinctBy { it.idHex }.toImmutableList() + + val oldNotesState = _feedContent.value + if (oldNotesState is FeedState.Loaded) { + if (!equalImmutableLists(notes, oldNotesState.feed.value)) { + updateFeed(notes) + } + } else { + updateFeed(notes) } - } } - } - fun refreshFromOldState(newItems: Set) { - val oldNotesState = _feedContent.value - if (localFilter is AdditiveFeedFilter && lastFeedKey == localFilter.feedKey()) { - if (oldNotesState is FeedState.Loaded) { - val newList = - localFilter - .updateListWith(oldNotesState.feed.value, newItems) - .distinctBy { it.idHex } - .toImmutableList() - if (!equalImmutableLists(newList, oldNotesState.feed.value)) { - updateFeed(newList) + private fun updateFeed(notes: ImmutableList) { + viewModelScope.launch(Dispatchers.Main) { + val currentState = _feedContent.value + if (notes.isEmpty()) { + _feedContent.update { FeedState.Empty } + } else if (currentState is FeedState.Loaded) { + // updates the current list + currentState.showHidden.value = localFilter.showHiddenKey() + currentState.feed.value = notes + } else { + _feedContent.update { + FeedState.Loaded(mutableStateOf(notes), mutableStateOf(localFilter.showHiddenKey())) + } + } } - } else if (oldNotesState is FeedState.Empty) { - val newList = - localFilter - .updateListWith(emptyList(), newItems) - .distinctBy { it.idHex } - .toImmutableList() - if (newList.isNotEmpty()) { - updateFeed(newList) - } - } else { - // Refresh Everything - refreshSuspended() - } - } else { - // Refresh Everything - refreshSuspended() } - } - private val bundler = BundledUpdate(250, Dispatchers.IO) - private val bundlerInsert = BundledInsert>(250, Dispatchers.IO) - - override fun invalidateData(ignoreIfDoing: Boolean) { - viewModelScope.launch(Dispatchers.IO) { - bundler.invalidate(ignoreIfDoing) { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - refreshSuspended() - } - } - } - - fun checkKeysInvalidateDataAndSendToTop() { - if (lastFeedKey != localFilter.feedKey()) { - viewModelScope.launch(Dispatchers.IO) { - bundler.invalidate(false) { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - refreshSuspended() - sendToTop() - } - } - } - } - - fun invalidateInsertData(newItems: Set) { - bundlerInsert.invalidateList(newItems) { refreshFromOldState(it.flatten().toSet()) } - } - - private var collectorJob: Job? = null - - init { - Log.d("Init", "Starting new Model: ${this.javaClass.simpleName}") - collectorJob = - viewModelScope.launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { newNotes -> - checkNotInMainThread() - - if ( - localFilter is AdditiveFeedFilter && - (_feedContent.value is FeedState.Loaded || _feedContent.value is FeedState.Empty) - ) { - invalidateInsertData(newNotes) - } else { + fun refreshFromOldState(newItems: Set) { + val oldNotesState = _feedContent.value + if (localFilter is AdditiveFeedFilter && lastFeedKey == localFilter.feedKey()) { + if (oldNotesState is FeedState.Loaded) { + val newList = + localFilter + .updateListWith(oldNotesState.feed.value, newItems) + .distinctBy { it.idHex } + .toImmutableList() + if (!equalImmutableLists(newList, oldNotesState.feed.value)) { + updateFeed(newList) + } + } else if (oldNotesState is FeedState.Empty) { + val newList = + localFilter + .updateListWith(emptyList(), newItems) + .distinctBy { it.idHex } + .toImmutableList() + if (newList.isNotEmpty()) { + updateFeed(newList) + } + } else { + // Refresh Everything + refreshSuspended() + } + } else { // Refresh Everything - invalidateData() - } + refreshSuspended() } - } - } + } - override fun onCleared() { - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - bundlerInsert.cancel() - bundler.cancel() - collectorJob?.cancel() - super.onCleared() - } + private val bundler = BundledUpdate(250, Dispatchers.IO) + private val bundlerInsert = BundledInsert>(250, Dispatchers.IO) + + override fun invalidateData(ignoreIfDoing: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + bundler.invalidate(ignoreIfDoing) { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + refreshSuspended() + } + } + } + + fun checkKeysInvalidateDataAndSendToTop() { + if (lastFeedKey != localFilter.feedKey()) { + viewModelScope.launch(Dispatchers.IO) { + bundler.invalidate(false) { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + refreshSuspended() + sendToTop() + } + } + } + } + + fun invalidateInsertData(newItems: Set) { + bundlerInsert.invalidateList(newItems) { refreshFromOldState(it.flatten().toSet()) } + } + + private var collectorJob: Job? = null + + init { + Log.d("Init", "Starting new Model: ${this.javaClass.simpleName}") + collectorJob = + viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + checkNotInMainThread() + + if ( + localFilter is AdditiveFeedFilter && + (_feedContent.value is FeedState.Loaded || _feedContent.value is FeedState.Empty) + ) { + invalidateInsertData(newNotes) + } else { + // Refresh Everything + invalidateData() + } + } + } + } + + override fun onCleared() { + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + bundlerInsert.cancel() + bundler.cancel() + collectorJob?.cancel() + super.onCleared() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt index 71eef8d05..1002208f8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt @@ -30,11 +30,11 @@ import kotlinx.collections.immutable.ImmutableList @Stable sealed class LnZapFeedState { - object Loading : LnZapFeedState() + object Loading : LnZapFeedState() - class Loaded(val feed: MutableState>) : LnZapFeedState() + class Loaded(val feed: MutableState>) : LnZapFeedState() - object Empty : LnZapFeedState() + object Empty : LnZapFeedState() - class FeedError(val errorMessage: String) : LnZapFeedState() + class FeedError(val errorMessage: String) : LnZapFeedState() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt index 90012088f..a245e3829 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt @@ -34,44 +34,44 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding @Composable fun LnZapFeedView( - viewModel: LnZapFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + viewModel: LnZapFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> - when (state) { - is LnZapFeedState.Empty -> { - FeedEmpty { viewModel.invalidateData() } - } - is LnZapFeedState.FeedError -> { - FeedError(state.errorMessage) { viewModel.invalidateData() } - } - is LnZapFeedState.Loaded -> { - LnZapFeedLoaded(state, accountViewModel, nav) - } - is LnZapFeedState.Loading -> { - LoadingFeed() - } + Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> + when (state) { + is LnZapFeedState.Empty -> { + FeedEmpty { viewModel.invalidateData() } + } + is LnZapFeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + is LnZapFeedState.Loaded -> { + LnZapFeedLoaded(state, accountViewModel, nav) + } + is LnZapFeedState.Loading -> { + LoadingFeed() + } + } } - } } @Composable private fun LnZapFeedLoaded( - state: LnZapFeedState.Loaded, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + state: LnZapFeedState.Loaded, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val listState = rememberLazyListState() + val listState = rememberLazyListState() - LazyColumn( - contentPadding = FeedPadding, - state = listState, - ) { - itemsIndexed(state.feed.value, key = { _, item -> item.zapEvent.idHex }) { _, item -> - ZapNoteCompose(item, accountViewModel = accountViewModel, nav = nav) + LazyColumn( + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(state.feed.value, key = { _, item -> item.zapEvent.idHex }) { _, item -> + ZapNoteCompose(item, accountViewModel = accountViewModel, nav = nav) + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt index 6994e2787..1ad83429d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt @@ -42,84 +42,82 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class NostrUserProfileZapsFeedViewModel(user: User) : - LnZapFeedViewModel(UserProfileZapsFeedFilter(user)) { - class Factory(val user: User) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrUserProfileZapsFeedViewModel { - return NostrUserProfileZapsFeedViewModel(user) as NostrUserProfileZapsFeedViewModel + LnZapFeedViewModel(UserProfileZapsFeedFilter(user)) { + class Factory(val user: User) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrUserProfileZapsFeedViewModel { + return NostrUserProfileZapsFeedViewModel(user) as NostrUserProfileZapsFeedViewModel + } } - } } @Stable open class LnZapFeedViewModel(val dataSource: FeedFilter) : ViewModel() { - private val _feedContent = MutableStateFlow(LnZapFeedState.Loading) - val feedContent = _feedContent.asStateFlow() + private val _feedContent = MutableStateFlow(LnZapFeedState.Loading) + val feedContent = _feedContent.asStateFlow() - private fun refresh() { - viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } - } - - private fun refreshSuspended() { - checkNotInMainThread() - val notes = dataSource.loadTop().toImmutableList() - - val oldNotesState = _feedContent.value - if (oldNotesState is LnZapFeedState.Loaded) { - // Using size as a proxy for has changed. - if (!equalImmutableLists(notes, oldNotesState.feed.value)) { - updateFeed(notes) - } - } else { - updateFeed(notes) + private fun refresh() { + viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } } - } - private fun updateFeed(notes: ImmutableList) { - viewModelScope.launch(Dispatchers.Main) { - val currentState = _feedContent.value - if (notes.isEmpty()) { - _feedContent.update { LnZapFeedState.Empty } - } else if (currentState is LnZapFeedState.Loaded) { - // updates the current list - currentState.feed.value = notes - } else { - _feedContent.update { LnZapFeedState.Loaded(mutableStateOf(notes)) } - } - } - } - - private val bundler = BundledUpdate(250, Dispatchers.IO) - - fun invalidateData() { - bundler.invalidate { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - refreshSuspended() - } - } - - var collectorJob: Job? = null - - init { - Log.d("Init", "${this.javaClass.simpleName}") - collectorJob = - viewModelScope.launch(Dispatchers.IO) { + private fun refreshSuspended() { checkNotInMainThread() + val notes = dataSource.loadTop().toImmutableList() - LocalCache.live.newEventBundles.collect { newNotes -> - checkNotInMainThread() - - invalidateData() + val oldNotesState = _feedContent.value + if (oldNotesState is LnZapFeedState.Loaded) { + // Using size as a proxy for has changed. + if (!equalImmutableLists(notes, oldNotesState.feed.value)) { + updateFeed(notes) + } + } else { + updateFeed(notes) } - } - } + } - override fun onCleared() { - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - bundler.cancel() - collectorJob?.cancel() - super.onCleared() - } + private fun updateFeed(notes: ImmutableList) { + viewModelScope.launch(Dispatchers.Main) { + val currentState = _feedContent.value + if (notes.isEmpty()) { + _feedContent.update { LnZapFeedState.Empty } + } else if (currentState is LnZapFeedState.Loaded) { + // updates the current list + currentState.feed.value = notes + } else { + _feedContent.update { LnZapFeedState.Loaded(mutableStateOf(notes)) } + } + } + } + + private val bundler = BundledUpdate(250, Dispatchers.IO) + + fun invalidateData() { + bundler.invalidate { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + refreshSuspended() + } + } + + var collectorJob: Job? = null + + init { + Log.d("Init", "${this.javaClass.simpleName}") + collectorJob = + viewModelScope.launch(Dispatchers.IO) { + checkNotInMainThread() + + LocalCache.live.newEventBundles.collect { newNotes -> + checkNotInMainThread() + + invalidateData() + } + } + } + + override fun onCleared() { + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + bundler.cancel() + collectorJob?.cancel() + super.onCleared() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt index 454e6fa75..d3d8c1c6b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt @@ -56,124 +56,124 @@ import kotlinx.coroutines.launch @Stable class RelayFeedViewModel : ViewModel() { - val order = - compareByDescending { it.lastEvent } - .thenByDescending { it.counter } - .thenBy { it.url } + val order = + compareByDescending { it.lastEvent } + .thenByDescending { it.counter } + .thenBy { it.url } - private val _feedContent = MutableStateFlow>(emptyList()) - val feedContent = _feedContent.asStateFlow() + private val _feedContent = MutableStateFlow>(emptyList()) + val feedContent = _feedContent.asStateFlow() - var currentUser: User? = null + var currentUser: User? = null - fun refresh() { - viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } - } + fun refresh() { + viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } + } - fun refreshSuspended() { - val beingUsed = currentUser?.relaysBeingUsed?.values ?: emptyList() - val beingUsedSet = currentUser?.relaysBeingUsed?.keys ?: emptySet() + fun refreshSuspended() { + val beingUsed = currentUser?.relaysBeingUsed?.values ?: emptyList() + val beingUsedSet = currentUser?.relaysBeingUsed?.keys ?: emptySet() - val newRelaysFromRecord = - currentUser?.latestContactList?.relays()?.entries?.mapNotNull { - if (it.key !in beingUsedSet) { - RelayInfo(it.key, 0, 0) - } else { - null + val newRelaysFromRecord = + currentUser?.latestContactList?.relays()?.entries?.mapNotNull { + if (it.key !in beingUsedSet) { + RelayInfo(it.key, 0, 0) + } else { + null + } + } + ?: emptyList() + + val newList = (beingUsed + newRelaysFromRecord).sortedWith(order) + + _feedContent.update { newList } + } + + val listener: (UserState) -> Unit = { invalidateData() } + + fun subscribeTo(user: User) { + if (currentUser != user) { + currentUser = user + user.live().relays.observeForever(listener) + user.live().relayInfo.observeForever(listener) + invalidateData() } - } - ?: emptyList() - - val newList = (beingUsed + newRelaysFromRecord).sortedWith(order) - - _feedContent.update { newList } - } - - val listener: (UserState) -> Unit = { invalidateData() } - - fun subscribeTo(user: User) { - if (currentUser != user) { - currentUser = user - user.live().relays.observeForever(listener) - user.live().relayInfo.observeForever(listener) - invalidateData() } - } - fun unsubscribeTo(user: User) { - if (currentUser == user) { - user.live().relays.removeObserver(listener) - user.live().relayInfo.removeObserver(listener) - currentUser = null + fun unsubscribeTo(user: User) { + if (currentUser == user) { + user.live().relays.removeObserver(listener) + user.live().relayInfo.removeObserver(listener) + currentUser = null + } } - } - private val bundler = BundledUpdate(250, Dispatchers.IO) + private val bundler = BundledUpdate(250, Dispatchers.IO) - fun invalidateData() { - bundler.invalidate { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - refreshSuspended() + fun invalidateData() { + bundler.invalidate { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + refreshSuspended() + } } - } - override fun onCleared() { - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - bundler.cancel() - super.onCleared() - } + override fun onCleared() { + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + bundler.cancel() + super.onCleared() + } } @Composable fun RelayFeedView( - viewModel: RelayFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - enablePullRefresh: Boolean = true, + viewModel: RelayFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + enablePullRefresh: Boolean = true, ) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - var wantsToAddRelay by remember { mutableStateOf("") } + var wantsToAddRelay by remember { mutableStateOf("") } - if (wantsToAddRelay.isNotEmpty()) { - NewRelayListView({ wantsToAddRelay = "" }, accountViewModel, wantsToAddRelay, nav = nav) - } - - var refreshing by remember { mutableStateOf(false) } - val refresh = { - refreshing = true - viewModel.refresh() - refreshing = false - } - val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - - val modifier = - if (enablePullRefresh) { - Modifier.fillMaxSize().pullRefresh(pullRefreshState) - } else { - Modifier.fillMaxSize() + if (wantsToAddRelay.isNotEmpty()) { + NewRelayListView({ wantsToAddRelay = "" }, accountViewModel, wantsToAddRelay, nav = nav) } - Box(modifier) { - val listState = rememberLazyListState() - - LazyColumn( - contentPadding = FeedPadding, - state = listState, - ) { - itemsIndexed(feedState, key = { _, item -> item.url }) { _, item -> - RelayCompose( - item, - accountViewModel = accountViewModel, - onAddRelay = { wantsToAddRelay = item.url }, - onRemoveRelay = { wantsToAddRelay = item.url }, - ) - } + var refreshing by remember { mutableStateOf(false) } + val refresh = { + refreshing = true + viewModel.refresh() + refreshing = false } + val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - if (enablePullRefresh) { - PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) + val modifier = + if (enablePullRefresh) { + Modifier.fillMaxSize().pullRefresh(pullRefreshState) + } else { + Modifier.fillMaxSize() + } + + Box(modifier) { + val listState = rememberLazyListState() + + LazyColumn( + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(feedState, key = { _, item -> item.url }) { _, item -> + RelayCompose( + item, + accountViewModel = accountViewModel, + onAddRelay = { wantsToAddRelay = item.url }, + onRemoveRelay = { wantsToAddRelay = item.url }, + ) + } + } + + if (enablePullRefresh) { + PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt index c799f2610..969426642 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt @@ -36,104 +36,104 @@ private val savedScrollStates = mutableMapOf() private data class ScrollState(val index: Int, val scrollOffsetFraction: Float) object ScrollStateKeys { - const val GLOBAL_SCREEN = "Global" - const val NOTIFICATION_SCREEN = "Notifications" - const val VIDEO_SCREEN = "Video" - const val DISCOVER_SCREEN = "Discover" - val HOME_FOLLOWS = Route.Home.base + "Follows" - val HOME_REPLIES = Route.Home.base + "FollowsReplies" + const val GLOBAL_SCREEN = "Global" + const val NOTIFICATION_SCREEN = "Notifications" + const val VIDEO_SCREEN = "Video" + const val DISCOVER_SCREEN = "Discover" + val HOME_FOLLOWS = Route.Home.base + "Follows" + val HOME_REPLIES = Route.Home.base + "FollowsReplies" - val DISCOVER_MARKETPLACE = Route.Home.base + "Marketplace" - val DISCOVER_LIVE = Route.Home.base + "Live" - val DISCOVER_COMMUNITY = Route.Home.base + "Communities" - val DISCOVER_CHATS = Route.Home.base + "Chats" + val DISCOVER_MARKETPLACE = Route.Home.base + "Marketplace" + val DISCOVER_LIVE = Route.Home.base + "Live" + val DISCOVER_COMMUNITY = Route.Home.base + "Communities" + val DISCOVER_CHATS = Route.Home.base + "Chats" } object PagerStateKeys { - const val HOME_SCREEN = "PagerHome" - const val DISCOVER_SCREEN = "PagerDiscover" + const val HOME_SCREEN = "PagerHome" + const val DISCOVER_SCREEN = "PagerDiscover" } @Composable fun rememberForeverLazyGridState( - key: String, - initialFirstVisibleItemIndex: Int = 0, - initialFirstVisibleItemScrollOffset: Int = 0, + key: String, + initialFirstVisibleItemIndex: Int = 0, + initialFirstVisibleItemScrollOffset: Int = 0, ): LazyGridState { - val scrollState = - rememberSaveable(saver = LazyGridState.Saver) { - val savedValue = savedScrollStates[key] - val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex - val savedOffset = - savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset.toFloat() - LazyGridState( - savedIndex, - savedOffset.roundToInt(), - ) + val scrollState = + rememberSaveable(saver = LazyGridState.Saver) { + val savedValue = savedScrollStates[key] + val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex + val savedOffset = + savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset.toFloat() + LazyGridState( + savedIndex, + savedOffset.roundToInt(), + ) + } + DisposableEffect(scrollState) { + onDispose { + val lastIndex = scrollState.firstVisibleItemIndex + val lastOffset = scrollState.firstVisibleItemScrollOffset + savedScrollStates[key] = ScrollState(lastIndex, lastOffset.toFloat()) + } } - DisposableEffect(scrollState) { - onDispose { - val lastIndex = scrollState.firstVisibleItemIndex - val lastOffset = scrollState.firstVisibleItemScrollOffset - savedScrollStates[key] = ScrollState(lastIndex, lastOffset.toFloat()) - } - } - return scrollState + return scrollState } @Composable fun rememberForeverLazyListState( - key: String, - initialFirstVisibleItemIndex: Int = 0, - initialFirstVisibleItemScrollOffset: Int = 0, + key: String, + initialFirstVisibleItemIndex: Int = 0, + initialFirstVisibleItemScrollOffset: Int = 0, ): LazyListState { - val scrollState = - rememberSaveable(saver = LazyListState.Saver) { - val savedValue = savedScrollStates[key] - val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex - val savedOffset = - savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset.toFloat() - LazyListState( - savedIndex, - savedOffset.roundToInt(), - ) + val scrollState = + rememberSaveable(saver = LazyListState.Saver) { + val savedValue = savedScrollStates[key] + val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex + val savedOffset = + savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset.toFloat() + LazyListState( + savedIndex, + savedOffset.roundToInt(), + ) + } + DisposableEffect(scrollState) { + onDispose { + val lastIndex = scrollState.firstVisibleItemIndex + val lastOffset = scrollState.firstVisibleItemScrollOffset + savedScrollStates[key] = ScrollState(lastIndex, lastOffset.toFloat()) + } } - DisposableEffect(scrollState) { - onDispose { - val lastIndex = scrollState.firstVisibleItemIndex - val lastOffset = scrollState.firstVisibleItemScrollOffset - savedScrollStates[key] = ScrollState(lastIndex, lastOffset.toFloat()) - } - } - return scrollState + return scrollState } @OptIn(ExperimentalFoundationApi::class) @Composable fun rememberForeverPagerState( - key: String, - initialFirstVisibleItemIndex: Int = 0, - initialFirstVisibleItemScrollOffset: Float = 0.0f, - pageCount: () -> Int, + key: String, + initialFirstVisibleItemIndex: Int = 0, + initialFirstVisibleItemScrollOffset: Float = 0.0f, + pageCount: () -> Int, ): PagerState { - val savedValue = savedScrollStates[key] - val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex - val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset + val savedValue = savedScrollStates[key] + val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex + val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset - val scrollState = - rememberPagerState( - savedIndex, - savedOffset, - pageCount, - ) + val scrollState = + rememberPagerState( + savedIndex, + savedOffset, + pageCount, + ) - DisposableEffect(scrollState) { - onDispose { - val lastIndex = scrollState.currentPage - val lastOffset = scrollState.currentPageOffsetFraction - savedScrollStates[key] = ScrollState(lastIndex, lastOffset) + DisposableEffect(scrollState) { + onDispose { + val lastIndex = scrollState.currentPage + val lastOffset = scrollState.currentPageOffsetFraction + savedScrollStates[key] = ScrollState(lastIndex, lastOffset) + } } - } - return scrollState + return scrollState } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt index a48042962..0037aa303 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt @@ -42,188 +42,192 @@ import kotlinx.coroutines.launch @Stable class SettingsState() { - var theme by mutableStateOf(ThemeType.SYSTEM) - var language by mutableStateOf(null) + var theme by mutableStateOf(ThemeType.SYSTEM) + var language by mutableStateOf(null) - var automaticallyShowImages by mutableStateOf(ConnectivityType.ALWAYS) - var automaticallyStartPlayback by mutableStateOf(ConnectivityType.ALWAYS) - var automaticallyShowUrlPreview by mutableStateOf(ConnectivityType.ALWAYS) - var automaticallyHideNavigationBars by mutableStateOf(BooleanType.ALWAYS) - var automaticallyShowProfilePictures by mutableStateOf(ConnectivityType.ALWAYS) - var dontShowPushNotificationSelector by mutableStateOf(false) - var dontAskForNotificationPermissions by mutableStateOf(false) + var automaticallyShowImages by mutableStateOf(ConnectivityType.ALWAYS) + var automaticallyStartPlayback by mutableStateOf(ConnectivityType.ALWAYS) + var automaticallyShowUrlPreview by mutableStateOf(ConnectivityType.ALWAYS) + var automaticallyHideNavigationBars by mutableStateOf(BooleanType.ALWAYS) + var automaticallyShowProfilePictures by mutableStateOf(ConnectivityType.ALWAYS) + var dontShowPushNotificationSelector by mutableStateOf(false) + var dontAskForNotificationPermissions by mutableStateOf(false) - var isOnMobileData: State = mutableStateOf(false) + var isOnMobileData: State = mutableStateOf(false) - var windowSizeClass = mutableStateOf(null) - var displayFeatures = mutableStateOf>(emptyList()) + var windowSizeClass = mutableStateOf(null) + var displayFeatures = mutableStateOf>(emptyList()) - val showProfilePictures = derivedStateOf { - when (automaticallyShowProfilePictures) { - ConnectivityType.WIFI_ONLY -> !isOnMobileData.value - ConnectivityType.NEVER -> false - ConnectivityType.ALWAYS -> true - } - } + val showProfilePictures = + derivedStateOf { + when (automaticallyShowProfilePictures) { + ConnectivityType.WIFI_ONLY -> !isOnMobileData.value + ConnectivityType.NEVER -> false + ConnectivityType.ALWAYS -> true + } + } - val showUrlPreview = derivedStateOf { - when (automaticallyShowUrlPreview) { - ConnectivityType.WIFI_ONLY -> !isOnMobileData.value - ConnectivityType.NEVER -> false - ConnectivityType.ALWAYS -> true - } - } + val showUrlPreview = + derivedStateOf { + when (automaticallyShowUrlPreview) { + ConnectivityType.WIFI_ONLY -> !isOnMobileData.value + ConnectivityType.NEVER -> false + ConnectivityType.ALWAYS -> true + } + } - val startVideoPlayback = derivedStateOf { - when (automaticallyStartPlayback) { - ConnectivityType.WIFI_ONLY -> !isOnMobileData.value - ConnectivityType.NEVER -> false - ConnectivityType.ALWAYS -> true - } - } + val startVideoPlayback = + derivedStateOf { + when (automaticallyStartPlayback) { + ConnectivityType.WIFI_ONLY -> !isOnMobileData.value + ConnectivityType.NEVER -> false + ConnectivityType.ALWAYS -> true + } + } - val showImages = derivedStateOf { - when (automaticallyShowImages) { - ConnectivityType.WIFI_ONLY -> !isOnMobileData.value - ConnectivityType.NEVER -> false - ConnectivityType.ALWAYS -> true - } - } + val showImages = + derivedStateOf { + when (automaticallyShowImages) { + ConnectivityType.WIFI_ONLY -> !isOnMobileData.value + ConnectivityType.NEVER -> false + ConnectivityType.ALWAYS -> true + } + } } @Stable class SharedPreferencesViewModel : ViewModel() { - val sharedPrefs: SettingsState = SettingsState() + val sharedPrefs: SettingsState = SettingsState() - fun init() { - viewModelScope.launch(Dispatchers.IO) { - val savedSettings = - LocalPreferences.loadSharedSettings() - ?: LocalPreferences.migrateOldSharedSettings() ?: Settings() + fun init() { + viewModelScope.launch(Dispatchers.IO) { + val savedSettings = + LocalPreferences.loadSharedSettings() + ?: LocalPreferences.migrateOldSharedSettings() ?: Settings() - sharedPrefs.theme = savedSettings.theme - sharedPrefs.language = savedSettings.preferredLanguage - sharedPrefs.automaticallyShowImages = savedSettings.automaticallyShowImages - sharedPrefs.automaticallyStartPlayback = savedSettings.automaticallyStartPlayback - sharedPrefs.automaticallyShowUrlPreview = savedSettings.automaticallyShowUrlPreview - sharedPrefs.automaticallyHideNavigationBars = savedSettings.automaticallyHideNavigationBars - sharedPrefs.automaticallyShowProfilePictures = savedSettings.automaticallyShowProfilePictures - sharedPrefs.dontShowPushNotificationSelector = savedSettings.dontShowPushNotificationSelector - sharedPrefs.dontAskForNotificationPermissions = - savedSettings.dontAskForNotificationPermissions + sharedPrefs.theme = savedSettings.theme + sharedPrefs.language = savedSettings.preferredLanguage + sharedPrefs.automaticallyShowImages = savedSettings.automaticallyShowImages + sharedPrefs.automaticallyStartPlayback = savedSettings.automaticallyStartPlayback + sharedPrefs.automaticallyShowUrlPreview = savedSettings.automaticallyShowUrlPreview + sharedPrefs.automaticallyHideNavigationBars = savedSettings.automaticallyHideNavigationBars + sharedPrefs.automaticallyShowProfilePictures = savedSettings.automaticallyShowProfilePictures + sharedPrefs.dontShowPushNotificationSelector = savedSettings.dontShowPushNotificationSelector + sharedPrefs.dontAskForNotificationPermissions = + savedSettings.dontAskForNotificationPermissions - updateLanguageInTheUI() + updateLanguageInTheUI() + } } - } - fun updateTheme(newTheme: ThemeType) { - if (sharedPrefs.theme != newTheme) { - sharedPrefs.theme = newTheme + fun updateTheme(newTheme: ThemeType) { + if (sharedPrefs.theme != newTheme) { + sharedPrefs.theme = newTheme - saveSharedSettings() + saveSharedSettings() + } } - } - fun updateLanguage(newLanguage: String?) { - if (sharedPrefs.language != newLanguage) { - sharedPrefs.language = newLanguage - updateLanguageInTheUI() - saveSharedSettings() + fun updateLanguage(newLanguage: String?) { + if (sharedPrefs.language != newLanguage) { + sharedPrefs.language = newLanguage + updateLanguageInTheUI() + saveSharedSettings() + } } - } - fun updateLanguageInTheUI() { - if (sharedPrefs.language != null) { - viewModelScope.launch(Dispatchers.Main) { - AppCompatDelegate.setApplicationLocales( - LocaleListCompat.forLanguageTags(sharedPrefs.language), - ) - } + fun updateLanguageInTheUI() { + if (sharedPrefs.language != null) { + viewModelScope.launch(Dispatchers.Main) { + AppCompatDelegate.setApplicationLocales( + LocaleListCompat.forLanguageTags(sharedPrefs.language), + ) + } + } } - } - fun updateAutomaticallyStartPlayback(newAutomaticallyStartPlayback: ConnectivityType) { - if (sharedPrefs.automaticallyStartPlayback != newAutomaticallyStartPlayback) { - sharedPrefs.automaticallyStartPlayback = newAutomaticallyStartPlayback - saveSharedSettings() + fun updateAutomaticallyStartPlayback(newAutomaticallyStartPlayback: ConnectivityType) { + if (sharedPrefs.automaticallyStartPlayback != newAutomaticallyStartPlayback) { + sharedPrefs.automaticallyStartPlayback = newAutomaticallyStartPlayback + saveSharedSettings() + } } - } - fun updateAutomaticallyShowUrlPreview(newAutomaticallyShowUrlPreview: ConnectivityType) { - if (sharedPrefs.automaticallyShowUrlPreview != newAutomaticallyShowUrlPreview) { - sharedPrefs.automaticallyShowUrlPreview = newAutomaticallyShowUrlPreview - saveSharedSettings() + fun updateAutomaticallyShowUrlPreview(newAutomaticallyShowUrlPreview: ConnectivityType) { + if (sharedPrefs.automaticallyShowUrlPreview != newAutomaticallyShowUrlPreview) { + sharedPrefs.automaticallyShowUrlPreview = newAutomaticallyShowUrlPreview + saveSharedSettings() + } } - } - fun updateAutomaticallyShowProfilePicture(newAutomaticallyShowProfilePictures: ConnectivityType) { - if (sharedPrefs.automaticallyShowProfilePictures != newAutomaticallyShowProfilePictures) { - sharedPrefs.automaticallyShowProfilePictures = newAutomaticallyShowProfilePictures - saveSharedSettings() + fun updateAutomaticallyShowProfilePicture(newAutomaticallyShowProfilePictures: ConnectivityType) { + if (sharedPrefs.automaticallyShowProfilePictures != newAutomaticallyShowProfilePictures) { + sharedPrefs.automaticallyShowProfilePictures = newAutomaticallyShowProfilePictures + saveSharedSettings() + } } - } - fun updateAutomaticallyHideNavBars(newAutomaticallyHideHavBars: BooleanType) { - if (sharedPrefs.automaticallyHideNavigationBars != newAutomaticallyHideHavBars) { - sharedPrefs.automaticallyHideNavigationBars = newAutomaticallyHideHavBars - saveSharedSettings() + fun updateAutomaticallyHideNavBars(newAutomaticallyHideHavBars: BooleanType) { + if (sharedPrefs.automaticallyHideNavigationBars != newAutomaticallyHideHavBars) { + sharedPrefs.automaticallyHideNavigationBars = newAutomaticallyHideHavBars + saveSharedSettings() + } } - } - fun updateAutomaticallyShowImages(newAutomaticallyShowImages: ConnectivityType) { - if (sharedPrefs.automaticallyShowImages != newAutomaticallyShowImages) { - sharedPrefs.automaticallyShowImages = newAutomaticallyShowImages - saveSharedSettings() + fun updateAutomaticallyShowImages(newAutomaticallyShowImages: ConnectivityType) { + if (sharedPrefs.automaticallyShowImages != newAutomaticallyShowImages) { + sharedPrefs.automaticallyShowImages = newAutomaticallyShowImages + saveSharedSettings() + } } - } - fun dontShowPushNotificationSelector() { - if (sharedPrefs.dontShowPushNotificationSelector == false) { - sharedPrefs.dontShowPushNotificationSelector = true - saveSharedSettings() + fun dontShowPushNotificationSelector() { + if (sharedPrefs.dontShowPushNotificationSelector == false) { + sharedPrefs.dontShowPushNotificationSelector = true + saveSharedSettings() + } } - } - fun dontAskForNotificationPermissions() { - if (sharedPrefs.dontAskForNotificationPermissions == false) { - sharedPrefs.dontAskForNotificationPermissions = true - saveSharedSettings() + fun dontAskForNotificationPermissions() { + if (sharedPrefs.dontAskForNotificationPermissions == false) { + sharedPrefs.dontAskForNotificationPermissions = true + saveSharedSettings() + } } - } - fun updateConnectivityStatusState(isOnMobileDataState: State) { - if (sharedPrefs.isOnMobileData != isOnMobileDataState) { - sharedPrefs.isOnMobileData = isOnMobileDataState + fun updateConnectivityStatusState(isOnMobileDataState: State) { + if (sharedPrefs.isOnMobileData != isOnMobileDataState) { + sharedPrefs.isOnMobileData = isOnMobileDataState + } } - } - fun updateDisplaySettings( - windowSizeClass: WindowSizeClass, - displayFeatures: List, - ) { - if (sharedPrefs.windowSizeClass.value != windowSizeClass) { - sharedPrefs.windowSizeClass.value = windowSizeClass + fun updateDisplaySettings( + windowSizeClass: WindowSizeClass, + displayFeatures: List, + ) { + if (sharedPrefs.windowSizeClass.value != windowSizeClass) { + sharedPrefs.windowSizeClass.value = windowSizeClass + } + if (sharedPrefs.displayFeatures.value != displayFeatures) { + sharedPrefs.displayFeatures.value = displayFeatures + } } - if (sharedPrefs.displayFeatures.value != displayFeatures) { - sharedPrefs.displayFeatures.value = displayFeatures - } - } - fun saveSharedSettings() { - viewModelScope.launch(Dispatchers.IO) { - LocalPreferences.saveSharedSettings( - Settings( - sharedPrefs.theme, - sharedPrefs.language, - sharedPrefs.automaticallyShowImages, - sharedPrefs.automaticallyStartPlayback, - sharedPrefs.automaticallyShowUrlPreview, - sharedPrefs.automaticallyHideNavigationBars, - sharedPrefs.automaticallyShowProfilePictures, - sharedPrefs.dontShowPushNotificationSelector, - sharedPrefs.dontAskForNotificationPermissions, - ), - ) + fun saveSharedSettings() { + viewModelScope.launch(Dispatchers.IO) { + LocalPreferences.saveSharedSettings( + Settings( + sharedPrefs.theme, + sharedPrefs.language, + sharedPrefs.automaticallyShowImages, + sharedPrefs.automaticallyStartPlayback, + sharedPrefs.automaticallyShowUrlPreview, + sharedPrefs.automaticallyHideNavigationBars, + sharedPrefs.automaticallyShowProfilePictures, + sharedPrefs.dontShowPushNotificationSelector, + sharedPrefs.dontAskForNotificationPermissions, + ), + ) + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedState.kt index 6492104bb..40914cba6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedState.kt @@ -24,11 +24,11 @@ import androidx.compose.runtime.MutableState import kotlinx.collections.immutable.ImmutableList sealed class StringFeedState { - object Loading : StringFeedState() + object Loading : StringFeedState() - class Loaded(val feed: MutableState>) : StringFeedState() + class Loaded(val feed: MutableState>) : StringFeedState() - object Empty : StringFeedState() + object Empty : StringFeedState() - class FeedError(val errorMessage: String) : StringFeedState() + class FeedError(val errorMessage: String) : StringFeedState() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt index c5ec0a7a3..526272cda 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt @@ -41,79 +41,79 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding @Composable fun RefreshingFeedStringFeedView( - viewModel: StringFeedViewModel, - enablePullRefresh: Boolean = true, - inner: @Composable (String) -> Unit, + viewModel: StringFeedViewModel, + enablePullRefresh: Boolean = true, + inner: @Composable (String) -> Unit, ) { - RefresheableView(viewModel, enablePullRefresh) { StringFeedView(viewModel, inner = inner) } + RefresheableView(viewModel, enablePullRefresh) { StringFeedView(viewModel, inner = inner) } } @Composable fun StringFeedView( - viewModel: StringFeedViewModel, - pre: (@Composable () -> Unit)? = null, - post: (@Composable () -> Unit)? = null, - inner: @Composable (String) -> Unit, + viewModel: StringFeedViewModel, + pre: (@Composable () -> Unit)? = null, + post: (@Composable () -> Unit)? = null, + inner: @Composable (String) -> Unit, ) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> - when (state) { - is StringFeedState.Empty -> { - StringFeedEmpty(pre, post) { viewModel.invalidateData() } - } - is StringFeedState.FeedError -> { - FeedError(state.errorMessage) { viewModel.invalidateData() } - } - is StringFeedState.Loaded -> { - FeedLoaded(state, pre, post, inner) - } - is StringFeedState.Loading -> { - LoadingFeed() - } + Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> + when (state) { + is StringFeedState.Empty -> { + StringFeedEmpty(pre, post) { viewModel.invalidateData() } + } + is StringFeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + is StringFeedState.Loaded -> { + FeedLoaded(state, pre, post, inner) + } + is StringFeedState.Loading -> { + LoadingFeed() + } + } } - } } @Composable fun StringFeedEmpty( - pre: (@Composable () -> Unit)? = null, - post: (@Composable () -> Unit)? = null, - onRefresh: () -> Unit, + pre: (@Composable () -> Unit)? = null, + post: (@Composable () -> Unit)? = null, + onRefresh: () -> Unit, ) { - Column { - pre?.let { it() } + Column { + pre?.let { it() } - Column( - Modifier.weight(1f).fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text(stringResource(R.string.feed_is_empty)) - OutlinedButton(onClick = onRefresh) { Text(text = stringResource(R.string.refresh)) } + Column( + Modifier.weight(1f).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(stringResource(R.string.feed_is_empty)) + OutlinedButton(onClick = onRefresh) { Text(text = stringResource(R.string.refresh)) } + } + + post?.let { it() } } - - post?.let { it() } - } } @Composable private fun FeedLoaded( - state: StringFeedState.Loaded, - pre: (@Composable () -> Unit)? = null, - post: (@Composable () -> Unit)? = null, - inner: @Composable (String) -> Unit, + state: StringFeedState.Loaded, + pre: (@Composable () -> Unit)? = null, + post: (@Composable () -> Unit)? = null, + inner: @Composable (String) -> Unit, ) { - val listState = rememberLazyListState() + val listState = rememberLazyListState() - LazyColumn( - contentPadding = FeedPadding, - state = listState, - ) { - item { pre?.let { it() } } + LazyColumn( + contentPadding = FeedPadding, + state = listState, + ) { + item { pre?.let { it() } } - itemsIndexed(state.feed.value, key = { _, item -> item }) { _, item -> inner(item) } + itemsIndexed(state.feed.value, key = { _, item -> item }) { _, item -> inner(item) } - item { post?.let { it() } } - } + item { post?.let { it() } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedViewModel.kt index 6e0809f3d..6425aad40 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedViewModel.kt @@ -42,90 +42,88 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class NostrHiddenWordsFeedViewModel(val account: Account) : - StringFeedViewModel( - HiddenWordsFeedFilter(account), - ) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrHiddenWordsFeedViewModel { - return NostrHiddenWordsFeedViewModel(account) as NostrHiddenWordsFeedViewModel + StringFeedViewModel( + HiddenWordsFeedFilter(account), + ) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrHiddenWordsFeedViewModel { + return NostrHiddenWordsFeedViewModel(account) as NostrHiddenWordsFeedViewModel + } } - } } @Stable open class StringFeedViewModel(val dataSource: FeedFilter) : - ViewModel(), InvalidatableViewModel { - private val _feedContent = MutableStateFlow(StringFeedState.Loading) - val feedContent = _feedContent.asStateFlow() + ViewModel(), InvalidatableViewModel { + private val _feedContent = MutableStateFlow(StringFeedState.Loading) + val feedContent = _feedContent.asStateFlow() - private fun refresh() { - viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } - } - - private fun refreshSuspended() { - checkNotInMainThread() - - val notes = dataSource.loadTop().toImmutableList() - - val oldNotesState = _feedContent.value - if (oldNotesState is StringFeedState.Loaded) { - // Using size as a proxy for has changed. - if (!equalImmutableLists(notes, oldNotesState.feed.value)) { - updateFeed(notes) - } - } else { - updateFeed(notes) + private fun refresh() { + viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } } - } - private fun updateFeed(notes: ImmutableList) { - viewModelScope.launch(Dispatchers.Main) { - val currentState = _feedContent.value - if (notes.isEmpty()) { - _feedContent.update { StringFeedState.Empty } - } else if (currentState is StringFeedState.Loaded) { - // updates the current list - currentState.feed.value = notes - } else { - _feedContent.update { StringFeedState.Loaded(mutableStateOf(notes)) } - } - } - } - - private val bundler = BundledUpdate(250, Dispatchers.IO) - - override fun invalidateData(ignoreIfDoing: Boolean) { - viewModelScope.launch(Dispatchers.IO) { - bundler.invalidate(ignoreIfDoing) { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - refreshSuspended() - } - } - } - - var collectorJob: Job? = null - - init { - Log.d("Init", this.javaClass.simpleName) - collectorJob = - viewModelScope.launch(Dispatchers.IO) { + private fun refreshSuspended() { checkNotInMainThread() - LocalCache.live.newEventBundles.collect { newNotes -> - checkNotInMainThread() + val notes = dataSource.loadTop().toImmutableList() - invalidateData() + val oldNotesState = _feedContent.value + if (oldNotesState is StringFeedState.Loaded) { + // Using size as a proxy for has changed. + if (!equalImmutableLists(notes, oldNotesState.feed.value)) { + updateFeed(notes) + } + } else { + updateFeed(notes) } - } - } + } - override fun onCleared() { - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - bundler.cancel() - collectorJob?.cancel() - super.onCleared() - } + private fun updateFeed(notes: ImmutableList) { + viewModelScope.launch(Dispatchers.Main) { + val currentState = _feedContent.value + if (notes.isEmpty()) { + _feedContent.update { StringFeedState.Empty } + } else if (currentState is StringFeedState.Loaded) { + // updates the current list + currentState.feed.value = notes + } else { + _feedContent.update { StringFeedState.Loaded(mutableStateOf(notes)) } + } + } + } + + private val bundler = BundledUpdate(250, Dispatchers.IO) + + override fun invalidateData(ignoreIfDoing: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + bundler.invalidate(ignoreIfDoing) { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + refreshSuspended() + } + } + } + + var collectorJob: Job? = null + + init { + Log.d("Init", this.javaClass.simpleName) + collectorJob = + viewModelScope.launch(Dispatchers.IO) { + checkNotInMainThread() + + LocalCache.live.newEventBundles.collect { newNotes -> + checkNotInMainThread() + + invalidateData() + } + } + } + + override fun onCleared() { + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + bundler.cancel() + collectorJob?.cancel() + super.onCleared() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index 5797cb0af..80dd11153 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -161,621 +161,622 @@ import kotlinx.coroutines.withContext @Composable fun ThreadFeedView( - noteId: String, - viewModel: FeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + noteId: String, + viewModel: FeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - val listState = rememberLazyListState() + val listState = rememberLazyListState() - var refreshing by remember { mutableStateOf(false) } - val refresh = { - refreshing = true - viewModel.invalidateData() - refreshing = false - } - val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - - Box(Modifier.pullRefresh(pullRefreshState)) { - Column { - Crossfade( - targetState = feedState, - animationSpec = tween(durationMillis = 100), - label = "ThreadViewMainState", - ) { state -> - when (state) { - is FeedState.Empty -> { - FeedEmpty { refreshing = true } - } - is FeedState.FeedError -> { - FeedError(state.errorMessage) { refreshing = true } - } - is FeedState.Loaded -> { - refreshing = false - LaunchedEffect(noteId) { - launch(Dispatchers.IO) { - // waits to load the thread to scroll to item. - delay(100) - val noteForPosition = state.feed.value.filter { it.idHex == noteId }.firstOrNull() - var position = state.feed.value.indexOf(noteForPosition) - - if (position >= 0) { - if (position >= 1 && position < state.feed.value.size - 1) { - position-- // show the replying note - } - - withContext(Dispatchers.Main) { listState.scrollToItem(position) } - } - } - } - - LazyColumn( - contentPadding = FeedPadding, - state = listState, - ) { - itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { index, item -> - if (index == 0) { - ProvideTextStyle(TextStyle(fontSize = 18.sp, lineHeight = 1.20.em)) { - NoteMaster( - item, - modifier = - Modifier.drawReplyLevel( - item.replyLevel(), - MaterialTheme.colorScheme.placeholderText, - if (item.idHex == noteId) { - MaterialTheme.colorScheme.lessImportantLink - } else { - MaterialTheme.colorScheme.placeholderText - }, - ), - accountViewModel = accountViewModel, - nav = nav, - ) - } - } else { - Column { - Row { - val selectedNoteColor = MaterialTheme.colorScheme.selectedNote - val background = remember { - if (item.idHex == noteId) mutableStateOf(selectedNoteColor) else null - } - - NoteCompose( - item, - modifier = - Modifier.drawReplyLevel( - item.replyLevel(), - MaterialTheme.colorScheme.placeholderText, - if (item.idHex == noteId) { - MaterialTheme.colorScheme.lessImportantLink - } else { - MaterialTheme.colorScheme.placeholderText - }, - ), - parentBackgroundColor = background, - isBoostedNote = false, - unPackReply = false, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - } - } - } - FeedState.Loading -> { - LoadingFeed() - } - } - } + var refreshing by remember { mutableStateOf(false) } + val refresh = { + refreshing = true + viewModel.invalidateData() + refreshing = false } + val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) - } + Box(Modifier.pullRefresh(pullRefreshState)) { + Column { + Crossfade( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + label = "ThreadViewMainState", + ) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty { refreshing = true } + } + is FeedState.FeedError -> { + FeedError(state.errorMessage) { refreshing = true } + } + is FeedState.Loaded -> { + refreshing = false + LaunchedEffect(noteId) { + launch(Dispatchers.IO) { + // waits to load the thread to scroll to item. + delay(100) + val noteForPosition = state.feed.value.filter { it.idHex == noteId }.firstOrNull() + var position = state.feed.value.indexOf(noteForPosition) + + if (position >= 0) { + if (position >= 1 && position < state.feed.value.size - 1) { + position-- // show the replying note + } + + withContext(Dispatchers.Main) { listState.scrollToItem(position) } + } + } + } + + LazyColumn( + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { index, item -> + if (index == 0) { + ProvideTextStyle(TextStyle(fontSize = 18.sp, lineHeight = 1.20.em)) { + NoteMaster( + item, + modifier = + Modifier.drawReplyLevel( + item.replyLevel(), + MaterialTheme.colorScheme.placeholderText, + if (item.idHex == noteId) { + MaterialTheme.colorScheme.lessImportantLink + } else { + MaterialTheme.colorScheme.placeholderText + }, + ), + accountViewModel = accountViewModel, + nav = nav, + ) + } + } else { + Column { + Row { + val selectedNoteColor = MaterialTheme.colorScheme.selectedNote + val background = + remember { + if (item.idHex == noteId) mutableStateOf(selectedNoteColor) else null + } + + NoteCompose( + item, + modifier = + Modifier.drawReplyLevel( + item.replyLevel(), + MaterialTheme.colorScheme.placeholderText, + if (item.idHex == noteId) { + MaterialTheme.colorScheme.lessImportantLink + } else { + MaterialTheme.colorScheme.placeholderText + }, + ), + parentBackgroundColor = background, + isBoostedNote = false, + unPackReply = false, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + } + } + } + FeedState.Loading -> { + LoadingFeed() + } + } + } + } + + PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) + } } // Creates a Zebra pattern where each bar is a reply level. fun Modifier.drawReplyLevel( - level: Int, - color: Color, - selected: Color, + level: Int, + color: Color, + selected: Color, ): Modifier = - this.drawBehind { - val paddingDp = 2 - val strokeWidthDp = 2 - val levelWidthDp = strokeWidthDp + 1 + this.drawBehind { + val paddingDp = 2 + val strokeWidthDp = 2 + val levelWidthDp = strokeWidthDp + 1 - val padding = paddingDp.dp.toPx() - val strokeWidth = strokeWidthDp.dp.toPx() - val levelWidth = levelWidthDp.dp.toPx() + val padding = paddingDp.dp.toPx() + val strokeWidth = strokeWidthDp.dp.toPx() + val levelWidth = levelWidthDp.dp.toPx() - repeat(level) { - this.drawLine( - if (it == level - 1) selected else color, - Offset(padding + it * levelWidth, 0f), - Offset(padding + it * levelWidth, size.height), - strokeWidth = strokeWidth, - ) - } + repeat(level) { + this.drawLine( + if (it == level - 1) selected else color, + Offset(padding + it * levelWidth, 0f), + Offset(padding + it * levelWidth, size.height), + strokeWidth = strokeWidth, + ) + } - return@drawBehind + return@drawBehind } - .padding(start = (2 + (level * 3)).dp) + .padding(start = (2 + (level * 3)).dp) @OptIn(ExperimentalFoundationApi::class) @Composable fun NoteMaster( - baseNote: Note, - modifier: Modifier = Modifier, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteState by baseNote.live().metadata.observeAsState() - val note = noteState?.note + val noteState by baseNote.live().metadata.observeAsState() + val note = noteState?.note - val noteReportsState by baseNote.live().reports.observeAsState() - val noteForReports = noteReportsState?.note ?: return + val noteReportsState by baseNote.live().reports.observeAsState() + val noteForReports = noteReportsState?.note ?: return - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return - var showHiddenNote by remember { mutableStateOf(false) } + var showHiddenNote by remember { mutableStateOf(false) } - val context = LocalContext.current + val context = LocalContext.current - val moreActionsExpanded = remember { mutableStateOf(false) } - val enablePopup = remember { { moreActionsExpanded.value = true } } + val moreActionsExpanded = remember { mutableStateOf(false) } + val enablePopup = remember { { moreActionsExpanded.value = true } } - val noteEvent = note?.event + val noteEvent = note?.event - var popupExpanded by remember { mutableStateOf(false) } + var popupExpanded by remember { mutableStateOf(false) } - val defaultBackgroundColor = MaterialTheme.colorScheme.background - val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } + val defaultBackgroundColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } - if (noteEvent == null) { - BlankNote() - } else if (!account.isAcceptable(noteForReports) && !showHiddenNote) { - val reports = remember { account.getRelevantReports(noteForReports).toImmutableSet() } + if (noteEvent == null) { + BlankNote() + } else if (!account.isAcceptable(noteForReports) && !showHiddenNote) { + val reports = remember { account.getRelevantReports(noteForReports).toImmutableSet() } - HiddenNote( - reports, - note.author?.let { account.isHidden(it) } ?: false, - accountViewModel, - Modifier, - false, - nav, - onClick = { showHiddenNote = true }, - ) - } else { - Column( - modifier.fillMaxWidth().padding(top = 10.dp), - ) { - Row( - modifier = - Modifier.padding(start = 12.dp, end = 12.dp) - .clickable(onClick = { note.author?.let { nav("User/${it.pubkeyHex}") } }), - ) { - NoteAuthorPicture( - baseNote = baseNote, - nav = nav, - accountViewModel = accountViewModel, - size = 55.dp, + HiddenNote( + reports, + note.author?.let { account.isHidden(it) } ?: false, + accountViewModel, + Modifier, + false, + nav, + onClick = { showHiddenNote = true }, ) - - Column(modifier = Modifier.padding(start = 10.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - NoteUsernameDisplay(baseNote, Modifier.weight(1f)) - - val isCommunityPost by - remember(baseNote) { - derivedStateOf { - baseNote.event?.isTaggedAddressableKind(CommunityDefinitionEvent.KIND) == true - } - } - - if (isCommunityPost) { - DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav) - } else { - DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav) - } - - Text( - timeAgo(note.createdAt(), context = context), - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - ) - - IconButton( - modifier = Modifier.then(Modifier.size(24.dp)), - onClick = enablePopup, + } else { + Column( + modifier.fillMaxWidth().padding(top = 10.dp), + ) { + Row( + modifier = + Modifier.padding(start = 12.dp, end = 12.dp) + .clickable(onClick = { note.author?.let { nav("User/${it.pubkeyHex}") } }), ) { - Icon( - imageVector = Icons.Default.MoreVert, - null, - modifier = Modifier.size(15.dp), - tint = MaterialTheme.colorScheme.placeholderText, - ) + NoteAuthorPicture( + baseNote = baseNote, + nav = nav, + accountViewModel = accountViewModel, + size = 55.dp, + ) - NoteDropDownMenu(baseNote, moreActionsExpanded, accountViewModel) + Column(modifier = Modifier.padding(start = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + NoteUsernameDisplay(baseNote, Modifier.weight(1f)) + + val isCommunityPost by + remember(baseNote) { + derivedStateOf { + baseNote.event?.isTaggedAddressableKind(CommunityDefinitionEvent.KIND) == true + } + } + + if (isCommunityPost) { + DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav) + } else { + DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav) + } + + Text( + timeAgo(note.createdAt(), context = context), + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + + IconButton( + modifier = Modifier.then(Modifier.size(24.dp)), + onClick = enablePopup, + ) { + Icon( + imageVector = Icons.Default.MoreVert, + null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) + + NoteDropDownMenu(baseNote, moreActionsExpanded, accountViewModel) + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + ObserveDisplayNip05Status( + baseNote, + remember { Modifier.weight(1f) }, + accountViewModel, + nav, + ) + + val geo = remember { noteEvent.getGeoHash() } + if (geo != null) { + DisplayLocation(geo, nav) + } + + val baseReward = remember { noteEvent.getReward()?.let { Reward(it) } } + if (baseReward != null) { + DisplayReward(baseReward, baseNote, accountViewModel, nav) + } + + val pow = remember { noteEvent.getPoWRank() } + if (pow > 20) { + DisplayPoW(pow) + } + } + } } - } - Row(verticalAlignment = Alignment.CenterVertically) { - ObserveDisplayNip05Status( - baseNote, - remember { Modifier.weight(1f) }, - accountViewModel, - nav, + Spacer(modifier = Modifier.height(10.dp)) + + if (noteEvent is BadgeDefinitionEvent) { + BadgeDisplay(baseNote = note) + } else if (noteEvent is LongTextNoteEvent) { + RenderLongFormHeaderForThread(noteEvent) + } else if (noteEvent is ClassifiedsEvent) { + RenderClassifiedsReaderForThread(noteEvent, note, accountViewModel, nav) + } + + Row( + modifier = + Modifier.padding(horizontal = 12.dp) + .combinedClickable( + onClick = {}, + onLongClick = { popupExpanded = true }, + ), + ) { + Column { + if ( + (noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && + note.channelHex() != null + ) { + ChannelHeader( + channelHex = note.channelHex()!!, + showVideo = true, + showBottomDiviser = false, + sendToChannel = true, + accountViewModel = accountViewModel, + nav = nav, + ) + } else if (noteEvent is VideoEvent) { + VideoDisplay(baseNote, false, true, backgroundColor, accountViewModel, nav) + } else if (noteEvent is FileHeaderEvent) { + FileHeaderDisplay(baseNote, true, accountViewModel) + } else if (noteEvent is FileStorageHeaderEvent) { + FileStorageHeaderDisplay(baseNote, true, accountViewModel) + } else if (noteEvent is PeopleListEvent) { + DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is AudioTrackEvent) { + AudioTrackHeader(noteEvent, baseNote, accountViewModel, nav) + } else if (noteEvent is AudioHeaderEvent) { + AudioHeader(noteEvent, baseNote, accountViewModel, nav) + } else if (noteEvent is CommunityPostApprovalEvent) { + RenderPostApproval( + baseNote, + false, + true, + backgroundColor, + accountViewModel, + nav, + ) + } else if (noteEvent is PinListEvent) { + RenderPinListEvent( + baseNote, + backgroundColor, + accountViewModel, + nav, + ) + } else if (noteEvent is EmojiPackEvent) { + RenderEmojiPack( + baseNote, + true, + backgroundColor, + accountViewModel, + ) + } else if (noteEvent is RelaySetEvent) { + DisplayRelaySet( + baseNote, + backgroundColor, + accountViewModel, + nav, + ) + } else if (noteEvent is AppDefinitionEvent) { + RenderAppDefinition(baseNote, accountViewModel, nav) + } else if (noteEvent is HighlightEvent) { + DisplayHighlight( + noteEvent.quote(), + noteEvent.author(), + noteEvent.inUrl(), + noteEvent.inPost(), + false, + true, + backgroundColor, + accountViewModel, + nav, + ) + } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { + RenderRepost(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is PollNoteEvent) { + val canPreview = + note.author == account.userProfile() || + (note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) || + !noteForReports.hasAnyReports() + + RenderPoll( + baseNote, + false, + canPreview, + backgroundColor, + accountViewModel, + nav, + ) + } else { + val canPreview = + note.author == account.userProfile() || + (note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) || + !noteForReports.hasAnyReports() + + RenderTextEvent( + baseNote, + false, + canPreview, + backgroundColor, + accountViewModel, + nav, + ) + } + } + } + + val noteEvent = baseNote.event + val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false } + if (zapSplits && noteEvent != null) { + Spacer(modifier = DoubleVertSpacer) + DisplayZapSplits(noteEvent, accountViewModel, nav) + } + + ReactionsRow(note, true, accountViewModel, nav) + + Divider( + thickness = DividerThickness, ) - - val geo = remember { noteEvent.getGeoHash() } - if (geo != null) { - DisplayLocation(geo, nav) - } - - val baseReward = remember { noteEvent.getReward()?.let { Reward(it) } } - if (baseReward != null) { - DisplayReward(baseReward, baseNote, accountViewModel, nav) - } - - val pow = remember { noteEvent.getPoWRank() } - if (pow > 20) { - DisplayPoW(pow) - } - } } - } - Spacer(modifier = Modifier.height(10.dp)) - - if (noteEvent is BadgeDefinitionEvent) { - BadgeDisplay(baseNote = note) - } else if (noteEvent is LongTextNoteEvent) { - RenderLongFormHeaderForThread(noteEvent) - } else if (noteEvent is ClassifiedsEvent) { - RenderClassifiedsReaderForThread(noteEvent, note, accountViewModel, nav) - } - - Row( - modifier = - Modifier.padding(horizontal = 12.dp) - .combinedClickable( - onClick = {}, - onLongClick = { popupExpanded = true }, - ), - ) { - Column { - if ( - (noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && - note.channelHex() != null - ) { - ChannelHeader( - channelHex = note.channelHex()!!, - showVideo = true, - showBottomDiviser = false, - sendToChannel = true, - accountViewModel = accountViewModel, - nav = nav, - ) - } else if (noteEvent is VideoEvent) { - VideoDisplay(baseNote, false, true, backgroundColor, accountViewModel, nav) - } else if (noteEvent is FileHeaderEvent) { - FileHeaderDisplay(baseNote, true, accountViewModel) - } else if (noteEvent is FileStorageHeaderEvent) { - FileStorageHeaderDisplay(baseNote, true, accountViewModel) - } else if (noteEvent is PeopleListEvent) { - DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav) - } else if (noteEvent is AudioTrackEvent) { - AudioTrackHeader(noteEvent, baseNote, accountViewModel, nav) - } else if (noteEvent is AudioHeaderEvent) { - AudioHeader(noteEvent, baseNote, accountViewModel, nav) - } else if (noteEvent is CommunityPostApprovalEvent) { - RenderPostApproval( - baseNote, - false, - true, - backgroundColor, - accountViewModel, - nav, - ) - } else if (noteEvent is PinListEvent) { - RenderPinListEvent( - baseNote, - backgroundColor, - accountViewModel, - nav, - ) - } else if (noteEvent is EmojiPackEvent) { - RenderEmojiPack( - baseNote, - true, - backgroundColor, - accountViewModel, - ) - } else if (noteEvent is RelaySetEvent) { - DisplayRelaySet( - baseNote, - backgroundColor, - accountViewModel, - nav, - ) - } else if (noteEvent is AppDefinitionEvent) { - RenderAppDefinition(baseNote, accountViewModel, nav) - } else if (noteEvent is HighlightEvent) { - DisplayHighlight( - noteEvent.quote(), - noteEvent.author(), - noteEvent.inUrl(), - noteEvent.inPost(), - false, - true, - backgroundColor, - accountViewModel, - nav, - ) - } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { - RenderRepost(baseNote, backgroundColor, accountViewModel, nav) - } else if (noteEvent is PollNoteEvent) { - val canPreview = - note.author == account.userProfile() || - (note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) || - !noteForReports.hasAnyReports() - - RenderPoll( - baseNote, - false, - canPreview, - backgroundColor, - accountViewModel, - nav, - ) - } else { - val canPreview = - note.author == account.userProfile() || - (note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) || - !noteForReports.hasAnyReports() - - RenderTextEvent( - baseNote, - false, - canPreview, - backgroundColor, - accountViewModel, - nav, - ) - } - } - } - - val noteEvent = baseNote.event - val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false } - if (zapSplits && noteEvent != null) { - Spacer(modifier = DoubleVertSpacer) - DisplayZapSplits(noteEvent, accountViewModel, nav) - } - - ReactionsRow(note, true, accountViewModel, nav) - - Divider( - thickness = DividerThickness, - ) + NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) } - - NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) - } } @Composable private fun RenderClassifiedsReaderForThread( - noteEvent: ClassifiedsEvent, - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + noteEvent: ClassifiedsEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val images = remember(noteEvent) { noteEvent.images().toImmutableList() } - val title = remember(noteEvent) { noteEvent.title() } - val summary = - remember(noteEvent) { - val sum = noteEvent.summary() - if (sum != noteEvent.content) { - sum - } else { - null - } - } - val price = remember(noteEvent) { noteEvent.price() } - val location = remember(noteEvent) { noteEvent.location() } - - Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { - Column { - if (images.isNotEmpty()) { - Row { - InlineCarrousel( - images, - images.first(), - ) + val images = remember(noteEvent) { noteEvent.images().toImmutableList() } + val title = remember(noteEvent) { noteEvent.title() } + val summary = + remember(noteEvent) { + val sum = noteEvent.summary() + if (sum != noteEvent.content) { + sum + } else { + null + } } - } else { - CreateImageHeader(note, accountViewModel) - } + val price = remember(noteEvent) { noteEvent.price() } + val location = remember(noteEvent) { noteEvent.location() } - Row( - Modifier.padding(top = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - title?.let { - Text( - text = it, - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.weight(1f), - ) - } - } - - price?.let { - Row( - Modifier.padding(top = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - val newAmount = price.amount.toBigDecimalOrNull()?.let { showAmount(it) } ?: price.amount - - val priceTag = - remember(noteEvent) { - if (price.frequency != null && price.currency != null) { - "$newAmount ${price.currency}/${price.frequency}" - } else if (price.currency != null) { - "$newAmount ${price.currency}" - } else { - newAmount - } + Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { + Column { + if (images.isNotEmpty()) { + Row { + InlineCarrousel( + images, + images.first(), + ) + } + } else { + CreateImageHeader(note, accountViewModel) } - Text( - text = priceTag, - maxLines = 1, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - ) - - location?.let { - Text( - text = it, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } - - summary?.let { - Row( - Modifier.padding(top = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.weight(1f), - color = Color.Gray, - overflow = TextOverflow.Ellipsis, - ) - } - } - - Row( - Modifier.padding(start = 20.dp, end = 20.dp, bottom = 5.dp, top = 15.dp).fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - painter = painterResource(R.drawable.ic_dm), - stringResource(R.string.send_a_direct_message), - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary, - ) - - Spacer(modifier = StdHorzSpacer) - - Text(stringResource(id = R.string.send_the_seller_a_message)) - } - - Row( - modifier = - Modifier.padding(start = 10.dp, end = 10.dp, bottom = 5.dp, top = 5.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - val sellerName = note.author?.bestDisplayName() ?: note.author?.bestUsername() - - val msg = - if (sellerName != null) { - stringResource( - id = R.string.hi_seller_is_this_still_available, - sellerName, - ) - } else { - stringResource(id = R.string.hi_there_is_this_still_available) - } - - var message by remember { mutableStateOf(TextFieldValue(msg)) } - - TextField( - value = message, - onValueChange = { message = it }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - shape = EditFieldBorder, - modifier = Modifier.weight(1f, true), - placeholder = { - Text( - text = stringResource(R.string.reply_here), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - trailingIcon = { - ThinSendButton( - isActive = message.text.isNotBlank(), - modifier = EditFieldTrailingIconModifier, + Row( + Modifier.padding(top = 10.dp), + verticalAlignment = Alignment.CenterVertically, ) { - note.author?.let { nav(routeToMessage(it, msg, accountViewModel)) } + title?.let { + Text( + text = it, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.weight(1f), + ) + } } - }, - colors = - TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), - ) - } + + price?.let { + Row( + Modifier.padding(top = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val newAmount = price.amount.toBigDecimalOrNull()?.let { showAmount(it) } ?: price.amount + + val priceTag = + remember(noteEvent) { + if (price.frequency != null && price.currency != null) { + "$newAmount ${price.currency}/${price.frequency}" + } else if (price.currency != null) { + "$newAmount ${price.currency}" + } else { + newAmount + } + } + + Text( + text = priceTag, + maxLines = 1, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + + location?.let { + Text( + text = it, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + summary?.let { + Row( + Modifier.padding(top = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + color = Color.Gray, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Row( + Modifier.padding(start = 20.dp, end = 20.dp, bottom = 5.dp, top = 15.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.ic_dm), + stringResource(R.string.send_a_direct_message), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(modifier = StdHorzSpacer) + + Text(stringResource(id = R.string.send_the_seller_a_message)) + } + + Row( + modifier = + Modifier.padding(start = 10.dp, end = 10.dp, bottom = 5.dp, top = 5.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + val sellerName = note.author?.bestDisplayName() ?: note.author?.bestUsername() + + val msg = + if (sellerName != null) { + stringResource( + id = R.string.hi_seller_is_this_still_available, + sellerName, + ) + } else { + stringResource(id = R.string.hi_there_is_this_still_available) + } + + var message by remember { mutableStateOf(TextFieldValue(msg)) } + + TextField( + value = message, + onValueChange = { message = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + shape = EditFieldBorder, + modifier = Modifier.weight(1f, true), + placeholder = { + Text( + text = stringResource(R.string.reply_here), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + trailingIcon = { + ThinSendButton( + isActive = message.text.isNotBlank(), + modifier = EditFieldTrailingIconModifier, + ) { + note.author?.let { nav(routeToMessage(it, msg, accountViewModel)) } + } + }, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + } + } } - } } @Composable private fun RenderLongFormHeaderForThread(noteEvent: LongTextNoteEvent) { - Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { - Column { - noteEvent.image()?.let { - AsyncImage( - model = it, - contentDescription = - stringResource( - R.string.preview_card_image_for, - it, - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) - } + Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { + Column { + noteEvent.image()?.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } - noteEvent.title()?.let { - Spacer(modifier = DoubleVertSpacer) - Text( - text = it, - fontSize = 28.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.fillMaxWidth(), - ) - } + noteEvent.title()?.let { + Spacer(modifier = DoubleVertSpacer) + Text( + text = it, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.fillMaxWidth(), + ) + } - noteEvent - .summary() - ?.ifBlank { null } - ?.let { - Spacer(modifier = DoubleVertSpacer) - Text( - text = it, - modifier = Modifier.fillMaxWidth(), - color = Color.Gray, - ) + noteEvent + .summary() + ?.ifBlank { null } + ?.let { + Spacer(modifier = DoubleVertSpacer) + Text( + text = it, + modifier = Modifier.fillMaxWidth(), + color = Color.Gray, + ) + } } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt index bae76d22a..6b687e67e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt @@ -25,11 +25,11 @@ import com.vitorpamplona.amethyst.model.User import kotlinx.collections.immutable.ImmutableList sealed class UserFeedState { - object Loading : UserFeedState() + object Loading : UserFeedState() - class Loaded(val feed: MutableState>) : UserFeedState() + class Loaded(val feed: MutableState>) : UserFeedState() - object Empty : UserFeedState() + object Empty : UserFeedState() - class FeedError(val errorMessage: String) : UserFeedState() + class FeedError(val errorMessage: String) : UserFeedState() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt index 1a8a6090f..b82da0e36 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt @@ -34,54 +34,54 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding @Composable fun RefreshingFeedUserFeedView( - viewModel: UserFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - enablePullRefresh: Boolean = true, + viewModel: UserFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + enablePullRefresh: Boolean = true, ) { - RefresheableView(viewModel, enablePullRefresh) { UserFeedView(viewModel, accountViewModel, nav) } + RefresheableView(viewModel, enablePullRefresh) { UserFeedView(viewModel, accountViewModel, nav) } } @Composable fun UserFeedView( - viewModel: UserFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + viewModel: UserFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> - when (state) { - is UserFeedState.Empty -> { - FeedEmpty { viewModel.invalidateData() } - } - is UserFeedState.FeedError -> { - FeedError(state.errorMessage) { viewModel.invalidateData() } - } - is UserFeedState.Loaded -> { - FeedLoaded(state, accountViewModel, nav) - } - is UserFeedState.Loading -> { - LoadingFeed() - } + Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> + when (state) { + is UserFeedState.Empty -> { + FeedEmpty { viewModel.invalidateData() } + } + is UserFeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + is UserFeedState.Loaded -> { + FeedLoaded(state, accountViewModel, nav) + } + is UserFeedState.Loading -> { + LoadingFeed() + } + } } - } } @Composable private fun FeedLoaded( - state: UserFeedState.Loaded, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + state: UserFeedState.Loaded, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val listState = rememberLazyListState() + val listState = rememberLazyListState() - LazyColumn( - contentPadding = FeedPadding, - state = listState, - ) { - itemsIndexed(state.feed.value, key = { _, item -> item.pubkeyHex }) { _, item -> - UserCompose(item, accountViewModel = accountViewModel, nav = nav) + LazyColumn( + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(state.feed.value, key = { _, item -> item.pubkeyHex }) { _, item -> + UserCompose(item, accountViewModel = accountViewModel, nav = nav) + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt index 662c75e4e..352674e31 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt @@ -46,127 +46,119 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class NostrUserProfileFollowsUserFeedViewModel(val user: User, val account: Account) : - UserFeedViewModel(UserProfileFollowsFeedFilter(user, account)) { - class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrUserProfileFollowsUserFeedViewModel { - return NostrUserProfileFollowsUserFeedViewModel(user, account) - as NostrUserProfileFollowsUserFeedViewModel + UserFeedViewModel(UserProfileFollowsFeedFilter(user, account)) { + class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrUserProfileFollowsUserFeedViewModel { + return NostrUserProfileFollowsUserFeedViewModel(user, account) + as NostrUserProfileFollowsUserFeedViewModel + } } - } } class NostrUserProfileFollowersUserFeedViewModel(val user: User, val account: Account) : - UserFeedViewModel(UserProfileFollowersFeedFilter(user, account)) { - class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrUserProfileFollowersUserFeedViewModel { - return NostrUserProfileFollowersUserFeedViewModel(user, account) - as NostrUserProfileFollowersUserFeedViewModel + UserFeedViewModel(UserProfileFollowersFeedFilter(user, account)) { + class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrUserProfileFollowersUserFeedViewModel { + return NostrUserProfileFollowersUserFeedViewModel(user, account) + as NostrUserProfileFollowersUserFeedViewModel + } } - } } class NostrHiddenAccountsFeedViewModel(val account: Account) : - UserFeedViewModel(HiddenAccountsFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrHiddenAccountsFeedViewModel { - return NostrHiddenAccountsFeedViewModel(account) as NostrHiddenAccountsFeedViewModel + UserFeedViewModel(HiddenAccountsFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrHiddenAccountsFeedViewModel { + return NostrHiddenAccountsFeedViewModel(account) as NostrHiddenAccountsFeedViewModel + } } - } } class NostrSpammerAccountsFeedViewModel(val account: Account) : - UserFeedViewModel(SpammerAccountsFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): NostrSpammerAccountsFeedViewModel { - return NostrSpammerAccountsFeedViewModel(account) as NostrSpammerAccountsFeedViewModel + UserFeedViewModel(SpammerAccountsFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrSpammerAccountsFeedViewModel { + return NostrSpammerAccountsFeedViewModel(account) as NostrSpammerAccountsFeedViewModel + } } - } } @Stable open class UserFeedViewModel(val dataSource: FeedFilter) : - ViewModel(), InvalidatableViewModel { - private val _feedContent = MutableStateFlow(UserFeedState.Loading) - val feedContent = _feedContent.asStateFlow() + ViewModel(), InvalidatableViewModel { + private val _feedContent = MutableStateFlow(UserFeedState.Loading) + val feedContent = _feedContent.asStateFlow() - private fun refresh() { - viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } - } - - private fun refreshSuspended() { - checkNotInMainThread() - - val notes = dataSource.loadTop().toImmutableList() - - val oldNotesState = _feedContent.value - if (oldNotesState is UserFeedState.Loaded) { - // Using size as a proxy for has changed. - if (!equalImmutableLists(notes, oldNotesState.feed.value)) { - updateFeed(notes) - } - } else { - updateFeed(notes) + private fun refresh() { + viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } } - } - private fun updateFeed(notes: ImmutableList) { - viewModelScope.launch(Dispatchers.Main) { - val currentState = _feedContent.value - if (notes.isEmpty()) { - _feedContent.update { UserFeedState.Empty } - } else if (currentState is UserFeedState.Loaded) { - // updates the current list - currentState.feed.value = notes - } else { - _feedContent.update { UserFeedState.Loaded(mutableStateOf(notes)) } - } - } - } - - private val bundler = BundledUpdate(250, Dispatchers.IO) - - override fun invalidateData(ignoreIfDoing: Boolean) { - viewModelScope.launch(Dispatchers.IO) { - bundler.invalidate(ignoreIfDoing) { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - refreshSuspended() - } - } - } - - var collectorJob: Job? = null - - init { - Log.d("Init", "${this.javaClass.simpleName}") - collectorJob = - viewModelScope.launch(Dispatchers.IO) { + private fun refreshSuspended() { checkNotInMainThread() - LocalCache.live.newEventBundles.collect { newNotes -> - checkNotInMainThread() + val notes = dataSource.loadTop().toImmutableList() - invalidateData() + val oldNotesState = _feedContent.value + if (oldNotesState is UserFeedState.Loaded) { + // Using size as a proxy for has changed. + if (!equalImmutableLists(notes, oldNotesState.feed.value)) { + updateFeed(notes) + } + } else { + updateFeed(notes) } - } - } + } - override fun onCleared() { - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - bundler.cancel() - collectorJob?.cancel() - super.onCleared() - } + private fun updateFeed(notes: ImmutableList) { + viewModelScope.launch(Dispatchers.Main) { + val currentState = _feedContent.value + if (notes.isEmpty()) { + _feedContent.update { UserFeedState.Empty } + } else if (currentState is UserFeedState.Loaded) { + // updates the current list + currentState.feed.value = notes + } else { + _feedContent.update { UserFeedState.Loaded(mutableStateOf(notes)) } + } + } + } + + private val bundler = BundledUpdate(250, Dispatchers.IO) + + override fun invalidateData(ignoreIfDoing: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + bundler.invalidate(ignoreIfDoing) { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + refreshSuspended() + } + } + } + + var collectorJob: Job? = null + + init { + Log.d("Init", "${this.javaClass.simpleName}") + collectorJob = + viewModelScope.launch(Dispatchers.IO) { + checkNotInMainThread() + + LocalCache.live.newEventBundles.collect { newNotes -> + checkNotInMainThread() + + invalidateData() + } + } + } + + override fun onCleared() { + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + bundler.cancel() + collectorJob?.cancel() + super.onCleared() + } } interface InvalidatableViewModel { - fun invalidateData(ignoreIfDoing: Boolean = false) + fun invalidateData(ignoreIfDoing: Boolean = false) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt index 65d006b49..28871c35d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt @@ -73,119 +73,119 @@ import kotlinx.coroutines.launch @Composable fun AccountBackupDialog( - accountViewModel: AccountViewModel, - onClose: () -> Unit, + accountViewModel: AccountViewModel, + onClose: () -> Unit, ) { - Dialog( - onDismissRequest = onClose, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Surface(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier.background(MaterialTheme.colorScheme.background).fillMaxSize(), - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CloseButton(onPress = onClose) + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.background(MaterialTheme.colorScheme.background).fillMaxSize(), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton(onPress = onClose) + } + + Column( + modifier = Modifier.fillMaxSize().padding(horizontal = 30.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Material3RichText( + style = RichTextStyle().resolveDefaults(), + ) { + Markdown( + content = stringResource(R.string.account_backup_tips_md), + ) + } + + Spacer(modifier = Modifier.height(30.dp)) + + NSecCopyButton(accountViewModel) + } + } } - - Column( - modifier = Modifier.fillMaxSize().padding(horizontal = 30.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Material3RichText( - style = RichTextStyle().resolveDefaults(), - ) { - Markdown( - content = stringResource(R.string.account_backup_tips_md), - ) - } - - Spacer(modifier = Modifier.height(30.dp)) - - NSecCopyButton(accountViewModel) - } - } } - } } @Composable private fun NSecCopyButton(accountViewModel: AccountViewModel) { - val clipboardManager = LocalClipboardManager.current - val context = LocalContext.current - val scope = rememberCoroutineScope() + val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current + val scope = rememberCoroutineScope() - val keyguardLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { - result: ActivityResult -> - if (result.resultCode == Activity.RESULT_OK) { - copyNSec(context, scope, accountViewModel.account, clipboardManager) - } + val keyguardLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + result: ActivityResult -> + if (result.resultCode == Activity.RESULT_OK) { + copyNSec(context, scope, accountViewModel.account, clipboardManager) + } + } + + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { + authenticate( + title = context.getString(R.string.copy_my_secret_key), + context = context, + keyguardLauncher = keyguardLauncher, + onApproved = { copyNSec(context, scope, accountViewModel.account, clipboardManager) }, + onError = { title, message -> accountViewModel.toast(title, message) }, + ) + }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Icon( + tint = MaterialTheme.colorScheme.onPrimary, + imageVector = Icons.Default.Key, + contentDescription = + stringResource(R.string.copies_the_nsec_id_your_password_to_the_clipboard_for_backup), + modifier = Modifier.padding(end = 5.dp), + ) + Text( + stringResource(id = R.string.copy_my_secret_key), + color = MaterialTheme.colorScheme.onPrimary, + ) } - - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { - authenticate( - title = context.getString(R.string.copy_my_secret_key), - context = context, - keyguardLauncher = keyguardLauncher, - onApproved = { copyNSec(context, scope, accountViewModel.account, clipboardManager) }, - onError = { title, message -> accountViewModel.toast(title, message) }, - ) - }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - contentPadding = ButtonPadding, - ) { - Icon( - tint = MaterialTheme.colorScheme.onPrimary, - imageVector = Icons.Default.Key, - contentDescription = - stringResource(R.string.copies_the_nsec_id_your_password_to_the_clipboard_for_backup), - modifier = Modifier.padding(end = 5.dp), - ) - Text( - stringResource(id = R.string.copy_my_secret_key), - color = MaterialTheme.colorScheme.onPrimary, - ) - } } fun Context.getFragmentActivity(): FragmentActivity? { - var currentContext = this - while (currentContext is ContextWrapper) { - if (currentContext is FragmentActivity) { - return currentContext + var currentContext = this + while (currentContext is ContextWrapper) { + if (currentContext is FragmentActivity) { + return currentContext + } + currentContext = currentContext.baseContext } - currentContext = currentContext.baseContext - } - return null + return null } private fun copyNSec( - context: Context, - scope: CoroutineScope, - account: Account, - clipboardManager: ClipboardManager, + context: Context, + scope: CoroutineScope, + account: Account, + clipboardManager: ClipboardManager, ) { - account.keyPair.privKey?.let { - clipboardManager.setText(AnnotatedString(it.toNsec())) - scope.launch { - Toast.makeText( - context, - context.getString(R.string.secret_key_copied_to_clipboard), - Toast.LENGTH_SHORT, - ) - .show() + account.keyPair.privKey?.let { + clipboardManager.setText(AnnotatedString(it.toNsec())) + scope.launch { + Toast.makeText( + context, + context.getString(R.string.secret_key_copied_to_clipboard), + Toast.LENGTH_SHORT, + ) + .show() + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index e9b72677f..dc7dd7ed1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -82,9 +82,6 @@ import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.events.UserMetadata import com.vitorpamplona.quartz.utils.TimeUtils -import java.util.Locale -import kotlin.coroutines.resume -import kotlin.time.measureTimedValue import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf @@ -98,6 +95,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull +import java.util.Locale +import kotlin.coroutines.resume +import kotlin.time.measureTimedValue @Immutable open class ToastMsg() @@ -107,1149 +107,1143 @@ import kotlinx.coroutines.withTimeoutOrNull @Stable class AccountViewModel(val account: Account, val settings: SettingsState) : ViewModel(), Dao { - val accountLiveData: LiveData = account.live.map { it } - val accountLanguagesLiveData: LiveData = account.liveLanguages.map { it } - val accountMarkAsReadUpdates = mutableIntStateOf(0) + val accountLiveData: LiveData = account.live.map { it } + val accountLanguagesLiveData: LiveData = account.liveLanguages.map { it } + val accountMarkAsReadUpdates = mutableIntStateOf(0) - val userFollows: LiveData = account.userProfile().live().follows.map { it } - val userRelays: LiveData = account.userProfile().live().relays.map { it } + val userFollows: LiveData = account.userProfile().live().follows.map { it } + val userRelays: LiveData = account.userProfile().live().relays.map { it } - val toasts = MutableSharedFlow(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val toasts = MutableSharedFlow(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST) - var serviceManager: ServiceManager? = null + var serviceManager: ServiceManager? = null - val showSensitiveContentChanges = - account.live.map { it.account.showSensitiveContent }.distinctUntilChanged() + val showSensitiveContentChanges = + account.live.map { it.account.showSensitiveContent }.distinctUntilChanged() - fun clearToasts() { - viewModelScope.launch { toasts.emit(null) } - } + fun clearToasts() { + viewModelScope.launch { toasts.emit(null) } + } - fun toast( - title: String, - message: String, - ) { - viewModelScope.launch { toasts.emit(StringToastMsg(title, message)) } - } + fun toast( + title: String, + message: String, + ) { + viewModelScope.launch { toasts.emit(StringToastMsg(title, message)) } + } - fun toast( - titleResId: Int, - resourceId: Int, - ) { - viewModelScope.launch { toasts.emit(ResourceToastMsg(titleResId, resourceId)) } - } + fun toast( + titleResId: Int, + resourceId: Int, + ) { + viewModelScope.launch { toasts.emit(ResourceToastMsg(titleResId, resourceId)) } + } - fun isWriteable(): Boolean { - return account.isWriteable() - } + fun isWriteable(): Boolean { + return account.isWriteable() + } - fun userProfile(): User { - return account.userProfile() - } + fun userProfile(): User { + return account.userProfile() + } - suspend fun reactTo( - note: Note, - reaction: String, - ) { - account.reactTo(note, reaction) - } - - fun reactToOrDelete( - note: Note, - reaction: String, - ) { - viewModelScope.launch(Dispatchers.IO) { - val currentReactions = account.reactionTo(note, reaction) - if (currentReactions.isNotEmpty()) { - account.delete(currentReactions) - } else { + suspend fun reactTo( + note: Note, + reaction: String, + ) { account.reactTo(note, reaction) - } } - } - fun reactToOrDelete(note: Note) { - viewModelScope.launch(Dispatchers.IO) { - val reaction = account.reactionChoices.first() - if (hasReactedTo(note, reaction)) { - deleteReactionTo(note, reaction) - } else { - reactTo(note, reaction) - } - } - } - - fun isNoteHidden(note: Note): Boolean { - return note.isHiddenFor(account.flowHiddenUsers.value) - } - - fun hasReactedTo( - baseNote: Note, - reaction: String, - ): Boolean { - return account.hasReacted(baseNote, reaction) - } - - suspend fun deleteReactionTo( - note: Note, - reaction: String, - ) { - account.delete(account.reactionTo(note, reaction)) - } - - fun hasBoosted(baseNote: Note): Boolean { - return account.hasBoosted(baseNote) - } - - fun deleteBoostsTo(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.delete(account.boostsTo(note)) } - } - - fun calculateIfNoteWasZappedByAccount( - zappedNote: Note, - onWasZapped: (Boolean) -> Unit, - ) { - viewModelScope.launch(Dispatchers.Default) { - account.calculateIfNoteWasZappedByAccount(zappedNote) { onWasZapped(true) } - } - } - - fun calculateZapAmount( - zappedNote: Note, - onZapAmount: (String) -> Unit, - ) { - if (zappedNote.zapPayments.isNotEmpty()) { - viewModelScope.launch(Dispatchers.IO) { - account.calculateZappedAmount(zappedNote) { onZapAmount(showAmount(it)) } - } - } else { - onZapAmount(showAmount(zappedNote.zapsAmount)) - } - } - - fun calculateZapraiser( - zappedNote: Note, - onZapraiserStatus: (ZapraiserStatus) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - val zapraiserAmount = zappedNote.event?.zapraiserAmount() ?: 0 - account.calculateZappedAmount(zappedNote) { newZapAmount -> - var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat() - - if (percentage > 1) { - percentage = 1f - } - - val newZapraiserProgress = percentage - val newZapraiserLeft = - if (percentage > 0.99) { - "0" - } else { - showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal()) - } - onZapraiserStatus(ZapraiserStatus(newZapraiserProgress, newZapraiserLeft)) - } - } - } - - fun decryptAmountMessageInGroup( - zaps: ImmutableList, - onNewState: (ImmutableList) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - val myList = zaps.toList() - - val initialResults = - myList - .associate { - it.request to - ZapAmountCommentNotification( - it.request.author, - it.request.event?.content()?.ifBlank { null }, - showAmountAxis((it.response?.event as? LnZapEvent)?.amount), - ) - } - .toMutableMap() - - collectSuccessfulSigningOperations( - operationsInput = myList, - runRequestFor = { next, onReady -> - innerDecryptAmountMessage(next.request, next.response, onReady) - }, - ) { - it.forEach { decrypted -> initialResults[decrypted.key.request] = decrypted.value } - - onNewState(initialResults.values.toImmutableList()) - } - } - } - - fun cachedDecryptAmountMessageInGroup( - zapNotes: List - ): ImmutableList { - return zapNotes - .map { - val request = it.request.event as? LnZapRequestEvent - if (request?.isPrivateZap() == true) { - val cachedPrivateRequest = request.cachedPrivateZap() - if (cachedPrivateRequest != null) { - ZapAmountCommentNotification( - LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.request.author, - cachedPrivateRequest.content.ifBlank { null }, - showAmountAxis((it.response.event as? LnZapEvent)?.amount), - ) - } else { - ZapAmountCommentNotification( - it.request.author, - it.request.event?.content()?.ifBlank { null }, - showAmountAxis((it.response.event as? LnZapEvent)?.amount), - ) - } - } else { - ZapAmountCommentNotification( - it.request.author, - it.request.event?.content()?.ifBlank { null }, - showAmountAxis((it.response.event as? LnZapEvent)?.amount), - ) - } - } - .toImmutableList() - } - - fun cachedDecryptAmountMessageInGroup( - baseNote: Note - ): ImmutableList { - val myList = baseNote.zaps.toList() - - return myList - .map { - val request = it.first.event as? LnZapRequestEvent - if (request?.isPrivateZap() == true) { - val cachedPrivateRequest = request.cachedPrivateZap() - if (cachedPrivateRequest != null) { - ZapAmountCommentNotification( - LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.first.author, - cachedPrivateRequest.content.ifBlank { null }, - showAmountAxis((it.second?.event as? LnZapEvent)?.amount), - ) - } else { - ZapAmountCommentNotification( - it.first.author, - it.first.event?.content()?.ifBlank { null }, - showAmountAxis((it.second?.event as? LnZapEvent)?.amount), - ) - } - } else { - ZapAmountCommentNotification( - it.first.author, - it.first.event?.content()?.ifBlank { null }, - showAmountAxis((it.second?.event as? LnZapEvent)?.amount), - ) - } - } - .toImmutableList() - } - - fun decryptAmountMessageInGroup( - baseNote: Note, - onNewState: (ImmutableList) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - val myList = baseNote.zaps.toList() - - val initialResults = - myList - .associate { - it.first to - ZapAmountCommentNotification( - it.first.author, - it.first.event?.content()?.ifBlank { null }, - showAmountAxis((it.second?.event as? LnZapEvent)?.amount), - ) - } - .toMutableMap() - - collectSuccessfulSigningOperations, ZapAmountCommentNotification>( - operationsInput = myList, - runRequestFor = { next, onReady -> - innerDecryptAmountMessage(next.first, next.second, onReady) - }, - ) { - it.forEach { decrypted -> initialResults[decrypted.key.first] = decrypted.value } - - onNewState(initialResults.values.toImmutableList()) - } - } - } - - fun decryptAmountMessage( - zapRequest: Note, - zapEvent: Note?, - onNewState: (ZapAmountCommentNotification?) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - innerDecryptAmountMessage(zapRequest, zapEvent, onNewState) - } - } - - private fun innerDecryptAmountMessage( - zapRequest: Note, - zapEvent: Note?, - onReady: (ZapAmountCommentNotification) -> Unit, - ) { - checkNotInMainThread() - - (zapRequest.event as? LnZapRequestEvent)?.let { - if (it.isPrivateZap()) { - decryptZap(zapRequest) { decryptedContent -> - val amount = (zapEvent?.event as? LnZapEvent)?.amount - val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey) - onReady( - ZapAmountCommentNotification( - newAuthor, - decryptedContent.content.ifBlank { null }, - showAmountAxis(amount), - ), - ) - } - } else { - val amount = (zapEvent?.event as? LnZapEvent)?.amount - if (!zapRequest.event?.content().isNullOrBlank() || amount != null) { - onReady( - ZapAmountCommentNotification( - zapRequest.author, - zapRequest.event?.content()?.ifBlank { null }, - showAmountAxis(amount), - ), - ) - } - } - } - } - - fun zap( - note: Note, - amount: Long, - pollOption: Int?, - message: String, - context: Context, - onError: (String, String) -> Unit, - onProgress: (percent: Float) -> Unit, - onPayViaIntent: (ImmutableList) -> Unit, - zapType: LnZapEvent.ZapType, - ) { - viewModelScope.launch(Dispatchers.IO) { - ZapPaymentHandler(account) - .zap( - note, - amount, - pollOption, - message, - context, - onError, - onProgress, - onPayViaIntent, - zapType, - ) - } - } - - fun report( - note: Note, - type: ReportEvent.ReportType, - content: String = "", - ) { - viewModelScope.launch(Dispatchers.IO) { account.report(note, type, content) } - } - - fun report( - user: User, - type: ReportEvent.ReportType, - ) { - viewModelScope.launch(Dispatchers.IO) { - account.report(user, type) - account.hideUser(user.pubkeyHex) - } - } - - fun boost(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.boost(note) } - } - - fun removeEmojiPack( - usersEmojiList: Note, - emojiList: Note, - ) { - viewModelScope.launch(Dispatchers.IO) { account.removeEmojiPack(usersEmojiList, emojiList) } - } - - fun addEmojiPack( - usersEmojiList: Note, - emojiList: Note, - ) { - viewModelScope.launch(Dispatchers.IO) { account.addEmojiPack(usersEmojiList, emojiList) } - } - - fun addPrivateBookmark(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.addBookmark(note, true) } - } - - fun addPublicBookmark(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.addBookmark(note, false) } - } - - fun removePrivateBookmark(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.removeBookmark(note, true) } - } - - fun removePublicBookmark(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.removeBookmark(note, false) } - } - - fun isInPrivateBookmarks( - note: Note, - onReady: (Boolean) -> Unit, - ) { - account.isInPrivateBookmarks(note, onReady) - } - - fun isInPublicBookmarks(note: Note): Boolean { - return account.isInPublicBookmarks(note) - } - - fun broadcast(note: Note) { - account.broadcast(note) - } - - fun delete(note: Note) { - viewModelScope.launch(Dispatchers.IO) { account.delete(note) } - } - - fun cachedDecrypt(note: Note): String? { - return account.cachedDecryptContent(note) - } - - fun decrypt( - note: Note, - onReady: (String) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { account.decryptContent(note, onReady) } - } - - fun decryptZap( - note: Note, - onReady: (Event) -> Unit, - ) { - account.decryptZapContentAuthor(note, onReady) - } - - fun translateTo(lang: Locale) { - account.updateTranslateTo(lang.language) - } - - fun dontTranslateFrom(lang: String) { - account.addDontTranslateFrom(lang) - } - - fun prefer( - source: String, - target: String, - preference: String, - ) { - account.prefer(source, target, preference) - } - - fun follow(user: User) { - viewModelScope.launch(Dispatchers.IO) { account.follow(user) } - } - - fun unfollow(user: User) { - viewModelScope.launch(Dispatchers.IO) { account.unfollow(user) } - } - - fun followGeohash(tag: String) { - viewModelScope.launch(Dispatchers.IO) { account.followGeohash(tag) } - } - - fun unfollowGeohash(tag: String) { - viewModelScope.launch(Dispatchers.IO) { account.unfollowGeohash(tag) } - } - - fun followHashtag(tag: String) { - viewModelScope.launch(Dispatchers.IO) { account.followHashtag(tag) } - } - - fun unfollowHashtag(tag: String) { - viewModelScope.launch(Dispatchers.IO) { account.unfollowHashtag(tag) } - } - - fun showWord(word: String) { - viewModelScope.launch(Dispatchers.IO) { account.showWord(word) } - } - - fun hideWord(word: String) { - viewModelScope.launch(Dispatchers.IO) { account.hideWord(word) } - } - - fun isLoggedUser(user: User?): Boolean { - return account.userProfile().pubkeyHex == user?.pubkeyHex - } - - fun isFollowing(user: User?): Boolean { - if (user == null) return false - return account.userProfile().isFollowingCached(user) - } - - fun isFollowing(user: HexKey): Boolean { - return account.userProfile().isFollowingCached(user) - } - - val hideDeleteRequestDialog: Boolean - get() = account.hideDeleteRequestDialog - - fun dontShowDeleteRequestDialog() { - viewModelScope.launch(Dispatchers.IO) { account.setHideDeleteRequestDialog() } - } - - val hideNIP24WarningDialog: Boolean - get() = account.hideNIP24WarningDialog - - fun dontShowNIP24WarningDialog() { - account.setHideNIP24WarningDialog() - } - - val hideBlockAlertDialog: Boolean - get() = account.hideBlockAlertDialog - - fun dontShowBlockAlertDialog() { - account.setHideBlockAlertDialog() - } - - fun hideSensitiveContent() { - account.updateShowSensitiveContent(false) - } - - fun disableContentWarnings() { - account.updateShowSensitiveContent(true) - } - - fun seeContentWarnings() { - account.updateShowSensitiveContent(null) - } - - fun defaultZapType(): LnZapEvent.ZapType { - return account.defaultZapType - } - - @Immutable - data class NoteComposeReportState( - val isAcceptable: Boolean = true, - val canPreview: Boolean = true, - val isHiddenAuthor: Boolean = false, - val relevantReports: ImmutableSet = persistentSetOf(), - ) - - fun isNoteAcceptable( - note: Note, - onReady: (NoteComposeReportState) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - val isFromLoggedIn = note.author?.pubkeyHex == userProfile().pubkeyHex - val isFromLoggedInFollow = note.author?.let { userProfile().isFollowingCached(it) } ?: true - - if (isFromLoggedIn || isFromLoggedInFollow) { - // No need to process if from trusted people - onReady(NoteComposeReportState(true, true, false, persistentSetOf())) - } else if (note.author?.let { account.isHidden(it) } == true) { - onReady(NoteComposeReportState(false, false, true, persistentSetOf())) - } else { - val newCanPreview = !note.hasAnyReports() - - val newIsAcceptable = account.isAcceptable(note) - - if (newCanPreview && newIsAcceptable) { - // No need to process reports if nothing is wrong - onReady(NoteComposeReportState(true, true, false, persistentSetOf())) - } else { - val newRelevantReports = account.getRelevantReports(note) - - onReady( - NoteComposeReportState( - newIsAcceptable, - newCanPreview, - false, - newRelevantReports.toImmutableSet(), - ), - ) - } - } - } - } - - fun unwrap( - event: GiftWrapEvent, - onReady: (Event) -> Unit, - ) { - account.unwrap(event, onReady) - } - - fun unseal( - event: SealedGossipEvent, - onReady: (Event) -> Unit, - ) { - account.unseal(event, onReady) - } - - fun show(user: User) { - viewModelScope.launch(Dispatchers.IO) { account.showUser(user.pubkeyHex) } - } - - fun hide(user: User) { - viewModelScope.launch(Dispatchers.IO) { account.hideUser(user.pubkeyHex) } - } - - fun hide(word: String) { - viewModelScope.launch(Dispatchers.IO) { account.hideWord(word) } - } - - fun showUser(pubkeyHex: String) { - viewModelScope.launch(Dispatchers.IO) { account.showUser(pubkeyHex) } - } - - fun createStatus(newStatus: String) { - viewModelScope.launch(Dispatchers.IO) { account.createStatus(newStatus) } - } - - fun updateStatus( - it: ATag, - newStatus: String, - ) { - viewModelScope.launch(Dispatchers.IO) { - account.updateStatus(LocalCache.getOrCreateAddressableNote(it), newStatus) - } - } - - fun deleteStatus(it: ATag) { - viewModelScope.launch(Dispatchers.IO) { - account.deleteStatus(LocalCache.getOrCreateAddressableNote(it)) - } - } - - fun urlPreview( - url: String, - onResult: suspend (UrlPreviewState) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { UrlCachedPreviewer.previewInfo(url, onResult) } - } - - fun loadReactionTo( - note: Note?, - onNewReactionType: (String?) -> Unit, - ) { - if (note == null) return - - viewModelScope.launch(Dispatchers.Default) { - onNewReactionType(note.getReactionBy(userProfile())) - } - } - - fun verifyNip05( - userMetadata: UserMetadata, - pubkeyHex: String, - onResult: (Boolean) -> Unit, - ) { - val nip05 = userMetadata.nip05?.ifBlank { null } ?: return - - viewModelScope.launch(Dispatchers.IO) { - Nip05NostrAddressVerifier() - .verifyNip05( - nip05, - onSuccess = { - // Marks user as verified - if (it == pubkeyHex) { - userMetadata.nip05Verified = true - userMetadata.nip05LastVerificationTime = TimeUtils.now() - - onResult(userMetadata.nip05Verified) + fun reactToOrDelete( + note: Note, + reaction: String, + ) { + viewModelScope.launch(Dispatchers.IO) { + val currentReactions = account.reactionTo(note, reaction) + if (currentReactions.isNotEmpty()) { + account.delete(currentReactions) } else { - userMetadata.nip05Verified = false - userMetadata.nip05LastVerificationTime = 0 - - onResult(userMetadata.nip05Verified) + account.reactTo(note, reaction) } - }, - onError = { - userMetadata.nip05LastVerificationTime = 0 - userMetadata.nip05Verified = false - - onResult(userMetadata.nip05Verified) - }, - ) - } - } - - fun retrieveRelayDocument( - dirtyUrl: String, - onInfo: (RelayInformation) -> Unit, - onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - Nip11CachedRetriever.loadRelayInfo(dirtyUrl, onInfo, onError) - } - } - - fun runOnIO(runOnIO: () -> Unit) { - viewModelScope.launch(Dispatchers.IO) { runOnIO() } - } - - suspend fun checkGetOrCreateUser(key: HexKey): User? { - return LocalCache.checkGetOrCreateUser(key) - } - - override suspend fun getOrCreateUser(key: HexKey): User { - return LocalCache.getOrCreateUser(key) - } - - fun checkGetOrCreateUser( - key: HexKey, - onResult: (User?) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateUser(key)) } - } - - fun getUserIfExists(hex: HexKey): User? { - return LocalCache.getUserIfExists(hex) - } - - private suspend fun checkGetOrCreateNote(key: HexKey): Note? { - return LocalCache.checkGetOrCreateNote(key) - } - - override suspend fun getOrCreateNote(key: HexKey): Note { - return LocalCache.getOrCreateNote(key) - } - - fun checkGetOrCreateNote( - key: HexKey, - onResult: (Note?) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateNote(key)) } - } - - fun getNoteIfExists(hex: HexKey): Note? { - return LocalCache.getNoteIfExists(hex) - } - - override suspend fun checkGetOrCreateAddressableNote(key: HexKey): AddressableNote? { - return LocalCache.checkGetOrCreateAddressableNote(key) - } - - fun checkGetOrCreateAddressableNote( - key: HexKey, - onResult: (AddressableNote?) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateAddressableNote(key)) } - } - - private suspend fun getOrCreateAddressableNote(key: ATag): AddressableNote? { - return LocalCache.getOrCreateAddressableNote(key) - } - - fun getOrCreateAddressableNote( - key: ATag, - onResult: (AddressableNote?) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { onResult(getOrCreateAddressableNote(key)) } - } - - fun getAddressableNoteIfExists(key: String): AddressableNote? { - return LocalCache.addressables[key] - } - - fun findStatusesForUser( - myUser: User, - onResult: (ImmutableList) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { onResult(LocalCache.findStatusesForUser(myUser)) } - } - - private suspend fun checkGetOrCreateChannel(key: HexKey): Channel? { - return LocalCache.checkGetOrCreateChannel(key) - } - - fun checkGetOrCreateChannel( - key: HexKey, - onResult: (Channel?) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateChannel(key)) } - } - - fun getChannelIfExists(hex: HexKey): Channel? { - return LocalCache.getChannelIfExists(hex) - } - - fun loadParticipants( - participants: List, - onReady: (ImmutableList>) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - val participantUsers = - participants - .mapNotNull { part -> - checkGetOrCreateUser(part.key)?.let { - Pair( - part, - it, - ) - } - } - .toImmutableList() - - onReady(participantUsers) - } - } - - fun loadUsers( - hexList: List, - onReady: (ImmutableList) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - onReady( - hexList - .mapNotNull { hex -> checkGetOrCreateUser(hex) } - .sortedBy { account.isFollowing(it) } - .reversed() - .toImmutableList(), - ) - } - } - - fun returnNIP19References( - content: String, - tags: ImmutableListOfLists?, - onNewReferences: (List) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - onNewReferences(MarkdownParser().returnNIP19References(content, tags)) - } - } - - fun returnMarkdownWithSpecialContent( - content: String, - tags: ImmutableListOfLists?, - onNewContent: (String) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - onNewContent(MarkdownParser().returnMarkdownWithSpecialContent(content, tags)) - } - } - - fun parseNIP19( - str: String, - onNote: (LoadedBechLink) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - Nip19.uriToRoute(str)?.let { - var returningNote: Note? = null - if ( - it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS - ) { - LocalCache.checkGetOrCreateNote(it.hex)?.let { note -> returningNote = note } } - - onNote(LoadedBechLink(returningNote, it)) - } } - } - fun checkIsOnline( - media: String?, - onDone: (Boolean) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { onDone(OnlineChecker.isOnline(media)) } - } - - suspend fun refreshMarkAsReadObservers() { - updateNotificationDots() - accountMarkAsReadUpdates.value++ - } - - fun loadAndMarkAsRead( - routeForLastRead: String, - createdAt: Long?, - onIsNew: (Boolean) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - val lastTime = account.loadLastRead(routeForLastRead) - - if (createdAt != null) { - if (account.markAsRead(routeForLastRead, createdAt)) { - refreshMarkAsReadObservers() - } - onIsNew(createdAt > lastTime) - } else { - onIsNew(false) - } - } - } - - fun markAllAsRead( - notes: ImmutableList, - onDone: () -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - var atLeastOne = false - - for (note in notes) { - note.event?.let { noteEvent -> - val channelHex = note.channelHex() - val route = - if (channelHex != null) { - "Channel/$channelHex" - } else if (note.event is ChatroomKeyable) { - val withKey = (note.event as ChatroomKeyable).chatroomKey(userProfile().pubkeyHex) - "Room/${withKey.hashCode()}" + fun reactToOrDelete(note: Note) { + viewModelScope.launch(Dispatchers.IO) { + val reaction = account.reactionChoices.first() + if (hasReactedTo(note, reaction)) { + deleteReactionTo(note, reaction) } else { - null + reactTo(note, reaction) } - - route?.let { - if (account.markAsRead(route, noteEvent.createdAt())) { - atLeastOne = true - } - } } - } - - if (atLeastOne) { - refreshMarkAsReadObservers() - } - - onDone() } - } - fun createChatRoomFor( - user: User, - then: (Int) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - val withKey = ChatroomKey(persistentSetOf(user.pubkeyHex)) - account.userProfile().createChatroom(withKey) - then(withKey.hashCode()) + fun isNoteHidden(note: Note): Boolean { + return note.isHiddenFor(account.flowHiddenUsers.value) } - } - fun enableTor( - checked: Boolean, - portNumber: MutableState, - ) { - viewModelScope.launch(Dispatchers.IO) { - account.proxyPort = portNumber.value.toInt() - account.proxy = HttpClient.initProxy(checked, "127.0.0.1", account.proxyPort) - account.saveable.invalidateData() - serviceManager?.forceRestart() + fun hasReactedTo( + baseNote: Note, + reaction: String, + ): Boolean { + return account.hasReacted(baseNote, reaction) } - } - class Factory(val account: Account, val settings: SettingsState) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): AccountViewModel { - return AccountViewModel(account, settings) as AccountViewModel + suspend fun deleteReactionTo( + note: Note, + reaction: String, + ) { + account.delete(account.reactionTo(note, reaction)) } - } - private var collectorJob: Job? = null - val notificationDots = HasNotificationDot(bottomNavigationItems) - private val bundlerInsert = BundledInsert>(3000, Dispatchers.IO) + fun hasBoosted(baseNote: Note): Boolean { + return account.hasBoosted(baseNote) + } - fun invalidateInsertData(newItems: Set) { - bundlerInsert.invalidateList(newItems) { updateNotificationDots(it.flatten().toSet()) } - } + fun deleteBoostsTo(note: Note) { + viewModelScope.launch(Dispatchers.IO) { account.delete(account.boostsTo(note)) } + } - fun updateNotificationDots(newNotes: Set = emptySet()) { - val (value, elapsed) = measureTimedValue { notificationDots.update(newNotes, account) } - Log.d( - "Rendering Metrics", - "Notification Dots Calculation in $elapsed for ${newNotes.size} new notes", + fun calculateIfNoteWasZappedByAccount( + zappedNote: Note, + onWasZapped: (Boolean) -> Unit, + ) { + viewModelScope.launch(Dispatchers.Default) { + account.calculateIfNoteWasZappedByAccount(zappedNote) { onWasZapped(true) } + } + } + + fun calculateZapAmount( + zappedNote: Note, + onZapAmount: (String) -> Unit, + ) { + if (zappedNote.zapPayments.isNotEmpty()) { + viewModelScope.launch(Dispatchers.IO) { + account.calculateZappedAmount(zappedNote) { onZapAmount(showAmount(it)) } + } + } else { + onZapAmount(showAmount(zappedNote.zapsAmount)) + } + } + + fun calculateZapraiser( + zappedNote: Note, + onZapraiserStatus: (ZapraiserStatus) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + val zapraiserAmount = zappedNote.event?.zapraiserAmount() ?: 0 + account.calculateZappedAmount(zappedNote) { newZapAmount -> + var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat() + + if (percentage > 1) { + percentage = 1f + } + + val newZapraiserProgress = percentage + val newZapraiserLeft = + if (percentage > 0.99) { + "0" + } else { + showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal()) + } + onZapraiserStatus(ZapraiserStatus(newZapraiserProgress, newZapraiserLeft)) + } + } + } + + fun decryptAmountMessageInGroup( + zaps: ImmutableList, + onNewState: (ImmutableList) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + val myList = zaps.toList() + + val initialResults = + myList + .associate { + it.request to + ZapAmountCommentNotification( + it.request.author, + it.request.event?.content()?.ifBlank { null }, + showAmountAxis((it.response?.event as? LnZapEvent)?.amount), + ) + } + .toMutableMap() + + collectSuccessfulSigningOperations( + operationsInput = myList, + runRequestFor = { next, onReady -> + innerDecryptAmountMessage(next.request, next.response, onReady) + }, + ) { + it.forEach { decrypted -> initialResults[decrypted.key.request] = decrypted.value } + + onNewState(initialResults.values.toImmutableList()) + } + } + } + + fun cachedDecryptAmountMessageInGroup(zapNotes: List): ImmutableList { + return zapNotes + .map { + val request = it.request.event as? LnZapRequestEvent + if (request?.isPrivateZap() == true) { + val cachedPrivateRequest = request.cachedPrivateZap() + if (cachedPrivateRequest != null) { + ZapAmountCommentNotification( + LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.request.author, + cachedPrivateRequest.content.ifBlank { null }, + showAmountAxis((it.response.event as? LnZapEvent)?.amount), + ) + } else { + ZapAmountCommentNotification( + it.request.author, + it.request.event?.content()?.ifBlank { null }, + showAmountAxis((it.response.event as? LnZapEvent)?.amount), + ) + } + } else { + ZapAmountCommentNotification( + it.request.author, + it.request.event?.content()?.ifBlank { null }, + showAmountAxis((it.response.event as? LnZapEvent)?.amount), + ) + } + } + .toImmutableList() + } + + fun cachedDecryptAmountMessageInGroup(baseNote: Note): ImmutableList { + val myList = baseNote.zaps.toList() + + return myList + .map { + val request = it.first.event as? LnZapRequestEvent + if (request?.isPrivateZap() == true) { + val cachedPrivateRequest = request.cachedPrivateZap() + if (cachedPrivateRequest != null) { + ZapAmountCommentNotification( + LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.first.author, + cachedPrivateRequest.content.ifBlank { null }, + showAmountAxis((it.second?.event as? LnZapEvent)?.amount), + ) + } else { + ZapAmountCommentNotification( + it.first.author, + it.first.event?.content()?.ifBlank { null }, + showAmountAxis((it.second?.event as? LnZapEvent)?.amount), + ) + } + } else { + ZapAmountCommentNotification( + it.first.author, + it.first.event?.content()?.ifBlank { null }, + showAmountAxis((it.second?.event as? LnZapEvent)?.amount), + ) + } + } + .toImmutableList() + } + + fun decryptAmountMessageInGroup( + baseNote: Note, + onNewState: (ImmutableList) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + val myList = baseNote.zaps.toList() + + val initialResults = + myList + .associate { + it.first to + ZapAmountCommentNotification( + it.first.author, + it.first.event?.content()?.ifBlank { null }, + showAmountAxis((it.second?.event as? LnZapEvent)?.amount), + ) + } + .toMutableMap() + + collectSuccessfulSigningOperations, ZapAmountCommentNotification>( + operationsInput = myList, + runRequestFor = { next, onReady -> + innerDecryptAmountMessage(next.first, next.second, onReady) + }, + ) { + it.forEach { decrypted -> initialResults[decrypted.key.first] = decrypted.value } + + onNewState(initialResults.values.toImmutableList()) + } + } + } + + fun decryptAmountMessage( + zapRequest: Note, + zapEvent: Note?, + onNewState: (ZapAmountCommentNotification?) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + innerDecryptAmountMessage(zapRequest, zapEvent, onNewState) + } + } + + private fun innerDecryptAmountMessage( + zapRequest: Note, + zapEvent: Note?, + onReady: (ZapAmountCommentNotification) -> Unit, + ) { + checkNotInMainThread() + + (zapRequest.event as? LnZapRequestEvent)?.let { + if (it.isPrivateZap()) { + decryptZap(zapRequest) { decryptedContent -> + val amount = (zapEvent?.event as? LnZapEvent)?.amount + val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey) + onReady( + ZapAmountCommentNotification( + newAuthor, + decryptedContent.content.ifBlank { null }, + showAmountAxis(amount), + ), + ) + } + } else { + val amount = (zapEvent?.event as? LnZapEvent)?.amount + if (!zapRequest.event?.content().isNullOrBlank() || amount != null) { + onReady( + ZapAmountCommentNotification( + zapRequest.author, + zapRequest.event?.content()?.ifBlank { null }, + showAmountAxis(amount), + ), + ) + } + } + } + } + + fun zap( + note: Note, + amount: Long, + pollOption: Int?, + message: String, + context: Context, + onError: (String, String) -> Unit, + onProgress: (percent: Float) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit, + zapType: LnZapEvent.ZapType, + ) { + viewModelScope.launch(Dispatchers.IO) { + ZapPaymentHandler(account) + .zap( + note, + amount, + pollOption, + message, + context, + onError, + onProgress, + onPayViaIntent, + zapType, + ) + } + } + + fun report( + note: Note, + type: ReportEvent.ReportType, + content: String = "", + ) { + viewModelScope.launch(Dispatchers.IO) { account.report(note, type, content) } + } + + fun report( + user: User, + type: ReportEvent.ReportType, + ) { + viewModelScope.launch(Dispatchers.IO) { + account.report(user, type) + account.hideUser(user.pubkeyHex) + } + } + + fun boost(note: Note) { + viewModelScope.launch(Dispatchers.IO) { account.boost(note) } + } + + fun removeEmojiPack( + usersEmojiList: Note, + emojiList: Note, + ) { + viewModelScope.launch(Dispatchers.IO) { account.removeEmojiPack(usersEmojiList, emojiList) } + } + + fun addEmojiPack( + usersEmojiList: Note, + emojiList: Note, + ) { + viewModelScope.launch(Dispatchers.IO) { account.addEmojiPack(usersEmojiList, emojiList) } + } + + fun addPrivateBookmark(note: Note) { + viewModelScope.launch(Dispatchers.IO) { account.addBookmark(note, true) } + } + + fun addPublicBookmark(note: Note) { + viewModelScope.launch(Dispatchers.IO) { account.addBookmark(note, false) } + } + + fun removePrivateBookmark(note: Note) { + viewModelScope.launch(Dispatchers.IO) { account.removeBookmark(note, true) } + } + + fun removePublicBookmark(note: Note) { + viewModelScope.launch(Dispatchers.IO) { account.removeBookmark(note, false) } + } + + fun isInPrivateBookmarks( + note: Note, + onReady: (Boolean) -> Unit, + ) { + account.isInPrivateBookmarks(note, onReady) + } + + fun isInPublicBookmarks(note: Note): Boolean { + return account.isInPublicBookmarks(note) + } + + fun broadcast(note: Note) { + account.broadcast(note) + } + + fun delete(note: Note) { + viewModelScope.launch(Dispatchers.IO) { account.delete(note) } + } + + fun cachedDecrypt(note: Note): String? { + return account.cachedDecryptContent(note) + } + + fun decrypt( + note: Note, + onReady: (String) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { account.decryptContent(note, onReady) } + } + + fun decryptZap( + note: Note, + onReady: (Event) -> Unit, + ) { + account.decryptZapContentAuthor(note, onReady) + } + + fun translateTo(lang: Locale) { + account.updateTranslateTo(lang.language) + } + + fun dontTranslateFrom(lang: String) { + account.addDontTranslateFrom(lang) + } + + fun prefer( + source: String, + target: String, + preference: String, + ) { + account.prefer(source, target, preference) + } + + fun follow(user: User) { + viewModelScope.launch(Dispatchers.IO) { account.follow(user) } + } + + fun unfollow(user: User) { + viewModelScope.launch(Dispatchers.IO) { account.unfollow(user) } + } + + fun followGeohash(tag: String) { + viewModelScope.launch(Dispatchers.IO) { account.followGeohash(tag) } + } + + fun unfollowGeohash(tag: String) { + viewModelScope.launch(Dispatchers.IO) { account.unfollowGeohash(tag) } + } + + fun followHashtag(tag: String) { + viewModelScope.launch(Dispatchers.IO) { account.followHashtag(tag) } + } + + fun unfollowHashtag(tag: String) { + viewModelScope.launch(Dispatchers.IO) { account.unfollowHashtag(tag) } + } + + fun showWord(word: String) { + viewModelScope.launch(Dispatchers.IO) { account.showWord(word) } + } + + fun hideWord(word: String) { + viewModelScope.launch(Dispatchers.IO) { account.hideWord(word) } + } + + fun isLoggedUser(user: User?): Boolean { + return account.userProfile().pubkeyHex == user?.pubkeyHex + } + + fun isFollowing(user: User?): Boolean { + if (user == null) return false + return account.userProfile().isFollowingCached(user) + } + + fun isFollowing(user: HexKey): Boolean { + return account.userProfile().isFollowingCached(user) + } + + val hideDeleteRequestDialog: Boolean + get() = account.hideDeleteRequestDialog + + fun dontShowDeleteRequestDialog() { + viewModelScope.launch(Dispatchers.IO) { account.setHideDeleteRequestDialog() } + } + + val hideNIP24WarningDialog: Boolean + get() = account.hideNIP24WarningDialog + + fun dontShowNIP24WarningDialog() { + account.setHideNIP24WarningDialog() + } + + val hideBlockAlertDialog: Boolean + get() = account.hideBlockAlertDialog + + fun dontShowBlockAlertDialog() { + account.setHideBlockAlertDialog() + } + + fun hideSensitiveContent() { + account.updateShowSensitiveContent(false) + } + + fun disableContentWarnings() { + account.updateShowSensitiveContent(true) + } + + fun seeContentWarnings() { + account.updateShowSensitiveContent(null) + } + + fun defaultZapType(): LnZapEvent.ZapType { + return account.defaultZapType + } + + @Immutable + data class NoteComposeReportState( + val isAcceptable: Boolean = true, + val canPreview: Boolean = true, + val isHiddenAuthor: Boolean = false, + val relevantReports: ImmutableSet = persistentSetOf(), ) - } - init { - Log.d("Init", "AccountViewModel") - collectorJob = - viewModelScope.launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { newNotes -> - Log.d( - "Rendering Metrics", - "Notification Dots Calculation refresh ${this@AccountViewModel} for ${account.userProfile().toBestDisplayName()}", - ) - invalidateInsertData(newNotes) + fun isNoteAcceptable( + note: Note, + onReady: (NoteComposeReportState) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + val isFromLoggedIn = note.author?.pubkeyHex == userProfile().pubkeyHex + val isFromLoggedInFollow = note.author?.let { userProfile().isFollowingCached(it) } ?: true + + if (isFromLoggedIn || isFromLoggedInFollow) { + // No need to process if from trusted people + onReady(NoteComposeReportState(true, true, false, persistentSetOf())) + } else if (note.author?.let { account.isHidden(it) } == true) { + onReady(NoteComposeReportState(false, false, true, persistentSetOf())) + } else { + val newCanPreview = !note.hasAnyReports() + + val newIsAcceptable = account.isAcceptable(note) + + if (newCanPreview && newIsAcceptable) { + // No need to process reports if nothing is wrong + onReady(NoteComposeReportState(true, true, false, persistentSetOf())) + } else { + val newRelevantReports = account.getRelevantReports(note) + + onReady( + NoteComposeReportState( + newIsAcceptable, + newCanPreview, + false, + newRelevantReports.toImmutableSet(), + ), + ) + } + } } - } - } - - override fun onCleared() { - Log.d("Init", "AccountViewModel onCleared") - collectorJob?.cancel() - super.onCleared() - } - - fun loadThumb( - context: Context, - thumbUri: String, - onReady: (Drawable?) -> Unit, - onError: (String?) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - try { - val request = ImageRequest.Builder(context).data(thumbUri).build() - val myCover = context.imageLoader.execute(request).drawable - onReady(myCover) - } catch (e: Exception) { - Log.e("VideoView", "Fail to load cover $thumbUri", e) - onError(e.message) - } } - } - fun loadMentions( - mentions: ImmutableList, - onReady: (ImmutableList) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - val newSortedMentions = - mentions - .mapNotNull { LocalCache.checkGetOrCreateUser(it) } - .toSet() - .sortedBy { account.isFollowing(it) } - .toImmutableList() - - onReady(newSortedMentions) + fun unwrap( + event: GiftWrapEvent, + onReady: (Event) -> Unit, + ) { + account.unwrap(event, onReady) } - } - fun tryBoost( - baseNote: Note, - onMore: () -> Unit, - ) { - if (isWriteable()) { - if (hasBoosted(baseNote)) { - deleteBoostsTo(baseNote) - } else { - onMore() - } - } else { - toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_boost_posts, - ) + fun unseal( + event: SealedGossipEvent, + onReady: (Event) -> Unit, + ) { + account.unseal(event, onReady) } - } - fun dismissPaymentRequest(request: Account.PaymentRequest) { - viewModelScope.launch(Dispatchers.IO) { account.dismissPaymentRequest(request) } - } - - fun meltCashu( - token: CashuToken, - context: Context, - onDone: (String, String) -> Unit, - ) { - val lud16 = account.userProfile().info?.lud16 - if (lud16 != null) { - viewModelScope.launch(Dispatchers.IO) { - CashuProcessor() - .melt( - token, - lud16, - onSuccess = { title, message -> onDone(title, message) }, - onError = { title, message -> onDone(title, message) }, - context, - ) - } - } else { - onDone( - context.getString(R.string.no_lightning_address_set), - context.getString( - R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, - account.userProfile().toBestDisplayName(), - ), - ) + fun show(user: User) { + viewModelScope.launch(Dispatchers.IO) { account.showUser(user.pubkeyHex) } + } + + fun hide(user: User) { + viewModelScope.launch(Dispatchers.IO) { account.hideUser(user.pubkeyHex) } + } + + fun hide(word: String) { + viewModelScope.launch(Dispatchers.IO) { account.hideWord(word) } + } + + fun showUser(pubkeyHex: String) { + viewModelScope.launch(Dispatchers.IO) { account.showUser(pubkeyHex) } + } + + fun createStatus(newStatus: String) { + viewModelScope.launch(Dispatchers.IO) { account.createStatus(newStatus) } + } + + fun updateStatus( + it: ATag, + newStatus: String, + ) { + viewModelScope.launch(Dispatchers.IO) { + account.updateStatus(LocalCache.getOrCreateAddressableNote(it), newStatus) + } + } + + fun deleteStatus(it: ATag) { + viewModelScope.launch(Dispatchers.IO) { + account.deleteStatus(LocalCache.getOrCreateAddressableNote(it)) + } + } + + fun urlPreview( + url: String, + onResult: suspend (UrlPreviewState) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { UrlCachedPreviewer.previewInfo(url, onResult) } + } + + fun loadReactionTo( + note: Note?, + onNewReactionType: (String?) -> Unit, + ) { + if (note == null) return + + viewModelScope.launch(Dispatchers.Default) { + onNewReactionType(note.getReactionBy(userProfile())) + } + } + + fun verifyNip05( + userMetadata: UserMetadata, + pubkeyHex: String, + onResult: (Boolean) -> Unit, + ) { + val nip05 = userMetadata.nip05?.ifBlank { null } ?: return + + viewModelScope.launch(Dispatchers.IO) { + Nip05NostrAddressVerifier() + .verifyNip05( + nip05, + onSuccess = { + // Marks user as verified + if (it == pubkeyHex) { + userMetadata.nip05Verified = true + userMetadata.nip05LastVerificationTime = TimeUtils.now() + + onResult(userMetadata.nip05Verified) + } else { + userMetadata.nip05Verified = false + userMetadata.nip05LastVerificationTime = 0 + + onResult(userMetadata.nip05Verified) + } + }, + onError = { + userMetadata.nip05LastVerificationTime = 0 + userMetadata.nip05Verified = false + + onResult(userMetadata.nip05Verified) + }, + ) + } + } + + fun retrieveRelayDocument( + dirtyUrl: String, + onInfo: (RelayInformation) -> Unit, + onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + Nip11CachedRetriever.loadRelayInfo(dirtyUrl, onInfo, onError) + } + } + + fun runOnIO(runOnIO: () -> Unit) { + viewModelScope.launch(Dispatchers.IO) { runOnIO() } + } + + suspend fun checkGetOrCreateUser(key: HexKey): User? { + return LocalCache.checkGetOrCreateUser(key) + } + + override suspend fun getOrCreateUser(key: HexKey): User { + return LocalCache.getOrCreateUser(key) + } + + fun checkGetOrCreateUser( + key: HexKey, + onResult: (User?) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateUser(key)) } + } + + fun getUserIfExists(hex: HexKey): User? { + return LocalCache.getUserIfExists(hex) + } + + private suspend fun checkGetOrCreateNote(key: HexKey): Note? { + return LocalCache.checkGetOrCreateNote(key) + } + + override suspend fun getOrCreateNote(key: HexKey): Note { + return LocalCache.getOrCreateNote(key) + } + + fun checkGetOrCreateNote( + key: HexKey, + onResult: (Note?) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateNote(key)) } + } + + fun getNoteIfExists(hex: HexKey): Note? { + return LocalCache.getNoteIfExists(hex) + } + + override suspend fun checkGetOrCreateAddressableNote(key: HexKey): AddressableNote? { + return LocalCache.checkGetOrCreateAddressableNote(key) + } + + fun checkGetOrCreateAddressableNote( + key: HexKey, + onResult: (AddressableNote?) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateAddressableNote(key)) } + } + + private suspend fun getOrCreateAddressableNote(key: ATag): AddressableNote? { + return LocalCache.getOrCreateAddressableNote(key) + } + + fun getOrCreateAddressableNote( + key: ATag, + onResult: (AddressableNote?) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { onResult(getOrCreateAddressableNote(key)) } + } + + fun getAddressableNoteIfExists(key: String): AddressableNote? { + return LocalCache.addressables[key] + } + + fun findStatusesForUser( + myUser: User, + onResult: (ImmutableList) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { onResult(LocalCache.findStatusesForUser(myUser)) } + } + + private suspend fun checkGetOrCreateChannel(key: HexKey): Channel? { + return LocalCache.checkGetOrCreateChannel(key) + } + + fun checkGetOrCreateChannel( + key: HexKey, + onResult: (Channel?) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateChannel(key)) } + } + + fun getChannelIfExists(hex: HexKey): Channel? { + return LocalCache.getChannelIfExists(hex) + } + + fun loadParticipants( + participants: List, + onReady: (ImmutableList>) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + val participantUsers = + participants + .mapNotNull { part -> + checkGetOrCreateUser(part.key)?.let { + Pair( + part, + it, + ) + } + } + .toImmutableList() + + onReady(participantUsers) + } + } + + fun loadUsers( + hexList: List, + onReady: (ImmutableList) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + onReady( + hexList + .mapNotNull { hex -> checkGetOrCreateUser(hex) } + .sortedBy { account.isFollowing(it) } + .reversed() + .toImmutableList(), + ) + } + } + + fun returnNIP19References( + content: String, + tags: ImmutableListOfLists?, + onNewReferences: (List) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + onNewReferences(MarkdownParser().returnNIP19References(content, tags)) + } + } + + fun returnMarkdownWithSpecialContent( + content: String, + tags: ImmutableListOfLists?, + onNewContent: (String) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + onNewContent(MarkdownParser().returnMarkdownWithSpecialContent(content, tags)) + } + } + + fun parseNIP19( + str: String, + onNote: (LoadedBechLink) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + Nip19.uriToRoute(str)?.let { + var returningNote: Note? = null + if ( + it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS + ) { + LocalCache.checkGetOrCreateNote(it.hex)?.let { note -> returningNote = note } + } + + onNote(LoadedBechLink(returningNote, it)) + } + } + } + + fun checkIsOnline( + media: String?, + onDone: (Boolean) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { onDone(OnlineChecker.isOnline(media)) } + } + + suspend fun refreshMarkAsReadObservers() { + updateNotificationDots() + accountMarkAsReadUpdates.value++ + } + + fun loadAndMarkAsRead( + routeForLastRead: String, + createdAt: Long?, + onIsNew: (Boolean) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + val lastTime = account.loadLastRead(routeForLastRead) + + if (createdAt != null) { + if (account.markAsRead(routeForLastRead, createdAt)) { + refreshMarkAsReadObservers() + } + onIsNew(createdAt > lastTime) + } else { + onIsNew(false) + } + } + } + + fun markAllAsRead( + notes: ImmutableList, + onDone: () -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + var atLeastOne = false + + for (note in notes) { + note.event?.let { noteEvent -> + val channelHex = note.channelHex() + val route = + if (channelHex != null) { + "Channel/$channelHex" + } else if (note.event is ChatroomKeyable) { + val withKey = (note.event as ChatroomKeyable).chatroomKey(userProfile().pubkeyHex) + "Room/${withKey.hashCode()}" + } else { + null + } + + route?.let { + if (account.markAsRead(route, noteEvent.createdAt())) { + atLeastOne = true + } + } + } + } + + if (atLeastOne) { + refreshMarkAsReadObservers() + } + + onDone() + } + } + + fun createChatRoomFor( + user: User, + then: (Int) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + val withKey = ChatroomKey(persistentSetOf(user.pubkeyHex)) + account.userProfile().createChatroom(withKey) + then(withKey.hashCode()) + } + } + + fun enableTor( + checked: Boolean, + portNumber: MutableState, + ) { + viewModelScope.launch(Dispatchers.IO) { + account.proxyPort = portNumber.value.toInt() + account.proxy = HttpClient.initProxy(checked, "127.0.0.1", account.proxyPort) + account.saveable.invalidateData() + serviceManager?.forceRestart() + } + } + + class Factory(val account: Account, val settings: SettingsState) : ViewModelProvider.Factory { + override fun create(modelClass: Class): AccountViewModel { + return AccountViewModel(account, settings) as AccountViewModel + } + } + + private var collectorJob: Job? = null + val notificationDots = HasNotificationDot(bottomNavigationItems) + private val bundlerInsert = BundledInsert>(3000, Dispatchers.IO) + + fun invalidateInsertData(newItems: Set) { + bundlerInsert.invalidateList(newItems) { updateNotificationDots(it.flatten().toSet()) } + } + + fun updateNotificationDots(newNotes: Set = emptySet()) { + val (value, elapsed) = measureTimedValue { notificationDots.update(newNotes, account) } + Log.d( + "Rendering Metrics", + "Notification Dots Calculation in $elapsed for ${newNotes.size} new notes", + ) + } + + init { + Log.d("Init", "AccountViewModel") + collectorJob = + viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + Log.d( + "Rendering Metrics", + "Notification Dots Calculation refresh ${this@AccountViewModel} for ${account.userProfile().toBestDisplayName()}", + ) + invalidateInsertData(newNotes) + } + } + } + + override fun onCleared() { + Log.d("Init", "AccountViewModel onCleared") + collectorJob?.cancel() + super.onCleared() + } + + fun loadThumb( + context: Context, + thumbUri: String, + onReady: (Drawable?) -> Unit, + onError: (String?) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + try { + val request = ImageRequest.Builder(context).data(thumbUri).build() + val myCover = context.imageLoader.execute(request).drawable + onReady(myCover) + } catch (e: Exception) { + Log.e("VideoView", "Fail to load cover $thumbUri", e) + onError(e.message) + } + } + } + + fun loadMentions( + mentions: ImmutableList, + onReady: (ImmutableList) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + val newSortedMentions = + mentions + .mapNotNull { LocalCache.checkGetOrCreateUser(it) } + .toSet() + .sortedBy { account.isFollowing(it) } + .toImmutableList() + + onReady(newSortedMentions) + } + } + + fun tryBoost( + baseNote: Note, + onMore: () -> Unit, + ) { + if (isWriteable()) { + if (hasBoosted(baseNote)) { + deleteBoostsTo(baseNote) + } else { + onMore() + } + } else { + toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_boost_posts, + ) + } + } + + fun dismissPaymentRequest(request: Account.PaymentRequest) { + viewModelScope.launch(Dispatchers.IO) { account.dismissPaymentRequest(request) } + } + + fun meltCashu( + token: CashuToken, + context: Context, + onDone: (String, String) -> Unit, + ) { + val lud16 = account.userProfile().info?.lud16 + if (lud16 != null) { + viewModelScope.launch(Dispatchers.IO) { + CashuProcessor() + .melt( + token, + lud16, + onSuccess = { title, message -> onDone(title, message) }, + onError = { title, message -> onDone(title, message) }, + context, + ) + } + } else { + onDone( + context.getString(R.string.no_lightning_address_set), + context.getString( + R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, + account.userProfile().toBestDisplayName(), + ), + ) + } } - } } class HasNotificationDot(bottomNavigationItems: ImmutableList) { - val hasNewItems = bottomNavigationItems.associateWith { MutableStateFlow(false) } + val hasNewItems = bottomNavigationItems.associateWith { MutableStateFlow(false) } - fun update( - newNotes: Set, - account: Account, - ) { - checkNotInMainThread() + fun update( + newNotes: Set, + account: Account, + ) { + checkNotInMainThread() - hasNewItems.forEach { - val (value, elapsed) = - measureTimedValue { - val newResult = it.key.hasNewItems(account, newNotes) - if (newResult != it.value.value) { - it.value.value = newResult - } + hasNewItems.forEach { + val (value, elapsed) = + measureTimedValue { + val newResult = it.key.hasNewItems(account, newNotes) + if (newResult != it.value.value) { + it.value.value = newResult + } + } + Log.d( + "Rendering Metrics", + "Notification Dots Calculation for ${it.key.route} in $elapsed for ${newNotes.size} new notes", + ) } - Log.d( - "Rendering Metrics", - "Notification Dots Calculation for ${it.key.route} in $elapsed for ${newNotes.size} new notes", - ) } - } } @Immutable data class LoadedBechLink(val baseNote: Note?, val nip19: Nip19.Return) public fun allOrNothingSigningOperations( - remainingTos: List, - runRequestFor: (T, (K) -> Unit) -> Unit, - output: MutableList = mutableListOf(), - onReady: (List) -> Unit, + remainingTos: List, + runRequestFor: (T, (K) -> Unit) -> Unit, + output: MutableList = mutableListOf(), + onReady: (List) -> Unit, ) { - if (remainingTos.isEmpty()) { - onReady(output) - return - } + if (remainingTos.isEmpty()) { + onReady(output) + return + } - val next = remainingTos.first() + val next = remainingTos.first() - runRequestFor(next) { result: K -> - output.add(result) - allOrNothingSigningOperations(remainingTos.minus(next), runRequestFor, output, onReady) - } + runRequestFor(next) { result: K -> + output.add(result) + allOrNothingSigningOperations(remainingTos.minus(next), runRequestFor, output, onReady) + } } public suspend fun collectSuccessfulSigningOperations( - operationsInput: List, - runRequestFor: (T, (K) -> Unit) -> Unit, - output: MutableMap = mutableMapOf(), - onReady: (MutableMap) -> Unit, + operationsInput: List, + runRequestFor: (T, (K) -> Unit) -> Unit, + output: MutableMap = mutableMapOf(), + onReady: (MutableMap) -> Unit, ) { - if (operationsInput.isEmpty()) { - onReady(output) - return - } - - for (input in operationsInput) { - // runs in sequence to avoid overcrowding Amber. - val result = - withTimeoutOrNull(100) { - suspendCancellableCoroutine { continuation -> - runRequestFor(input) { result: K -> continuation.resume(result) } - } - } - if (result != null) { - output[input] = result + if (operationsInput.isEmpty()) { + onReady(output) + return } - } - onReady(output) + for (input in operationsInput) { + // runs in sequence to avoid overcrowding Amber. + val result = + withTimeoutOrNull(100) { + suspendCancellableCoroutine { continuation -> + runRequestFor(input) { result: K -> continuation.resume(result) } + } + } + if (result != null) { + output[input] = result + } + } + + onReady(output) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt index b8ecc2f3d..cc4847078 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt @@ -47,66 +47,66 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable fun BookmarkListScreen( - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val publicFeedViewModel: NostrBookmarkPublicFeedViewModel = - viewModel( - key = "NotificationViewModel", - factory = NostrBookmarkPublicFeedViewModel.Factory(accountViewModel.account), - ) + val publicFeedViewModel: NostrBookmarkPublicFeedViewModel = + viewModel( + key = "NotificationViewModel", + factory = NostrBookmarkPublicFeedViewModel.Factory(accountViewModel.account), + ) - val privateFeedViewModel: NostrBookmarkPrivateFeedViewModel = - viewModel( - key = "NotificationViewModel", - factory = NostrBookmarkPrivateFeedViewModel.Factory(accountViewModel.account), - ) + val privateFeedViewModel: NostrBookmarkPrivateFeedViewModel = + viewModel( + key = "NotificationViewModel", + factory = NostrBookmarkPrivateFeedViewModel.Factory(accountViewModel.account), + ) - val userState by accountViewModel.account.decryptBookmarks.observeAsState() + val userState by accountViewModel.account.decryptBookmarks.observeAsState() - LaunchedEffect(userState) { - publicFeedViewModel.invalidateData() - privateFeedViewModel.invalidateData() - } - - Column(Modifier.fillMaxHeight()) { - val pagerState = rememberPagerState { 2 } - val coroutineScope = rememberCoroutineScope() - - TabRow( - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground, - selectedTabIndex = pagerState.currentPage, - modifier = TabRowHeight, - ) { - Tab( - selected = pagerState.currentPage == 0, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, - text = { Text(text = stringResource(R.string.private_bookmarks)) }, - ) - Tab( - selected = pagerState.currentPage == 1, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, - text = { Text(text = stringResource(R.string.public_bookmarks)) }, - ) + LaunchedEffect(userState) { + publicFeedViewModel.invalidateData() + privateFeedViewModel.invalidateData() } - HorizontalPager(state = pagerState) { page -> - when (page) { - 0 -> - RefresheableFeedView( - privateFeedViewModel, - null, - accountViewModel = accountViewModel, - nav = nav, - ) - 1 -> - RefresheableFeedView( - publicFeedViewModel, - null, - accountViewModel = accountViewModel, - nav = nav, - ) - } + + Column(Modifier.fillMaxHeight()) { + val pagerState = rememberPagerState { 2 } + val coroutineScope = rememberCoroutineScope() + + TabRow( + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + selectedTabIndex = pagerState.currentPage, + modifier = TabRowHeight, + ) { + Tab( + selected = pagerState.currentPage == 0, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, + text = { Text(text = stringResource(R.string.private_bookmarks)) }, + ) + Tab( + selected = pagerState.currentPage == 1, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, + text = { Text(text = stringResource(R.string.public_bookmarks)) }, + ) + } + HorizontalPager(state = pagerState) { page -> + when (page) { + 0 -> + RefresheableFeedView( + privateFeedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + 1 -> + RefresheableFeedView( + publicFeedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index 58776be22..a3b3d692d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -157,1042 +157,1044 @@ import com.vitorpamplona.quartz.events.EmptyTagList import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE import com.vitorpamplona.quartz.events.Participant import com.vitorpamplona.quartz.events.toImmutableListOfLists -import java.util.Locale import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.Locale @Composable fun ChannelScreen( - channelId: String?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + channelId: String?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (channelId == null) return + if (channelId == null) return - LoadChannel(channelId, accountViewModel) { - PrepareChannelViewModels( - baseChannel = it, - accountViewModel = accountViewModel, - nav = nav, - ) - } + LoadChannel(channelId, accountViewModel) { + PrepareChannelViewModels( + baseChannel = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } } @Composable fun PrepareChannelViewModels( - baseChannel: Channel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseChannel: Channel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val feedViewModel: NostrChannelFeedViewModel = - viewModel( - key = baseChannel.idHex + "ChannelFeedViewModel", - factory = - NostrChannelFeedViewModel.Factory( - baseChannel, - accountViewModel.account, - ), + val feedViewModel: NostrChannelFeedViewModel = + viewModel( + key = baseChannel.idHex + "ChannelFeedViewModel", + factory = + NostrChannelFeedViewModel.Factory( + baseChannel, + accountViewModel.account, + ), + ) + + val channelScreenModel: NewPostViewModel = viewModel() + channelScreenModel.accountViewModel = accountViewModel + channelScreenModel.account = accountViewModel.account + + ChannelScreen( + channel = baseChannel, + feedViewModel = feedViewModel, + newPostModel = channelScreenModel, + accountViewModel = accountViewModel, + nav = nav, ) - - val channelScreenModel: NewPostViewModel = viewModel() - channelScreenModel.accountViewModel = accountViewModel - channelScreenModel.account = accountViewModel.account - - ChannelScreen( - channel = baseChannel, - feedViewModel = feedViewModel, - newPostModel = channelScreenModel, - accountViewModel = accountViewModel, - nav = nav, - ) } @Composable fun ChannelScreen( - channel: Channel, - feedViewModel: NostrChannelFeedViewModel, - newPostModel: NewPostViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + channel: Channel, + feedViewModel: NostrChannelFeedViewModel, + newPostModel: NewPostViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val context = LocalContext.current + val context = LocalContext.current - NostrChannelDataSource.loadMessagesBetween(accountViewModel.account, channel) - - val lifeCycleOwner = LocalLifecycleOwner.current - - LaunchedEffect(Unit) { - launch(Dispatchers.IO) { - newPostModel.imageUploadingError.collect { error -> - withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() } - } - } - } - - DisposableEffect(accountViewModel) { NostrChannelDataSource.loadMessagesBetween(accountViewModel.account, channel) - NostrChannelDataSource.start() - feedViewModel.invalidateData(true) - onDispose { - NostrChannelDataSource.clear() - NostrChannelDataSource.stop() + val lifeCycleOwner = LocalLifecycleOwner.current + + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + newPostModel.imageUploadingError.collect { error -> + withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() } + } + } } - } - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Channel Start") + DisposableEffect(accountViewModel) { + NostrChannelDataSource.loadMessagesBetween(accountViewModel.account, channel) NostrChannelDataSource.start() feedViewModel.invalidateData(true) - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Channel Stop") - NostrChannelDataSource.clear() - NostrChannelDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } - - Column(Modifier.fillMaxHeight()) { - val replyTo = remember { mutableStateOf(null) } - - Column( - modifier = remember { Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true) }, - ) { - if (channel is LiveActivitiesChannel) { - ShowVideoStreaming(channel, accountViewModel) - } - RefreshingChatroomFeedView( - viewModel = feedViewModel, - accountViewModel = accountViewModel, - nav = nav, - routeForLastRead = "Channel/${channel.idHex}", - onWantsToReply = { replyTo.value = it }, - ) - } - - Spacer(modifier = DoubleVertSpacer) - - replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { replyTo.value = null } } - - val scope = rememberCoroutineScope() - - // LAST ROW - EditFieldRow(newPostModel, isPrivate = false, accountViewModel = accountViewModel) { - scope.launch(Dispatchers.IO) { - val tagger = - NewMessageTagger( - message = newPostModel.message.text, - pTags = listOfNotNull(replyTo.value?.author), - eTags = listOfNotNull(replyTo.value), - channelHex = channel.idHex, - dao = accountViewModel, - ) - tagger.run() - if (channel is PublicChatChannel) { - accountViewModel.account.sendChannelMessage( - message = tagger.message, - toChannel = channel.idHex, - replyTo = tagger.eTags, - mentions = tagger.pTags, - wantsToMarkAsSensitive = false, - ) - } else if (channel is LiveActivitiesChannel) { - accountViewModel.account.sendLiveMessage( - message = tagger.message, - toChannel = channel.address, - replyTo = tagger.eTags, - mentions = tagger.pTags, - wantsToMarkAsSensitive = false, - ) + onDispose { + NostrChannelDataSource.clear() + NostrChannelDataSource.stop() + } + } + + DisposableEffect(lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Channel Start") + NostrChannelDataSource.start() + feedViewModel.invalidateData(true) + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Channel Stop") + + NostrChannelDataSource.clear() + NostrChannelDataSource.stop() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } + + Column(Modifier.fillMaxHeight()) { + val replyTo = remember { mutableStateOf(null) } + + Column( + modifier = remember { Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true) }, + ) { + if (channel is LiveActivitiesChannel) { + ShowVideoStreaming(channel, accountViewModel) + } + RefreshingChatroomFeedView( + viewModel = feedViewModel, + accountViewModel = accountViewModel, + nav = nav, + routeForLastRead = "Channel/${channel.idHex}", + onWantsToReply = { replyTo.value = it }, + ) + } + + Spacer(modifier = DoubleVertSpacer) + + replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { replyTo.value = null } } + + val scope = rememberCoroutineScope() + + // LAST ROW + EditFieldRow(newPostModel, isPrivate = false, accountViewModel = accountViewModel) { + scope.launch(Dispatchers.IO) { + val tagger = + NewMessageTagger( + message = newPostModel.message.text, + pTags = listOfNotNull(replyTo.value?.author), + eTags = listOfNotNull(replyTo.value), + channelHex = channel.idHex, + dao = accountViewModel, + ) + tagger.run() + if (channel is PublicChatChannel) { + accountViewModel.account.sendChannelMessage( + message = tagger.message, + toChannel = channel.idHex, + replyTo = tagger.eTags, + mentions = tagger.pTags, + wantsToMarkAsSensitive = false, + ) + } else if (channel is LiveActivitiesChannel) { + accountViewModel.account.sendLiveMessage( + message = tagger.message, + toChannel = channel.address, + replyTo = tagger.eTags, + mentions = tagger.pTags, + wantsToMarkAsSensitive = false, + ) + } + newPostModel.message = TextFieldValue("") + replyTo.value = null + feedViewModel.sendToTop() + } } - newPostModel.message = TextFieldValue("") - replyTo.value = null - feedViewModel.sendToTop() - } } - } } @Composable fun DisplayReplyingToNote( - replyingNote: Note?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - onCancel: () -> Unit, + replyingNote: Note?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onCancel: () -> Unit, ) { - Row( - Modifier.padding(horizontal = 10.dp).animateContentSize(), - verticalAlignment = Alignment.CenterVertically, - ) { - if (replyingNote != null) { - Column(remember { Modifier.weight(1f) }) { - ChatroomMessageCompose( - baseNote = replyingNote, - null, - innerQuote = true, - accountViewModel = accountViewModel, - nav = nav, - onWantsToReply = {}, - ) - } + Row( + Modifier.padding(horizontal = 10.dp).animateContentSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (replyingNote != null) { + Column(remember { Modifier.weight(1f) }) { + ChatroomMessageCompose( + baseNote = replyingNote, + null, + innerQuote = true, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = {}, + ) + } - Column(Modifier.padding(end = 10.dp)) { - IconButton( - modifier = Modifier.size(30.dp), - onClick = onCancel, - ) { - Icon( - imageVector = Icons.Default.Cancel, - null, - modifier = Modifier.padding(end = 5.dp).size(30.dp), - tint = MaterialTheme.colorScheme.placeholderText, - ) + Column(Modifier.padding(end = 10.dp)) { + IconButton( + modifier = Modifier.size(30.dp), + onClick = onCancel, + ) { + Icon( + imageVector = Icons.Default.Cancel, + null, + modifier = Modifier.padding(end = 5.dp).size(30.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + } } - } } - } } @Composable fun EditFieldRow( - channelScreenModel: NewPostViewModel, - isPrivate: Boolean, - accountViewModel: AccountViewModel, - onSendNewMessage: () -> Unit, + channelScreenModel: NewPostViewModel, + isPrivate: Boolean, + accountViewModel: AccountViewModel, + onSendNewMessage: () -> Unit, ) { - Row( - modifier = EditFieldModifier, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - val context = LocalContext.current + Row( + modifier = EditFieldModifier, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + val context = LocalContext.current - MyTextField( - value = channelScreenModel.message, - onValueChange = { channelScreenModel.updateMessage(it) }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - shape = EditFieldBorder, - modifier = Modifier.weight(1f, true), - placeholder = { - Text( - text = stringResource(R.string.reply_here), - color = MaterialTheme.colorScheme.placeholderText, + MyTextField( + value = channelScreenModel.message, + onValueChange = { channelScreenModel.updateMessage(it) }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + shape = EditFieldBorder, + modifier = Modifier.weight(1f, true), + placeholder = { + Text( + text = stringResource(R.string.reply_here), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + trailingIcon = { + ThinSendButton( + isActive = + channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage, + modifier = EditFieldTrailingIconModifier, + ) { + onSendNewMessage() + } + }, + leadingIcon = { + UploadFromGallery( + isUploading = channelScreenModel.isUploadingImage, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = EditFieldLeadingIconModifier, + ) { + channelScreenModel.upload( + galleryUri = it, + alt = null, + sensitiveContent = false, + server = ServerOption(accountViewModel.account.defaultFileServer, false), + context = context, + ) + } + }, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), ) - }, - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - trailingIcon = { - ThinSendButton( - isActive = - channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage, - modifier = EditFieldTrailingIconModifier, - ) { - onSendNewMessage() - } - }, - leadingIcon = { - UploadFromGallery( - isUploading = channelScreenModel.isUploadingImage, - tint = MaterialTheme.colorScheme.placeholderText, - modifier = EditFieldLeadingIconModifier, - ) { - channelScreenModel.upload( - galleryUri = it, - alt = null, - sensitiveContent = false, - server = ServerOption(accountViewModel.account.defaultFileServer, false), - context = context, - ) - } - }, - colors = - TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), - ) - } + } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun MyTextField( - value: TextFieldValue, - onValueChange: (TextFieldValue) -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - readOnly: Boolean = false, - textStyle: TextStyle = LocalTextStyle.current, - label: @Composable (() -> Unit)? = null, - placeholder: @Composable (() -> Unit)? = null, - leadingIcon: @Composable (() -> Unit)? = null, - trailingIcon: @Composable (() -> Unit)? = null, - prefix: @Composable (() -> Unit)? = null, - suffix: @Composable (() -> Unit)? = null, - supportingText: @Composable (() -> Unit)? = null, - isError: Boolean = false, - visualTransformation: VisualTransformation = VisualTransformation.None, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - keyboardActions: KeyboardActions = KeyboardActions(), - singleLine: Boolean = false, - maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, - minLines: Int = 1, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - shape: Shape = TextFieldDefaults.shape, - colors: TextFieldColors = TextFieldDefaults.colors(), + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions(), + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors(), ) { - // COPIED FROM TEXT FIELD - // The only change is the contentPadding below + // COPIED FROM TEXT FIELD + // The only change is the contentPadding below - val textColor = - textStyle.color.takeOrElse { - val focused by interactionSource.collectIsFocusedAsState() + val textColor = + textStyle.color.takeOrElse { + val focused by interactionSource.collectIsFocusedAsState() - val targetValue = - when { - !enabled -> MaterialTheme.colorScheme.placeholderText - isError -> MaterialTheme.colorScheme.onSurface - focused -> MaterialTheme.colorScheme.onSurface - else -> MaterialTheme.colorScheme.onSurface + val targetValue = + when { + !enabled -> MaterialTheme.colorScheme.placeholderText + isError -> MaterialTheme.colorScheme.onSurface + focused -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurface + } + + rememberUpdatedState(targetValue).value + } + val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) + + CompositionLocalProvider(LocalTextSelectionColors provides LocalTextSelectionColors.current) { + BasicTextField( + value = value, + modifier = + modifier.defaultMinSize( + minWidth = TextFieldDefaults.MinWidth, + minHeight = 36.dp, + ), + onValueChange = onValueChange, + enabled = enabled, + readOnly = readOnly, + textStyle = mergedTextStyle, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + interactionSource = interactionSource, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + decorationBox = + @Composable { innerTextField -> + TextFieldDefaults.DecorationBox( + value = value.text, + visualTransformation = visualTransformation, + innerTextField = innerTextField, + placeholder = placeholder, + label = label, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + shape = shape, + singleLine = singleLine, + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + contentPadding = + TextFieldDefaults.contentPaddingWithoutLabel( + start = 10.dp, + top = 12.dp, + end = 10.dp, + bottom = 12.dp, + ), + ) + }, + ) + } +} + +@Composable +fun ChannelHeader( + channelNote: Note, + showVideo: Boolean, + showBottomDiviser: Boolean, + sendToChannel: Boolean, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val channelHex by remember { derivedStateOf { channelNote.channelHex() } } + channelHex?.let { + ChannelHeader( + channelHex = it, + showVideo = showVideo, + showBottomDiviser = showBottomDiviser, + sendToChannel = sendToChannel, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +fun ChannelHeader( + channelHex: String, + showVideo: Boolean, + showBottomDiviser: Boolean, + showFlag: Boolean = true, + sendToChannel: Boolean = false, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + LoadChannel(channelHex, accountViewModel) { + ChannelHeader( + it, + showVideo, + showBottomDiviser, + showFlag, + sendToChannel, + modifier, + accountViewModel, + nav, + ) + } +} + +@Composable +fun ChannelHeader( + baseChannel: Channel, + showVideo: Boolean, + showBottomDiviser: Boolean, + showFlag: Boolean = true, + sendToChannel: Boolean = false, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + Column(Modifier.fillMaxWidth()) { + if (showVideo && baseChannel is LiveActivitiesChannel) { + ShowVideoStreaming(baseChannel, accountViewModel) } - rememberUpdatedState(targetValue).value + val expanded = remember { mutableStateOf(false) } + + Column( + verticalArrangement = Arrangement.Center, + modifier = + modifier.clickable { + if (sendToChannel) { + nav(routeFor(baseChannel)) + } else { + expanded.value = !expanded.value + } + }, + ) { + ShortChannelHeader( + baseChannel = baseChannel, + accountViewModel = accountViewModel, + nav = nav, + showFlag = showFlag, + ) + + if (expanded.value) { + LongChannelHeader(baseChannel = baseChannel, accountViewModel = accountViewModel, nav = nav) + } + } + + if (showBottomDiviser) { + Divider( + thickness = DividerThickness, + ) + } } - val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) - - CompositionLocalProvider(LocalTextSelectionColors provides LocalTextSelectionColors.current) { - BasicTextField( - value = value, - modifier = - modifier.defaultMinSize( - minWidth = TextFieldDefaults.MinWidth, - minHeight = 36.dp, - ), - onValueChange = onValueChange, - enabled = enabled, - readOnly = readOnly, - textStyle = mergedTextStyle, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - visualTransformation = visualTransformation, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - interactionSource = interactionSource, - singleLine = singleLine, - maxLines = maxLines, - minLines = minLines, - decorationBox = - @Composable { innerTextField -> - TextFieldDefaults.DecorationBox( - value = value.text, - visualTransformation = visualTransformation, - innerTextField = innerTextField, - placeholder = placeholder, - label = label, - leadingIcon = leadingIcon, - trailingIcon = trailingIcon, - prefix = prefix, - suffix = suffix, - supportingText = supportingText, - shape = shape, - singleLine = singleLine, - enabled = enabled, - isError = isError, - interactionSource = interactionSource, - colors = colors, - contentPadding = - TextFieldDefaults.contentPaddingWithoutLabel( - start = 10.dp, - top = 12.dp, - end = 10.dp, - bottom = 12.dp, - ), - ) - }, - ) - } -} - -@Composable -fun ChannelHeader( - channelNote: Note, - showVideo: Boolean, - showBottomDiviser: Boolean, - sendToChannel: Boolean, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val channelHex by remember { derivedStateOf { channelNote.channelHex() } } - channelHex?.let { - ChannelHeader( - channelHex = it, - showVideo = showVideo, - showBottomDiviser = showBottomDiviser, - sendToChannel = sendToChannel, - accountViewModel = accountViewModel, - nav = nav, - ) - } -} - -@Composable -fun ChannelHeader( - channelHex: String, - showVideo: Boolean, - showBottomDiviser: Boolean, - showFlag: Boolean = true, - sendToChannel: Boolean = false, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - LoadChannel(channelHex, accountViewModel) { - ChannelHeader( - it, - showVideo, - showBottomDiviser, - showFlag, - sendToChannel, - modifier, - accountViewModel, - nav, - ) - } -} - -@Composable -fun ChannelHeader( - baseChannel: Channel, - showVideo: Boolean, - showBottomDiviser: Boolean, - showFlag: Boolean = true, - sendToChannel: Boolean = false, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - Column(Modifier.fillMaxWidth()) { - if (showVideo && baseChannel is LiveActivitiesChannel) { - ShowVideoStreaming(baseChannel, accountViewModel) - } - - val expanded = remember { mutableStateOf(false) } - - Column( - verticalArrangement = Arrangement.Center, - modifier = - modifier.clickable { - if (sendToChannel) { - nav(routeFor(baseChannel)) - } else { - expanded.value = !expanded.value - } - }, - ) { - ShortChannelHeader( - baseChannel = baseChannel, - accountViewModel = accountViewModel, - nav = nav, - showFlag = showFlag, - ) - - if (expanded.value) { - LongChannelHeader(baseChannel = baseChannel, accountViewModel = accountViewModel, nav = nav) - } - } - - if (showBottomDiviser) { - Divider( - thickness = DividerThickness, - ) - } - } } @Composable fun ShowVideoStreaming( - baseChannel: LiveActivitiesChannel, - accountViewModel: AccountViewModel, + baseChannel: LiveActivitiesChannel, + accountViewModel: AccountViewModel, ) { - baseChannel.info?.let { - SensitivityWarning( - event = it, - accountViewModel = accountViewModel, - ) { - val streamingInfo by - baseChannel.live - .map { - val activity = it.channel as? LiveActivitiesChannel - activity?.info - } - .distinctUntilChanged() - .observeAsState(baseChannel.info) + baseChannel.info?.let { + SensitivityWarning( + event = it, + accountViewModel = accountViewModel, + ) { + val streamingInfo by + baseChannel.live + .map { + val activity = it.channel as? LiveActivitiesChannel + activity?.info + } + .distinctUntilChanged() + .observeAsState(baseChannel.info) - streamingInfo?.let { event -> - val url = remember(streamingInfo) { event.streaming() } - val artworkUri = remember(streamingInfo) { event.image() } - val title = remember(streamingInfo) { baseChannel.toBestDisplayName() } + streamingInfo?.let { event -> + val url = remember(streamingInfo) { event.streaming() } + val artworkUri = remember(streamingInfo) { event.image() } + val title = remember(streamingInfo) { baseChannel.toBestDisplayName() } - val author = remember(streamingInfo) { baseChannel.creatorName() } + val author = remember(streamingInfo) { baseChannel.creatorName() } - url?.let { - CrossfadeCheckIfUrlIsOnline(url, accountViewModel) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = remember { Modifier.heightIn(max = 300.dp) }, - ) { - val zoomableUrlVideo = - remember(it) { - ZoomableUrlVideo( - url = url, - description = title, - artworkUri = artworkUri, - authorName = author, - uri = event.toNostrUri(), - ) + url?.let { + CrossfadeCheckIfUrlIsOnline(url, accountViewModel) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = remember { Modifier.heightIn(max = 300.dp) }, + ) { + val zoomableUrlVideo = + remember(it) { + ZoomableUrlVideo( + url = url, + description = title, + artworkUri = artworkUri, + authorName = author, + uri = event.toNostrUri(), + ) + } + + ZoomableContentView( + content = zoomableUrlVideo, + roundedCorner = false, + accountViewModel = accountViewModel, + ) + } + } } - - ZoomableContentView( - content = zoomableUrlVideo, - roundedCorner = false, - accountViewModel = accountViewModel, - ) } - } } - } } - } } @Composable fun ShortChannelHeader( - baseChannel: Channel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - showFlag: Boolean, + baseChannel: Channel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + showFlag: Boolean, ) { - val channelState = baseChannel.live.observeAsState() - val channel = remember(channelState) { channelState.value?.channel } ?: return + val channelState = baseChannel.live.observeAsState() + val channel = remember(channelState) { channelState.value?.channel } ?: return - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } + val automaticallyShowProfilePicture = + remember { + accountViewModel.settings.showProfilePictures.value + } - Row(verticalAlignment = Alignment.CenterVertically) { - if (channel is LiveActivitiesChannel) { - channel.creator?.let { - UserPicture( - user = it, - size = Size34dp, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } else { - channel.profilePicture()?.let { - RobohashFallbackAsyncImage( - robot = channel.idHex, - model = it, - contentDescription = stringResource(R.string.profile_image), - contentScale = ContentScale.Crop, - modifier = HeaderPictureModifier, - loadProfilePicture = automaticallyShowProfilePicture, - ) - } + Row(verticalAlignment = Alignment.CenterVertically) { + if (channel is LiveActivitiesChannel) { + channel.creator?.let { + UserPicture( + user = it, + size = Size34dp, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } else { + channel.profilePicture()?.let { + RobohashFallbackAsyncImage( + robot = channel.idHex, + model = it, + contentDescription = stringResource(R.string.profile_image), + contentScale = ContentScale.Crop, + modifier = HeaderPictureModifier, + loadProfilePicture = automaticallyShowProfilePicture, + ) + } + } + + Column( + modifier = Modifier.padding(start = 10.dp).height(35.dp).weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = remember(channelState) { channel.toBestDisplayName() }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Row( + modifier = Modifier.height(Size35dp).padding(start = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (channel is PublicChatChannel) { + ShortChannelActionOptions(channel, accountViewModel, nav) + } + if (channel is LiveActivitiesChannel) { + LiveChannelActionOptions(channel, showFlag, accountViewModel, nav) + } + } } - - Column( - modifier = Modifier.padding(start = 10.dp).height(35.dp).weight(1f), - verticalArrangement = Arrangement.Center, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = remember(channelState) { channel.toBestDisplayName() }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - - Row( - modifier = Modifier.height(Size35dp).padding(start = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (channel is PublicChatChannel) { - ShortChannelActionOptions(channel, accountViewModel, nav) - } - if (channel is LiveActivitiesChannel) { - LiveChannelActionOptions(channel, showFlag, accountViewModel, nav) - } - } - } } @Composable fun LongChannelHeader( - baseChannel: Channel, - lineModifier: Modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseChannel: Channel, + lineModifier: Modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val channelState = baseChannel.live.observeAsState() - val channel = remember(channelState) { channelState.value?.channel } ?: return + val channelState = baseChannel.live.observeAsState() + val channel = remember(channelState) { channelState.value?.channel } ?: return - Row( - lineModifier, - ) { - val summary = remember(channelState) { channel.summary()?.ifBlank { null } } - - Column( - Modifier.weight(1f), + Row( + lineModifier, ) { - Row(verticalAlignment = Alignment.CenterVertically) { - val defaultBackground = MaterialTheme.colorScheme.background - val background = remember { mutableStateOf(defaultBackground) } + val summary = remember(channelState) { channel.summary()?.ifBlank { null } } - val tags = - remember(channelState) { - if (baseChannel is LiveActivitiesChannel) { - baseChannel.info?.tags()?.toImmutableListOfLists() ?: EmptyTagList - } else { - EmptyTagList - } - } - - TranslatableRichTextViewer( - content = summary ?: stringResource(id = R.string.groups_no_descriptor), - canPreview = false, - tags = tags, - backgroundColor = background, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (baseChannel is LiveActivitiesChannel && baseChannel.info?.hasHashtags() == true) { - val hashtags = - remember(baseChannel.info) { - baseChannel.info?.hashtags()?.toImmutableList() ?: persistentListOf() - } - DisplayUncitedHashtags(hashtags, summary ?: "", nav) - } - } - - Column { - if (channel is PublicChatChannel) { - Row { - Spacer(DoubleHorzSpacer) - LongChannelActionOptions(channel, accountViewModel, nav) - } - } - } - } - - LoadNote(baseNoteHex = channel.idHex, accountViewModel) { loadingNote -> - loadingNote?.let { note -> - Row( - lineModifier, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(id = R.string.owner), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(75.dp), - ) - Spacer(DoubleHorzSpacer) - NoteAuthorPicture(note, nav, accountViewModel, Size25dp) - Spacer(DoubleHorzSpacer) - NoteUsernameDisplay(note, remember { Modifier.weight(1f) }) - } - - Row( - lineModifier, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(id = R.string.created_at), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(75.dp), - ) - Spacer(DoubleHorzSpacer) - NormalTimeAgo(note, remember { Modifier.weight(1f) }) - MoreOptionsButton(note, accountViewModel) - } - } - } - - var participantUsers by - remember(baseChannel) { - mutableStateOf>>( - persistentListOf(), - ) - } - - if (channel is LiveActivitiesChannel) { - LaunchedEffect(key1 = channelState) { - launch(Dispatchers.IO) { - val newParticipantUsers = - channel.info - ?.participants() - ?.mapNotNull { part -> - LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) } - } - ?.toImmutableList() - - if ( - newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers) + Column( + Modifier.weight(1f), ) { - participantUsers = newParticipantUsers + Row(verticalAlignment = Alignment.CenterVertically) { + val defaultBackground = MaterialTheme.colorScheme.background + val background = remember { mutableStateOf(defaultBackground) } + + val tags = + remember(channelState) { + if (baseChannel is LiveActivitiesChannel) { + baseChannel.info?.tags()?.toImmutableListOfLists() ?: EmptyTagList + } else { + EmptyTagList + } + } + + TranslatableRichTextViewer( + content = summary ?: stringResource(id = R.string.groups_no_descriptor), + canPreview = false, + tags = tags, + backgroundColor = background, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (baseChannel is LiveActivitiesChannel && baseChannel.info?.hasHashtags() == true) { + val hashtags = + remember(baseChannel.info) { + baseChannel.info?.hashtags()?.toImmutableList() ?: persistentListOf() + } + DisplayUncitedHashtags(hashtags, summary ?: "", nav) + } + } + + Column { + if (channel is PublicChatChannel) { + Row { + Spacer(DoubleHorzSpacer) + LongChannelActionOptions(channel, accountViewModel, nav) + } + } } - } } - participantUsers.forEach { - Row( - lineModifier.clickable { nav("User/${it.second.pubkeyHex}") }, - verticalAlignment = Alignment.CenterVertically, - ) { - it.first.role?.let { it1 -> - Text( - text = it1.capitalize(Locale.ROOT), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(55.dp), - ) + LoadNote(baseNoteHex = channel.idHex, accountViewModel) { loadingNote -> + loadingNote?.let { note -> + Row( + lineModifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(id = R.string.owner), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(75.dp), + ) + Spacer(DoubleHorzSpacer) + NoteAuthorPicture(note, nav, accountViewModel, Size25dp) + Spacer(DoubleHorzSpacer) + NoteUsernameDisplay(note, remember { Modifier.weight(1f) }) + } + + Row( + lineModifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(id = R.string.created_at), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(75.dp), + ) + Spacer(DoubleHorzSpacer) + NormalTimeAgo(note, remember { Modifier.weight(1f) }) + MoreOptionsButton(note, accountViewModel) + } + } + } + + var participantUsers by + remember(baseChannel) { + mutableStateOf>>( + persistentListOf(), + ) + } + + if (channel is LiveActivitiesChannel) { + LaunchedEffect(key1 = channelState) { + launch(Dispatchers.IO) { + val newParticipantUsers = + channel.info + ?.participants() + ?.mapNotNull { part -> + LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) } + } + ?.toImmutableList() + + if ( + newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers) + ) { + participantUsers = newParticipantUsers + } + } + } + + participantUsers.forEach { + Row( + lineModifier.clickable { nav("User/${it.second.pubkeyHex}") }, + verticalAlignment = Alignment.CenterVertically, + ) { + it.first.role?.let { it1 -> + Text( + text = it1.capitalize(Locale.ROOT), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(55.dp), + ) + } + Spacer(DoubleHorzSpacer) + ClickableUserPicture(it.second, Size25dp, accountViewModel) + Spacer(DoubleHorzSpacer) + UsernameDisplay(it.second, remember { Modifier.weight(1f) }) + } } - Spacer(DoubleHorzSpacer) - ClickableUserPicture(it.second, Size25dp, accountViewModel) - Spacer(DoubleHorzSpacer) - UsernameDisplay(it.second, remember { Modifier.weight(1f) }) - } } - } } @Composable fun NormalTimeAgo( - baseNote: Note, - modifier: Modifier, + baseNote: Note, + modifier: Modifier, ) { - val nowStr = stringResource(id = R.string.now) + val nowStr = stringResource(id = R.string.now) - val time by - remember(baseNote) { derivedStateOf { timeAgoShort(baseNote.createdAt() ?: 0, nowStr) } } + val time by + remember(baseNote) { derivedStateOf { timeAgoShort(baseNote.createdAt() ?: 0, nowStr) } } - Text( - text = time, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = modifier, - ) + Text( + text = time, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) } @Composable private fun ShortChannelActionOptions( - channel: PublicChatChannel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + channel: PublicChatChannel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LoadNote(baseNoteHex = channel.idHex, accountViewModel) { - it?.let { - Spacer(modifier = StdHorzSpacer) - LikeReaction( - baseNote = it, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav, - ) - Spacer(modifier = StdHorzSpacer) - ZapReaction( - baseNote = it, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav = nav, - ) - Spacer(modifier = StdHorzSpacer) + LoadNote(baseNoteHex = channel.idHex, accountViewModel) { + it?.let { + Spacer(modifier = StdHorzSpacer) + LikeReaction( + baseNote = it, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav, + ) + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = it, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = StdHorzSpacer) + } } - } - WatchChannelFollows(channel, accountViewModel) { isFollowing -> - if (!isFollowing) { - JoinChatButton(accountViewModel, channel, nav) + WatchChannelFollows(channel, accountViewModel) { isFollowing -> + if (!isFollowing) { + JoinChatButton(accountViewModel, channel, nav) + } } - } } @Composable private fun WatchChannelFollows( - channel: PublicChatChannel, - accountViewModel: AccountViewModel, - content: @Composable (Boolean) -> Unit, + channel: PublicChatChannel, + accountViewModel: AccountViewModel, + content: @Composable (Boolean) -> Unit, ) { - val isFollowing by - accountViewModel - .userProfile() - .live() - .follows - .map { it.user.latestContactList?.isTaggedEvent(channel.idHex) ?: false } - .distinctUntilChanged() - .observeAsState( - accountViewModel.userProfile().latestContactList?.isTaggedEvent(channel.idHex) ?: false, - ) + val isFollowing by + accountViewModel + .userProfile() + .live() + .follows + .map { it.user.latestContactList?.isTaggedEvent(channel.idHex) ?: false } + .distinctUntilChanged() + .observeAsState( + accountViewModel.userProfile().latestContactList?.isTaggedEvent(channel.idHex) ?: false, + ) - content(isFollowing) + content(isFollowing) } @Composable private fun LongChannelActionOptions( - channel: PublicChatChannel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + channel: PublicChatChannel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val isMe by - remember(accountViewModel) { - derivedStateOf { channel.creator == accountViewModel.account.userProfile() } + val isMe by + remember(accountViewModel) { + derivedStateOf { channel.creator == accountViewModel.account.userProfile() } + } + + if (isMe) { + EditButton(accountViewModel, channel) } - if (isMe) { - EditButton(accountViewModel, channel) - } - - WatchChannelFollows(channel, accountViewModel) { isFollowing -> - if (isFollowing) { - LeaveChatButton(accountViewModel, channel, nav) + WatchChannelFollows(channel, accountViewModel) { isFollowing -> + if (isFollowing) { + LeaveChatButton(accountViewModel, channel, nav) + } } - } } @Composable private fun LiveChannelActionOptions( - channel: LiveActivitiesChannel, - showFlag: Boolean = true, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + channel: LiveActivitiesChannel, + showFlag: Boolean = true, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val isLive by remember(channel) { derivedStateOf { channel.info?.status() == STATUS_LIVE } } + val isLive by remember(channel) { derivedStateOf { channel.info?.status() == STATUS_LIVE } } - val note = remember(channel.idHex) { LocalCache.getNoteIfExists(channel.idHex) } + val note = remember(channel.idHex) { LocalCache.getNoteIfExists(channel.idHex) } - note?.let { - if (showFlag && isLive) { - LiveFlag() - Spacer(modifier = StdHorzSpacer) + note?.let { + if (showFlag && isLive) { + LiveFlag() + Spacer(modifier = StdHorzSpacer) + } + + LikeReaction( + baseNote = it, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav, + ) + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = it, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) } - - LikeReaction( - baseNote = it, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav, - ) - Spacer(modifier = StdHorzSpacer) - ZapReaction( - baseNote = it, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav = nav, - ) - } } @Composable fun LiveFlag() { - Text( - text = stringResource(id = R.string.live_stream_live_tag), - color = Color.White, - fontWeight = FontWeight.Bold, - fontSize = 16.sp, - modifier = - remember { Modifier.clip(SmallBorder).background(Color.Red).padding(horizontal = 5.dp) }, - ) + Text( + text = stringResource(id = R.string.live_stream_live_tag), + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + modifier = + remember { Modifier.clip(SmallBorder).background(Color.Red).padding(horizontal = 5.dp) }, + ) } @Composable fun EndedFlag() { - Text( - text = stringResource(id = R.string.live_stream_ended_tag), - color = Color.White, - fontWeight = FontWeight.Bold, - modifier = - remember { Modifier.clip(SmallBorder).background(Color.Black).padding(horizontal = 5.dp) }, - ) + Text( + text = stringResource(id = R.string.live_stream_ended_tag), + color = Color.White, + fontWeight = FontWeight.Bold, + modifier = + remember { Modifier.clip(SmallBorder).background(Color.Black).padding(horizontal = 5.dp) }, + ) } @Composable fun OfflineFlag() { - Text( - text = stringResource(id = R.string.live_stream_offline_tag), - color = Color.White, - fontWeight = FontWeight.Bold, - modifier = - remember { Modifier.clip(SmallBorder).background(Color.Black).padding(horizontal = 5.dp) }, - ) + Text( + text = stringResource(id = R.string.live_stream_offline_tag), + color = Color.White, + fontWeight = FontWeight.Bold, + modifier = + remember { Modifier.clip(SmallBorder).background(Color.Black).padding(horizontal = 5.dp) }, + ) } @Composable fun ScheduledFlag(starts: Long?) { - val context = LocalContext.current - val startsIn = starts?.let { timeAgo(it, context) } + val context = LocalContext.current + val startsIn = starts?.let { timeAgo(it, context) } - Text( - text = startsIn ?: stringResource(id = R.string.live_stream_planned_tag), - color = Color.White, - fontWeight = FontWeight.Bold, - modifier = - remember { Modifier.clip(SmallBorder).background(Color.Black).padding(horizontal = 5.dp) }, - ) + Text( + text = startsIn ?: stringResource(id = R.string.live_stream_planned_tag), + color = Color.White, + fontWeight = FontWeight.Bold, + modifier = + remember { Modifier.clip(SmallBorder).background(Color.Black).padding(horizontal = 5.dp) }, + ) } @Composable private fun NoteCopyButton(note: Channel) { - var popupExpanded by remember { mutableStateOf(false) } + var popupExpanded by remember { mutableStateOf(false) } - Button( - modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), - onClick = { popupExpanded = true }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.placeholderText, - ), - ) { - Icon( - tint = Color.White, - imageVector = Icons.Default.Share, - contentDescription = stringResource(R.string.copies_the_note_id_to_the_clipboard_for_sharing), - ) - - DropdownMenu( - expanded = popupExpanded, - onDismissRequest = { popupExpanded = false }, + Button( + modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), + onClick = { popupExpanded = true }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.placeholderText, + ), ) { - val clipboardManager = LocalClipboardManager.current + Icon( + tint = Color.White, + imageVector = Icons.Default.Share, + contentDescription = stringResource(R.string.copies_the_note_id_to_the_clipboard_for_sharing), + ) - DropdownMenuItem( - text = { Text(stringResource(R.string.copy_channel_id_note_to_the_clipboard)) }, - onClick = { - clipboardManager.setText(AnnotatedString("nostr:" + note.idNote())) - popupExpanded = false - }, - ) + DropdownMenu( + expanded = popupExpanded, + onDismissRequest = { popupExpanded = false }, + ) { + val clipboardManager = LocalClipboardManager.current + + DropdownMenuItem( + text = { Text(stringResource(R.string.copy_channel_id_note_to_the_clipboard)) }, + onClick = { + clipboardManager.setText(AnnotatedString("nostr:" + note.idNote())) + popupExpanded = false + }, + ) + } } - } } @Composable private fun EditButton( - accountViewModel: AccountViewModel, - channel: PublicChatChannel, + accountViewModel: AccountViewModel, + channel: PublicChatChannel, ) { - var wantsToPost by remember { mutableStateOf(false) } + var wantsToPost by remember { mutableStateOf(false) } - if (wantsToPost) { - NewChannelView({ wantsToPost = false }, accountViewModel, channel) - } + if (wantsToPost) { + NewChannelView({ wantsToPost = false }, accountViewModel, channel) + } - Button( - modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), - onClick = { wantsToPost = true }, - contentPadding = ZeroPadding, - ) { - Icon( - tint = Color.White, - imageVector = Icons.Default.EditNote, - contentDescription = stringResource(R.string.edits_the_channel_metadata), - ) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), + onClick = { wantsToPost = true }, + contentPadding = ZeroPadding, + ) { + Icon( + tint = Color.White, + imageVector = Icons.Default.EditNote, + contentDescription = stringResource(R.string.edits_the_channel_metadata), + ) + } } @Composable fun JoinChatButton( - accountViewModel: AccountViewModel, - channel: Channel, - nav: (String) -> Unit, + accountViewModel: AccountViewModel, + channel: Channel, + nav: (String) -> Unit, ) { - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { scope.launch(Dispatchers.IO) { accountViewModel.account.follow(channel) } }, - contentPadding = ButtonPadding, - ) { - Text(text = stringResource(R.string.join), color = Color.White) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { scope.launch(Dispatchers.IO) { accountViewModel.account.follow(channel) } }, + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(R.string.join), color = Color.White) + } } @Composable fun LeaveChatButton( - accountViewModel: AccountViewModel, - channel: Channel, - nav: (String) -> Unit, + accountViewModel: AccountViewModel, + channel: Channel, + nav: (String) -> Unit, ) { - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { scope.launch(Dispatchers.IO) { accountViewModel.account.unfollow(channel) } }, - contentPadding = ButtonPadding, - ) { - Text(text = stringResource(R.string.leave), color = Color.White) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { scope.launch(Dispatchers.IO) { accountViewModel.account.unfollow(channel) } }, + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(R.string.leave), color = Color.White) + } } @Composable fun JoinCommunityButton( - accountViewModel: AccountViewModel, - note: AddressableNote, - nav: (String) -> Unit, + accountViewModel: AccountViewModel, + note: AddressableNote, + nav: (String) -> Unit, ) { - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { scope.launch(Dispatchers.IO) { accountViewModel.account.follow(note) } }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - contentPadding = ButtonPadding, - ) { - Text(text = stringResource(R.string.join), color = Color.White) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { scope.launch(Dispatchers.IO) { accountViewModel.account.follow(note) } }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(R.string.join), color = Color.White) + } } @Composable fun LeaveCommunityButton( - accountViewModel: AccountViewModel, - note: AddressableNote, - nav: (String) -> Unit, + accountViewModel: AccountViewModel, + note: AddressableNote, + nav: (String) -> Unit, ) { - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { scope.launch(Dispatchers.IO) { accountViewModel.account.unfollow(note) } }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - contentPadding = ButtonPadding, - ) { - Text(text = stringResource(R.string.leave), color = Color.White) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { scope.launch(Dispatchers.IO) { accountViewModel.account.unfollow(note) } }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(R.string.leave), color = Color.White) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt index f53f5022c..4f8965bfc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt @@ -82,266 +82,270 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun ChatroomListScreen( - knownFeedViewModel: NostrChatroomListKnownFeedViewModel, - newFeedViewModel: NostrChatroomListNewFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + knownFeedViewModel: NostrChatroomListKnownFeedViewModel, + newFeedViewModel: NostrChatroomListNewFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val windowSizeClass = accountViewModel.settings.windowSizeClass.value + val windowSizeClass = accountViewModel.settings.windowSizeClass.value - val twoPane by remember { - derivedStateOf { - when (windowSizeClass?.widthSizeClass) { - WindowWidthSizeClass.Compact -> false - WindowWidthSizeClass.Expanded, - WindowWidthSizeClass.Medium, -> true - else -> false - } + val twoPane by remember { + derivedStateOf { + when (windowSizeClass?.widthSizeClass) { + WindowWidthSizeClass.Compact -> false + WindowWidthSizeClass.Expanded, + WindowWidthSizeClass.Medium, + -> true + else -> false + } + } } - } - if (twoPane && windowSizeClass != null) { - ChatroomListTwoPane( - knownFeedViewModel = knownFeedViewModel, - newFeedViewModel = newFeedViewModel, - widthSizeClass = windowSizeClass.widthSizeClass, - accountViewModel = accountViewModel, - nav = nav, - ) - } else { - ChatroomListScreenOnlyList( - knownFeedViewModel = knownFeedViewModel, - newFeedViewModel = newFeedViewModel, - accountViewModel = accountViewModel, - nav = nav, - ) - } + if (twoPane && windowSizeClass != null) { + ChatroomListTwoPane( + knownFeedViewModel = knownFeedViewModel, + newFeedViewModel = newFeedViewModel, + widthSizeClass = windowSizeClass.widthSizeClass, + accountViewModel = accountViewModel, + nav = nav, + ) + } else { + ChatroomListScreenOnlyList( + knownFeedViewModel = knownFeedViewModel, + newFeedViewModel = newFeedViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) + } } data class RouteId(val route: String, val id: String) @Composable fun ChatroomListTwoPane( - knownFeedViewModel: NostrChatroomListKnownFeedViewModel, - newFeedViewModel: NostrChatroomListNewFeedViewModel, - widthSizeClass: WindowWidthSizeClass, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + knownFeedViewModel: NostrChatroomListKnownFeedViewModel, + newFeedViewModel: NostrChatroomListNewFeedViewModel, + widthSizeClass: WindowWidthSizeClass, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - /** The index of the currently selected word, or `null` if none is selected */ - var selectedRoute: RouteId? by remember { mutableStateOf(null) } + /** The index of the currently selected word, or `null` if none is selected */ + var selectedRoute: RouteId? by remember { mutableStateOf(null) } - val navInterceptor = remember { - { fullRoute: String -> - if (fullRoute.startsWith("Room/") || fullRoute.startsWith("Channel/")) { - val route = fullRoute.substringBefore("/") - val id = fullRoute.substringAfter("/") - selectedRoute = RouteId(route, id) - } else { - nav(fullRoute) - } - } - } - - val strategy = remember { - if (widthSizeClass == WindowWidthSizeClass.Expanded) { - HorizontalTwoPaneStrategy( - splitFraction = 1f / 3f, - ) - } else { - HorizontalTwoPaneStrategy( - splitFraction = 1f / 2.5f, - ) - } - } - - TwoPane( - first = { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomEnd) { - ChatroomListScreenOnlyList( - knownFeedViewModel, - newFeedViewModel, - accountViewModel, - navInterceptor, - ) - Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { - ChannelFabColumn(accountViewModel, nav) - } - Divider( - modifier = - Modifier.fillMaxHeight() // fill the max height - .width(DividerThickness), - ) - } - }, - second = { - selectedRoute?.let { - if (it.route == "Room") { - ChatroomScreen( - roomId = it.id, - accountViewModel = accountViewModel, - nav = nav, - ) + val navInterceptor = + remember { + { fullRoute: String -> + if (fullRoute.startsWith("Room/") || fullRoute.startsWith("Channel/")) { + val route = fullRoute.substringBefore("/") + val id = fullRoute.substringAfter("/") + selectedRoute = RouteId(route, id) + } else { + nav(fullRoute) + } + } } - if (it.route == "Channel") { - ChannelScreen( - channelId = it.id, - accountViewModel = accountViewModel, - nav = nav, - ) + val strategy = + remember { + if (widthSizeClass == WindowWidthSizeClass.Expanded) { + HorizontalTwoPaneStrategy( + splitFraction = 1f / 3f, + ) + } else { + HorizontalTwoPaneStrategy( + splitFraction = 1f / 2.5f, + ) + } } - } - }, - strategy = strategy, - displayFeatures = accountViewModel.settings.displayFeatures.value, - foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, - modifier = Modifier.fillMaxSize(), - ) + + TwoPane( + first = { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomEnd) { + ChatroomListScreenOnlyList( + knownFeedViewModel, + newFeedViewModel, + accountViewModel, + navInterceptor, + ) + Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { + ChannelFabColumn(accountViewModel, nav) + } + Divider( + modifier = + Modifier.fillMaxHeight() // fill the max height + .width(DividerThickness), + ) + } + }, + second = { + selectedRoute?.let { + if (it.route == "Room") { + ChatroomScreen( + roomId = it.id, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (it.route == "Channel") { + ChannelScreen( + channelId = it.id, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + }, + strategy = strategy, + displayFeatures = accountViewModel.settings.displayFeatures.value, + foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, + modifier = Modifier.fillMaxSize(), + ) } @OptIn(ExperimentalFoundationApi::class) @Composable fun ChatroomListScreenOnlyList( - knownFeedViewModel: NostrChatroomListKnownFeedViewModel, - newFeedViewModel: NostrChatroomListNewFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + knownFeedViewModel: NostrChatroomListKnownFeedViewModel, + newFeedViewModel: NostrChatroomListNewFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val pagerState = rememberPagerState { 2 } - val coroutineScope = rememberCoroutineScope() + val pagerState = rememberPagerState { 2 } + val coroutineScope = rememberCoroutineScope() - var moreActionsExpanded by remember { mutableStateOf(false) } - val markKnownAsRead = remember { mutableStateOf(false) } - val markNewAsRead = remember { mutableStateOf(false) } + var moreActionsExpanded by remember { mutableStateOf(false) } + val markKnownAsRead = remember { mutableStateOf(false) } + val markNewAsRead = remember { mutableStateOf(false) } - WatchAccountForListScreen(knownFeedViewModel, newFeedViewModel, accountViewModel) + WatchAccountForListScreen(knownFeedViewModel, newFeedViewModel, accountViewModel) - val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - NostrChatroomListDataSource.start() - } + val lifeCycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + NostrChatroomListDataSource.start() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } - - val tabs by - remember(knownFeedViewModel, markKnownAsRead) { - derivedStateOf { - listOf( - ChatroomListTabItem(R.string.known, knownFeedViewModel, markKnownAsRead), - ChatroomListTabItem(R.string.new_requests, newFeedViewModel, markNewAsRead), - ) - } - } - - Column( - modifier = Modifier.fillMaxHeight(), - ) { - Box(Modifier.fillMaxWidth()) { - TabRow( - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground, - selectedTabIndex = pagerState.currentPage, - modifier = TabRowHeight, - ) { - tabs.forEachIndexed { index, tab -> - Tab( - selected = pagerState.currentPage == index, - text = { Text(text = stringResource(tab.resource)) }, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, - ) + val tabs by + remember(knownFeedViewModel, markKnownAsRead) { + derivedStateOf { + listOf( + ChatroomListTabItem(R.string.known, knownFeedViewModel, markKnownAsRead), + ChatroomListTabItem(R.string.new_requests, newFeedViewModel, markNewAsRead), + ) + } } - } - IconButton( - modifier = Modifier.size(40.dp).align(Alignment.CenterEnd), - onClick = { moreActionsExpanded = true }, - ) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = null, - tint = MaterialTheme.colorScheme.placeholderText, - ) + Column( + modifier = Modifier.fillMaxHeight(), + ) { + Box(Modifier.fillMaxWidth()) { + TabRow( + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + selectedTabIndex = pagerState.currentPage, + modifier = TabRowHeight, + ) { + tabs.forEachIndexed { index, tab -> + Tab( + selected = pagerState.currentPage == index, + text = { Text(text = stringResource(tab.resource)) }, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, + ) + } + } - ChatroomTabMenu( - moreActionsExpanded, - { moreActionsExpanded = false }, - { markKnownAsRead.value = true }, - { markNewAsRead.value = true }, - ) - } + IconButton( + modifier = Modifier.size(40.dp).align(Alignment.CenterEnd), + onClick = { moreActionsExpanded = true }, + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = null, + tint = MaterialTheme.colorScheme.placeholderText, + ) + + ChatroomTabMenu( + moreActionsExpanded, + { moreActionsExpanded = false }, + { markKnownAsRead.value = true }, + { markNewAsRead.value = true }, + ) + } + } + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { page -> + ChatroomListFeedView( + viewModel = tabs[page].viewModel, + accountViewModel = accountViewModel, + nav = nav, + markAsRead = tabs[page].markAsRead, + ) + } } - - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - ) { page -> - ChatroomListFeedView( - viewModel = tabs[page].viewModel, - accountViewModel = accountViewModel, - nav = nav, - markAsRead = tabs[page].markAsRead, - ) - } - } } @Composable fun WatchAccountForListScreen( - knownFeedViewModel: NostrChatroomListKnownFeedViewModel, - newFeedViewModel: NostrChatroomListNewFeedViewModel, - accountViewModel: AccountViewModel, + knownFeedViewModel: NostrChatroomListKnownFeedViewModel, + newFeedViewModel: NostrChatroomListNewFeedViewModel, + accountViewModel: AccountViewModel, ) { - LaunchedEffect(accountViewModel) { - launch(Dispatchers.IO) { - NostrChatroomListDataSource.start() - knownFeedViewModel.invalidateData(true) - newFeedViewModel.invalidateData(true) + LaunchedEffect(accountViewModel) { + launch(Dispatchers.IO) { + NostrChatroomListDataSource.start() + knownFeedViewModel.invalidateData(true) + newFeedViewModel.invalidateData(true) + } } - } } @Immutable class ChatroomListTabItem( - val resource: Int, - val viewModel: FeedViewModel, - val markAsRead: MutableState, + val resource: Int, + val viewModel: FeedViewModel, + val markAsRead: MutableState, ) @Composable fun ChatroomTabMenu( - expanded: Boolean, - onDismiss: () -> Unit, - onMarkKnownAsRead: () -> Unit, - onMarkNewAsRead: () -> Unit, + expanded: Boolean, + onDismiss: () -> Unit, + onMarkKnownAsRead: () -> Unit, + onMarkNewAsRead: () -> Unit, ) { - DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { - DropdownMenuItem( - text = { Text(stringResource(R.string.mark_all_known_as_read)) }, - onClick = { - onMarkKnownAsRead() - onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.mark_all_new_as_read)) }, - onClick = { - onMarkNewAsRead() - onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.mark_all_as_read)) }, - onClick = { - onMarkKnownAsRead() - onMarkNewAsRead() - onDismiss() - }, - ) - } + DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + DropdownMenuItem( + text = { Text(stringResource(R.string.mark_all_known_as_read)) }, + onClick = { + onMarkKnownAsRead() + onDismiss() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.mark_all_new_as_read)) }, + onClick = { + onMarkNewAsRead() + onDismiss() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.mark_all_as_read)) }, + onClick = { + onMarkKnownAsRead() + onMarkNewAsRead() + onDismiss() + }, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt index e791a1185..d045b83f1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -123,679 +123,681 @@ import kotlinx.coroutines.withContext @Composable fun ChatroomScreen( - roomId: String?, - draftMessage: String? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + roomId: String?, + draftMessage: String? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (roomId == null) return + if (roomId == null) return - LoadRoom(roomId, accountViewModel) { - it?.let { - PrepareChatroomViewModels( - room = it, - draftMessage = draftMessage, - accountViewModel = accountViewModel, - nav = nav, - ) + LoadRoom(roomId, accountViewModel) { + it?.let { + PrepareChatroomViewModels( + room = it, + draftMessage = draftMessage, + accountViewModel = accountViewModel, + nav = nav, + ) + } } - } } @Composable fun ChatroomScreenByAuthor( - authorPubKeyHex: String?, - draftMessage: String? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + authorPubKeyHex: String?, + draftMessage: String? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (authorPubKeyHex == null) return + if (authorPubKeyHex == null) return - LoadRoomByAuthor(authorPubKeyHex, accountViewModel) { - it?.let { - PrepareChatroomViewModels( - room = it, - draftMessage = draftMessage, - accountViewModel = accountViewModel, - nav = nav, - ) + LoadRoomByAuthor(authorPubKeyHex, accountViewModel) { + it?.let { + PrepareChatroomViewModels( + room = it, + draftMessage = draftMessage, + accountViewModel = accountViewModel, + nav = nav, + ) + } } - } } @Composable fun LoadRoom( - roomId: String, - accountViewModel: AccountViewModel, - content: @Composable (ChatroomKey?) -> Unit, + roomId: String, + accountViewModel: AccountViewModel, + content: @Composable (ChatroomKey?) -> Unit, ) { - var room by remember(roomId) { mutableStateOf(null) } + var room by remember(roomId) { mutableStateOf(null) } - if (room == null) { - LaunchedEffect(key1 = roomId) { - launch(Dispatchers.IO) { - val newRoom = - accountViewModel.userProfile().privateChatrooms.keys.firstOrNull { - it.hashCode().toString() == roomId - } - if (room != newRoom) { - room = newRoom + if (room == null) { + LaunchedEffect(key1 = roomId) { + launch(Dispatchers.IO) { + val newRoom = + accountViewModel.userProfile().privateChatrooms.keys.firstOrNull { + it.hashCode().toString() == roomId + } + if (room != newRoom) { + room = newRoom + } + } } - } } - } - content(room) + content(room) } @Composable fun LoadRoomByAuthor( - authorPubKeyHex: String, - accountViewModel: AccountViewModel, - content: @Composable (ChatroomKey?) -> Unit, + authorPubKeyHex: String, + accountViewModel: AccountViewModel, + content: @Composable (ChatroomKey?) -> Unit, ) { - val room by - remember(authorPubKeyHex) { - mutableStateOf(ChatroomKey(persistentSetOf(authorPubKeyHex))) - } + val room by + remember(authorPubKeyHex) { + mutableStateOf(ChatroomKey(persistentSetOf(authorPubKeyHex))) + } - content(room) + content(room) } @Composable fun PrepareChatroomViewModels( - room: ChatroomKey, - draftMessage: String?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + room: ChatroomKey, + draftMessage: String?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val feedViewModel: NostrChatroomFeedViewModel = - viewModel( - key = room.hashCode().toString() + "ChatroomViewModels", - factory = - NostrChatroomFeedViewModel.Factory( - room, - accountViewModel.account, - ), - ) + val feedViewModel: NostrChatroomFeedViewModel = + viewModel( + key = room.hashCode().toString() + "ChatroomViewModels", + factory = + NostrChatroomFeedViewModel.Factory( + room, + accountViewModel.account, + ), + ) - val newPostModel: NewPostViewModel = viewModel() - newPostModel.accountViewModel = accountViewModel - newPostModel.account = accountViewModel.account - newPostModel.requiresNIP24 = room.users.size > 1 - if (newPostModel.requiresNIP24) { - newPostModel.nip24 = true - } - - LaunchedEffect(key1 = newPostModel) { - launch(Dispatchers.IO) { - val hasNIP24 = - accountViewModel.userProfile().privateChatrooms[room]?.roomMessages?.any { - it.event is ChatMessageEvent && - (it.event as ChatMessageEvent).pubKey != accountViewModel.userProfile().pubkeyHex - } - if (hasNIP24 == true && newPostModel.nip24 == false) { + val newPostModel: NewPostViewModel = viewModel() + newPostModel.accountViewModel = accountViewModel + newPostModel.account = accountViewModel.account + newPostModel.requiresNIP24 = room.users.size > 1 + if (newPostModel.requiresNIP24) { newPostModel.nip24 = true - } } - } - if (draftMessage != null) { - LaunchedEffect(key1 = draftMessage) { newPostModel.message = TextFieldValue(draftMessage) } - } + LaunchedEffect(key1 = newPostModel) { + launch(Dispatchers.IO) { + val hasNIP24 = + accountViewModel.userProfile().privateChatrooms[room]?.roomMessages?.any { + it.event is ChatMessageEvent && + (it.event as ChatMessageEvent).pubKey != accountViewModel.userProfile().pubkeyHex + } + if (hasNIP24 == true && newPostModel.nip24 == false) { + newPostModel.nip24 = true + } + } + } - ChatroomScreen( - room = room, - feedViewModel = feedViewModel, - newPostModel = newPostModel, - accountViewModel = accountViewModel, - nav = nav, - ) + if (draftMessage != null) { + LaunchedEffect(key1 = draftMessage) { newPostModel.message = TextFieldValue(draftMessage) } + } + + ChatroomScreen( + room = room, + feedViewModel = feedViewModel, + newPostModel = newPostModel, + accountViewModel = accountViewModel, + nav = nav, + ) } @Composable fun ChatroomScreen( - room: ChatroomKey, - feedViewModel: NostrChatroomFeedViewModel, - newPostModel: NewPostViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + room: ChatroomKey, + feedViewModel: NostrChatroomFeedViewModel, + newPostModel: NewPostViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val context = LocalContext.current + val context = LocalContext.current - NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, room) - - val lifeCycleOwner = LocalLifecycleOwner.current - - LaunchedEffect(room, accountViewModel) { - launch(Dispatchers.IO) { - newPostModel.imageUploadingError.collect { error -> - withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() } - } - } - } - - DisposableEffect(room, accountViewModel) { NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, room) - NostrChatroomDataSource.start() - feedViewModel.invalidateData() - onDispose { NostrChatroomDataSource.stop() } - } + val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Private Message Start") + LaunchedEffect(room, accountViewModel) { + launch(Dispatchers.IO) { + newPostModel.imageUploadingError.collect { error -> + withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() } + } + } + } + + DisposableEffect(room, accountViewModel) { + NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, room) NostrChatroomDataSource.start() feedViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Private Message Stop") - NostrChatroomDataSource.stop() - } + + onDispose { NostrChatroomDataSource.stop() } } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + DisposableEffect(lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Private Message Start") + NostrChatroomDataSource.start() + feedViewModel.invalidateData() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Private Message Stop") + NostrChatroomDataSource.stop() + } + } - Column(Modifier.fillMaxHeight()) { - val replyTo = remember { mutableStateOf(null) } - Column( - modifier = Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true), - ) { - RefreshingChatroomFeedView( - viewModel = feedViewModel, - accountViewModel = accountViewModel, - nav = nav, - routeForLastRead = "Room/${room.hashCode()}", - onWantsToReply = { replyTo.value = it }, - ) + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } } - Spacer(modifier = Modifier.height(10.dp)) - - replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { replyTo.value = null } } - - val scope = rememberCoroutineScope() - - // LAST ROW - PrivateMessageEditFieldRow(newPostModel, isPrivate = true, accountViewModel) { - scope.launch(Dispatchers.IO) { - if (newPostModel.nip24 || room.users.size > 1 || replyTo.value?.event is ChatMessageEvent) { - accountViewModel.account.sendNIP24PrivateMessage( - message = newPostModel.message.text, - toUsers = room.users.toList(), - replyingTo = replyTo.value, - mentions = null, - wantsToMarkAsSensitive = false, - ) - } else { - accountViewModel.account.sendPrivateMessage( - message = newPostModel.message.text, - toUser = room.users.first(), - replyingTo = replyTo.value, - mentions = null, - wantsToMarkAsSensitive = false, - ) + Column(Modifier.fillMaxHeight()) { + val replyTo = remember { mutableStateOf(null) } + Column( + modifier = Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true), + ) { + RefreshingChatroomFeedView( + viewModel = feedViewModel, + accountViewModel = accountViewModel, + nav = nav, + routeForLastRead = "Room/${room.hashCode()}", + onWantsToReply = { replyTo.value = it }, + ) } - newPostModel.message = TextFieldValue("") - replyTo.value = null - feedViewModel.sendToTop() - } + Spacer(modifier = Modifier.height(10.dp)) + + replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { replyTo.value = null } } + + val scope = rememberCoroutineScope() + + // LAST ROW + PrivateMessageEditFieldRow(newPostModel, isPrivate = true, accountViewModel) { + scope.launch(Dispatchers.IO) { + if (newPostModel.nip24 || room.users.size > 1 || replyTo.value?.event is ChatMessageEvent) { + accountViewModel.account.sendNIP24PrivateMessage( + message = newPostModel.message.text, + toUsers = room.users.toList(), + replyingTo = replyTo.value, + mentions = null, + wantsToMarkAsSensitive = false, + ) + } else { + accountViewModel.account.sendPrivateMessage( + message = newPostModel.message.text, + toUser = room.users.first(), + replyingTo = replyTo.value, + mentions = null, + wantsToMarkAsSensitive = false, + ) + } + + newPostModel.message = TextFieldValue("") + replyTo.value = null + feedViewModel.sendToTop() + } + } } - } } @Composable fun PrivateMessageEditFieldRow( - channelScreenModel: NewPostViewModel, - isPrivate: Boolean, - accountViewModel: AccountViewModel, - onSendNewMessage: () -> Unit, + channelScreenModel: NewPostViewModel, + isPrivate: Boolean, + accountViewModel: AccountViewModel, + onSendNewMessage: () -> Unit, ) { - Row( - modifier = EditFieldModifier, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - val context = LocalContext.current + Row( + modifier = EditFieldModifier, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + val context = LocalContext.current - MyTextField( - value = channelScreenModel.message, - onValueChange = { channelScreenModel.updateMessage(it) }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - shape = EditFieldBorder, - modifier = Modifier.weight(1f, true), - placeholder = { - Text( - text = stringResource(R.string.reply_here), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - trailingIcon = { - ThinSendButton( - isActive = - channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage, - modifier = EditFieldTrailingIconModifier, - ) { - onSendNewMessage() - } - }, - leadingIcon = { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 6.dp), - ) { - UploadFromGallery( - isUploading = channelScreenModel.isUploadingImage, - tint = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.size(30.dp).padding(start = 2.dp), - ) { - channelScreenModel.upload( - galleryUri = it, - alt = null, - sensitiveContent = false, - isPrivate = isPrivate, - server = ServerOption(accountViewModel.account.defaultFileServer, false), - context = context, - ) - } - - var wantsToActivateNIP24 by remember { mutableStateOf(false) } - - if (wantsToActivateNIP24) { - NewFeatureNIP24AlertDialog( - accountViewModel = accountViewModel, - onConfirm = { channelScreenModel.toggleNIP04And24() }, - onDismiss = { wantsToActivateNIP24 = false }, - ) - } - - IconButton( - modifier = Size30Modifier, - onClick = { - if ( - !accountViewModel.hideNIP24WarningDialog && - !channelScreenModel.nip24 && - !channelScreenModel.requiresNIP24 - ) { - wantsToActivateNIP24 = true - } else { - channelScreenModel.toggleNIP04And24() - } + MyTextField( + value = channelScreenModel.message, + onValueChange = { channelScreenModel.updateMessage(it) }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + shape = EditFieldBorder, + modifier = Modifier.weight(1f, true), + placeholder = { + Text( + text = stringResource(R.string.reply_here), + color = MaterialTheme.colorScheme.placeholderText, + ) }, - ) { - if (channelScreenModel.nip24) { - Icon( - painter = painterResource(id = R.drawable.incognito), - null, - modifier = Modifier.padding(top = 2.dp).size(18.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } else { - Icon( - painter = painterResource(id = R.drawable.incognito_off), - null, - modifier = Modifier.padding(top = 2.dp).size(18.dp), - tint = MaterialTheme.colorScheme.placeholderText, - ) - } - } - } - }, - colors = - TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), - ) - } + trailingIcon = { + ThinSendButton( + isActive = + channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage, + modifier = EditFieldTrailingIconModifier, + ) { + onSendNewMessage() + } + }, + leadingIcon = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 6.dp), + ) { + UploadFromGallery( + isUploading = channelScreenModel.isUploadingImage, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.size(30.dp).padding(start = 2.dp), + ) { + channelScreenModel.upload( + galleryUri = it, + alt = null, + sensitiveContent = false, + isPrivate = isPrivate, + server = ServerOption(accountViewModel.account.defaultFileServer, false), + context = context, + ) + } + + var wantsToActivateNIP24 by remember { mutableStateOf(false) } + + if (wantsToActivateNIP24) { + NewFeatureNIP24AlertDialog( + accountViewModel = accountViewModel, + onConfirm = { channelScreenModel.toggleNIP04And24() }, + onDismiss = { wantsToActivateNIP24 = false }, + ) + } + + IconButton( + modifier = Size30Modifier, + onClick = { + if ( + !accountViewModel.hideNIP24WarningDialog && + !channelScreenModel.nip24 && + !channelScreenModel.requiresNIP24 + ) { + wantsToActivateNIP24 = true + } else { + channelScreenModel.toggleNIP04And24() + } + }, + ) { + if (channelScreenModel.nip24) { + Icon( + painter = painterResource(id = R.drawable.incognito), + null, + modifier = Modifier.padding(top = 2.dp).size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } else { + Icon( + painter = painterResource(id = R.drawable.incognito_off), + null, + modifier = Modifier.padding(top = 2.dp).size(18.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + } + } + }, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + } } @Composable fun NewFeatureNIP24AlertDialog( - accountViewModel: AccountViewModel, - onConfirm: () -> Unit, - onDismiss: () -> Unit, + accountViewModel: AccountViewModel, + onConfirm: () -> Unit, + onDismiss: () -> Unit, ) { - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - QuickActionAlertDialog( - title = stringResource(R.string.new_feature_nip24_might_not_be_available_title), - textContent = stringResource(R.string.new_feature_nip24_might_not_be_available_description), - buttonIconResource = R.drawable.incognito, - buttonText = stringResource(R.string.new_feature_nip24_activate), - onClickDoOnce = { - scope.launch(Dispatchers.IO) { onConfirm() } - onDismiss() - }, - onClickDontShowAgain = { - scope.launch(Dispatchers.IO) { - onConfirm() - accountViewModel.dontShowNIP24WarningDialog() - } - onDismiss() - }, - onDismiss = onDismiss, - ) + QuickActionAlertDialog( + title = stringResource(R.string.new_feature_nip24_might_not_be_available_title), + textContent = stringResource(R.string.new_feature_nip24_might_not_be_available_description), + buttonIconResource = R.drawable.incognito, + buttonText = stringResource(R.string.new_feature_nip24_activate), + onClickDoOnce = { + scope.launch(Dispatchers.IO) { onConfirm() } + onDismiss() + }, + onClickDontShowAgain = { + scope.launch(Dispatchers.IO) { + onConfirm() + accountViewModel.dontShowNIP24WarningDialog() + } + onDismiss() + }, + onDismiss = onDismiss, + ) } @Composable fun ThinSendButton( - isActive: Boolean, - modifier: Modifier, - onClick: () -> Unit, + isActive: Boolean, + modifier: Modifier, + onClick: () -> Unit, ) { - IconButton( - enabled = isActive, - modifier = modifier, - onClick = onClick, - ) { - Icon( - imageVector = Icons.Default.Send, - null, - modifier = Size20Modifier, - ) - } -} - -@Composable -fun ChatroomHeader( - room: ChatroomKey, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - if (room.users.size == 1) { - LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser -> - if (baseUser != null) { - ChatroomHeader( - baseUser = baseUser, - modifier = modifier, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } else { - GroupChatroomHeader( - room = room, - modifier = modifier, - accountViewModel = accountViewModel, - nav = nav, - ) - } -} - -@Composable -fun ChatroomHeader( - baseUser: User, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - Column( - modifier = - Modifier.fillMaxWidth() - .clickable( - onClick = { nav("User/${baseUser.pubkeyHex}") }, - ), - ) { - Column( - verticalArrangement = Arrangement.Center, - modifier = modifier, + IconButton( + enabled = isActive, + modifier = modifier, + onClick = onClick, ) { - Row(verticalAlignment = Alignment.CenterVertically) { - ClickableUserPicture( - baseUser = baseUser, - accountViewModel = accountViewModel, - size = Size34dp, + Icon( + imageVector = Icons.Default.Send, + null, + modifier = Size20Modifier, ) - - Column(modifier = Modifier.padding(start = 10.dp)) { - UsernameDisplay(baseUser) - ObserveDisplayNip05Status(baseUser, accountViewModel = accountViewModel, nav = nav) - } - } } +} - Divider( - thickness = DividerThickness, - ) - } +@Composable +fun ChatroomHeader( + room: ChatroomKey, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + if (room.users.size == 1) { + LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser -> + if (baseUser != null) { + ChatroomHeader( + baseUser = baseUser, + modifier = modifier, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } else { + GroupChatroomHeader( + room = room, + modifier = modifier, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +fun ChatroomHeader( + baseUser: User, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + Column( + modifier = + Modifier.fillMaxWidth() + .clickable( + onClick = { nav("User/${baseUser.pubkeyHex}") }, + ), + ) { + Column( + verticalArrangement = Arrangement.Center, + modifier = modifier, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + ClickableUserPicture( + baseUser = baseUser, + accountViewModel = accountViewModel, + size = Size34dp, + ) + + Column(modifier = Modifier.padding(start = 10.dp)) { + UsernameDisplay(baseUser) + ObserveDisplayNip05Status(baseUser, accountViewModel = accountViewModel, nav = nav) + } + } + } + + Divider( + thickness = DividerThickness, + ) + } } @Composable fun GroupChatroomHeader( - room: ChatroomKey, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + room: ChatroomKey, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val expanded = remember { mutableStateOf(false) } + val expanded = remember { mutableStateOf(false) } - Column( - modifier = Modifier.fillMaxWidth().clickable { expanded.value = !expanded.value }, - ) { Column( - verticalArrangement = Arrangement.Center, - modifier = modifier, + modifier = Modifier.fillMaxWidth().clickable { expanded.value = !expanded.value }, ) { - Row(verticalAlignment = Alignment.CenterVertically) { - NonClickableUserPictures( - users = room.users, - accountViewModel = accountViewModel, - size = Size34dp, - ) + Column( + verticalArrangement = Arrangement.Center, + modifier = modifier, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + NonClickableUserPictures( + users = room.users, + accountViewModel = accountViewModel, + size = Size34dp, + ) - Column(modifier = Modifier.padding(start = 10.dp)) { - RoomNameOnlyDisplay(room, Modifier, FontWeight.Bold, accountViewModel.userProfile()) - DisplayUserSetAsSubject(room, accountViewModel, FontWeight.Normal) + Column(modifier = Modifier.padding(start = 10.dp)) { + RoomNameOnlyDisplay(room, Modifier, FontWeight.Bold, accountViewModel.userProfile()) + DisplayUserSetAsSubject(room, accountViewModel, FontWeight.Normal) + } + } + + if (expanded.value) { + LongRoomHeader(room = room, accountViewModel = accountViewModel, nav = nav) + } } - } - if (expanded.value) { - LongRoomHeader(room = room, accountViewModel = accountViewModel, nav = nav) - } + Divider( + thickness = DividerThickness, + ) } - - Divider( - thickness = DividerThickness, - ) - } } @Composable private fun EditRoomSubjectButton( - room: ChatroomKey, - accountViewModel: AccountViewModel, + room: ChatroomKey, + accountViewModel: AccountViewModel, ) { - var wantsToPost by remember { mutableStateOf(false) } + var wantsToPost by remember { mutableStateOf(false) } - if (wantsToPost) { - NewSubjectView({ wantsToPost = false }, accountViewModel, room) - } + if (wantsToPost) { + NewSubjectView({ wantsToPost = false }, accountViewModel, room) + } - Button( - modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), - onClick = { wantsToPost = true }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Icon( - tint = Color.White, - imageVector = Icons.Default.EditNote, - contentDescription = stringResource(R.string.edits_the_channel_metadata), - ) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), + onClick = { wantsToPost = true }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Icon( + tint = Color.White, + imageVector = Icons.Default.EditNote, + contentDescription = stringResource(R.string.edits_the_channel_metadata), + ) + } } @Composable fun NewSubjectView( - onClose: () -> Unit, - accountViewModel: AccountViewModel, - room: ChatroomKey, + onClose: () -> Unit, + accountViewModel: AccountViewModel, + room: ChatroomKey, ) { - Dialog( - onDismissRequest = { onClose() }, - properties = - DialogProperties( - dismissOnClickOutside = false, - ), - ) { - Surface { - val groupName = remember { - mutableStateOf(accountViewModel.userProfile().privateChatrooms[room]?.subject ?: "") - } - val message = remember { mutableStateOf("") } - val scope = rememberCoroutineScope() + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + dismissOnClickOutside = false, + ), + ) { + Surface { + val groupName = + remember { + mutableStateOf(accountViewModel.userProfile().privateChatrooms[room]?.subject ?: "") + } + val message = remember { mutableStateOf("") } + val scope = rememberCoroutineScope() - Column( - modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CloseButton(onPress = { onClose() }) + Column( + modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton(onPress = { onClose() }) - PostButton( - onPost = { - scope.launch(Dispatchers.IO) { - accountViewModel.account.sendNIP24PrivateMessage( - message = message.value, - toUsers = room.users.toList(), - subject = groupName.value.ifBlank { null }, - replyingTo = null, - mentions = null, - wantsToMarkAsSensitive = false, + PostButton( + onPost = { + scope.launch(Dispatchers.IO) { + accountViewModel.account.sendNIP24PrivateMessage( + message = message.value, + toUsers = room.users.toList(), + subject = groupName.value.ifBlank { null }, + replyingTo = null, + mentions = null, + wantsToMarkAsSensitive = false, + ) + } + + onClose() + }, + true, + ) + } + + Spacer(modifier = Modifier.height(15.dp)) + + OutlinedTextField( + label = { Text(text = stringResource(R.string.messages_new_message_subject)) }, + modifier = Modifier.fillMaxWidth(), + value = groupName.value, + onValueChange = { groupName.value = it }, + placeholder = { + Text( + text = stringResource(R.string.messages_new_message_subject_caption), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), ) - } - onClose() - }, - true, - ) + Spacer(modifier = Modifier.height(15.dp)) + + OutlinedTextField( + label = { Text(text = stringResource(R.string.messages_new_subject_message)) }, + modifier = Modifier.fillMaxWidth().height(100.dp), + value = message.value, + onValueChange = { message.value = it }, + placeholder = { + Text( + text = stringResource(R.string.messages_new_subject_message_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + maxLines = 10, + ) + } } - - Spacer(modifier = Modifier.height(15.dp)) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.messages_new_message_subject)) }, - modifier = Modifier.fillMaxWidth(), - value = groupName.value, - onValueChange = { groupName.value = it }, - placeholder = { - Text( - text = stringResource(R.string.messages_new_message_subject_caption), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - ) - - Spacer(modifier = Modifier.height(15.dp)) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.messages_new_subject_message)) }, - modifier = Modifier.fillMaxWidth().height(100.dp), - value = message.value, - onValueChange = { message.value = it }, - placeholder = { - Text( - text = stringResource(R.string.messages_new_subject_message_placeholder), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - maxLines = 10, - ) - } } - } } @Composable fun LongRoomHeader( - room: ChatroomKey, - lineModifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + room: ChatroomKey, + lineModifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val list = remember(room) { room.users.toPersistentList() } + val list = remember(room) { room.users.toPersistentList() } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(id = R.string.messages_group_descriptor), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center, - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(id = R.string.messages_group_descriptor), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + ) - EditRoomSubjectButton(room, accountViewModel) - } - - LazyColumn( - modifier = Modifier, - state = rememberLazyListState(), - ) { - itemsIndexed(list, key = { _, item -> item }) { _, item -> - LoadUser(baseUserHex = item, accountViewModel) { - if (it != null) { - UserCompose( - baseUser = it, - overallModifier = lineModifier, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } + EditRoomSubjectButton(room, accountViewModel) + } + + LazyColumn( + modifier = Modifier, + state = rememberLazyListState(), + ) { + itemsIndexed(list, key = { _, item -> item }) { _, item -> + LoadUser(baseUserHex = item, accountViewModel) { + if (it != null) { + UserCompose( + baseUser = it, + overallModifier = lineModifier, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } } - } } @Composable fun RoomNameOnlyDisplay( - room: ChatroomKey, - modifier: Modifier, - fontWeight: FontWeight = FontWeight.Bold, - loggedInUser: User, + room: ChatroomKey, + modifier: Modifier, + fontWeight: FontWeight = FontWeight.Bold, + loggedInUser: User, ) { - val roomSubject by - loggedInUser - .live() - .messages - .map { it.user.privateChatrooms[room]?.subject } - .distinctUntilChanged() - .observeAsState(loggedInUser.privateChatrooms[room]?.subject) + val roomSubject by + loggedInUser + .live() + .messages + .map { it.user.privateChatrooms[room]?.subject } + .distinctUntilChanged() + .observeAsState(loggedInUser.privateChatrooms[room]?.subject) - Crossfade(targetState = roomSubject, modifier) { - if (it != null && it.isNotBlank()) { - DisplayRoomSubject(it, fontWeight) + Crossfade(targetState = roomSubject, modifier) { + if (it != null && it.isNotBlank()) { + DisplayRoomSubject(it, fontWeight) + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/CommunityScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/CommunityScreen.kt index 3b58ac6b6..dbc623aa2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/CommunityScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/CommunityScreen.kt @@ -38,79 +38,80 @@ import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView @Composable fun CommunityScreen( - aTagHex: String?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + aTagHex: String?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (aTagHex == null) return + if (aTagHex == null) return - LoadAddressableNote(aTagHex = aTagHex, accountViewModel) { - it?.let { - PrepareViewModelsCommunityScreen( - note = it, - accountViewModel = accountViewModel, - nav = nav, - ) + LoadAddressableNote(aTagHex = aTagHex, accountViewModel) { + it?.let { + PrepareViewModelsCommunityScreen( + note = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } } - } } @Composable fun PrepareViewModelsCommunityScreen( - note: AddressableNote, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: AddressableNote, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val followsFeedViewModel: NostrCommunityFeedViewModel = - viewModel( - key = note.idHex + "CommunityFeedViewModel", - factory = - NostrCommunityFeedViewModel.Factory( - note, - accountViewModel.account, - ), - ) + val followsFeedViewModel: NostrCommunityFeedViewModel = + viewModel( + key = note.idHex + "CommunityFeedViewModel", + factory = + NostrCommunityFeedViewModel.Factory( + note, + accountViewModel.account, + ), + ) - CommunityScreen(note, followsFeedViewModel, accountViewModel, nav) + CommunityScreen(note, followsFeedViewModel, accountViewModel, nav) } @Composable fun CommunityScreen( - note: AddressableNote, - feedViewModel: NostrCommunityFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: AddressableNote, + feedViewModel: NostrCommunityFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val lifeCycleOwner = LocalLifecycleOwner.current + val lifeCycleOwner = LocalLifecycleOwner.current - NostrCommunityDataSource.loadCommunity(note) + NostrCommunityDataSource.loadCommunity(note) - LaunchedEffect(note) { feedViewModel.invalidateData() } + LaunchedEffect(note) { feedViewModel.invalidateData() } - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Community Start") - NostrCommunityDataSource.start() - feedViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Community Stop") - NostrCommunityDataSource.loadCommunity(null) - NostrCommunityDataSource.stop() - } + DisposableEffect(lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Community Start") + NostrCommunityDataSource.start() + feedViewModel.invalidateData() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Community Stop") + NostrCommunityDataSource.loadCommunity(null) + NostrCommunityDataSource.stop() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } - - Column(Modifier.fillMaxSize()) { - RefresheableFeedView( - feedViewModel, - null, - accountViewModel = accountViewModel, - nav = nav, - ) - } + Column(Modifier.fillMaxSize()) { + RefresheableFeedView( + feedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt index 46ad9162e..64b2536c4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt @@ -57,116 +57,116 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable fun ConnectOrbotDialog( - onClose: () -> Unit, - onPost: () -> Unit, - onError: (String) -> Unit, - portNumber: MutableState, + onClose: () -> Unit, + onPost: () -> Unit, + onError: (String) -> Unit, + portNumber: MutableState, ) { - Dialog( - onDismissRequest = onClose, - properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false), - ) { - Surface { - Column( - modifier = Modifier.padding(10.dp), - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - CloseButton(onPress = { onClose() }) - - val toastMessage = stringResource(R.string.invalid_port_number) - - UseOrbotButton( - onPost = { - try { - Integer.parseInt(portNumber.value) - } catch (_: Exception) { - onError(toastMessage) - return@UseOrbotButton - } - - onPost() - }, - isActive = true, - ) - } - - Column( - modifier = Modifier.padding(30.dp), - ) { - val myMarkDownStyle = - RichTextDefaults.copy( - stringStyle = - RichTextDefaults.stringStyle?.copy( - linkStyle = - SpanStyle( - textDecoration = TextDecoration.Underline, - color = MaterialTheme.colorScheme.primary, - ), - ), - ) - - Row { - Material3RichText( - style = myMarkDownStyle, + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false), + ) { + Surface { + Column( + modifier = Modifier.padding(10.dp), ) { - Markdown( - content = stringResource(R.string.connect_through_your_orbot_setup_markdown), - ) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + CloseButton(onPress = { onClose() }) + + val toastMessage = stringResource(R.string.invalid_port_number) + + UseOrbotButton( + onPost = { + try { + Integer.parseInt(portNumber.value) + } catch (_: Exception) { + onError(toastMessage) + return@UseOrbotButton + } + + onPost() + }, + isActive = true, + ) + } + + Column( + modifier = Modifier.padding(30.dp), + ) { + val myMarkDownStyle = + RichTextDefaults.copy( + stringStyle = + RichTextDefaults.stringStyle?.copy( + linkStyle = + SpanStyle( + textDecoration = TextDecoration.Underline, + color = MaterialTheme.colorScheme.primary, + ), + ), + ) + + Row { + Material3RichText( + style = myMarkDownStyle, + ) { + Markdown( + content = stringResource(R.string.connect_through_your_orbot_setup_markdown), + ) + } + } + + Spacer(modifier = Modifier.height(15.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + OutlinedTextField( + value = portNumber.value, + onValueChange = { portNumber.value = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Number, + ), + label = { Text(text = stringResource(R.string.orbot_socks_port)) }, + placeholder = { + Text( + text = "9050", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) + } + } } - } - - Spacer(modifier = Modifier.height(15.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - OutlinedTextField( - value = portNumber.value, - onValueChange = { portNumber.value = it }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Number, - ), - label = { Text(text = stringResource(R.string.orbot_socks_port)) }, - placeholder = { - Text( - text = "9050", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - } } - } } - } } @Composable fun UseOrbotButton( - onPost: () -> Unit = {}, - isActive: Boolean, - modifier: Modifier = Modifier, + onPost: () -> Unit = {}, + isActive: Boolean, + modifier: Modifier = Modifier, ) { - Button( - modifier = modifier, - onClick = { - if (isActive) { - onPost() - } - }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = if (isActive) MaterialTheme.colorScheme.primary else Color.Gray, - ), - ) { - Text(text = stringResource(R.string.use_orbot), color = Color.White) - } + Button( + modifier = modifier, + onClick = { + if (isActive) { + onPost() + } + }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = if (isActive) MaterialTheme.colorScheme.primary else Color.Gray, + ), + ) { + Text(text = stringResource(R.string.use_orbot), color = Color.White) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt index 8de0a47ab..c9d74cf9a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt @@ -88,273 +88,274 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable fun DiscoverScreen( - discoveryMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel, - discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, - discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, - discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + discoveryMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel, + discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, + discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, + discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val lifeCycleOwner = LocalLifecycleOwner.current + val lifeCycleOwner = LocalLifecycleOwner.current - val tabs by - remember( - discoveryLiveFeedViewModel, - discoveryCommunityFeedViewModel, - discoveryChatFeedViewModel, - ) { - mutableStateOf( - listOf( - TabItem( - R.string.discover_marketplace, - discoveryMarketplaceFeedViewModel, - Route.Discover.base + "Marketplace", - ScrollStateKeys.DISCOVER_MARKETPLACE, - ClassifiedsEvent.KIND, - ), - TabItem( - R.string.discover_live, - discoveryLiveFeedViewModel, - Route.Discover.base + "Live", - ScrollStateKeys.DISCOVER_LIVE, - LiveActivitiesEvent.KIND, - ), - TabItem( - R.string.discover_community, - discoveryCommunityFeedViewModel, - Route.Discover.base + "Community", - ScrollStateKeys.DISCOVER_COMMUNITY, - CommunityDefinitionEvent.KIND, - ), - TabItem( - R.string.discover_chat, - discoveryChatFeedViewModel, - Route.Discover.base + "Chats", - ScrollStateKeys.DISCOVER_CHATS, - ChannelCreateEvent.KIND, - ), - ) - .toImmutableList(), - ) + val tabs by + remember( + discoveryLiveFeedViewModel, + discoveryCommunityFeedViewModel, + discoveryChatFeedViewModel, + ) { + mutableStateOf( + listOf( + TabItem( + R.string.discover_marketplace, + discoveryMarketplaceFeedViewModel, + Route.Discover.base + "Marketplace", + ScrollStateKeys.DISCOVER_MARKETPLACE, + ClassifiedsEvent.KIND, + ), + TabItem( + R.string.discover_live, + discoveryLiveFeedViewModel, + Route.Discover.base + "Live", + ScrollStateKeys.DISCOVER_LIVE, + LiveActivitiesEvent.KIND, + ), + TabItem( + R.string.discover_community, + discoveryCommunityFeedViewModel, + Route.Discover.base + "Community", + ScrollStateKeys.DISCOVER_COMMUNITY, + CommunityDefinitionEvent.KIND, + ), + TabItem( + R.string.discover_chat, + discoveryChatFeedViewModel, + Route.Discover.base + "Chats", + ScrollStateKeys.DISCOVER_CHATS, + ChannelCreateEvent.KIND, + ), + ) + .toImmutableList(), + ) + } + + val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN) { tabs.size } + + WatchAccountForDiscoveryScreen( + discoverMarketplaceFeedViewModel = discoveryMarketplaceFeedViewModel, + discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, + discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, + discoveryChatFeedViewModel = discoveryChatFeedViewModel, + accountViewModel = accountViewModel, + ) + + DisposableEffect(lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Discovery Start") + NostrDiscoveryDataSource.start() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } } - val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN) { tabs.size } - - WatchAccountForDiscoveryScreen( - discoverMarketplaceFeedViewModel = discoveryMarketplaceFeedViewModel, - discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, - discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, - discoveryChatFeedViewModel = discoveryChatFeedViewModel, - accountViewModel = accountViewModel, - ) - - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Discovery Start") - NostrDiscoveryDataSource.start() - } + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp), + ) { + DiscoverPages(pagerState, tabs, accountViewModel, nav) + } } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } - - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp), - ) { - DiscoverPages(pagerState, tabs, accountViewModel, nav) - } - } } @Composable @OptIn(ExperimentalFoundationApi::class) private fun DiscoverPages( - pagerState: PagerState, - tabs: ImmutableList, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + pagerState: PagerState, + tabs: ImmutableList, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - ScrollableTabRow( - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground, - selectedTabIndex = pagerState.currentPage, - modifier = TabRowHeight, - edgePadding = 8.dp, - ) { - val coroutineScope = rememberCoroutineScope() + ScrollableTabRow( + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + selectedTabIndex = pagerState.currentPage, + modifier = TabRowHeight, + edgePadding = 8.dp, + ) { + val coroutineScope = rememberCoroutineScope() - tabs.forEachIndexed { index, tab -> - Tab( - selected = pagerState.currentPage == index, - text = { Text(text = stringResource(tab.resource)) }, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, - ) + tabs.forEachIndexed { index, tab -> + Tab( + selected = pagerState.currentPage == index, + text = { Text(text = stringResource(tab.resource)) }, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, + ) + } } - } - HorizontalPager(state = pagerState) { page -> - RefresheableView(tabs[page].viewModel, true) { - if (tabs[page].viewModel is NostrDiscoverMarketplaceFeedViewModel) { - SaveableGridFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { - listState -> - RenderDiscoverFeed( - viewModel = tabs[page].viewModel, - routeForLastRead = tabs[page].routeForLastRead, - forceEventKind = tabs[page].forceEventKind, - listState = listState, - accountViewModel = accountViewModel, - nav = nav, - ) + HorizontalPager(state = pagerState) { page -> + RefresheableView(tabs[page].viewModel, true) { + if (tabs[page].viewModel is NostrDiscoverMarketplaceFeedViewModel) { + SaveableGridFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { + listState -> + RenderDiscoverFeed( + viewModel = tabs[page].viewModel, + routeForLastRead = tabs[page].routeForLastRead, + forceEventKind = tabs[page].forceEventKind, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } else { + SaveableFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { + listState -> + RenderDiscoverFeed( + viewModel = tabs[page].viewModel, + routeForLastRead = tabs[page].routeForLastRead, + forceEventKind = tabs[page].forceEventKind, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } } - } else { - SaveableFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { - listState -> - RenderDiscoverFeed( - viewModel = tabs[page].viewModel, - routeForLastRead = tabs[page].routeForLastRead, - forceEventKind = tabs[page].forceEventKind, - listState = listState, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } } - } } @Composable private fun RenderDiscoverFeed( - viewModel: FeedViewModel, - routeForLastRead: String?, - forceEventKind: Int?, - listState: ScrollableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + viewModel: FeedViewModel, + routeForLastRead: String?, + forceEventKind: Int?, + listState: ScrollableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - Crossfade( - targetState = feedState, - animationSpec = tween(durationMillis = 100), - label = "RenderDiscoverFeed", - ) { state -> - when (state) { - is FeedState.Empty -> { - FeedEmpty { viewModel.invalidateData() } - } - is FeedState.FeedError -> { - FeedError(state.errorMessage) { viewModel.invalidateData() } - } - is FeedState.Loaded -> { - if (listState is LazyGridState) { - DiscoverFeedColumnsLoaded( - state, - routeForLastRead, - listState, - forceEventKind, - accountViewModel, - nav, - ) - } else if (listState is LazyListState) { - DiscoverFeedLoaded( - state, - routeForLastRead, - listState, - forceEventKind, - accountViewModel, - nav, - ) + Crossfade( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + label = "RenderDiscoverFeed", + ) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty { viewModel.invalidateData() } + } + is FeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + is FeedState.Loaded -> { + if (listState is LazyGridState) { + DiscoverFeedColumnsLoaded( + state, + routeForLastRead, + listState, + forceEventKind, + accountViewModel, + nav, + ) + } else if (listState is LazyListState) { + DiscoverFeedLoaded( + state, + routeForLastRead, + listState, + forceEventKind, + accountViewModel, + nav, + ) + } + } + is FeedState.Loading -> { + LoadingFeed() + } } - } - is FeedState.Loading -> { - LoadingFeed() - } } - } } @Composable fun WatchAccountForDiscoveryScreen( - discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel, - discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, - discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, - discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel, - accountViewModel: AccountViewModel, + discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel, + discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, + discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, + discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel, + accountViewModel: AccountViewModel, ) { - val listState by accountViewModel.account.liveStoriesFollowLists.collectAsStateWithLifecycle() + val listState by accountViewModel.account.liveStoriesFollowLists.collectAsStateWithLifecycle() - LaunchedEffect(accountViewModel, listState) { - NostrDiscoveryDataSource.resetFilters() - discoverMarketplaceFeedViewModel.checkKeysInvalidateDataAndSendToTop() - discoveryLiveFeedViewModel.checkKeysInvalidateDataAndSendToTop() - discoveryCommunityFeedViewModel.checkKeysInvalidateDataAndSendToTop() - discoveryChatFeedViewModel.checkKeysInvalidateDataAndSendToTop() - } + LaunchedEffect(accountViewModel, listState) { + NostrDiscoveryDataSource.resetFilters() + discoverMarketplaceFeedViewModel.checkKeysInvalidateDataAndSendToTop() + discoveryLiveFeedViewModel.checkKeysInvalidateDataAndSendToTop() + discoveryCommunityFeedViewModel.checkKeysInvalidateDataAndSendToTop() + discoveryChatFeedViewModel.checkKeysInvalidateDataAndSendToTop() + } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun DiscoverFeedLoaded( - state: FeedState.Loaded, - routeForLastRead: String?, - listState: LazyListState, - forceEventKind: Int?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + state: FeedState.Loaded, + routeForLastRead: String?, + listState: LazyListState, + forceEventKind: Int?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LazyColumn( - contentPadding = FeedPadding, - state = listState, - ) { - itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> - val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() } + LazyColumn( + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> + val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() } - Row(defaultModifier) { - ChannelCardCompose( - baseNote = item, - routeForLastRead = routeForLastRead, - modifier = Modifier, - forceEventKind = forceEventKind, - accountViewModel = accountViewModel, - nav = nav, - ) - } + Row(defaultModifier) { + ChannelCardCompose( + baseNote = item, + routeForLastRead = routeForLastRead, + modifier = Modifier, + forceEventKind = forceEventKind, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } } - } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun DiscoverFeedColumnsLoaded( - state: FeedState.Loaded, - routeForLastRead: String?, - listState: LazyGridState, - forceEventKind: Int?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + state: FeedState.Loaded, + routeForLastRead: String?, + listState: LazyGridState, + forceEventKind: Int?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LazyVerticalGrid( - columns = GridCells.Fixed(2), - contentPadding = FeedPadding, - state = listState, - ) { - itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> - val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() } + LazyVerticalGrid( + columns = GridCells.Fixed(2), + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> + val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() } - Row(defaultModifier) { - ChannelCardCompose( - baseNote = item, - routeForLastRead = routeForLastRead, - modifier = Modifier, - forceEventKind = forceEventKind, - accountViewModel = accountViewModel, - nav = nav, - ) - } + Row(defaultModifier) { + ChannelCardCompose( + baseNote = item, + routeForLastRead = routeForLastRead, + modifier = Modifier, + forceEventKind = forceEventKind, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt index d7628967b..3ba380791 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt @@ -60,174 +60,175 @@ import kotlinx.coroutines.launch @Composable fun GeoHashScreen( - tag: String?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + tag: String?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (tag == null) return + if (tag == null) return - PrepareViewModelsGeoHashScreen(tag, accountViewModel, nav) + PrepareViewModelsGeoHashScreen(tag, accountViewModel, nav) } @Composable fun PrepareViewModelsGeoHashScreen( - tag: String, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + tag: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val followsFeedViewModel: NostrGeoHashFeedViewModel = - viewModel( - key = tag + "GeoHashFeedViewModel", - factory = - NostrGeoHashFeedViewModel.Factory( - tag, - accountViewModel.account, - ), - ) + val followsFeedViewModel: NostrGeoHashFeedViewModel = + viewModel( + key = tag + "GeoHashFeedViewModel", + factory = + NostrGeoHashFeedViewModel.Factory( + tag, + accountViewModel.account, + ), + ) - GeoHashScreen(tag, followsFeedViewModel, accountViewModel, nav) + GeoHashScreen(tag, followsFeedViewModel, accountViewModel, nav) } @Composable fun GeoHashScreen( - tag: String, - feedViewModel: NostrGeoHashFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + tag: String, + feedViewModel: NostrGeoHashFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val lifeCycleOwner = LocalLifecycleOwner.current + val lifeCycleOwner = LocalLifecycleOwner.current - NostrGeohashDataSource.loadHashtag(tag) + NostrGeohashDataSource.loadHashtag(tag) - DisposableEffect(tag) { - NostrGeohashDataSource.start() - feedViewModel.invalidateData() - onDispose { - NostrGeohashDataSource.loadHashtag(null) - NostrGeohashDataSource.stop() - } - } - - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Hashtag Start") - NostrGeohashDataSource.loadHashtag(tag) + DisposableEffect(tag) { NostrGeohashDataSource.start() feedViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Hashtag Stop") - NostrGeohashDataSource.loadHashtag(null) - NostrGeohashDataSource.stop() - } + onDispose { + NostrGeohashDataSource.loadHashtag(null) + NostrGeohashDataSource.stop() + } } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + DisposableEffect(lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Hashtag Start") + NostrGeohashDataSource.loadHashtag(tag) + NostrGeohashDataSource.start() + feedViewModel.invalidateData() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Hashtag Stop") + NostrGeohashDataSource.loadHashtag(null) + NostrGeohashDataSource.stop() + } + } - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp), - ) { - RefresheableFeedView( - feedViewModel, - null, - accountViewModel = accountViewModel, - nav = nav, - ) + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp), + ) { + RefresheableFeedView( + feedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } } - } } @Composable fun GeoHashHeader( - tag: String, - modifier: Modifier = StdPadding, - account: AccountViewModel, - onClick: () -> Unit = {}, + tag: String, + modifier: Modifier = StdPadding, + account: AccountViewModel, + onClick: () -> Unit = {}, ) { - Column( - Modifier.fillMaxWidth().clickable { onClick() }, - ) { - Column(modifier = modifier) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - DislayGeoTagHeader(tag, remember { Modifier.weight(1f) }) + Column( + Modifier.fillMaxWidth().clickable { onClick() }, + ) { + Column(modifier = modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + DislayGeoTagHeader(tag, remember { Modifier.weight(1f) }) - GeoHashActionOptions(tag, account) - } + GeoHashActionOptions(tag, account) + } + } + + Divider( + thickness = DividerThickness, + ) } - - Divider( - thickness = DividerThickness, - ) - } } @Composable fun DislayGeoTagHeader( - geohash: String, - modifier: Modifier, + geohash: String, + modifier: Modifier, ) { - val context = LocalContext.current + val context = LocalContext.current - var cityName by remember(geohash) { mutableStateOf(geohash) } + var cityName by remember(geohash) { mutableStateOf(geohash) } - LaunchedEffect(key1 = geohash) { - launch(Dispatchers.IO) { - val newCityName = - ReverseGeoLocationUtil().execute(geohash.toGeoHash().toLocation(), context)?.ifBlank { - null + LaunchedEffect(key1 = geohash) { + launch(Dispatchers.IO) { + val newCityName = + ReverseGeoLocationUtil().execute(geohash.toGeoHash().toLocation(), context)?.ifBlank { + null + } + if (newCityName != null && newCityName != cityName) { + cityName = "$newCityName ($geohash)" + } } - if (newCityName != null && newCityName != cityName) { - cityName = "$newCityName ($geohash)" - } } - } - Text( - cityName, - fontWeight = FontWeight.Bold, - modifier = modifier, - ) + Text( + cityName, + fontWeight = FontWeight.Bold, + modifier = modifier, + ) } @Composable fun GeoHashActionOptions( - tag: String, - accountViewModel: AccountViewModel, + tag: String, + accountViewModel: AccountViewModel, ) { - val userState by accountViewModel.userProfile().live().follows.observeAsState() - val isFollowingTag by - remember(userState) { - derivedStateOf { userState?.user?.isFollowingGeohashCached(tag) ?: false } - } + val userState by accountViewModel.userProfile().live().follows.observeAsState() + val isFollowingTag by + remember(userState) { + derivedStateOf { userState?.user?.isFollowingGeohashCached(tag) ?: false } + } - if (isFollowingTag) { - UnfollowButton { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_unfollow, - ) - } else { - accountViewModel.unfollowGeohash(tag) - } + if (isFollowingTag) { + UnfollowButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_unfollow, + ) + } else { + accountViewModel.unfollowGeohash(tag) + } + } + } else { + FollowButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_follow, + ) + } else { + accountViewModel.followGeohash(tag) + } + } } - } else { - FollowButton { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_follow, - ) - } else { - accountViewModel.followGeohash(tag) - } - } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt index ede6d4bb8..5caaa7b69 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt @@ -52,151 +52,152 @@ import com.vitorpamplona.amethyst.ui.theme.StdPadding @Composable fun HashtagScreen( - tag: String?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + tag: String?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (tag == null) return + if (tag == null) return - PrepareViewModelsHashtagScreen(tag, accountViewModel, nav) + PrepareViewModelsHashtagScreen(tag, accountViewModel, nav) } @Composable fun PrepareViewModelsHashtagScreen( - tag: String, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + tag: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val followsFeedViewModel: NostrHashtagFeedViewModel = - viewModel( - key = tag + "HashtagFeedViewModel", - factory = - NostrHashtagFeedViewModel.Factory( - tag, - accountViewModel.account, - ), - ) + val followsFeedViewModel: NostrHashtagFeedViewModel = + viewModel( + key = tag + "HashtagFeedViewModel", + factory = + NostrHashtagFeedViewModel.Factory( + tag, + accountViewModel.account, + ), + ) - HashtagScreen(tag, followsFeedViewModel, accountViewModel, nav) + HashtagScreen(tag, followsFeedViewModel, accountViewModel, nav) } @Composable fun HashtagScreen( - tag: String, - feedViewModel: NostrHashtagFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + tag: String, + feedViewModel: NostrHashtagFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val lifeCycleOwner = LocalLifecycleOwner.current + val lifeCycleOwner = LocalLifecycleOwner.current - NostrHashtagDataSource.loadHashtag(tag) + NostrHashtagDataSource.loadHashtag(tag) - DisposableEffect(tag) { - NostrHashtagDataSource.start() - feedViewModel.invalidateData() - - onDispose { - NostrHashtagDataSource.loadHashtag(null) - NostrHashtagDataSource.stop() - } - } - - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Hashtag Start") - NostrHashtagDataSource.loadHashtag(tag) + DisposableEffect(tag) { NostrHashtagDataSource.start() feedViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Hashtag Stop") - NostrHashtagDataSource.loadHashtag(null) - NostrHashtagDataSource.stop() - } + + onDispose { + NostrHashtagDataSource.loadHashtag(null) + NostrHashtagDataSource.stop() + } } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + DisposableEffect(lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Hashtag Start") + NostrHashtagDataSource.loadHashtag(tag) + NostrHashtagDataSource.start() + feedViewModel.invalidateData() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Hashtag Stop") + NostrHashtagDataSource.loadHashtag(null) + NostrHashtagDataSource.stop() + } + } - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp), - ) { - RefresheableFeedView( - feedViewModel, - null, - accountViewModel = accountViewModel, - nav = nav, - ) + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp), + ) { + RefresheableFeedView( + feedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } } - } } @Composable fun HashtagHeader( - tag: String, - modifier: Modifier = StdPadding, - account: AccountViewModel, - onClick: () -> Unit = {}, + tag: String, + modifier: Modifier = StdPadding, + account: AccountViewModel, + onClick: () -> Unit = {}, ) { - Column( - Modifier.fillMaxWidth().clickable { onClick() }, - ) { - Column(modifier = modifier) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Text( - "#$tag", - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), + Column( + Modifier.fillMaxWidth().clickable { onClick() }, + ) { + Column(modifier = modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + "#$tag", + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + + HashtagActionOptions(tag, account) + } + } + + Divider( + thickness = DividerThickness, ) - - HashtagActionOptions(tag, account) - } } - - Divider( - thickness = DividerThickness, - ) - } } @Composable fun HashtagActionOptions( - tag: String, - accountViewModel: AccountViewModel, + tag: String, + accountViewModel: AccountViewModel, ) { - val userState by accountViewModel.userProfile().live().follows.observeAsState() - val isFollowingTag by - remember(userState) { - derivedStateOf { userState?.user?.isFollowingHashtagCached(tag) ?: false } - } + val userState by accountViewModel.userProfile().live().follows.observeAsState() + val isFollowingTag by + remember(userState) { + derivedStateOf { userState?.user?.isFollowingHashtagCached(tag) ?: false } + } - if (isFollowingTag) { - UnfollowButton { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_unfollow, - ) - } else { - accountViewModel.unfollowHashtag(tag) - } + if (isFollowingTag) { + UnfollowButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_unfollow, + ) + } else { + accountViewModel.unfollowHashtag(tag) + } + } + } else { + FollowButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_follow, + ) + } else { + accountViewModel.followHashtag(tag) + } + } } - } else { - FollowButton { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_follow, - ) - } else { - accountViewModel.followHashtag(tag) - } - } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt index 4c1271bdc..06364986e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt @@ -86,305 +86,306 @@ import kotlinx.coroutines.launch @Composable fun HiddenUsersScreen( - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val hiddenFeedViewModel: NostrHiddenAccountsFeedViewModel = - viewModel( - factory = NostrHiddenAccountsFeedViewModel.Factory(accountViewModel.account), - ) + val hiddenFeedViewModel: NostrHiddenAccountsFeedViewModel = + viewModel( + factory = NostrHiddenAccountsFeedViewModel.Factory(accountViewModel.account), + ) - val hiddenWordsFeedViewModel: NostrHiddenWordsFeedViewModel = - viewModel( - factory = NostrHiddenWordsFeedViewModel.Factory(accountViewModel.account), - ) + val hiddenWordsFeedViewModel: NostrHiddenWordsFeedViewModel = + viewModel( + factory = NostrHiddenWordsFeedViewModel.Factory(accountViewModel.account), + ) - val spammerFeedViewModel: NostrSpammerAccountsFeedViewModel = - viewModel( - factory = NostrSpammerAccountsFeedViewModel.Factory(accountViewModel.account), - ) + val spammerFeedViewModel: NostrSpammerAccountsFeedViewModel = + viewModel( + factory = NostrSpammerAccountsFeedViewModel.Factory(accountViewModel.account), + ) - HiddenUsersScreen( - hiddenFeedViewModel, - hiddenWordsFeedViewModel, - spammerFeedViewModel, - accountViewModel, - nav, - ) + HiddenUsersScreen( + hiddenFeedViewModel, + hiddenWordsFeedViewModel, + spammerFeedViewModel, + accountViewModel, + nav, + ) } @OptIn(ExperimentalFoundationApi::class) @Composable fun HiddenUsersScreen( - hiddenFeedViewModel: NostrHiddenAccountsFeedViewModel, - hiddenWordsViewModel: NostrHiddenWordsFeedViewModel, - spammerFeedViewModel: NostrSpammerAccountsFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + hiddenFeedViewModel: NostrHiddenAccountsFeedViewModel, + hiddenWordsViewModel: NostrHiddenWordsFeedViewModel, + spammerFeedViewModel: NostrSpammerAccountsFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val lifeCycleOwner = LocalLifecycleOwner.current + val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Hidden Users Start") - hiddenWordsViewModel.invalidateData() - hiddenFeedViewModel.invalidateData() - spammerFeedViewModel.invalidateData() - } + DisposableEffect(lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Hidden Users Start") + hiddenWordsViewModel.invalidateData() + hiddenFeedViewModel.invalidateData() + spammerFeedViewModel.invalidateData() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + Column(Modifier.fillMaxHeight()) { + val pagerState = rememberPagerState { 3 } + val coroutineScope = rememberCoroutineScope() + var warnAboutReports by remember { + mutableStateOf(accountViewModel.account.warnAboutPostsWithReports) + } + var filterSpam by remember { mutableStateOf(accountViewModel.account.filterSpamFromStrangers) } - Column(Modifier.fillMaxHeight()) { - val pagerState = rememberPagerState { 3 } - val coroutineScope = rememberCoroutineScope() - var warnAboutReports by remember { - mutableStateOf(accountViewModel.account.warnAboutPostsWithReports) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = warnAboutReports, + onCheckedChange = { + warnAboutReports = it + accountViewModel.account.updateOptOutOptions(warnAboutReports, filterSpam) + }, + ) + + Text(stringResource(R.string.warn_when_posts_have_reports_from_your_follows)) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = filterSpam, + onCheckedChange = { + filterSpam = it + accountViewModel.account.updateOptOutOptions(warnAboutReports, filterSpam) + }, + ) + + Text(stringResource(R.string.filter_spam_from_strangers)) + } + + ScrollableTabRow( + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + edgePadding = 8.dp, + selectedTabIndex = pagerState.currentPage, + modifier = TabRowHeight, + divider = { Divider(thickness = DividerThickness) }, + ) { + Tab( + selected = pagerState.currentPage == 0, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, + text = { Text(text = stringResource(R.string.blocked_users)) }, + ) + Tab( + selected = pagerState.currentPage == 1, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, + text = { Text(text = stringResource(R.string.spamming_users)) }, + ) + Tab( + selected = pagerState.currentPage == 2, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } }, + text = { Text(text = stringResource(R.string.hidden_words)) }, + ) + } + HorizontalPager(state = pagerState) { page -> + when (page) { + 0 -> + RefreshingUserFeedView(hiddenFeedViewModel, accountViewModel) { + RefreshingFeedUserFeedView(hiddenFeedViewModel, accountViewModel, nav) + } + 1 -> + RefreshingUserFeedView(spammerFeedViewModel, accountViewModel) { + RefreshingFeedUserFeedView(spammerFeedViewModel, accountViewModel, nav) + } + 2 -> HiddenWordsFeed(hiddenWordsViewModel, accountViewModel) + } + } } - var filterSpam by remember { mutableStateOf(accountViewModel.account.filterSpamFromStrangers) } - - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = warnAboutReports, - onCheckedChange = { - warnAboutReports = it - accountViewModel.account.updateOptOutOptions(warnAboutReports, filterSpam) - }, - ) - - Text(stringResource(R.string.warn_when_posts_have_reports_from_your_follows)) - } - - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = filterSpam, - onCheckedChange = { - filterSpam = it - accountViewModel.account.updateOptOutOptions(warnAboutReports, filterSpam) - }, - ) - - Text(stringResource(R.string.filter_spam_from_strangers)) - } - - ScrollableTabRow( - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground, - edgePadding = 8.dp, - selectedTabIndex = pagerState.currentPage, - modifier = TabRowHeight, - divider = { Divider(thickness = DividerThickness) }, - ) { - Tab( - selected = pagerState.currentPage == 0, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, - text = { Text(text = stringResource(R.string.blocked_users)) }, - ) - Tab( - selected = pagerState.currentPage == 1, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, - text = { Text(text = stringResource(R.string.spamming_users)) }, - ) - Tab( - selected = pagerState.currentPage == 2, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } }, - text = { Text(text = stringResource(R.string.hidden_words)) }, - ) - } - HorizontalPager(state = pagerState) { page -> - when (page) { - 0 -> - RefreshingUserFeedView(hiddenFeedViewModel, accountViewModel) { - RefreshingFeedUserFeedView(hiddenFeedViewModel, accountViewModel, nav) - } - 1 -> - RefreshingUserFeedView(spammerFeedViewModel, accountViewModel) { - RefreshingFeedUserFeedView(spammerFeedViewModel, accountViewModel, nav) - } - 2 -> HiddenWordsFeed(hiddenWordsViewModel, accountViewModel) - } - } - } } @Composable private fun HiddenWordsFeed( - hiddenWordsViewModel: NostrHiddenWordsFeedViewModel, - accountViewModel: AccountViewModel, + hiddenWordsViewModel: NostrHiddenWordsFeedViewModel, + accountViewModel: AccountViewModel, ) { - RefresheableView(hiddenWordsViewModel, false) { - StringFeedView( - hiddenWordsViewModel, - post = { AddMuteWordTextField(accountViewModel) }, - ) { - MutedWordHeader(tag = it, account = accountViewModel) + RefresheableView(hiddenWordsViewModel, false) { + StringFeedView( + hiddenWordsViewModel, + post = { AddMuteWordTextField(accountViewModel) }, + ) { + MutedWordHeader(tag = it, account = accountViewModel) + } } - } } @Composable private fun AddMuteWordTextField(accountViewModel: AccountViewModel) { - Row { - val currentWordToAdd = remember { mutableStateOf("") } - val hasChanged by remember { derivedStateOf { currentWordToAdd.value != "" } } + Row { + val currentWordToAdd = remember { mutableStateOf("") } + val hasChanged by remember { derivedStateOf { currentWordToAdd.value != "" } } - OutlinedTextField( - value = currentWordToAdd.value, - onValueChange = { currentWordToAdd.value = it }, - label = { Text(text = stringResource(R.string.hide_new_word_label)) }, - modifier = Modifier.fillMaxWidth().padding(10.dp), - placeholder = { - Text( - text = stringResource(R.string.hide_new_word_label), - color = MaterialTheme.colorScheme.placeholderText, + OutlinedTextField( + value = currentWordToAdd.value, + onValueChange = { currentWordToAdd.value = it }, + label = { Text(text = stringResource(R.string.hide_new_word_label)) }, + modifier = Modifier.fillMaxWidth().padding(10.dp), + placeholder = { + Text( + text = stringResource(R.string.hide_new_word_label), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + imeAction = ImeAction.Send, + capitalization = KeyboardCapitalization.Sentences, + ), + keyboardActions = + KeyboardActions( + onSend = { + if (hasChanged) { + accountViewModel.hide(currentWordToAdd.value) + currentWordToAdd.value = "" + } + }, + ), + singleLine = true, + trailingIcon = { + AddButton(isActive = hasChanged, modifier = HorzPadding) { + accountViewModel.hide(currentWordToAdd.value) + currentWordToAdd.value = "" + } + }, ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - imeAction = ImeAction.Send, - capitalization = KeyboardCapitalization.Sentences, - ), - keyboardActions = - KeyboardActions( - onSend = { - if (hasChanged) { - accountViewModel.hide(currentWordToAdd.value) - currentWordToAdd.value = "" - } - }, - ), - singleLine = true, - trailingIcon = { - AddButton(isActive = hasChanged, modifier = HorzPadding) { - accountViewModel.hide(currentWordToAdd.value) - currentWordToAdd.value = "" - } - }, - ) - } + } } @Composable fun RefreshingUserFeedView( - feedViewModel: UserFeedViewModel, - accountViewModel: AccountViewModel, - inner: @Composable () -> Unit, + feedViewModel: UserFeedViewModel, + accountViewModel: AccountViewModel, + inner: @Composable () -> Unit, ) { - WatchAccountAndBlockList(feedViewModel, accountViewModel) - inner() + WatchAccountAndBlockList(feedViewModel, accountViewModel) + inner() } @Composable fun WatchAccountAndBlockList( - feedViewModel: UserFeedViewModel, - accountViewModel: AccountViewModel, + feedViewModel: UserFeedViewModel, + accountViewModel: AccountViewModel, ) { - val accountState by accountViewModel.accountLiveData.observeAsState() - val blockListState by accountViewModel.account.flowHiddenUsers.collectAsStateWithLifecycle() + val accountState by accountViewModel.accountLiveData.observeAsState() + val blockListState by accountViewModel.account.flowHiddenUsers.collectAsStateWithLifecycle() - LaunchedEffect(accountViewModel, accountState, blockListState) { feedViewModel.invalidateData() } + LaunchedEffect(accountViewModel, accountState, blockListState) { feedViewModel.invalidateData() } } @Composable fun MutedWordHeader( - tag: String, - modifier: Modifier = StdPadding, - account: AccountViewModel, + tag: String, + modifier: Modifier = StdPadding, + account: AccountViewModel, ) { - Column( - Modifier.fillMaxWidth(), - ) { - Column(modifier = modifier) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Text( - tag, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), + Column( + Modifier.fillMaxWidth(), + ) { + Column(modifier = modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + tag, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + + MutedWordActionOptions(tag, account) + } + } + + Divider( + thickness = DividerThickness, ) - - MutedWordActionOptions(tag, account) - } } - - Divider( - thickness = DividerThickness, - ) - } } @Composable fun MutedWordActionOptions( - word: String, - accountViewModel: AccountViewModel, + word: String, + accountViewModel: AccountViewModel, ) { - val isMutedWord by - accountViewModel.account.liveHiddenUsers - .map { word in it.hiddenWords } - .distinctUntilChanged() - .observeAsState() + val isMutedWord by + accountViewModel.account.liveHiddenUsers + .map { word in it.hiddenWords } + .distinctUntilChanged() + .observeAsState() - if (isMutedWord == true) { - ShowWordButton { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_show_word, - ) - } else { - accountViewModel.showWord(word) - } + if (isMutedWord == true) { + ShowWordButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_show_word, + ) + } else { + accountViewModel.showWord(word) + } + } + } else { + HideWordButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_hide_word, + ) + } else { + accountViewModel.hideWord(word) + } + } } - } else { - HideWordButton { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_hide_word, - ) - } else { - accountViewModel.hideWord(word) - } - } - } } @Composable fun HideWordButton(onClick: () -> Unit) { - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = onClick, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - contentPadding = ButtonPadding, - ) { - Text(text = stringResource(R.string.block_only), color = Color.White) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = onClick, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(R.string.block_only), color = Color.White) + } } @Composable fun ShowWordButton( - text: Int = R.string.unblock, - onClick: () -> Unit, + text: Int = R.string.unblock, + onClick: () -> Unit, ) { - Button( - modifier = Modifier.padding(start = 3.dp), - onClick = onClick, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - contentPadding = ButtonPadding, - ) { - Text(text = stringResource(text), color = Color.White, textAlign = TextAlign.Center) - } + Button( + modifier = Modifier.padding(start = 3.dp), + onClick = onClick, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(text), color = Color.White, textAlign = TextAlign.Center) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt index 08bc43812..87155161d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt @@ -65,205 +65,206 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable fun HomeScreen( - homeFeedViewModel: NostrHomeFeedViewModel, - repliesFeedViewModel: NostrHomeRepliesFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - nip47: String? = null, + homeFeedViewModel: NostrHomeFeedViewModel, + repliesFeedViewModel: NostrHomeRepliesFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + nip47: String? = null, ) { - ResolveNIP47(nip47, accountViewModel) + ResolveNIP47(nip47, accountViewModel) - WatchAccountForHomeScreen(homeFeedViewModel, repliesFeedViewModel, accountViewModel) + WatchAccountForHomeScreen(homeFeedViewModel, repliesFeedViewModel, accountViewModel) - WatchLifeCycleChanges(accountViewModel) + WatchLifeCycleChanges(accountViewModel) - AssembleHomeTabs(homeFeedViewModel, repliesFeedViewModel) { pagerState, tabItems -> - AssembleHomePage(pagerState, tabItems, accountViewModel, nav) - } + AssembleHomeTabs(homeFeedViewModel, repliesFeedViewModel) { pagerState, tabItems -> + AssembleHomePage(pagerState, tabItems, accountViewModel, nav) + } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun AssembleHomeTabs( - homeFeedViewModel: NostrHomeFeedViewModel, - repliesFeedViewModel: NostrHomeRepliesFeedViewModel, - inner: @Composable (PagerState, ImmutableList) -> Unit, + homeFeedViewModel: NostrHomeFeedViewModel, + repliesFeedViewModel: NostrHomeRepliesFeedViewModel, + inner: @Composable (PagerState, ImmutableList) -> Unit, ) { - val pagerState = rememberForeverPagerState(key = PagerStateKeys.HOME_SCREEN) { 2 } + val pagerState = rememberForeverPagerState(key = PagerStateKeys.HOME_SCREEN) { 2 } - val tabs by - remember(homeFeedViewModel, repliesFeedViewModel) { - mutableStateOf( - listOf( - TabItem( - R.string.new_threads, - homeFeedViewModel, - Route.Home.base + "Follows", - ScrollStateKeys.HOME_FOLLOWS, - ), - TabItem( - R.string.conversations, - repliesFeedViewModel, - Route.Home.base + "FollowsReplies", - ScrollStateKeys.HOME_REPLIES, - ), - ) - .toImmutableList(), - ) - } + val tabs by + remember(homeFeedViewModel, repliesFeedViewModel) { + mutableStateOf( + listOf( + TabItem( + R.string.new_threads, + homeFeedViewModel, + Route.Home.base + "Follows", + ScrollStateKeys.HOME_FOLLOWS, + ), + TabItem( + R.string.conversations, + repliesFeedViewModel, + Route.Home.base + "FollowsReplies", + ScrollStateKeys.HOME_REPLIES, + ), + ) + .toImmutableList(), + ) + } - inner(pagerState, tabs) + inner(pagerState, tabs) } @OptIn(ExperimentalFoundationApi::class) @Composable private fun AssembleHomePage( - pagerState: PagerState, - tabs: ImmutableList, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + pagerState: PagerState, + tabs: ImmutableList, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Column(Modifier.fillMaxHeight()) { HomePages(pagerState, tabs, accountViewModel, nav) } + Column(Modifier.fillMaxHeight()) { HomePages(pagerState, tabs, accountViewModel, nav) } } @Composable fun ResolveNIP47( - nip47: String?, - accountViewModel: AccountViewModel, + nip47: String?, + accountViewModel: AccountViewModel, ) { - var wantsToAddNip47 by remember(nip47) { mutableStateOf(nip47) } + var wantsToAddNip47 by remember(nip47) { mutableStateOf(nip47) } - if (wantsToAddNip47 != null) { - UpdateZapAmountDialog({ wantsToAddNip47 = null }, wantsToAddNip47, accountViewModel) - } + if (wantsToAddNip47 != null) { + UpdateZapAmountDialog({ wantsToAddNip47 = null }, wantsToAddNip47, accountViewModel) + } } @Composable private fun WatchLifeCycleChanges(accountViewModel: AccountViewModel) { - val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - NostrHomeDataSource.account = accountViewModel.account - NostrHomeDataSource.invalidateFilters() - } - } + val lifeCycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + NostrHomeDataSource.account = accountViewModel.account + NostrHomeDataSource.invalidateFilters() + } + } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } } @Composable @OptIn(ExperimentalFoundationApi::class) private fun HomePages( - pagerState: PagerState, - tabs: ImmutableList, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + pagerState: PagerState, + tabs: ImmutableList, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - TabRow( - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground, - modifier = TabRowHeight, - selectedTabIndex = pagerState.currentPage, - ) { - val coroutineScope = rememberCoroutineScope() + TabRow( + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + modifier = TabRowHeight, + selectedTabIndex = pagerState.currentPage, + ) { + val coroutineScope = rememberCoroutineScope() - tabs.forEachIndexed { index, tab -> - Tab( - selected = pagerState.currentPage == index, - text = { Text(text = stringResource(tab.resource)) }, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, - ) + tabs.forEachIndexed { index, tab -> + Tab( + selected = pagerState.currentPage == index, + text = { Text(text = stringResource(tab.resource)) }, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, + ) + } } - } - HorizontalPager(state = pagerState, userScrollEnabled = false) { page -> - RefresheableFeedView( - viewModel = tabs[page].viewModel, - routeForLastRead = tabs[page].routeForLastRead, - scrollStateKey = tabs[page].scrollStateKey, - accountViewModel = accountViewModel, - nav = nav, - ) - } + HorizontalPager(state = pagerState, userScrollEnabled = false) { page -> + RefresheableFeedView( + viewModel = tabs[page].viewModel, + routeForLastRead = tabs[page].routeForLastRead, + scrollStateKey = tabs[page].scrollStateKey, + accountViewModel = accountViewModel, + nav = nav, + ) + } } @Composable fun CheckIfUrlIsOnline( - url: String, - accountViewModel: AccountViewModel, - whenOnline: @Composable (Boolean) -> Unit, + url: String, + accountViewModel: AccountViewModel, + whenOnline: @Composable (Boolean) -> Unit, ) { - var online by remember { - mutableStateOf( - OnlineChecker.isOnlineCached(url), - ) - } - - LaunchedEffect(key1 = url) { - accountViewModel.checkIsOnline(url) { isOnline -> - if (online != isOnline) { - online = isOnline - } + var online by remember { + mutableStateOf( + OnlineChecker.isOnlineCached(url), + ) } - } - whenOnline(online) + LaunchedEffect(key1 = url) { + accountViewModel.checkIsOnline(url) { isOnline -> + if (online != isOnline) { + online = isOnline + } + } + } + + whenOnline(online) } @Composable fun CrossfadeCheckIfUrlIsOnline( - url: String, - accountViewModel: AccountViewModel, - whenOnline: @Composable () -> Unit, + url: String, + accountViewModel: AccountViewModel, + whenOnline: @Composable () -> Unit, ) { - var online by remember { - mutableStateOf( - OnlineChecker.isOnlineCached(url), - ) - } - - LaunchedEffect(key1 = url) { - accountViewModel.checkIsOnline(url) { isOnline -> - if (online != isOnline) { - online = isOnline - } + var online by remember { + mutableStateOf( + OnlineChecker.isOnlineCached(url), + ) } - } - Crossfade( - targetState = online, - label = "CheckIfUrlIsOnline", - ) { - if (it) { - whenOnline() + LaunchedEffect(key1 = url) { + accountViewModel.checkIsOnline(url) { isOnline -> + if (online != isOnline) { + online = isOnline + } + } + } + + Crossfade( + targetState = online, + label = "CheckIfUrlIsOnline", + ) { + if (it) { + whenOnline() + } } - } } @Composable fun WatchAccountForHomeScreen( - homeFeedViewModel: NostrHomeFeedViewModel, - repliesFeedViewModel: NostrHomeRepliesFeedViewModel, - accountViewModel: AccountViewModel, + homeFeedViewModel: NostrHomeFeedViewModel, + repliesFeedViewModel: NostrHomeRepliesFeedViewModel, + accountViewModel: AccountViewModel, ) { - val homeFollowList by accountViewModel.account.liveHomeFollowLists.collectAsStateWithLifecycle() + val homeFollowList by accountViewModel.account.liveHomeFollowLists.collectAsStateWithLifecycle() - LaunchedEffect(accountViewModel, homeFollowList) { - NostrHomeDataSource.account = accountViewModel.account - NostrHomeDataSource.invalidateFilters() - homeFeedViewModel.checkKeysInvalidateDataAndSendToTop() - repliesFeedViewModel.checkKeysInvalidateDataAndSendToTop() - } + LaunchedEffect(accountViewModel, homeFollowList) { + NostrHomeDataSource.account = accountViewModel.account + NostrHomeDataSource.invalidateFilters() + homeFeedViewModel.checkKeysInvalidateDataAndSendToTop() + repliesFeedViewModel.checkKeysInvalidateDataAndSendToTop() + } } @Immutable class TabItem( - val resource: Int, - val viewModel: FeedViewModel, - val routeForLastRead: String, - val scrollStateKey: String, - val forceEventKind: Int? = null, + val resource: Int, + val viewModel: FeedViewModel, + val routeForLastRead: String, + val scrollStateKey: String, + val forceEventKind: Int? = null, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt index fbe82bae5..053d00cac 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt @@ -55,99 +55,99 @@ import kotlinx.coroutines.withContext @Composable fun LoadRedirectScreen( - eventId: String?, - accountViewModel: AccountViewModel, - navController: NavController, + eventId: String?, + accountViewModel: AccountViewModel, + navController: NavController, ) { - if (eventId == null) return + if (eventId == null) return - var noteBase by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() + var noteBase by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() - val nav = - remember(navController) { - { route: String -> - scope.launch { - navController.navigate(route) { popUpTo(Route.Event.route) { inclusive = true } } + val nav = + remember(navController) { + { route: String -> + scope.launch { + navController.navigate(route) { popUpTo(Route.Event.route) { inclusive = true } } + } + Unit + } + } + + LaunchedEffect(eventId) { + launch(Dispatchers.IO) { + val newNoteBase = LocalCache.checkGetOrCreateNote(eventId) + if (newNoteBase != noteBase) { + noteBase = newNoteBase + } } - Unit - } } - LaunchedEffect(eventId) { - launch(Dispatchers.IO) { - val newNoteBase = LocalCache.checkGetOrCreateNote(eventId) - if (newNoteBase != noteBase) { - noteBase = newNoteBase - } + noteBase?.let { + LoadRedirectScreen( + baseNote = it, + accountViewModel = accountViewModel, + nav = nav, + ) } - } - - noteBase?.let { - LoadRedirectScreen( - baseNote = it, - accountViewModel = accountViewModel, - nav = nav, - ) - } } @Composable fun LoadRedirectScreen( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteState by baseNote.live().metadata.observeAsState() + val noteState by baseNote.live().metadata.observeAsState() - LaunchedEffect(key1 = noteState) { - val note = noteState?.note ?: return@LaunchedEffect - val event = note.event + LaunchedEffect(key1 = noteState) { + val note = noteState?.note ?: return@LaunchedEffect + val event = note.event - if (event != null) { - withContext(Dispatchers.IO) { redirect(event, note, accountViewModel, nav) } + if (event != null) { + withContext(Dispatchers.IO) { redirect(event, note, accountViewModel, nav) } + } } - } - Column( - Modifier.fillMaxHeight().fillMaxWidth().padding(horizontal = 50.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text(stringResource(R.string.looking_for_event, baseNote.idHex)) - } + Column( + Modifier.fillMaxHeight().fillMaxWidth().padding(horizontal = 50.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(stringResource(R.string.looking_for_event, baseNote.idHex)) + } } fun redirect( - event: EventInterface, - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + event: EventInterface, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val channelHex = note.channelHex() + val channelHex = note.channelHex() - if (event is GiftWrapEvent) { - accountViewModel.unwrap(event) { redirect(it, note, accountViewModel, nav) } - } else if (event is SealedGossipEvent) { - accountViewModel.unseal(event) { redirect(it, note, accountViewModel, nav) } - } else { - if (event == null) { - // stay here, loading - } else if (event is ChannelCreateEvent) { - nav("Channel/${note.idHex}") - } else if (event is ChatroomKeyable) { - note.author?.let { - val withKey = - (event as ChatroomKeyable).chatroomKey(accountViewModel.userProfile().pubkeyHex) - - accountViewModel.userProfile().createChatroom(withKey) - - nav("Room/${withKey.hashCode()}") - } - } else if (channelHex != null) { - nav("Channel/$channelHex") + if (event is GiftWrapEvent) { + accountViewModel.unwrap(event) { redirect(it, note, accountViewModel, nav) } + } else if (event is SealedGossipEvent) { + accountViewModel.unseal(event) { redirect(it, note, accountViewModel, nav) } } else { - nav("Note/${note.idHex}") + if (event == null) { + // stay here, loading + } else if (event is ChannelCreateEvent) { + nav("Channel/${note.idHex}") + } else if (event is ChatroomKeyable) { + note.author?.let { + val withKey = + (event as ChatroomKeyable).chatroomKey(accountViewModel.userProfile().pubkeyHex) + + accountViewModel.userProfile().createChatroom(withKey) + + nav("Room/${withKey.hashCode()}") + } + } else if (channelHex != null) { + nav("Channel/$channelHex") + } else { + nav("Note/${note.idHex}") + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index 42ba6983d..c2916d3b3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -109,484 +109,487 @@ import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel -import kotlin.math.abs import kotlinx.coroutines.launch +import kotlin.math.abs @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( - accountViewModel: AccountViewModel, - accountStateViewModel: AccountStateViewModel, - sharedPreferencesViewModel: SharedPreferencesViewModel, + accountViewModel: AccountViewModel, + accountStateViewModel: AccountStateViewModel, + sharedPreferencesViewModel: SharedPreferencesViewModel, ) { - val scope = rememberCoroutineScope() - var openBottomSheet by rememberSaveable { mutableStateOf(false) } + val scope = rememberCoroutineScope() + var openBottomSheet by rememberSaveable { mutableStateOf(false) } - val drawerState = rememberDrawerState(DrawerValue.Closed) - val sheetState = - rememberModalBottomSheetState( - skipPartiallyExpanded = true, - confirmValueChange = { it != SheetValue.PartiallyExpanded }, - ) + val drawerState = rememberDrawerState(DrawerValue.Closed) + val sheetState = + rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { it != SheetValue.PartiallyExpanded }, + ) - val openSheetFunction = remember { - { - scope.launch { - openBottomSheet = true - sheetState.show() - } - Unit - } - } - - val navController = rememberNavController() - val navState = navController.currentBackStackEntryAsState() - - val orientation = LocalConfiguration.current.orientation - val currentDrawerState = drawerState.currentValue - LaunchedEffect(key1 = orientation) { - if ( - orientation == Configuration.ORIENTATION_LANDSCAPE && currentDrawerState == DrawerValue.Closed - ) { - drawerState.close() - } - } - - val nav = - remember(navController) { - { route: String -> - scope.launch { - if (getRouteWithArguments(navController) != route) { - navController.navigate(route) - } - } - Unit - } - } - - DisplayErrorMessages(accountViewModel) - DisplayNotifyMessages(accountViewModel, nav) - - val navPopBack = - remember(navController) { - { - navController.popBackStack() - Unit - } - } - - val followLists: FollowListViewModel = - viewModel( - key = "FollowListViewModel", - factory = FollowListViewModel.Factory(accountViewModel.account), - ) - - // Avoids creating ViewModels for performance reasons (up to 1 second delays) - val homeFeedViewModel: NostrHomeFeedViewModel = - viewModel( - key = "NostrHomeFeedViewModel", - factory = NostrHomeFeedViewModel.Factory(accountViewModel.account), - ) - - val repliesFeedViewModel: NostrHomeRepliesFeedViewModel = - viewModel( - key = "NostrHomeRepliesFeedViewModel", - factory = NostrHomeRepliesFeedViewModel.Factory(accountViewModel.account), - ) - - val videoFeedViewModel: NostrVideoFeedViewModel = - viewModel( - key = "NostrVideoFeedViewModel", - factory = NostrVideoFeedViewModel.Factory(accountViewModel.account), - ) - - val discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel = - viewModel( - key = "NostrDiscoveryMarketplaceFeedViewModel", - factory = NostrDiscoverMarketplaceFeedViewModel.Factory(accountViewModel.account), - ) - - val discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel = - viewModel( - key = "NostrDiscoveryLiveFeedViewModel", - factory = NostrDiscoverLiveFeedViewModel.Factory(accountViewModel.account), - ) - - val discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel = - viewModel( - key = "NostrDiscoveryCommunityFeedViewModel", - factory = NostrDiscoverCommunityFeedViewModel.Factory(accountViewModel.account), - ) - - val discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel = - viewModel( - key = "NostrDiscoveryChatFeedViewModel", - factory = NostrDiscoverChatFeedViewModel.Factory(accountViewModel.account), - ) - - val notifFeedViewModel: NotificationViewModel = - viewModel( - key = "NotificationViewModel", - factory = NotificationViewModel.Factory(accountViewModel.account), - ) - - val userReactionsStatsModel: UserReactionsViewModel = - viewModel( - key = "UserReactionsViewModel", - factory = UserReactionsViewModel.Factory(accountViewModel.account), - ) - - val knownFeedViewModel: NostrChatroomListKnownFeedViewModel = - viewModel( - key = "NostrChatroomListKnownFeedViewModel", - factory = NostrChatroomListKnownFeedViewModel.Factory(accountViewModel.account), - ) - - val newFeedViewModel: NostrChatroomListNewFeedViewModel = - viewModel( - key = "NostrChatroomListNewFeedViewModel", - factory = NostrChatroomListNewFeedViewModel.Factory(accountViewModel.account), - ) - - val navBottomRow = - remember(navController) { - { route: Route, selected: Boolean -> - scope.launch { - if (!selected) { - navController.navigate(route.base) { - popUpTo(Route.Home.route) - launchSingleTop = true + val openSheetFunction = + remember { + { + scope.launch { + openBottomSheet = true + sheetState.show() + } + Unit } - } else { - // deals with scroll to top here to avoid passing as parameter - // and having to deal with all recompositions with scroll to top true - when (route.base) { - Route.Home.base -> { - homeFeedViewModel.sendToTop() - repliesFeedViewModel.sendToTop() - } - Route.Video.base -> { - videoFeedViewModel.sendToTop() - } - Route.Discover.base -> { - discoveryLiveFeedViewModel.sendToTop() - discoveryCommunityFeedViewModel.sendToTop() - discoveryChatFeedViewModel.sendToTop() - } - Route.Notification.base -> { - notifFeedViewModel.invalidateDataAndSendToTop() - } - } - - navController.navigate(route.route) { - popUpTo(route.route) - launchSingleTop = true - } - } } - Unit - } - } + val navController = rememberNavController() + val navState = navController.currentBackStackEntryAsState() - val bottomBarHeightPx = with(LocalDensity.current) { 50.dp.roundToPx().toFloat() } - val bottomBarOffsetHeightPx = remember { mutableFloatStateOf(0f) } - val shouldShow = remember { mutableStateOf(true) } - - val nestedScrollConnection = remember { - object : NestedScrollConnection { - override fun onPreScroll( - available: Offset, - source: NestedScrollSource, - ): Offset { - val newOffset = bottomBarOffsetHeightPx.floatValue + available.y - - if (accountViewModel.settings.automaticallyHideNavigationBars == BooleanType.ALWAYS) { - val newBottomBarOffset = - if (navState.value?.destination?.route !in InvertedLayouts) { - newOffset.coerceIn(-bottomBarHeightPx, 0f) - } else { - newOffset.coerceIn(0f, bottomBarHeightPx) - } - - if (newBottomBarOffset != bottomBarOffsetHeightPx.floatValue) { - bottomBarOffsetHeightPx.floatValue = newBottomBarOffset - } - } else { - if (abs(bottomBarOffsetHeightPx.floatValue) > 0.1) { - bottomBarOffsetHeightPx.floatValue = 0f - } - } - - val newShouldShow = abs(bottomBarOffsetHeightPx.floatValue) < bottomBarHeightPx / 2.0f - - if (shouldShow.value != newShouldShow) { - shouldShow.value = newShouldShow - } - - return Offset.Zero - } - } - } - - WatchNavStateToUpdateBarVisibility(navState) { - bottomBarOffsetHeightPx.floatValue = 0f - shouldShow.value = true - } - - ModalNavigationDrawer( - drawerState = drawerState, - drawerContent = { - DrawerContent(nav, drawerState, openSheetFunction, accountViewModel) - BackHandler(enabled = drawerState.isOpen) { scope.launch { drawerState.close() } } - }, - content = { - Scaffold( - modifier = - Modifier.background(MaterialTheme.colorScheme.secondary) - .statusBarsPadding() - .nestedScroll(nestedScrollConnection), - bottomBar = { - AnimatedContent( - targetState = shouldShow.value, - transitionSpec = AnimatedContentTransitionScope::bottomBarTransitionSpec, - label = "BottomBarAnimatedContent", - ) { isVisible -> - if (isVisible) { - AppBottomBar(accountViewModel, navState, navBottomRow) - } - } - }, - topBar = { - AnimatedContent( - targetState = shouldShow.value, - transitionSpec = AnimatedContentTransitionScope::topBarTransitionSpec, - label = "TopBarAnimatedContent", - ) { isVisible -> - if (isVisible) { - AppTopBar( - followLists, - navState, - drawerState, - accountViewModel, - nav = nav, - navPopBack, - ) - } - } - }, - floatingActionButton = { - AnimatedVisibility( - visible = shouldShow.value, - enter = remember { scaleIn() }, - exit = remember { scaleOut() }, - ) { - Box( - modifier = Modifier.defaultMinSize(minWidth = 55.dp, minHeight = 55.dp), - ) { - FloatingButtons( - navState, - accountViewModel, - accountStateViewModel, - nav, - navBottomRow, - ) - } - } - }, - ) { - Column( - modifier = - Modifier.padding( - top = it.calculateTopPadding(), - bottom = it.calculateBottomPadding(), - ), + val orientation = LocalConfiguration.current.orientation + val currentDrawerState = drawerState.currentValue + LaunchedEffect(key1 = orientation) { + if ( + orientation == Configuration.ORIENTATION_LANDSCAPE && currentDrawerState == DrawerValue.Closed ) { - AppNavigation( - homeFeedViewModel = homeFeedViewModel, - repliesFeedViewModel = repliesFeedViewModel, - knownFeedViewModel = knownFeedViewModel, - newFeedViewModel = newFeedViewModel, - videoFeedViewModel = videoFeedViewModel, - discoverMarketplaceFeedViewModel = discoverMarketplaceFeedViewModel, - discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, - discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, - discoveryChatFeedViewModel = discoveryChatFeedViewModel, - notifFeedViewModel = notifFeedViewModel, - userReactionsStatsModel = userReactionsStatsModel, - navController = navController, - accountViewModel = accountViewModel, - sharedPreferencesViewModel = sharedPreferencesViewModel, - ) + drawerState.close() + } + } + + val nav = + remember(navController) { + { route: String -> + scope.launch { + if (getRouteWithArguments(navController) != route) { + navController.navigate(route) + } + } + Unit + } + } + + DisplayErrorMessages(accountViewModel) + DisplayNotifyMessages(accountViewModel, nav) + + val navPopBack = + remember(navController) { + { + navController.popBackStack() + Unit + } + } + + val followLists: FollowListViewModel = + viewModel( + key = "FollowListViewModel", + factory = FollowListViewModel.Factory(accountViewModel.account), + ) + + // Avoids creating ViewModels for performance reasons (up to 1 second delays) + val homeFeedViewModel: NostrHomeFeedViewModel = + viewModel( + key = "NostrHomeFeedViewModel", + factory = NostrHomeFeedViewModel.Factory(accountViewModel.account), + ) + + val repliesFeedViewModel: NostrHomeRepliesFeedViewModel = + viewModel( + key = "NostrHomeRepliesFeedViewModel", + factory = NostrHomeRepliesFeedViewModel.Factory(accountViewModel.account), + ) + + val videoFeedViewModel: NostrVideoFeedViewModel = + viewModel( + key = "NostrVideoFeedViewModel", + factory = NostrVideoFeedViewModel.Factory(accountViewModel.account), + ) + + val discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel = + viewModel( + key = "NostrDiscoveryMarketplaceFeedViewModel", + factory = NostrDiscoverMarketplaceFeedViewModel.Factory(accountViewModel.account), + ) + + val discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel = + viewModel( + key = "NostrDiscoveryLiveFeedViewModel", + factory = NostrDiscoverLiveFeedViewModel.Factory(accountViewModel.account), + ) + + val discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel = + viewModel( + key = "NostrDiscoveryCommunityFeedViewModel", + factory = NostrDiscoverCommunityFeedViewModel.Factory(accountViewModel.account), + ) + + val discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel = + viewModel( + key = "NostrDiscoveryChatFeedViewModel", + factory = NostrDiscoverChatFeedViewModel.Factory(accountViewModel.account), + ) + + val notifFeedViewModel: NotificationViewModel = + viewModel( + key = "NotificationViewModel", + factory = NotificationViewModel.Factory(accountViewModel.account), + ) + + val userReactionsStatsModel: UserReactionsViewModel = + viewModel( + key = "UserReactionsViewModel", + factory = UserReactionsViewModel.Factory(accountViewModel.account), + ) + + val knownFeedViewModel: NostrChatroomListKnownFeedViewModel = + viewModel( + key = "NostrChatroomListKnownFeedViewModel", + factory = NostrChatroomListKnownFeedViewModel.Factory(accountViewModel.account), + ) + + val newFeedViewModel: NostrChatroomListNewFeedViewModel = + viewModel( + key = "NostrChatroomListNewFeedViewModel", + factory = NostrChatroomListNewFeedViewModel.Factory(accountViewModel.account), + ) + + val navBottomRow = + remember(navController) { + { route: Route, selected: Boolean -> + scope.launch { + if (!selected) { + navController.navigate(route.base) { + popUpTo(Route.Home.route) + launchSingleTop = true + } + } else { + // deals with scroll to top here to avoid passing as parameter + // and having to deal with all recompositions with scroll to top true + when (route.base) { + Route.Home.base -> { + homeFeedViewModel.sendToTop() + repliesFeedViewModel.sendToTop() + } + Route.Video.base -> { + videoFeedViewModel.sendToTop() + } + Route.Discover.base -> { + discoveryLiveFeedViewModel.sendToTop() + discoveryCommunityFeedViewModel.sendToTop() + discoveryChatFeedViewModel.sendToTop() + } + Route.Notification.base -> { + notifFeedViewModel.invalidateDataAndSendToTop() + } + } + + navController.navigate(route.route) { + popUpTo(route.route) + launchSingleTop = true + } + } + } + + Unit + } + } + + val bottomBarHeightPx = with(LocalDensity.current) { 50.dp.roundToPx().toFloat() } + val bottomBarOffsetHeightPx = remember { mutableFloatStateOf(0f) } + val shouldShow = remember { mutableStateOf(true) } + + val nestedScrollConnection = + remember { + object : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset { + val newOffset = bottomBarOffsetHeightPx.floatValue + available.y + + if (accountViewModel.settings.automaticallyHideNavigationBars == BooleanType.ALWAYS) { + val newBottomBarOffset = + if (navState.value?.destination?.route !in InvertedLayouts) { + newOffset.coerceIn(-bottomBarHeightPx, 0f) + } else { + newOffset.coerceIn(0f, bottomBarHeightPx) + } + + if (newBottomBarOffset != bottomBarOffsetHeightPx.floatValue) { + bottomBarOffsetHeightPx.floatValue = newBottomBarOffset + } + } else { + if (abs(bottomBarOffsetHeightPx.floatValue) > 0.1) { + bottomBarOffsetHeightPx.floatValue = 0f + } + } + + val newShouldShow = abs(bottomBarOffsetHeightPx.floatValue) < bottomBarHeightPx / 2.0f + + if (shouldShow.value != newShouldShow) { + shouldShow.value = newShouldShow + } + + return Offset.Zero + } + } + } + + WatchNavStateToUpdateBarVisibility(navState) { + bottomBarOffsetHeightPx.floatValue = 0f + shouldShow.value = true + } + + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + DrawerContent(nav, drawerState, openSheetFunction, accountViewModel) + BackHandler(enabled = drawerState.isOpen) { scope.launch { drawerState.close() } } + }, + content = { + Scaffold( + modifier = + Modifier.background(MaterialTheme.colorScheme.secondary) + .statusBarsPadding() + .nestedScroll(nestedScrollConnection), + bottomBar = { + AnimatedContent( + targetState = shouldShow.value, + transitionSpec = AnimatedContentTransitionScope::bottomBarTransitionSpec, + label = "BottomBarAnimatedContent", + ) { isVisible -> + if (isVisible) { + AppBottomBar(accountViewModel, navState, navBottomRow) + } + } + }, + topBar = { + AnimatedContent( + targetState = shouldShow.value, + transitionSpec = AnimatedContentTransitionScope::topBarTransitionSpec, + label = "TopBarAnimatedContent", + ) { isVisible -> + if (isVisible) { + AppTopBar( + followLists, + navState, + drawerState, + accountViewModel, + nav = nav, + navPopBack, + ) + } + } + }, + floatingActionButton = { + AnimatedVisibility( + visible = shouldShow.value, + enter = remember { scaleIn() }, + exit = remember { scaleOut() }, + ) { + Box( + modifier = Modifier.defaultMinSize(minWidth = 55.dp, minHeight = 55.dp), + ) { + FloatingButtons( + navState, + accountViewModel, + accountStateViewModel, + nav, + navBottomRow, + ) + } + } + }, + ) { + Column( + modifier = + Modifier.padding( + top = it.calculateTopPadding(), + bottom = it.calculateBottomPadding(), + ), + ) { + AppNavigation( + homeFeedViewModel = homeFeedViewModel, + repliesFeedViewModel = repliesFeedViewModel, + knownFeedViewModel = knownFeedViewModel, + newFeedViewModel = newFeedViewModel, + videoFeedViewModel = videoFeedViewModel, + discoverMarketplaceFeedViewModel = discoverMarketplaceFeedViewModel, + discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, + discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, + discoveryChatFeedViewModel = discoveryChatFeedViewModel, + notifFeedViewModel = notifFeedViewModel, + userReactionsStatsModel = userReactionsStatsModel, + navController = navController, + accountViewModel = accountViewModel, + sharedPreferencesViewModel = sharedPreferencesViewModel, + ) + } + } + }, + ) + + // Sheet content + if (openBottomSheet) { + ModalBottomSheet( + onDismissRequest = { + scope + .launch { sheetState.hide() } + .invokeOnCompletion { + if (!sheetState.isVisible) { + openBottomSheet = false + } + } + }, + sheetState = sheetState, + ) { + AccountSwitchBottomSheet( + accountViewModel = accountViewModel, + accountStateViewModel = accountStateViewModel, + ) } - } - }, - ) - - // Sheet content - if (openBottomSheet) { - ModalBottomSheet( - onDismissRequest = { - scope - .launch { sheetState.hide() } - .invokeOnCompletion { - if (!sheetState.isVisible) { - openBottomSheet = false - } - } - }, - sheetState = sheetState, - ) { - AccountSwitchBottomSheet( - accountViewModel = accountViewModel, - accountStateViewModel = accountStateViewModel, - ) } - } } @OptIn(ExperimentalAnimationApi::class) private fun AnimatedContentTransitionScope.topBarTransitionSpec(): ContentTransform { - return topBarAnimation + return topBarAnimation } @OptIn(ExperimentalAnimationApi::class) private fun AnimatedContentTransitionScope.bottomBarTransitionSpec(): ContentTransform { - return bottomBarAnimation + return bottomBarAnimation } @ExperimentalAnimationApi val topBarAnimation: ContentTransform = - slideInVertically { height -> 0 } togetherWith slideOutVertically { height -> 0 } + slideInVertically { height -> 0 } togetherWith slideOutVertically { height -> 0 } val bottomBarAnimation: ContentTransform = - slideInVertically { height -> height } togetherWith slideOutVertically { height -> height } + slideInVertically { height -> height } togetherWith slideOutVertically { height -> height } @Composable private fun DisplayErrorMessages(accountViewModel: AccountViewModel) { - val context = LocalContext.current - val openDialogMsg = accountViewModel.toasts.collectAsStateWithLifecycle(null) + val context = LocalContext.current + val openDialogMsg = accountViewModel.toasts.collectAsStateWithLifecycle(null) - openDialogMsg.value?.let { obj -> - when (obj) { - is ResourceToastMsg -> - InformationDialog( - context.getString(obj.titleResId), - context.getString(obj.resourceId), - ) { - accountViewModel.clearToasts() - } - is StringToastMsg -> - InformationDialog( - obj.title, - obj.msg, - ) { - accountViewModel.clearToasts() + openDialogMsg.value?.let { obj -> + when (obj) { + is ResourceToastMsg -> + InformationDialog( + context.getString(obj.titleResId), + context.getString(obj.resourceId), + ) { + accountViewModel.clearToasts() + } + is StringToastMsg -> + InformationDialog( + obj.title, + obj.msg, + ) { + accountViewModel.clearToasts() + } } } - } } @Composable private fun DisplayNotifyMessages( - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val openDialogMsg = - accountViewModel.account.transientPaymentRequests.collectAsStateWithLifecycle(null) + val openDialogMsg = + accountViewModel.account.transientPaymentRequests.collectAsStateWithLifecycle(null) - openDialogMsg.value?.firstOrNull()?.let { request -> - NotifyRequestDialog( - title = - stringResource( - id = R.string.payment_required_title, - request.relayUrl.removePrefix("wss://").removeSuffix("/"), - ), - textContent = request.description, - accountViewModel = accountViewModel, - nav = nav, - ) { - accountViewModel.dismissPaymentRequest(request) + openDialogMsg.value?.firstOrNull()?.let { request -> + NotifyRequestDialog( + title = + stringResource( + id = R.string.payment_required_title, + request.relayUrl.removePrefix("wss://").removeSuffix("/"), + ), + textContent = request.description, + accountViewModel = accountViewModel, + nav = nav, + ) { + accountViewModel.dismissPaymentRequest(request) + } } - } } @Composable fun WatchNavStateToUpdateBarVisibility( - navState: State, - onReset: () -> Unit, + navState: State, + onReset: () -> Unit, ) { - LaunchedEffect(key1 = navState.value) { onReset() } + LaunchedEffect(key1 = navState.value) { onReset() } - val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - onReset() - } + val lifeCycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + onReset() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } } @Composable fun FloatingButtons( - navEntryState: State, - accountViewModel: AccountViewModel, - accountStateViewModel: AccountStateViewModel, - nav: (String) -> Unit, - navScrollToTop: (Route, Boolean) -> Unit, + navEntryState: State, + accountViewModel: AccountViewModel, + accountStateViewModel: AccountStateViewModel, + nav: (String) -> Unit, + navScrollToTop: (Route, Boolean) -> Unit, ) { - val accountState by accountStateViewModel.accountContent.collectAsStateWithLifecycle() + val accountState by accountStateViewModel.accountContent.collectAsStateWithLifecycle() - when (accountState) { - is AccountState.Loading -> { - // Does nothing. + when (accountState) { + is AccountState.Loading -> { + // Does nothing. + } + is AccountState.LoggedInViewOnly -> { + WritePermissionButtons(navEntryState, accountViewModel, nav, navScrollToTop) + } + is AccountState.LoggedOff -> { + // Does nothing. + } + is AccountState.LoggedIn -> { + WritePermissionButtons(navEntryState, accountViewModel, nav, navScrollToTop) + } } - is AccountState.LoggedInViewOnly -> { - WritePermissionButtons(navEntryState, accountViewModel, nav, navScrollToTop) - } - is AccountState.LoggedOff -> { - // Does nothing. - } - is AccountState.LoggedIn -> { - WritePermissionButtons(navEntryState, accountViewModel, nav, navScrollToTop) - } - } } @Composable private fun WritePermissionButtons( - navEntryState: State, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - navScrollToTop: (Route, Boolean) -> Unit, + navEntryState: State, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + navScrollToTop: (Route, Boolean) -> Unit, ) { - val currentRoute by - remember(navEntryState.value) { - derivedStateOf { navEntryState.value?.destination?.route?.substringBefore("?") } - } - - when (currentRoute) { - Route.Home.base -> NewNoteButton(accountViewModel, nav) - Route.Message.base -> { - if ( - accountViewModel.settings.windowSizeClass.value?.widthSizeClass == - WindowWidthSizeClass.Compact - ) { - ChannelFabColumn(accountViewModel, nav) - } - } - Route.Video.base -> NewImageButton(accountViewModel, nav, navScrollToTop) - Route.Community.base -> { - val communityId by + val currentRoute by remember(navEntryState.value) { - derivedStateOf { navEntryState.value?.arguments?.getString("id") } + derivedStateOf { navEntryState.value?.destination?.route?.substringBefore("?") } } - communityId?.let { NewCommunityNoteButton(it, accountViewModel, nav) } + when (currentRoute) { + Route.Home.base -> NewNoteButton(accountViewModel, nav) + Route.Message.base -> { + if ( + accountViewModel.settings.windowSizeClass.value?.widthSizeClass == + WindowWidthSizeClass.Compact + ) { + ChannelFabColumn(accountViewModel, nav) + } + } + Route.Video.base -> NewImageButton(accountViewModel, nav, navScrollToTop) + Route.Community.base -> { + val communityId by + remember(navEntryState.value) { + derivedStateOf { navEntryState.value?.arguments?.getString("id") } + } + + communityId?.let { NewCommunityNoteButton(it, accountViewModel, nav) } + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt index 1110f902e..6a741006e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt @@ -92,214 +92,213 @@ import kotlin.math.roundToInt @Composable fun NotificationScreen( - notifFeedViewModel: NotificationViewModel, - userReactionsStatsModel: UserReactionsViewModel, - sharedPreferencesViewModel: SharedPreferencesViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + notifFeedViewModel: NotificationViewModel, + userReactionsStatsModel: UserReactionsViewModel, + sharedPreferencesViewModel: SharedPreferencesViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - SelectNotificationProvider(sharedPreferencesViewModel) + SelectNotificationProvider(sharedPreferencesViewModel) - WatchAccountForNotifications(notifFeedViewModel, accountViewModel) + WatchAccountForNotifications(notifFeedViewModel, accountViewModel) - val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - NostrAccountDataSource.account = accountViewModel.account - NostrAccountDataSource.invalidateFilters() - } + val lifeCycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + NostrAccountDataSource.account = accountViewModel.account + NostrAccountDataSource.invalidateFilters() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + Column(Modifier.fillMaxHeight()) { + SummaryBar( + model = userReactionsStatsModel, + ) - Column(Modifier.fillMaxHeight()) { - SummaryBar( - model = userReactionsStatsModel, - ) - - RefresheableCardView( - viewModel = notifFeedViewModel, - accountViewModel = accountViewModel, - nav = nav, - routeForLastRead = Route.Notification.base, - scrollStateKey = ScrollStateKeys.NOTIFICATION_SCREEN, - ) - } + RefresheableCardView( + viewModel = notifFeedViewModel, + accountViewModel = accountViewModel, + nav = nav, + routeForLastRead = Route.Notification.base, + scrollStateKey = ScrollStateKeys.NOTIFICATION_SCREEN, + ) + } } @OptIn(ExperimentalPermissionsApi::class) @Composable -fun CheckifItNeedsToRequestNotificationPermission( - sharedPreferencesViewModel: SharedPreferencesViewModel -): PermissionState { - val notificationPermissionState = - rememberPermissionState( - Manifest.permission.POST_NOTIFICATIONS, - ) +fun CheckifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel: SharedPreferencesViewModel): PermissionState { + val notificationPermissionState = + rememberPermissionState( + Manifest.permission.POST_NOTIFICATIONS, + ) - if (!sharedPreferencesViewModel.sharedPrefs.dontAskForNotificationPermissions) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (!notificationPermissionState.status.isGranted) { - sharedPreferencesViewModel.dontAskForNotificationPermissions() + if (!sharedPreferencesViewModel.sharedPrefs.dontAskForNotificationPermissions) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (!notificationPermissionState.status.isGranted) { + sharedPreferencesViewModel.dontAskForNotificationPermissions() - // This will pause the APP, including the connection with relays. - LaunchedEffect(notificationPermissionState) { - notificationPermissionState.launchPermissionRequest() + // This will pause the APP, including the connection with relays. + LaunchedEffect(notificationPermissionState) { + notificationPermissionState.launchPermissionRequest() + } + } } - } } - } - return notificationPermissionState + return notificationPermissionState } @Composable fun WatchAccountForNotifications( - notifFeedViewModel: NotificationViewModel, - accountViewModel: AccountViewModel, + notifFeedViewModel: NotificationViewModel, + accountViewModel: AccountViewModel, ) { - val listState by - accountViewModel.account.liveNotificationFollowLists.collectAsStateWithLifecycle() + val listState by + accountViewModel.account.liveNotificationFollowLists.collectAsStateWithLifecycle() - LaunchedEffect(accountViewModel, listState) { - NostrAccountDataSource.account = accountViewModel.account - NostrAccountDataSource.invalidateFilters() - notifFeedViewModel.checkKeysInvalidateDataAndSendToTop() - } + LaunchedEffect(accountViewModel, listState) { + NostrAccountDataSource.account = accountViewModel.account + NostrAccountDataSource.invalidateFilters() + notifFeedViewModel.checkKeysInvalidateDataAndSendToTop() + } } @Composable fun SummaryBar(model: UserReactionsViewModel) { - var showChart by remember { mutableStateOf(false) } + var showChart by remember { mutableStateOf(false) } - UserReactionsRow(model) { showChart = !showChart } + UserReactionsRow(model) { showChart = !showChart } - if (showChart) { - val lineChartCount = - lineChart( - lines = - listOf(RoyalBlue, Color.Green, Color.Red).map { lineChartColor -> - LineChart.LineSpec( - lineColor = lineChartColor.toArgb(), - lineBackgroundShader = - DynamicShaders.fromBrush( - Brush.verticalGradient( - listOf( - lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START), - lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END), - ), - ), - ), + if (showChart) { + val lineChartCount = + lineChart( + lines = + listOf(RoyalBlue, Color.Green, Color.Red).map { lineChartColor -> + LineChart.LineSpec( + lineColor = lineChartColor.toArgb(), + lineBackgroundShader = + DynamicShaders.fromBrush( + Brush.verticalGradient( + listOf( + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START), + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END), + ), + ), + ), + ) + }, + targetVerticalAxisPosition = AxisPosition.Vertical.Start, ) - }, - targetVerticalAxisPosition = AxisPosition.Vertical.Start, - ) - val lineChartZaps = - lineChart( - lines = - listOf(BitcoinOrange).map { lineChartColor -> - LineChart.LineSpec( - lineColor = lineChartColor.toArgb(), - lineBackgroundShader = - DynamicShaders.fromBrush( - Brush.verticalGradient( - listOf( - lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START), - lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END), - ), - ), - ), + val lineChartZaps = + lineChart( + lines = + listOf(BitcoinOrange).map { lineChartColor -> + LineChart.LineSpec( + lineColor = lineChartColor.toArgb(), + lineBackgroundShader = + DynamicShaders.fromBrush( + Brush.verticalGradient( + listOf( + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START), + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END), + ), + ), + ), + ) + }, + targetVerticalAxisPosition = AxisPosition.Vertical.End, ) - }, - targetVerticalAxisPosition = AxisPosition.Vertical.End, - ) - Row( - modifier = - Modifier.padding(vertical = 10.dp, horizontal = 20.dp) - .clickable(onClick = { showChart = !showChart }), - ) { - ProvideChartStyle( - chartStyle = MaterialTheme.colorScheme.chartStyle, - ) { - ObserveAndShowChart(model, lineChartCount, lineChartZaps) - } + Row( + modifier = + Modifier.padding(vertical = 10.dp, horizontal = 20.dp) + .clickable(onClick = { showChart = !showChart }), + ) { + ProvideChartStyle( + chartStyle = MaterialTheme.colorScheme.chartStyle, + ) { + ObserveAndShowChart(model, lineChartCount, lineChartZaps) + } + } } - } - Divider( - thickness = DividerThickness, - ) + Divider( + thickness = DividerThickness, + ) } @Composable private fun ObserveAndShowChart( - model: UserReactionsViewModel, - lineChartCount: LineChart, - lineChartZaps: LineChart, + model: UserReactionsViewModel, + lineChartCount: LineChart, + lineChartZaps: LineChart, ) { - val axisModel = model.axisLabels.collectAsStateWithLifecycle() - val chartModel by model.chartModel.collectAsStateWithLifecycle() + val axisModel = model.axisLabels.collectAsStateWithLifecycle() + val chartModel by model.chartModel.collectAsStateWithLifecycle() - chartModel?.let { - Chart( - chart = remember(lineChartCount, lineChartZaps) { lineChartCount.plus(lineChartZaps) }, - model = it, - startAxis = - rememberStartAxis( - valueFormatter = CountAxisValueFormatter(), - ), - endAxis = - rememberEndAxis( - label = axisLabelComponent(color = BitcoinOrange), - valueFormatter = AmountAxisValueFormatter(model.shouldShowDecimalsInAxis), - ), - bottomAxis = - rememberBottomAxis( - valueFormatter = LabelValueFormatter(axisModel), - ), - ) - } + chartModel?.let { + Chart( + chart = remember(lineChartCount, lineChartZaps) { lineChartCount.plus(lineChartZaps) }, + model = it, + startAxis = + rememberStartAxis( + valueFormatter = CountAxisValueFormatter(), + ), + endAxis = + rememberEndAxis( + label = axisLabelComponent(color = BitcoinOrange), + valueFormatter = AmountAxisValueFormatter(model.shouldShowDecimalsInAxis), + ), + bottomAxis = + rememberBottomAxis( + valueFormatter = LabelValueFormatter(axisModel), + ), + ) + } } @Stable class LabelValueFormatter(val axisLabels: State>) : - AxisValueFormatter { - override fun formatValue( - value: Float, - chartValues: ChartValues, - ): String { - return axisLabels.value[value.roundToInt()] - } + AxisValueFormatter { + override fun formatValue( + value: Float, + chartValues: ChartValues, + ): String { + return axisLabels.value[value.roundToInt()] + } } @Stable class CountAxisValueFormatter() : AxisValueFormatter { - override fun formatValue( - value: Float, - chartValues: ChartValues, - ): String { - return showCount(value.roundToInt()) - } + override fun formatValue( + value: Float, + chartValues: ChartValues, + ): String { + return showCount(value.roundToInt()) + } } @Stable class AmountAxisValueFormatter(val showDecimals: Boolean) : - AxisValueFormatter { - override fun formatValue( - value: Float, - chartValues: ChartValues, - ): String { - return if (showDecimals) { - showAmount(value.toBigDecimal()) - } else { - showAmountAxis(value.toBigDecimal()) + AxisValueFormatter { + override fun formatValue( + value: Float, + chartValues: ChartValues, + ): String { + return if (showDecimals) { + showAmount(value.toBigDecimal()) + } else { + showAmountAxis(value.toBigDecimal()) + } } - } } var dfG: DecimalFormat = DecimalFormat("#G") @@ -308,13 +307,13 @@ var dfK: DecimalFormat = DecimalFormat("#k") var dfN: DecimalFormat = DecimalFormat("#") fun showAmountAxis(amount: BigDecimal?): String { - if (amount == null) return "" - if (amount.abs() < BigDecimal(0.01)) return "" + if (amount == null) return "" + if (amount.abs() < BigDecimal(0.01)) return "" - return when { - amount >= OneGiga -> dfG.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) - amount >= OneMega -> dfM.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) - amount >= TenKilo -> dfK.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) - else -> dfN.format(amount) - } + return when { + amount >= OneGiga -> dfG.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) + amount >= OneMega -> dfM.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) + amount >= TenKilo -> dfK.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) + else -> dfN.format(amount) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 47a7528dc..b1996662d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -173,315 +173,191 @@ import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.TelegramIdentity import com.vitorpamplona.quartz.events.TwitterIdentity import com.vitorpamplona.quartz.events.toImmutableListOfLists -import java.math.BigDecimal import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.math.BigDecimal @Composable fun ProfileScreen( - userId: String?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + userId: String?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (userId == null) return + if (userId == null) return - var userBase by remember { mutableStateOf(LocalCache.getUserIfExists(userId)) } + var userBase by remember { mutableStateOf(LocalCache.getUserIfExists(userId)) } - if (userBase == null) { - LaunchedEffect(userId) { - // waits to resolve. - launch(Dispatchers.IO) { - val newUserBase = LocalCache.checkGetOrCreateUser(userId) - if (newUserBase != userBase) { - userBase = newUserBase + if (userBase == null) { + LaunchedEffect(userId) { + // waits to resolve. + launch(Dispatchers.IO) { + val newUserBase = LocalCache.checkGetOrCreateUser(userId) + if (newUserBase != userBase) { + userBase = newUserBase + } + } } - } } - } - userBase?.let { - PrepareViewModels( - baseUser = it, - accountViewModel = accountViewModel, - nav = nav, - ) - } + userBase?.let { + PrepareViewModels( + baseUser = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } } @Composable fun PrepareViewModels( - baseUser: User, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseUser: User, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel = - viewModel( - key = baseUser.pubkeyHex + "UserProfileFollowsUserFeedViewModel", - factory = - NostrUserProfileFollowsUserFeedViewModel.Factory( - baseUser, - accountViewModel.account, - ), - ) + val followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserProfileFollowsUserFeedViewModel", + factory = + NostrUserProfileFollowsUserFeedViewModel.Factory( + baseUser, + accountViewModel.account, + ), + ) - val followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel = - viewModel( - key = baseUser.pubkeyHex + "UserProfileFollowersUserFeedViewModel", - factory = - NostrUserProfileFollowersUserFeedViewModel.Factory( - baseUser, - accountViewModel.account, - ), - ) + val followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserProfileFollowersUserFeedViewModel", + factory = + NostrUserProfileFollowersUserFeedViewModel.Factory( + baseUser, + accountViewModel.account, + ), + ) - val appRecommendations: NostrUserAppRecommendationsFeedViewModel = - viewModel( - key = baseUser.pubkeyHex + "UserAppRecommendationsFeedViewModel", - factory = - NostrUserAppRecommendationsFeedViewModel.Factory( - baseUser, - ), - ) + val appRecommendations: NostrUserAppRecommendationsFeedViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserAppRecommendationsFeedViewModel", + factory = + NostrUserAppRecommendationsFeedViewModel.Factory( + baseUser, + ), + ) - val zapFeedViewModel: NostrUserProfileZapsFeedViewModel = - viewModel( - key = baseUser.pubkeyHex + "UserProfileZapsFeedViewModel", - factory = - NostrUserProfileZapsFeedViewModel.Factory( - baseUser, - ), - ) + val zapFeedViewModel: NostrUserProfileZapsFeedViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserProfileZapsFeedViewModel", + factory = + NostrUserProfileZapsFeedViewModel.Factory( + baseUser, + ), + ) - val threadsViewModel: NostrUserProfileNewThreadsFeedViewModel = - viewModel( - key = baseUser.pubkeyHex + "UserProfileNewThreadsFeedViewModel", - factory = - NostrUserProfileNewThreadsFeedViewModel.Factory( - baseUser, - accountViewModel.account, - ), - ) + val threadsViewModel: NostrUserProfileNewThreadsFeedViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserProfileNewThreadsFeedViewModel", + factory = + NostrUserProfileNewThreadsFeedViewModel.Factory( + baseUser, + accountViewModel.account, + ), + ) - val repliesViewModel: NostrUserProfileConversationsFeedViewModel = - viewModel( - key = baseUser.pubkeyHex + "UserProfileConversationsFeedViewModel", - factory = - NostrUserProfileConversationsFeedViewModel.Factory( - baseUser, - accountViewModel.account, - ), - ) + val repliesViewModel: NostrUserProfileConversationsFeedViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserProfileConversationsFeedViewModel", + factory = + NostrUserProfileConversationsFeedViewModel.Factory( + baseUser, + accountViewModel.account, + ), + ) - val bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel = - viewModel( - key = baseUser.pubkeyHex + "UserProfileBookmarksFeedViewModel", - factory = - NostrUserProfileBookmarksFeedViewModel.Factory( - baseUser, - accountViewModel.account, - ), - ) + val bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserProfileBookmarksFeedViewModel", + factory = + NostrUserProfileBookmarksFeedViewModel.Factory( + baseUser, + accountViewModel.account, + ), + ) - val reportsFeedViewModel: NostrUserProfileReportFeedViewModel = - viewModel( - key = baseUser.pubkeyHex + "UserProfileReportFeedViewModel", - factory = - NostrUserProfileReportFeedViewModel.Factory( - baseUser, - ), - ) + val reportsFeedViewModel: NostrUserProfileReportFeedViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserProfileReportFeedViewModel", + factory = + NostrUserProfileReportFeedViewModel.Factory( + baseUser, + ), + ) - ProfileScreen( - baseUser = baseUser, - threadsViewModel, - repliesViewModel, - followsFeedViewModel, - followersFeedViewModel, - appRecommendations, - zapFeedViewModel, - bookmarksFeedViewModel, - reportsFeedViewModel, - accountViewModel = accountViewModel, - nav = nav, - ) + ProfileScreen( + baseUser = baseUser, + threadsViewModel, + repliesViewModel, + followsFeedViewModel, + followersFeedViewModel, + appRecommendations, + zapFeedViewModel, + bookmarksFeedViewModel, + reportsFeedViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) } @Composable fun ProfileScreen( - baseUser: User, - threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, - repliesViewModel: NostrUserProfileConversationsFeedViewModel, - followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, - followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, - appRecommendations: NostrUserAppRecommendationsFeedViewModel, - zapFeedViewModel: NostrUserProfileZapsFeedViewModel, - bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, - reportsFeedViewModel: NostrUserProfileReportFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseUser: User, + threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, + repliesViewModel: NostrUserProfileConversationsFeedViewModel, + followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, + followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, + appRecommendations: NostrUserAppRecommendationsFeedViewModel, + zapFeedViewModel: NostrUserProfileZapsFeedViewModel, + bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, + reportsFeedViewModel: NostrUserProfileReportFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - NostrUserProfileDataSource.loadUserProfile(baseUser) + NostrUserProfileDataSource.loadUserProfile(baseUser) - val lifeCycleOwner = LocalLifecycleOwner.current + val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(accountViewModel) { - NostrUserProfileDataSource.start() - onDispose { - NostrUserProfileDataSource.loadUserProfile(null) - NostrUserProfileDataSource.stop() - } - } - - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Profidle Start") - NostrUserProfileDataSource.loadUserProfile(baseUser) + DisposableEffect(accountViewModel) { NostrUserProfileDataSource.start() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Profile Stop") - NostrUserProfileDataSource.loadUserProfile(null) - NostrUserProfileDataSource.stop() - } + onDispose { + NostrUserProfileDataSource.loadUserProfile(null) + NostrUserProfileDataSource.stop() + } } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + DisposableEffect(lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Profidle Start") + NostrUserProfileDataSource.loadUserProfile(baseUser) + NostrUserProfileDataSource.start() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Profile Stop") + NostrUserProfileDataSource.loadUserProfile(null) + NostrUserProfileDataSource.stop() + } + } - RenderSurface( - baseUser, - threadsViewModel, - repliesViewModel, - appRecommendations, - followsFeedViewModel, - followersFeedViewModel, - zapFeedViewModel, - bookmarksFeedViewModel, - reportsFeedViewModel, - accountViewModel, - nav, - ) -} - -@Composable -private fun RenderSurface( - baseUser: User, - threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, - repliesViewModel: NostrUserProfileConversationsFeedViewModel, - appRecommendations: NostrUserAppRecommendationsFeedViewModel, - followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, - followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, - zapFeedViewModel: NostrUserProfileZapsFeedViewModel, - bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, - reportsFeedViewModel: NostrUserProfileReportFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.background, - ) { - var columnSize by remember { mutableStateOf(IntSize.Zero) } - var tabsSize by remember { mutableStateOf(IntSize.Zero) } - - Column( - modifier = Modifier.fillMaxSize().onSizeChanged { columnSize = it }, - ) { - val coroutineScope = rememberCoroutineScope() - val scrollState = rememberScrollState() - - val tabRowModifier = remember { Modifier.onSizeChanged { tabsSize = it } } - - val pagerModifier = - with(LocalDensity.current) { Modifier.height((columnSize.height - tabsSize.height).toDp()) } - - Box( - modifier = - remember { - Modifier.verticalScroll(scrollState) - .nestedScroll( - object : NestedScrollConnection { - override fun onPreScroll( - available: Offset, - source: NestedScrollSource, - ): Offset { - // When scrolling vertically, scroll the container first. - return if (available.y < 0 && scrollState.canScrollForward) { - coroutineScope.launch { scrollState.scrollBy(-available.y) } - Offset(0f, available.y) - } else { - Offset.Zero - } - } - }, - ) - .fillMaxHeight() - }, - ) { - RenderScreen( - baseUser, - tabRowModifier, - pagerModifier, - threadsViewModel, - repliesViewModel, - appRecommendations, - followsFeedViewModel, - followersFeedViewModel, - zapFeedViewModel, - bookmarksFeedViewModel, - reportsFeedViewModel, - accountViewModel, - nav, - ) - } + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } } - } -} -@Composable -@OptIn(ExperimentalFoundationApi::class) -private fun RenderScreen( - baseUser: User, - tabRowModifier: Modifier, - pagerModifier: Modifier, - threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, - repliesViewModel: NostrUserProfileConversationsFeedViewModel, - appRecommendations: NostrUserAppRecommendationsFeedViewModel, - followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, - followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, - zapFeedViewModel: NostrUserProfileZapsFeedViewModel, - bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, - reportsFeedViewModel: NostrUserProfileReportFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val pagerState = rememberPagerState { 9 } - - Column { - ProfileHeader(baseUser, appRecommendations, nav, accountViewModel) - ScrollableTabRow( - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground, - selectedTabIndex = pagerState.currentPage, - edgePadding = 8.dp, - modifier = tabRowModifier, - divider = { Divider(thickness = DividerThickness) }, - ) { - CreateAndRenderTabs(baseUser, pagerState) - } - HorizontalPager( - state = pagerState, - modifier = pagerModifier, - ) { page -> - CreateAndRenderPages( - page, + RenderSurface( baseUser, threadsViewModel, repliesViewModel, + appRecommendations, followsFeedViewModel, followersFeedViewModel, zapFeedViewModel, @@ -489,1377 +365,1507 @@ private fun RenderScreen( reportsFeedViewModel, accountViewModel, nav, - ) + ) +} + +@Composable +private fun RenderSurface( + baseUser: User, + threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, + repliesViewModel: NostrUserProfileConversationsFeedViewModel, + appRecommendations: NostrUserAppRecommendationsFeedViewModel, + followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, + followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, + zapFeedViewModel: NostrUserProfileZapsFeedViewModel, + bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, + reportsFeedViewModel: NostrUserProfileReportFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.background, + ) { + var columnSize by remember { mutableStateOf(IntSize.Zero) } + var tabsSize by remember { mutableStateOf(IntSize.Zero) } + + Column( + modifier = Modifier.fillMaxSize().onSizeChanged { columnSize = it }, + ) { + val coroutineScope = rememberCoroutineScope() + val scrollState = rememberScrollState() + + val tabRowModifier = remember { Modifier.onSizeChanged { tabsSize = it } } + + val pagerModifier = + with(LocalDensity.current) { Modifier.height((columnSize.height - tabsSize.height).toDp()) } + + Box( + modifier = + remember { + Modifier.verticalScroll(scrollState) + .nestedScroll( + object : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset { + // When scrolling vertically, scroll the container first. + return if (available.y < 0 && scrollState.canScrollForward) { + coroutineScope.launch { scrollState.scrollBy(-available.y) } + Offset(0f, available.y) + } else { + Offset.Zero + } + } + }, + ) + .fillMaxHeight() + }, + ) { + RenderScreen( + baseUser, + tabRowModifier, + pagerModifier, + threadsViewModel, + repliesViewModel, + appRecommendations, + followsFeedViewModel, + followersFeedViewModel, + zapFeedViewModel, + bookmarksFeedViewModel, + reportsFeedViewModel, + accountViewModel, + nav, + ) + } + } + } +} + +@Composable +@OptIn(ExperimentalFoundationApi::class) +private fun RenderScreen( + baseUser: User, + tabRowModifier: Modifier, + pagerModifier: Modifier, + threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, + repliesViewModel: NostrUserProfileConversationsFeedViewModel, + appRecommendations: NostrUserAppRecommendationsFeedViewModel, + followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, + followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, + zapFeedViewModel: NostrUserProfileZapsFeedViewModel, + bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, + reportsFeedViewModel: NostrUserProfileReportFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val pagerState = rememberPagerState { 9 } + + Column { + ProfileHeader(baseUser, appRecommendations, nav, accountViewModel) + ScrollableTabRow( + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + selectedTabIndex = pagerState.currentPage, + edgePadding = 8.dp, + modifier = tabRowModifier, + divider = { Divider(thickness = DividerThickness) }, + ) { + CreateAndRenderTabs(baseUser, pagerState) + } + HorizontalPager( + state = pagerState, + modifier = pagerModifier, + ) { page -> + CreateAndRenderPages( + page, + baseUser, + threadsViewModel, + repliesViewModel, + followsFeedViewModel, + followersFeedViewModel, + zapFeedViewModel, + bookmarksFeedViewModel, + reportsFeedViewModel, + accountViewModel, + nav, + ) + } } - } } @Composable private fun CreateAndRenderPages( - page: Int, - baseUser: User, - threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, - repliesViewModel: NostrUserProfileConversationsFeedViewModel, - followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, - followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, - zapFeedViewModel: NostrUserProfileZapsFeedViewModel, - bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, - reportsFeedViewModel: NostrUserProfileReportFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + page: Int, + baseUser: User, + threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, + repliesViewModel: NostrUserProfileConversationsFeedViewModel, + followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, + followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, + zapFeedViewModel: NostrUserProfileZapsFeedViewModel, + bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, + reportsFeedViewModel: NostrUserProfileReportFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - UpdateThreadsAndRepliesWhenBlockUnblock( - baseUser, - threadsViewModel, - repliesViewModel, - accountViewModel, - ) + UpdateThreadsAndRepliesWhenBlockUnblock( + baseUser, + threadsViewModel, + repliesViewModel, + accountViewModel, + ) - when (page) { - 0 -> TabNotesNewThreads(threadsViewModel, accountViewModel, nav) - 1 -> TabNotesConversations(repliesViewModel, accountViewModel, nav) - 2 -> TabFollows(baseUser, followsFeedViewModel, accountViewModel, nav) - 3 -> TabFollowers(baseUser, followersFeedViewModel, accountViewModel, nav) - 4 -> TabReceivedZaps(baseUser, zapFeedViewModel, accountViewModel, nav) - 5 -> TabBookmarks(bookmarksFeedViewModel, accountViewModel, nav) - 6 -> TabFollowedTags(baseUser, accountViewModel, nav) - 7 -> TabReports(baseUser, reportsFeedViewModel, accountViewModel, nav) - 8 -> TabRelays(baseUser, accountViewModel, nav) - } + when (page) { + 0 -> TabNotesNewThreads(threadsViewModel, accountViewModel, nav) + 1 -> TabNotesConversations(repliesViewModel, accountViewModel, nav) + 2 -> TabFollows(baseUser, followsFeedViewModel, accountViewModel, nav) + 3 -> TabFollowers(baseUser, followersFeedViewModel, accountViewModel, nav) + 4 -> TabReceivedZaps(baseUser, zapFeedViewModel, accountViewModel, nav) + 5 -> TabBookmarks(bookmarksFeedViewModel, accountViewModel, nav) + 6 -> TabFollowedTags(baseUser, accountViewModel, nav) + 7 -> TabReports(baseUser, reportsFeedViewModel, accountViewModel, nav) + 8 -> TabRelays(baseUser, accountViewModel, nav) + } } @Composable fun UpdateThreadsAndRepliesWhenBlockUnblock( - baseUser: User, - threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, - repliesViewModel: NostrUserProfileConversationsFeedViewModel, - accountViewModel: AccountViewModel, + baseUser: User, + threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, + repliesViewModel: NostrUserProfileConversationsFeedViewModel, + accountViewModel: AccountViewModel, ) { - val isHidden by - accountViewModel.account.liveHiddenUsers - .map { - it.hiddenUsers.contains(baseUser.pubkeyHex) || it.spammers.contains(baseUser.pubkeyHex) - } - .observeAsState(accountViewModel.account.isHidden(baseUser)) + val isHidden by + accountViewModel.account.liveHiddenUsers + .map { + it.hiddenUsers.contains(baseUser.pubkeyHex) || it.spammers.contains(baseUser.pubkeyHex) + } + .observeAsState(accountViewModel.account.isHidden(baseUser)) - LaunchedEffect(key1 = isHidden) { - threadsViewModel.invalidateData() - repliesViewModel.invalidateData() - } + LaunchedEffect(key1 = isHidden) { + threadsViewModel.invalidateData() + repliesViewModel.invalidateData() + } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun CreateAndRenderTabs( - baseUser: User, - pagerState: PagerState, + baseUser: User, + pagerState: PagerState, ) { - val coroutineScope = rememberCoroutineScope() + val coroutineScope = rememberCoroutineScope() - val tabs = - listOf<@Composable (() -> Unit)?>( - { Text(text = stringResource(R.string.notes)) }, - { Text(text = stringResource(R.string.replies)) }, - { FollowTabHeader(baseUser) }, - { FollowersTabHeader(baseUser) }, - { ZapTabHeader(baseUser) }, - { BookmarkTabHeader(baseUser) }, - { FollowedTagsTabHeader(baseUser) }, - { ReportsTabHeader(baseUser) }, - { RelaysTabHeader(baseUser) }, - ) + val tabs = + listOf<@Composable (() -> Unit)?>( + { Text(text = stringResource(R.string.notes)) }, + { Text(text = stringResource(R.string.replies)) }, + { FollowTabHeader(baseUser) }, + { FollowersTabHeader(baseUser) }, + { ZapTabHeader(baseUser) }, + { BookmarkTabHeader(baseUser) }, + { FollowedTagsTabHeader(baseUser) }, + { ReportsTabHeader(baseUser) }, + { RelaysTabHeader(baseUser) }, + ) - tabs.forEachIndexed { index, function -> - Tab( - selected = pagerState.currentPage == index, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, - text = function, - ) - } + tabs.forEachIndexed { index, function -> + Tab( + selected = pagerState.currentPage == index, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, + text = function, + ) + } } @Composable private fun RelaysTabHeader(baseUser: User) { - val userState by baseUser.live().relays.observeAsState() - val userRelaysBeingUsed = remember(userState) { userState?.user?.relaysBeingUsed?.size ?: "--" } + val userState by baseUser.live().relays.observeAsState() + val userRelaysBeingUsed = remember(userState) { userState?.user?.relaysBeingUsed?.size ?: "--" } - val userStateRelayInfo by baseUser.live().relayInfo.observeAsState() - val userRelays = - remember(userStateRelayInfo) { - userStateRelayInfo?.user?.latestContactList?.relays()?.size ?: "--" - } + val userStateRelayInfo by baseUser.live().relayInfo.observeAsState() + val userRelays = + remember(userStateRelayInfo) { + userStateRelayInfo?.user?.latestContactList?.relays()?.size ?: "--" + } - Text(text = "$userRelaysBeingUsed / $userRelays ${stringResource(R.string.relays)}") + Text(text = "$userRelaysBeingUsed / $userRelays ${stringResource(R.string.relays)}") } @Composable private fun ReportsTabHeader(baseUser: User) { - val userState by baseUser.live().reports.observeAsState() - var userReports by remember { mutableIntStateOf(0) } + val userState by baseUser.live().reports.observeAsState() + var userReports by remember { mutableIntStateOf(0) } - LaunchedEffect(key1 = userState) { - launch(Dispatchers.IO) { - val newSize = UserProfileReportsFeedFilter(baseUser).feed().size + LaunchedEffect(key1 = userState) { + launch(Dispatchers.IO) { + val newSize = UserProfileReportsFeedFilter(baseUser).feed().size - if (newSize != userReports) { - userReports = newSize - } + if (newSize != userReports) { + userReports = newSize + } + } } - } - Text(text = "$userReports ${stringResource(R.string.reports)}") + Text(text = "$userReports ${stringResource(R.string.reports)}") } @Composable private fun FollowedTagsTabHeader(baseUser: User) { - var usertags by remember { mutableIntStateOf(0) } + var usertags by remember { mutableIntStateOf(0) } - LaunchedEffect(key1 = baseUser) { - launch(Dispatchers.IO) { - val contactList = baseUser.latestContactList + LaunchedEffect(key1 = baseUser) { + launch(Dispatchers.IO) { + val contactList = baseUser.latestContactList - val newTags = (contactList?.verifiedFollowTagSet?.count() ?: 0) + val newTags = (contactList?.verifiedFollowTagSet?.count() ?: 0) - if (newTags != usertags) { - usertags = newTags - } + if (newTags != usertags) { + usertags = newTags + } + } } - } - Text(text = "$usertags ${stringResource(R.string.followed_tags)}") + Text(text = "$usertags ${stringResource(R.string.followed_tags)}") } @Composable private fun BookmarkTabHeader(baseUser: User) { - val userState by baseUser.live().bookmarks.observeAsState() + val userState by baseUser.live().bookmarks.observeAsState() - var userBookmarks by remember { mutableIntStateOf(0) } + var userBookmarks by remember { mutableIntStateOf(0) } - LaunchedEffect(key1 = userState) { - launch(Dispatchers.IO) { - val bookmarkList = userState?.user?.latestBookmarkList + LaunchedEffect(key1 = userState) { + launch(Dispatchers.IO) { + val bookmarkList = userState?.user?.latestBookmarkList - val newBookmarks = - (bookmarkList?.taggedEvents()?.count() - ?: 0) + (bookmarkList?.taggedAddresses()?.count() ?: 0) + val newBookmarks = + ( + bookmarkList?.taggedEvents()?.count() + ?: 0 + ) + (bookmarkList?.taggedAddresses()?.count() ?: 0) - if (newBookmarks != userBookmarks) { - userBookmarks = newBookmarks - } + if (newBookmarks != userBookmarks) { + userBookmarks = newBookmarks + } + } } - } - Text(text = "$userBookmarks ${stringResource(R.string.bookmarks)}") + Text(text = "$userBookmarks ${stringResource(R.string.bookmarks)}") } @Composable private fun ZapTabHeader(baseUser: User) { - val userState by baseUser.live().zaps.observeAsState() - var zapAmount by remember { mutableStateOf(null) } + val userState by baseUser.live().zaps.observeAsState() + var zapAmount by remember { mutableStateOf(null) } - LaunchedEffect(key1 = userState) { - launch(Dispatchers.Default) { - val tempAmount = baseUser.zappedAmount() - if (zapAmount != tempAmount) { - zapAmount = tempAmount - } + LaunchedEffect(key1 = userState) { + launch(Dispatchers.Default) { + val tempAmount = baseUser.zappedAmount() + if (zapAmount != tempAmount) { + zapAmount = tempAmount + } + } } - } - Text(text = "${showAmountAxis(zapAmount)} ${stringResource(id = R.string.zaps)}") + Text(text = "${showAmountAxis(zapAmount)} ${stringResource(id = R.string.zaps)}") } @Composable private fun FollowersTabHeader(baseUser: User) { - val userState by baseUser.live().followers.observeAsState() - var followerCount by remember { mutableStateOf("--") } + val userState by baseUser.live().followers.observeAsState() + var followerCount by remember { mutableStateOf("--") } - val text = stringResource(R.string.followers) + val text = stringResource(R.string.followers) - LaunchedEffect(key1 = userState) { - launch(Dispatchers.IO) { - val newFollower = (userState?.user?.transientFollowerCount()?.toString() ?: "--") + " " + text + LaunchedEffect(key1 = userState) { + launch(Dispatchers.IO) { + val newFollower = (userState?.user?.transientFollowerCount()?.toString() ?: "--") + " " + text - if (followerCount != newFollower) { - followerCount = newFollower - } + if (followerCount != newFollower) { + followerCount = newFollower + } + } } - } - Text(text = followerCount) + Text(text = followerCount) } @Composable private fun FollowTabHeader(baseUser: User) { - val userState by baseUser.live().follows.observeAsState() - var followCount by remember { mutableStateOf("--") } + val userState by baseUser.live().follows.observeAsState() + var followCount by remember { mutableStateOf("--") } - val text = stringResource(R.string.follows) + val text = stringResource(R.string.follows) - LaunchedEffect(key1 = userState) { - launch(Dispatchers.IO) { - val newFollow = (userState?.user?.transientFollowCount()?.toString() ?: "--") + " " + text + LaunchedEffect(key1 = userState) { + launch(Dispatchers.IO) { + val newFollow = (userState?.user?.transientFollowCount()?.toString() ?: "--") + " " + text - if (followCount != newFollow) { - followCount = newFollow - } + if (followCount != newFollow) { + followCount = newFollow + } + } } - } - Text(text = followCount) + Text(text = followCount) } @Composable private fun ProfileHeader( - baseUser: User, - appRecommendations: NostrUserAppRecommendationsFeedViewModel, - nav: (String) -> Unit, - accountViewModel: AccountViewModel, + baseUser: User, + appRecommendations: NostrUserAppRecommendationsFeedViewModel, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - var popupExpanded by remember { mutableStateOf(false) } - var zoomImageDialogOpen by remember { mutableStateOf(false) } + var popupExpanded by remember { mutableStateOf(false) } + var zoomImageDialogOpen by remember { mutableStateOf(false) } - Box { - DrawBanner(baseUser, accountViewModel) + Box { + DrawBanner(baseUser, accountViewModel) - Box( - modifier = Modifier.padding(horizontal = 10.dp).size(40.dp).align(Alignment.TopEnd), - ) { - Button( - modifier = Modifier.size(30.dp).align(Alignment.Center), - onClick = { popupExpanded = true }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.background, - ), - contentPadding = ZeroPadding, - ) { - Icon( - tint = MaterialTheme.colorScheme.placeholderText, - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more_options), - ) - - UserProfileDropDownMenu( - baseUser, - popupExpanded, - { popupExpanded = false }, - accountViewModel, - ) - } - } - - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp).padding(top = 75.dp), - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Bottom, - ) { - val clipboardManager = LocalClipboardManager.current - - ClickableUserPicture( - baseUser = baseUser, - accountViewModel = accountViewModel, - size = 100.dp, - modifier = - Modifier.border( - 3.dp, - MaterialTheme.colorScheme.background, - CircleShape, - ), - onClick = { - if (baseUser.profilePicture() != null) { - zoomImageDialogOpen = true - } - }, - onLongClick = { - it.info?.picture?.let { it1 -> - clipboardManager.setText( - AnnotatedString(it1), - ) - } - }, - ) - - Spacer(Modifier.weight(1f)) - - Row( - modifier = Modifier.height(Size35dp).padding(bottom = 3.dp), + Box( + modifier = Modifier.padding(horizontal = 10.dp).size(40.dp).align(Alignment.TopEnd), ) { - MessageButton(baseUser, accountViewModel, nav) + Button( + modifier = Modifier.size(30.dp).align(Alignment.Center), + onClick = { popupExpanded = true }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.background, + ), + contentPadding = ZeroPadding, + ) { + Icon( + tint = MaterialTheme.colorScheme.placeholderText, + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more_options), + ) - ProfileActions(baseUser, accountViewModel) + UserProfileDropDownMenu( + baseUser, + popupExpanded, + { popupExpanded = false }, + accountViewModel, + ) + } } - } - DrawAdditionalInfo(baseUser, appRecommendations, accountViewModel, nav) + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp).padding(top = 75.dp), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, + ) { + val clipboardManager = LocalClipboardManager.current - Divider(modifier = Modifier.padding(top = 6.dp)) + ClickableUserPicture( + baseUser = baseUser, + accountViewModel = accountViewModel, + size = 100.dp, + modifier = + Modifier.border( + 3.dp, + MaterialTheme.colorScheme.background, + CircleShape, + ), + onClick = { + if (baseUser.profilePicture() != null) { + zoomImageDialogOpen = true + } + }, + onLongClick = { + it.info?.picture?.let { it1 -> + clipboardManager.setText( + AnnotatedString(it1), + ) + } + }, + ) + + Spacer(Modifier.weight(1f)) + + Row( + modifier = Modifier.height(Size35dp).padding(bottom = 3.dp), + ) { + MessageButton(baseUser, accountViewModel, nav) + + ProfileActions(baseUser, accountViewModel) + } + } + + DrawAdditionalInfo(baseUser, appRecommendations, accountViewModel, nav) + + Divider(modifier = Modifier.padding(top = 6.dp)) + } } - } - val profilePic = baseUser.profilePicture() - if (zoomImageDialogOpen && profilePic != null) { - ZoomableImageDialog( - figureOutMimeType(profilePic), - onDismiss = { zoomImageDialogOpen = false }, - accountViewModel = accountViewModel, - ) - } + val profilePic = baseUser.profilePicture() + if (zoomImageDialogOpen && profilePic != null) { + ZoomableImageDialog( + figureOutMimeType(profilePic), + onDismiss = { zoomImageDialogOpen = false }, + accountViewModel = accountViewModel, + ) + } } @Composable private fun ProfileActions( - baseUser: User, - accountViewModel: AccountViewModel, + baseUser: User, + accountViewModel: AccountViewModel, ) { - val isMe by - remember(accountViewModel) { derivedStateOf { accountViewModel.userProfile() == baseUser } } + val isMe by + remember(accountViewModel) { derivedStateOf { accountViewModel.userProfile() == baseUser } } - if (isMe) { - EditButton(accountViewModel.account) - } - - WatchIsHiddenUser(baseUser, accountViewModel) { isHidden -> - if (isHidden) { - ShowUserButton { accountViewModel.showUser(baseUser.pubkeyHex) } - } else { - DisplayFollowUnfollowButton(baseUser, accountViewModel) + if (isMe) { + EditButton(accountViewModel.account) + } + + WatchIsHiddenUser(baseUser, accountViewModel) { isHidden -> + if (isHidden) { + ShowUserButton { accountViewModel.showUser(baseUser.pubkeyHex) } + } else { + DisplayFollowUnfollowButton(baseUser, accountViewModel) + } } - } } @Composable private fun DisplayFollowUnfollowButton( - baseUser: User, - accountViewModel: AccountViewModel, + baseUser: User, + accountViewModel: AccountViewModel, ) { - val isLoggedInFollowingUser by - accountViewModel.account - .userProfile() - .live() - .follows - .map { it.user.isFollowing(baseUser) } - .distinctUntilChanged() - .observeAsState(initial = accountViewModel.account.isFollowing(baseUser)) + val isLoggedInFollowingUser by + accountViewModel.account + .userProfile() + .live() + .follows + .map { it.user.isFollowing(baseUser) } + .distinctUntilChanged() + .observeAsState(initial = accountViewModel.account.isFollowing(baseUser)) - val isUserFollowingLoggedIn by - baseUser - .live() - .follows - .map { it.user.isFollowing(accountViewModel.account.userProfile()) } - .distinctUntilChanged() - .observeAsState(initial = baseUser.isFollowing(accountViewModel.account.userProfile())) + val isUserFollowingLoggedIn by + baseUser + .live() + .follows + .map { it.user.isFollowing(accountViewModel.account.userProfile()) } + .distinctUntilChanged() + .observeAsState(initial = baseUser.isFollowing(accountViewModel.account.userProfile())) - if (isLoggedInFollowingUser) { - UnfollowButton { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_unfollow, - ) - } else { - accountViewModel.unfollow(baseUser) - } - } - } else { - if (isUserFollowingLoggedIn) { - FollowButton(R.string.follow_back) { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_follow, - ) - } else { - accountViewModel.follow(baseUser) + if (isLoggedInFollowingUser) { + UnfollowButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_unfollow, + ) + } else { + accountViewModel.unfollow(baseUser) + } } - } } else { - FollowButton(R.string.follow) { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_follow, - ) + if (isUserFollowingLoggedIn) { + FollowButton(R.string.follow_back) { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_follow, + ) + } else { + accountViewModel.follow(baseUser) + } + } } else { - accountViewModel.follow(baseUser) + FollowButton(R.string.follow) { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_follow, + ) + } else { + accountViewModel.follow(baseUser) + } + } } - } } - } } @Composable fun WatchIsHiddenUser( - baseUser: User, - accountViewModel: AccountViewModel, - content: @Composable (Boolean) -> Unit, + baseUser: User, + accountViewModel: AccountViewModel, + content: @Composable (Boolean) -> Unit, ) { - val isHidden by - accountViewModel.account.liveHiddenUsers - .map { - it.hiddenUsers.contains(baseUser.pubkeyHex) || it.spammers.contains(baseUser.pubkeyHex) - } - .observeAsState(accountViewModel.account.isHidden(baseUser)) + val isHidden by + accountViewModel.account.liveHiddenUsers + .map { + it.hiddenUsers.contains(baseUser.pubkeyHex) || it.spammers.contains(baseUser.pubkeyHex) + } + .observeAsState(accountViewModel.account.isHidden(baseUser)) - content(isHidden) + content(isHidden) } fun getIdentityClaimIcon(identity: IdentityClaim): Int { - return when (identity) { - is TwitterIdentity -> R.drawable.twitter - is TelegramIdentity -> R.drawable.telegram - is MastodonIdentity -> R.drawable.mastodon - is GitHubIdentity -> R.drawable.github - else -> R.drawable.github - } + return when (identity) { + is TwitterIdentity -> R.drawable.twitter + is TelegramIdentity -> R.drawable.telegram + is MastodonIdentity -> R.drawable.mastodon + is GitHubIdentity -> R.drawable.github + else -> R.drawable.github + } } fun getIdentityClaimDescription(identity: IdentityClaim): Int { - return when (identity) { - is TwitterIdentity -> R.string.twitter - is TelegramIdentity -> R.string.telegram - is MastodonIdentity -> R.string.mastodon - is GitHubIdentity -> R.string.github - else -> R.drawable.github - } + return when (identity) { + is TwitterIdentity -> R.string.twitter + is TelegramIdentity -> R.string.telegram + is MastodonIdentity -> R.string.mastodon + is GitHubIdentity -> R.string.github + else -> R.drawable.github + } } @Composable private fun DrawAdditionalInfo( - baseUser: User, - appRecommendations: NostrUserAppRecommendationsFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseUser: User, + appRecommendations: NostrUserAppRecommendationsFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val userState by baseUser.live().metadata.observeAsState() - val user = remember(userState) { userState?.user } ?: return - val tags = - remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } + val userState by baseUser.live().metadata.observeAsState() + val user = remember(userState) { userState?.user } ?: return + val tags = + remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } - val uri = LocalUriHandler.current - val clipboardManager = LocalClipboardManager.current + val uri = LocalUriHandler.current + val clipboardManager = LocalClipboardManager.current - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } + val automaticallyShowProfilePicture = + remember { + accountViewModel.settings.showProfilePictures.value + } - user.toBestDisplayName().let { - Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 7.dp)) { - CreateTextWithEmoji( - text = it, - tags = tags, - fontWeight = FontWeight.Bold, - fontSize = 25.sp, - ) - } - } - - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = user.pubkeyDisplayHex(), - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp), - color = MaterialTheme.colorScheme.placeholderText, - ) - - IconButton( - modifier = Modifier.size(25.dp).padding(start = 5.dp), - onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())) }, - ) { - Icon( - imageVector = Icons.Default.ContentCopy, - null, - modifier = Modifier.size(15.dp), - tint = MaterialTheme.colorScheme.placeholderText, - ) + user.toBestDisplayName().let { + Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 7.dp)) { + CreateTextWithEmoji( + text = it, + tags = tags, + fontWeight = FontWeight.Bold, + fontSize = 25.sp, + ) + } } - var dialogOpen by remember { mutableStateOf(false) } - - if (dialogOpen) { - ShowQRDialog( - user, - automaticallyShowProfilePicture, - onScan = { - dialogOpen = false - nav(it) - }, - onClose = { dialogOpen = false }, - ) - } - - IconButton( - modifier = Modifier.size(25.dp), - onClick = { dialogOpen = true }, - ) { - Icon( - painter = painterResource(R.drawable.ic_qrcode), - null, - modifier = Modifier.size(15.dp), - tint = MaterialTheme.colorScheme.placeholderText, - ) - } - } - - DisplayBadges(baseUser, accountViewModel, nav) - - DisplayNip05ProfileStatus(user, accountViewModel) - - val website = user.info?.website - if (!website.isNullOrEmpty()) { Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - tint = MaterialTheme.colorScheme.placeholderText, - imageVector = Icons.Default.Link, - contentDescription = stringResource(R.string.website), - modifier = Modifier.size(16.dp), - ) + Text( + text = user.pubkeyDisplayHex(), + modifier = Modifier.padding(top = 1.dp, bottom = 1.dp), + color = MaterialTheme.colorScheme.placeholderText, + ) - ClickableText( - text = AnnotatedString(website.removePrefix("https://")), - onClick = { - website.let { - runCatching { - if (it.contains("://")) { - uri.openUri(it) - } else { - uri.openUri("http://$it") - } + IconButton( + modifier = Modifier.size(25.dp).padding(start = 5.dp), + onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())) }, + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + + var dialogOpen by remember { mutableStateOf(false) } + + if (dialogOpen) { + ShowQRDialog( + user, + automaticallyShowProfilePicture, + onScan = { + dialogOpen = false + nav(it) + }, + onClose = { dialogOpen = false }, + ) + } + + IconButton( + modifier = Modifier.size(25.dp), + onClick = { dialogOpen = true }, + ) { + Icon( + painter = painterResource(R.drawable.ic_qrcode), + null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + } + + DisplayBadges(baseUser, accountViewModel, nav) + + DisplayNip05ProfileStatus(user, accountViewModel) + + val website = user.info?.website + if (!website.isNullOrEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + tint = MaterialTheme.colorScheme.placeholderText, + imageVector = Icons.Default.Link, + contentDescription = stringResource(R.string.website), + modifier = Modifier.size(16.dp), + ) + + ClickableText( + text = AnnotatedString(website.removePrefix("https://")), + onClick = { + website.let { + runCatching { + if (it.contains("://")) { + uri.openUri(it) + } else { + uri.openUri("http://$it") + } + } + } + }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp), + ) + } + } + + val lud16 = remember(userState) { user.info?.lud16?.trim() ?: user.info?.lud06?.trim() } + val pubkeyHex = remember { baseUser.pubkeyHex } + DisplayLNAddress(lud16, pubkeyHex, accountViewModel, nav) + + val identities = user.info?.latestMetadata?.identityClaims() + if (!identities.isNullOrEmpty()) { + identities.forEach { identity: IdentityClaim -> + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + tint = Color.Unspecified, + painter = painterResource(id = getIdentityClaimIcon(identity)), + contentDescription = stringResource(getIdentityClaimDescription(identity)), + modifier = Modifier.size(16.dp), + ) + + ClickableText( + text = AnnotatedString(identity.identity), + onClick = { runCatching { uri.openUri(identity.toProofUrl()) } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp).weight(1f), + ) } - } - }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp), - ) + } } - } - val lud16 = remember(userState) { user.info?.lud16?.trim() ?: user.info?.lud06?.trim() } - val pubkeyHex = remember { baseUser.pubkeyHex } - DisplayLNAddress(lud16, pubkeyHex, accountViewModel, nav) + user.info?.about?.let { + Row( + modifier = Modifier.padding(top = 5.dp, bottom = 5.dp), + ) { + val defaultBackground = MaterialTheme.colorScheme.background + val background = remember { mutableStateOf(defaultBackground) } - val identities = user.info?.latestMetadata?.identityClaims() - if (!identities.isNullOrEmpty()) { - identities.forEach { identity: IdentityClaim -> - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - tint = Color.Unspecified, - painter = painterResource(id = getIdentityClaimIcon(identity)), - contentDescription = stringResource(getIdentityClaimDescription(identity)), - modifier = Modifier.size(16.dp), - ) - - ClickableText( - text = AnnotatedString(identity.identity), - onClick = { runCatching { uri.openUri(identity.toProofUrl()) } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp).weight(1f), - ) - } + TranslatableRichTextViewer( + content = it, + canPreview = false, + tags = EmptyTagList, + backgroundColor = background, + accountViewModel = accountViewModel, + nav = nav, + ) + } } - } - user.info?.about?.let { - Row( - modifier = Modifier.padding(top = 5.dp, bottom = 5.dp), - ) { - val defaultBackground = MaterialTheme.colorScheme.background - val background = remember { mutableStateOf(defaultBackground) } - - TranslatableRichTextViewer( - content = it, - canPreview = false, - tags = EmptyTagList, - backgroundColor = background, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - - DisplayAppRecommendations(appRecommendations, nav) + DisplayAppRecommendations(appRecommendations, nav) } @Composable fun DisplayLNAddress( - lud16: String?, - userHex: String, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + lud16: String?, + userHex: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - var zapExpanded by remember { mutableStateOf(false) } + val context = LocalContext.current + val scope = rememberCoroutineScope() + var zapExpanded by remember { mutableStateOf(false) } - var showErrorMessageDialog by remember { mutableStateOf(null) } + var showErrorMessageDialog by remember { mutableStateOf(null) } - if (showErrorMessageDialog != null) { - ErrorMessageDialog( - title = stringResource(id = R.string.error_dialog_zap_error), - textContent = showErrorMessageDialog ?: "", - onClickStartMessage = { - scope.launch(Dispatchers.IO) { - val route = routeToMessage(userHex, showErrorMessageDialog, accountViewModel) - nav(route) - } - }, - onDismiss = { showErrorMessageDialog = null }, - ) - } - - var showInfoMessageDialog by remember { mutableStateOf(null) } - if (showInfoMessageDialog != null) { - InformationDialog( - title = context.getString(R.string.payment_successful), - textContent = showInfoMessageDialog ?: "", - ) { - showInfoMessageDialog = null - } - } - - if (!lud16.isNullOrEmpty()) { - Row(verticalAlignment = Alignment.CenterVertically) { - LightningAddressIcon(modifier = Size16Modifier, tint = BitcoinOrange) - - ClickableText( - text = AnnotatedString(lud16), - onClick = { zapExpanded = !zapExpanded }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp).weight(1f), - ) - } - - if (zapExpanded) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 5.dp), - ) { - InvoiceRequestCard( - lud16, - userHex, - accountViewModel.account, - onSuccess = { - zapExpanded = false - // pay directly - if (accountViewModel.account.hasWalletConnectSetup()) { - accountViewModel.account.sendZapPaymentRequestFor(it, null) { response -> - if (response is PayInvoiceSuccessResponse) { - showInfoMessageDialog = context.getString(R.string.payment_successful) - } else if (response is PayInvoiceErrorResponse) { - showErrorMessageDialog = - response.error?.message - ?: response.error?.code?.toString() - ?: context.getString(R.string.error_parsing_error_message) + if (showErrorMessageDialog != null) { + ErrorMessageDialog( + title = stringResource(id = R.string.error_dialog_zap_error), + textContent = showErrorMessageDialog ?: "", + onClickStartMessage = { + scope.launch(Dispatchers.IO) { + val route = routeToMessage(userHex, showErrorMessageDialog, accountViewModel) + nav(route) } - } - } else { - payViaIntent(it, context) { showErrorMessageDialog = it } - } - }, - onClose = { zapExpanded = false }, - onError = { title, message -> accountViewModel.toast(title, message) }, + }, + onDismiss = { showErrorMessageDialog = null }, ) - } } - } + + var showInfoMessageDialog by remember { mutableStateOf(null) } + if (showInfoMessageDialog != null) { + InformationDialog( + title = context.getString(R.string.payment_successful), + textContent = showInfoMessageDialog ?: "", + ) { + showInfoMessageDialog = null + } + } + + if (!lud16.isNullOrEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + LightningAddressIcon(modifier = Size16Modifier, tint = BitcoinOrange) + + ClickableText( + text = AnnotatedString(lud16), + onClick = { zapExpanded = !zapExpanded }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp).weight(1f), + ) + } + + if (zapExpanded) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 5.dp), + ) { + InvoiceRequestCard( + lud16, + userHex, + accountViewModel.account, + onSuccess = { + zapExpanded = false + // pay directly + if (accountViewModel.account.hasWalletConnectSetup()) { + accountViewModel.account.sendZapPaymentRequestFor(it, null) { response -> + if (response is PayInvoiceSuccessResponse) { + showInfoMessageDialog = context.getString(R.string.payment_successful) + } else if (response is PayInvoiceErrorResponse) { + showErrorMessageDialog = + response.error?.message + ?: response.error?.code?.toString() + ?: context.getString(R.string.error_parsing_error_message) + } + } + } else { + payViaIntent(it, context) { showErrorMessageDialog = it } + } + }, + onClose = { zapExpanded = false }, + onError = { title, message -> accountViewModel.toast(title, message) }, + ) + } + } + } } @Composable @OptIn(ExperimentalLayoutApi::class) private fun DisplayAppRecommendations( - appRecommendations: NostrUserAppRecommendationsFeedViewModel, - nav: (String) -> Unit, + appRecommendations: NostrUserAppRecommendationsFeedViewModel, + nav: (String) -> Unit, ) { - val feedState by appRecommendations.feedContent.collectAsStateWithLifecycle() + val feedState by appRecommendations.feedContent.collectAsStateWithLifecycle() - LaunchedEffect(key1 = Unit) { appRecommendations.invalidateData() } + LaunchedEffect(key1 = Unit) { appRecommendations.invalidateData() } - Crossfade( - targetState = feedState, - animationSpec = tween(durationMillis = 100), - ) { state -> - when (state) { - is FeedState.Loaded -> { - Column { - Text(stringResource(id = R.string.recommended_apps)) + Crossfade( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + ) { state -> + when (state) { + is FeedState.Loaded -> { + Column { + Text(stringResource(id = R.string.recommended_apps)) - FlowRow( - verticalArrangement = Arrangement.Center, - modifier = Modifier.padding(vertical = 5.dp), - ) { - state.feed.value.forEach { app -> WatchApp(app, nav) } - } + FlowRow( + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(vertical = 5.dp), + ) { + state.feed.value.forEach { app -> WatchApp(app, nav) } + } + } + } + else -> {} } - } - else -> {} } - } } @Composable private fun WatchApp( - baseApp: Note, - nav: (String) -> Unit, + baseApp: Note, + nav: (String) -> Unit, ) { - val appState by baseApp.live().metadata.observeAsState() + val appState by baseApp.live().metadata.observeAsState() - var appLogo by remember(baseApp) { mutableStateOf(null) } + var appLogo by remember(baseApp) { mutableStateOf(null) } - LaunchedEffect(key1 = appState) { - launch(Dispatchers.Default) { - val newAppLogo = - (appState?.note?.event as? AppDefinitionEvent)?.appMetaData()?.picture?.ifBlank { null } - if (newAppLogo != appLogo) { - appLogo = newAppLogo - } + LaunchedEffect(key1 = appState) { + launch(Dispatchers.Default) { + val newAppLogo = + (appState?.note?.event as? AppDefinitionEvent)?.appMetaData()?.picture?.ifBlank { null } + if (newAppLogo != appLogo) { + appLogo = newAppLogo + } + } } - } - appLogo?.let { - Box( - remember { Modifier.size(Size35dp).clickable { nav("Note/${baseApp.idHex}") } }, - ) { - AsyncImage( - model = appLogo, - contentDescription = null, - modifier = remember { Modifier.size(Size35dp).clip(shape = CircleShape) }, - ) + appLogo?.let { + Box( + remember { Modifier.size(Size35dp).clickable { nav("Note/${baseApp.idHex}") } }, + ) { + AsyncImage( + model = appLogo, + contentDescription = null, + modifier = remember { Modifier.size(Size35dp).clip(shape = CircleShape) }, + ) + } } - } } @Composable private fun DisplayBadges( - baseUser: User, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseUser: User, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } + val automaticallyShowProfilePicture = + remember { + accountViewModel.settings.showProfilePictures.value + } - LoadAddressableNote( - aTag = - ATag( - BadgeProfilesEvent.KIND, - baseUser.pubkeyHex, - BadgeProfilesEvent.STANDARD_D_TAG, - null, - ), - accountViewModel, - ) { note -> - if (note != null) { - WatchAndRenderBadgeList(note, automaticallyShowProfilePicture, nav) + LoadAddressableNote( + aTag = + ATag( + BadgeProfilesEvent.KIND, + baseUser.pubkeyHex, + BadgeProfilesEvent.STANDARD_D_TAG, + null, + ), + accountViewModel, + ) { note -> + if (note != null) { + WatchAndRenderBadgeList(note, automaticallyShowProfilePicture, nav) + } } - } } @Composable private fun WatchAndRenderBadgeList( - note: AddressableNote, - loadProfilePicture: Boolean, - nav: (String) -> Unit, + note: AddressableNote, + loadProfilePicture: Boolean, + nav: (String) -> Unit, ) { - val badgeList by - note - .live() - .metadata - .map { (it.note.event as? BadgeProfilesEvent)?.badgeAwardEvents()?.toImmutableList() } - .distinctUntilChanged() - .observeAsState() + val badgeList by + note + .live() + .metadata + .map { (it.note.event as? BadgeProfilesEvent)?.badgeAwardEvents()?.toImmutableList() } + .distinctUntilChanged() + .observeAsState() - badgeList?.let { list -> RenderBadgeList(list, loadProfilePicture, nav) } + badgeList?.let { list -> RenderBadgeList(list, loadProfilePicture, nav) } } @Composable @OptIn(ExperimentalLayoutApi::class) private fun RenderBadgeList( - list: ImmutableList, - loadProfilePicture: Boolean, - nav: (String) -> Unit, + list: ImmutableList, + loadProfilePicture: Boolean, + nav: (String) -> Unit, ) { - FlowRow( - verticalArrangement = Arrangement.Center, - modifier = Modifier.padding(vertical = 5.dp), - ) { - list.forEach { badgeAwardEvent -> LoadAndRenderBadge(badgeAwardEvent, loadProfilePicture, nav) } - } + FlowRow( + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(vertical = 5.dp), + ) { + list.forEach { badgeAwardEvent -> LoadAndRenderBadge(badgeAwardEvent, loadProfilePicture, nav) } + } } @Composable private fun LoadAndRenderBadge( - badgeAwardEventHex: String, - loadProfilePicture: Boolean, - nav: (String) -> Unit, + badgeAwardEventHex: String, + loadProfilePicture: Boolean, + nav: (String) -> Unit, ) { - var baseNote by remember { mutableStateOf(LocalCache.getNoteIfExists(badgeAwardEventHex)) } + var baseNote by remember { mutableStateOf(LocalCache.getNoteIfExists(badgeAwardEventHex)) } - LaunchedEffect(key1 = badgeAwardEventHex) { - if (baseNote == null) { - launch(Dispatchers.IO) { baseNote = LocalCache.checkGetOrCreateNote(badgeAwardEventHex) } + LaunchedEffect(key1 = badgeAwardEventHex) { + if (baseNote == null) { + launch(Dispatchers.IO) { baseNote = LocalCache.checkGetOrCreateNote(badgeAwardEventHex) } + } } - } - baseNote?.let { ObserveAndRenderBadge(it, loadProfilePicture, nav) } + baseNote?.let { ObserveAndRenderBadge(it, loadProfilePicture, nav) } } @Composable private fun ObserveAndRenderBadge( - it: Note, - loadProfilePicture: Boolean, - nav: (String) -> Unit, + it: Note, + loadProfilePicture: Boolean, + nav: (String) -> Unit, ) { - val badgeAwardState by it.live().metadata.observeAsState() - val baseBadgeDefinition by - remember(badgeAwardState) { derivedStateOf { badgeAwardState?.note?.replyTo?.firstOrNull() } } + val badgeAwardState by it.live().metadata.observeAsState() + val baseBadgeDefinition by + remember(badgeAwardState) { derivedStateOf { badgeAwardState?.note?.replyTo?.firstOrNull() } } - baseBadgeDefinition?.let { BadgeThumb(it, loadProfilePicture, nav, Size35dp) } + baseBadgeDefinition?.let { BadgeThumb(it, loadProfilePicture, nav, Size35dp) } } @Composable fun BadgeThumb( - note: Note, - loadProfilePicture: Boolean, - nav: (String) -> Unit, - size: Dp, - pictureModifier: Modifier = Modifier, + note: Note, + loadProfilePicture: Boolean, + nav: (String) -> Unit, + size: Dp, + pictureModifier: Modifier = Modifier, ) { - BadgeThumb(note, loadProfilePicture, size, pictureModifier) { nav("Note/${note.idHex}") } + BadgeThumb(note, loadProfilePicture, size, pictureModifier) { nav("Note/${note.idHex}") } } @Composable fun BadgeThumb( - baseNote: Note, - loadProfilePicture: Boolean, - size: Dp, - pictureModifier: Modifier = Modifier, - onClick: ((String) -> Unit)? = null, + baseNote: Note, + loadProfilePicture: Boolean, + size: Dp, + pictureModifier: Modifier = Modifier, + onClick: ((String) -> Unit)? = null, ) { - Box( - remember { Modifier.width(size).height(size) }, - ) { - WatchAndRenderBadgeImage(baseNote, loadProfilePicture, size, pictureModifier, onClick) - } + Box( + remember { Modifier.width(size).height(size) }, + ) { + WatchAndRenderBadgeImage(baseNote, loadProfilePicture, size, pictureModifier, onClick) + } } @Composable private fun WatchAndRenderBadgeImage( - baseNote: Note, - loadProfilePicture: Boolean, - size: Dp, - pictureModifier: Modifier, - onClick: ((String) -> Unit)?, + baseNote: Note, + loadProfilePicture: Boolean, + size: Dp, + pictureModifier: Modifier, + onClick: ((String) -> Unit)?, ) { - val noteState by baseNote.live().metadata.observeAsState() - val eventId = remember(noteState) { noteState?.note?.idHex } ?: return - val image by - remember(noteState) { - derivedStateOf { - val event = noteState?.note?.event as? BadgeDefinitionEvent - event?.thumb()?.ifBlank { null } ?: event?.image()?.ifBlank { null } - } - } - - val bgColor = MaterialTheme.colorScheme.background - - if (image == null) { - RobohashAsyncImage( - robot = "authornotfound", - contentDescription = stringResource(R.string.unknown_author), - modifier = remember { pictureModifier.width(size).height(size).background(bgColor) }, - ) - } else { - RobohashFallbackAsyncImage( - robot = eventId, - model = image!!, - contentDescription = stringResource(id = R.string.profile_image), - modifier = - remember { - pictureModifier - .width(size) - .height(size) - .clip(shape = CircleShape) - .background(bgColor) - .run { - if (onClick != null) { - this.clickable(onClick = { onClick(eventId) }) - } else { - this - } + val noteState by baseNote.live().metadata.observeAsState() + val eventId = remember(noteState) { noteState?.note?.idHex } ?: return + val image by + remember(noteState) { + derivedStateOf { + val event = noteState?.note?.event as? BadgeDefinitionEvent + event?.thumb()?.ifBlank { null } ?: event?.image()?.ifBlank { null } } - }, - loadProfilePicture = loadProfilePicture, - ) - } + } + + val bgColor = MaterialTheme.colorScheme.background + + if (image == null) { + RobohashAsyncImage( + robot = "authornotfound", + contentDescription = stringResource(R.string.unknown_author), + modifier = remember { pictureModifier.width(size).height(size).background(bgColor) }, + ) + } else { + RobohashFallbackAsyncImage( + robot = eventId, + model = image!!, + contentDescription = stringResource(id = R.string.profile_image), + modifier = + remember { + pictureModifier + .width(size) + .height(size) + .clip(shape = CircleShape) + .background(bgColor) + .run { + if (onClick != null) { + this.clickable(onClick = { onClick(eventId) }) + } else { + this + } + } + }, + loadProfilePicture = loadProfilePicture, + ) + } } @OptIn(ExperimentalFoundationApi::class) @Composable fun DrawBanner( - baseUser: User, - accountViewModel: AccountViewModel, + baseUser: User, + accountViewModel: AccountViewModel, ) { - val userState by baseUser.live().metadata.observeAsState() - val banner = remember(userState) { userState?.user?.info?.banner } + val userState by baseUser.live().metadata.observeAsState() + val banner = remember(userState) { userState?.user?.info?.banner } - val clipboardManager = LocalClipboardManager.current - var zoomImageDialogOpen by remember { mutableStateOf(false) } + val clipboardManager = LocalClipboardManager.current + var zoomImageDialogOpen by remember { mutableStateOf(false) } - if (!banner.isNullOrBlank()) { - AsyncImage( - model = banner, - contentDescription = stringResource(id = R.string.profile_image), - contentScale = ContentScale.FillWidth, - modifier = - Modifier.fillMaxWidth() - .height(125.dp) - .combinedClickable( - onClick = { zoomImageDialogOpen = true }, - onLongClick = { clipboardManager.setText(AnnotatedString(banner)) }, - ), - ) + if (!banner.isNullOrBlank()) { + AsyncImage( + model = banner, + contentDescription = stringResource(id = R.string.profile_image), + contentScale = ContentScale.FillWidth, + modifier = + Modifier.fillMaxWidth() + .height(125.dp) + .combinedClickable( + onClick = { zoomImageDialogOpen = true }, + onLongClick = { clipboardManager.setText(AnnotatedString(banner)) }, + ), + ) - if (zoomImageDialogOpen) { - ZoomableImageDialog( - imageUrl = figureOutMimeType(banner), - onDismiss = { zoomImageDialogOpen = false }, - accountViewModel = accountViewModel, - ) + if (zoomImageDialogOpen) { + ZoomableImageDialog( + imageUrl = figureOutMimeType(banner), + onDismiss = { zoomImageDialogOpen = false }, + accountViewModel = accountViewModel, + ) + } + } else { + Image( + painter = painterResource(R.drawable.profile_banner), + contentDescription = stringResource(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth().height(125.dp), + ) } - } else { - Image( - painter = painterResource(R.drawable.profile_banner), - contentDescription = stringResource(id = R.string.profile_banner), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth().height(125.dp), - ) - } } @Composable fun TabNotesNewThreads( - feedViewModel: NostrUserProfileNewThreadsFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + feedViewModel: NostrUserProfileNewThreadsFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp), - ) { - RefresheableFeedView( - feedViewModel, - null, - enablePullRefresh = false, - accountViewModel = accountViewModel, - nav = nav, - ) + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp), + ) { + RefresheableFeedView( + feedViewModel, + null, + enablePullRefresh = false, + accountViewModel = accountViewModel, + nav = nav, + ) + } } - } } @Composable fun TabNotesConversations( - feedViewModel: NostrUserProfileConversationsFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + feedViewModel: NostrUserProfileConversationsFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp), - ) { - RefresheableFeedView( - feedViewModel, - null, - enablePullRefresh = false, - accountViewModel = accountViewModel, - nav = nav, - ) + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp), + ) { + RefresheableFeedView( + feedViewModel, + null, + enablePullRefresh = false, + accountViewModel = accountViewModel, + nav = nav, + ) + } } - } } @Composable fun TabFollowedTags( - baseUser: User, - account: AccountViewModel, - nav: (String) -> Unit, + baseUser: User, + account: AccountViewModel, + nav: (String) -> Unit, ) { - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp), - ) { - baseUser.latestContactList?.let { - it.unverifiedFollowTagSet().forEach { hashtag -> - HashtagHeader( - tag = hashtag, - account = account, - onClick = { nav("Hashtag/$hashtag") }, - ) + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp), + ) { + baseUser.latestContactList?.let { + it.unverifiedFollowTagSet().forEach { hashtag -> + HashtagHeader( + tag = hashtag, + account = account, + onClick = { nav("Hashtag/$hashtag") }, + ) + } + } } - } } - } } @Composable fun TabBookmarks( - feedViewModel: NostrUserProfileBookmarksFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + feedViewModel: NostrUserProfileBookmarksFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LaunchedEffect(Unit) { feedViewModel.invalidateData() } + LaunchedEffect(Unit) { feedViewModel.invalidateData() } - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp), - ) { - RefresheableFeedView( - feedViewModel, - null, - enablePullRefresh = false, - accountViewModel = accountViewModel, - nav = nav, - ) + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp), + ) { + RefresheableFeedView( + feedViewModel, + null, + enablePullRefresh = false, + accountViewModel = accountViewModel, + nav = nav, + ) + } } - } } @Composable fun TabFollows( - baseUser: User, - feedViewModel: UserFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseUser: User, + feedViewModel: UserFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - WatchFollowChanges(baseUser, feedViewModel) + WatchFollowChanges(baseUser, feedViewModel) - Column(Modifier.fillMaxHeight()) { - Column { - RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false) + Column(Modifier.fillMaxHeight()) { + Column { + RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false) + } } - } } @Composable fun TabFollowers( - baseUser: User, - feedViewModel: UserFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseUser: User, + feedViewModel: UserFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - WatchFollowerChanges(baseUser, feedViewModel) + WatchFollowerChanges(baseUser, feedViewModel) - Column(Modifier.fillMaxHeight()) { - Column { - RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false) + Column(Modifier.fillMaxHeight()) { + Column { + RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false) + } } - } } @Composable private fun WatchFollowChanges( - baseUser: User, - feedViewModel: UserFeedViewModel, + baseUser: User, + feedViewModel: UserFeedViewModel, ) { - val userState by baseUser.live().follows.observeAsState() + val userState by baseUser.live().follows.observeAsState() - LaunchedEffect(userState) { feedViewModel.invalidateData() } + LaunchedEffect(userState) { feedViewModel.invalidateData() } } @Composable private fun WatchFollowerChanges( - baseUser: User, - feedViewModel: UserFeedViewModel, + baseUser: User, + feedViewModel: UserFeedViewModel, ) { - val userState by baseUser.live().followers.observeAsState() + val userState by baseUser.live().followers.observeAsState() - LaunchedEffect(userState) { feedViewModel.invalidateData() } + LaunchedEffect(userState) { feedViewModel.invalidateData() } } @Composable fun TabReceivedZaps( - baseUser: User, - zapFeedViewModel: NostrUserProfileZapsFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseUser: User, + zapFeedViewModel: NostrUserProfileZapsFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - WatchZapsAndUpdateFeed(baseUser, zapFeedViewModel) + WatchZapsAndUpdateFeed(baseUser, zapFeedViewModel) - Column(Modifier.fillMaxHeight()) { - Column { LnZapFeedView(zapFeedViewModel, accountViewModel, nav) } - } + Column(Modifier.fillMaxHeight()) { + Column { LnZapFeedView(zapFeedViewModel, accountViewModel, nav) } + } } @Composable private fun WatchZapsAndUpdateFeed( - baseUser: User, - feedViewModel: NostrUserProfileZapsFeedViewModel, + baseUser: User, + feedViewModel: NostrUserProfileZapsFeedViewModel, ) { - val userState by baseUser.live().zaps.observeAsState() + val userState by baseUser.live().zaps.observeAsState() - LaunchedEffect(userState) { feedViewModel.invalidateData() } + LaunchedEffect(userState) { feedViewModel.invalidateData() } } @Composable fun TabReports( - baseUser: User, - feedViewModel: NostrUserProfileReportFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseUser: User, + feedViewModel: NostrUserProfileReportFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - WatchReportsAndUpdateFeed(baseUser, feedViewModel) + WatchReportsAndUpdateFeed(baseUser, feedViewModel) - Column(Modifier.fillMaxHeight()) { - Column { - RefresheableFeedView( - feedViewModel, - null, - enablePullRefresh = false, - accountViewModel = accountViewModel, - nav = nav, - ) + Column(Modifier.fillMaxHeight()) { + Column { + RefresheableFeedView( + feedViewModel, + null, + enablePullRefresh = false, + accountViewModel = accountViewModel, + nav = nav, + ) + } } - } } @Composable private fun WatchReportsAndUpdateFeed( - baseUser: User, - feedViewModel: NostrUserProfileReportFeedViewModel, + baseUser: User, + feedViewModel: NostrUserProfileReportFeedViewModel, ) { - val userState by baseUser.live().reports.observeAsState() - LaunchedEffect(userState) { feedViewModel.invalidateData() } + val userState by baseUser.live().reports.observeAsState() + LaunchedEffect(userState) { feedViewModel.invalidateData() } } @Composable fun TabRelays( - user: User, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + user: User, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val feedViewModel: RelayFeedViewModel = viewModel() + val feedViewModel: RelayFeedViewModel = viewModel() - val lifeCycleOwner = LocalLifecycleOwner.current + val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(user) { - feedViewModel.subscribeTo(user) - onDispose { feedViewModel.unsubscribeTo(user) } - } - - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Profile Relay Start") + DisposableEffect(user) { feedViewModel.subscribeTo(user) - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Profile Relay Stop") - feedViewModel.unsubscribeTo(user) - } + onDispose { feedViewModel.unsubscribeTo(user) } } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - println("Profile Relay Dispose") - feedViewModel.unsubscribeTo(user) - } - } + DisposableEffect(lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Profile Relay Start") + feedViewModel.subscribeTo(user) + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Profile Relay Stop") + feedViewModel.unsubscribeTo(user) + } + } - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp), - ) { - RelayFeedView(feedViewModel, accountViewModel, enablePullRefresh = false, nav = nav) + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { + lifeCycleOwner.lifecycle.removeObserver(observer) + println("Profile Relay Dispose") + feedViewModel.unsubscribeTo(user) + } + } + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp), + ) { + RelayFeedView(feedViewModel, accountViewModel, enablePullRefresh = false, nav = nav) + } } - } } @Composable private fun MessageButton( - user: User, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + user: User, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - Button( - modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), - onClick = { - scope.launch(Dispatchers.IO) { accountViewModel.createChatRoomFor(user) { nav("Room/$it") } } - }, - contentPadding = ZeroPadding, - ) { - Icon( - painter = painterResource(R.drawable.ic_dm), - stringResource(R.string.send_a_direct_message), - modifier = Modifier.size(20.dp), - tint = Color.White, - ) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), + onClick = { + scope.launch(Dispatchers.IO) { accountViewModel.createChatRoomFor(user) { nav("Room/$it") } } + }, + contentPadding = ZeroPadding, + ) { + Icon( + painter = painterResource(R.drawable.ic_dm), + stringResource(R.string.send_a_direct_message), + modifier = Modifier.size(20.dp), + tint = Color.White, + ) + } } @Composable private fun EditButton(account: Account) { - var wantsToEdit by remember { mutableStateOf(false) } + var wantsToEdit by remember { mutableStateOf(false) } - if (wantsToEdit) { - NewUserMetadataView({ wantsToEdit = false }, account) - } + if (wantsToEdit) { + NewUserMetadataView({ wantsToEdit = false }, account) + } - InnerEditButton { wantsToEdit = true } + InnerEditButton { wantsToEdit = true } } @Preview @Composable private fun InnerEditButtonPreview() { - InnerEditButton {} + InnerEditButton {} } @Composable private fun InnerEditButton(onClick: () -> Unit) { - Button( - modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), - onClick = onClick, - contentPadding = ZeroPadding, - ) { - Icon( - tint = Color.White, - imageVector = Icons.Default.EditNote, - contentDescription = stringResource(R.string.edits_the_user_s_metadata), - ) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), + onClick = onClick, + contentPadding = ZeroPadding, + ) { + Icon( + tint = Color.White, + imageVector = Icons.Default.EditNote, + contentDescription = stringResource(R.string.edits_the_user_s_metadata), + ) + } } @Composable fun UnfollowButton(onClick: () -> Unit) { - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = onClick, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - contentPadding = ButtonPadding, - ) { - Text(text = stringResource(R.string.unfollow), color = Color.White) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = onClick, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(R.string.unfollow), color = Color.White) + } } @Composable fun FollowButton( - text: Int = R.string.follow, - onClick: () -> Unit, + text: Int = R.string.follow, + onClick: () -> Unit, ) { - Button( - modifier = Modifier.padding(start = 3.dp), - onClick = onClick, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - contentPadding = ButtonPadding, - ) { - Text(text = stringResource(text), color = Color.White, textAlign = TextAlign.Center) - } + Button( + modifier = Modifier.padding(start = 3.dp), + onClick = onClick, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(text), color = Color.White, textAlign = TextAlign.Center) + } } @Composable fun ShowUserButton(onClick: () -> Unit) { - Button( - modifier = Modifier.padding(start = 3.dp), - onClick = onClick, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - contentPadding = ButtonPadding, - ) { - Text(text = stringResource(R.string.unblock), color = Color.White) - } + Button( + modifier = Modifier.padding(start = 3.dp), + onClick = onClick, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(R.string.unblock), color = Color.White) + } } @Composable fun UserProfileDropDownMenu( - user: User, - popupExpanded: Boolean, - onDismiss: () -> Unit, - accountViewModel: AccountViewModel, + user: User, + popupExpanded: Boolean, + onDismiss: () -> Unit, + accountViewModel: AccountViewModel, ) { - DropdownMenu( - expanded = popupExpanded, - onDismissRequest = onDismiss, - ) { - val clipboardManager = LocalClipboardManager.current + DropdownMenu( + expanded = popupExpanded, + onDismissRequest = onDismiss, + ) { + val clipboardManager = LocalClipboardManager.current - DropdownMenuItem( - text = { Text(stringResource(R.string.copy_user_id)) }, - onClick = { - clipboardManager.setText(AnnotatedString(user.pubkeyNpub())) - onDismiss() - }, - ) + DropdownMenuItem( + text = { Text(stringResource(R.string.copy_user_id)) }, + onClick = { + clipboardManager.setText(AnnotatedString(user.pubkeyNpub())) + onDismiss() + }, + ) - if (accountViewModel.userProfile() != user) { - Divider() - if (accountViewModel.account.isHidden(user)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.unblock_user)) }, - onClick = { - accountViewModel.show(user) - onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringResource(id = R.string.block_hide_user)) }, - onClick = { - accountViewModel.hide(user) - onDismiss() - }, - ) - } - Divider() - DropdownMenuItem( - text = { Text(stringResource(id = R.string.report_spam_scam)) }, - onClick = { - accountViewModel.report(user, ReportEvent.ReportType.SPAM) - onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.report_hateful_speech)) }, - onClick = { - accountViewModel.report(user, ReportEvent.ReportType.PROFANITY) - onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringResource(id = R.string.report_impersonation)) }, - onClick = { - accountViewModel.report(user, ReportEvent.ReportType.IMPERSONATION) - onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.report_nudity_porn)) }, - onClick = { - accountViewModel.report(user, ReportEvent.ReportType.NUDITY) - onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringResource(id = R.string.report_illegal_behaviour)) }, - onClick = { - accountViewModel.report(user, ReportEvent.ReportType.ILLEGAL) - onDismiss() - }, - ) + if (accountViewModel.userProfile() != user) { + Divider() + if (accountViewModel.account.isHidden(user)) { + DropdownMenuItem( + text = { Text(stringResource(R.string.unblock_user)) }, + onClick = { + accountViewModel.show(user) + onDismiss() + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.block_hide_user)) }, + onClick = { + accountViewModel.hide(user) + onDismiss() + }, + ) + } + Divider() + DropdownMenuItem( + text = { Text(stringResource(id = R.string.report_spam_scam)) }, + onClick = { + accountViewModel.report(user, ReportEvent.ReportType.SPAM) + onDismiss() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.report_hateful_speech)) }, + onClick = { + accountViewModel.report(user, ReportEvent.ReportType.PROFANITY) + onDismiss() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.report_impersonation)) }, + onClick = { + accountViewModel.report(user, ReportEvent.ReportType.IMPERSONATION) + onDismiss() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.report_nudity_porn)) }, + onClick = { + accountViewModel.report(user, ReportEvent.ReportType.NUDITY) + onDismiss() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.report_illegal_behaviour)) }, + onClick = { + accountViewModel.report(user, ReportEvent.ReportType.ILLEGAL) + onDismiss() + }, + ) + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt index 779d7b14d..41753ac08 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt @@ -68,143 +68,142 @@ import kotlinx.collections.immutable.toImmutableList @OptIn(ExperimentalMaterial3Api::class) @Composable fun ReportNoteDialog( - note: Note, - accountViewModel: AccountViewModel, - onDismiss: () -> Unit, + note: Note, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, ) { - val reportTypes = - listOf( - Pair(ReportEvent.ReportType.SPAM, stringResource(R.string.report_dialog_spam)), - Pair(ReportEvent.ReportType.PROFANITY, stringResource(R.string.report_dialog_profanity)), - Pair( - ReportEvent.ReportType.IMPERSONATION, - stringResource(R.string.report_dialog_impersonation), - ), - Pair(ReportEvent.ReportType.NUDITY, stringResource(R.string.report_dialog_nudity)), - Pair(ReportEvent.ReportType.ILLEGAL, stringResource(R.string.report_dialog_illegal)), - ) - - val reasonOptions = remember { reportTypes.map { TitleExplainer(it.second) }.toImmutableList() } - var additionalReason by remember { mutableStateOf("") } - var selectedReason by remember { mutableStateOf(-1) } - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Scaffold( - topBar = { - TopAppBar( - title = { Text(text = stringResource(id = R.string.report_dialog_title)) }, - navigationIcon = { IconButton(onClick = onDismiss) { ArrowBackIcon() } }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, + val reportTypes = + listOf( + Pair(ReportEvent.ReportType.SPAM, stringResource(R.string.report_dialog_spam)), + Pair(ReportEvent.ReportType.PROFANITY, stringResource(R.string.report_dialog_profanity)), + Pair( + ReportEvent.ReportType.IMPERSONATION, + stringResource(R.string.report_dialog_impersonation), ), + Pair(ReportEvent.ReportType.NUDITY, stringResource(R.string.report_dialog_nudity)), + Pair(ReportEvent.ReportType.ILLEGAL, stringResource(R.string.report_dialog_illegal)), ) - }, - ) { pad -> - Column( - modifier = - Modifier.padding(16.dp, pad.calculateTopPadding(), 16.dp, pad.calculateBottomPadding()), - verticalArrangement = Arrangement.SpaceAround, - ) { - SpacerH16() - SectionHeader(text = stringResource(id = R.string.block_only)) - SpacerH16() - Text( - text = stringResource(R.string.report_dialog_blocking_a_user), - ) - SpacerH16() - ActionButton( - text = stringResource(R.string.report_dialog_block_hide_user_btn), - icon = Icons.Default.Block, - onClick = { - note.author?.let { accountViewModel.hide(it) } - onDismiss() - }, - ) - SpacerH16() - Divider(color = MaterialTheme.colorScheme.onSurface, thickness = 0.25.dp) + val reasonOptions = remember { reportTypes.map { TitleExplainer(it.second) }.toImmutableList() } + var additionalReason by remember { mutableStateOf("") } + var selectedReason by remember { mutableStateOf(-1) } - SpacerH16() - SectionHeader(text = stringResource(R.string.report_dialog_report_btn)) - SpacerH16() - Text(stringResource(R.string.report_dialog_reminder_public)) - SpacerH16() - TextSpinner( - label = stringResource(R.string.report_dialog_select_reason_label), - placeholder = stringResource(R.string.report_dialog_select_reason_placeholder), - options = reasonOptions, - onSelect = { selectedReason = it }, - modifier = Modifier.fillMaxWidth(), - ) - SpacerH16() - OutlinedTextField( - value = additionalReason, - onValueChange = { additionalReason = it }, - placeholder = { - Text(text = stringResource(R.string.report_dialog_additional_reason_placeholder)) - }, - label = { Text(stringResource(R.string.report_dialog_additional_reason_label)) }, - modifier = Modifier.fillMaxWidth(), - ) - SpacerH16() + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = stringResource(id = R.string.report_dialog_title)) }, + navigationIcon = { IconButton(onClick = onDismiss) { ArrowBackIcon() } }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + ) { pad -> + Column( + modifier = + Modifier.padding(16.dp, pad.calculateTopPadding(), 16.dp, pad.calculateBottomPadding()), + verticalArrangement = Arrangement.SpaceAround, + ) { + SpacerH16() + SectionHeader(text = stringResource(id = R.string.block_only)) + SpacerH16() + Text( + text = stringResource(R.string.report_dialog_blocking_a_user), + ) + SpacerH16() + ActionButton( + text = stringResource(R.string.report_dialog_block_hide_user_btn), + icon = Icons.Default.Block, + onClick = { + note.author?.let { accountViewModel.hide(it) } + onDismiss() + }, + ) + SpacerH16() - ActionButton( - text = stringResource(R.string.report_dialog_post_report_btn), - icon = Icons.Default.Report, - enabled = selectedReason in 0..reportTypes.lastIndex, - onClick = { - accountViewModel.report( - note, - reportTypes[selectedReason].first, - additionalReason, - ) - note.author?.let { accountViewModel.hide(it) } - onDismiss() - }, - ) - } + Divider(color = MaterialTheme.colorScheme.onSurface, thickness = 0.25.dp) + + SpacerH16() + SectionHeader(text = stringResource(R.string.report_dialog_report_btn)) + SpacerH16() + Text(stringResource(R.string.report_dialog_reminder_public)) + SpacerH16() + TextSpinner( + label = stringResource(R.string.report_dialog_select_reason_label), + placeholder = stringResource(R.string.report_dialog_select_reason_placeholder), + options = reasonOptions, + onSelect = { selectedReason = it }, + modifier = Modifier.fillMaxWidth(), + ) + SpacerH16() + OutlinedTextField( + value = additionalReason, + onValueChange = { additionalReason = it }, + placeholder = { + Text(text = stringResource(R.string.report_dialog_additional_reason_placeholder)) + }, + label = { Text(stringResource(R.string.report_dialog_additional_reason_label)) }, + modifier = Modifier.fillMaxWidth(), + ) + SpacerH16() + + ActionButton( + text = stringResource(R.string.report_dialog_post_report_btn), + icon = Icons.Default.Report, + enabled = selectedReason in 0..reportTypes.lastIndex, + onClick = { + accountViewModel.report( + note, + reportTypes[selectedReason].first, + additionalReason, + ) + note.author?.let { accountViewModel.hide(it) } + onDismiss() + }, + ) + } + } } - } } @Composable private fun SpacerH16() = Spacer(modifier = Modifier.height(16.dp)) @Composable private fun SectionHeader(text: String) = - Text( - text = text, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 18.sp, - ) + Text( + text = text, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 18.sp, + ) @Composable private fun ActionButton( - text: String, - icon: ImageVector, - enabled: Boolean = true, - onClick: () -> Unit, -) = - Button( + text: String, + icon: ImageVector, + enabled: Boolean = true, + onClick: () -> Unit, +) = Button( onClick = onClick, enabled = enabled, colors = ButtonDefaults.buttonColors(containerColor = WarningColor), modifier = Modifier.fillMaxWidth(), - ) { +) { Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = Color.White, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = text, color = Color.White) + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = text, color = Color.White) } - } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt index e212f32d4..8a0df8ac4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt @@ -91,7 +91,6 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.findHashtags import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.channels.Channel as CoroutineChannel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest @@ -101,389 +100,390 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.channels.Channel as CoroutineChannel @Composable fun SearchScreen( - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val searchBarViewModel: SearchBarViewModel = - viewModel( - key = "SearchBarViewModel", - factory = - SearchBarViewModel.Factory( - accountViewModel.account, - ), - ) + val searchBarViewModel: SearchBarViewModel = + viewModel( + key = "SearchBarViewModel", + factory = + SearchBarViewModel.Factory( + accountViewModel.account, + ), + ) - SearchScreen(searchBarViewModel, accountViewModel, nav) + SearchScreen(searchBarViewModel, accountViewModel, nav) } @Composable fun SearchScreen( - searchBarViewModel: SearchBarViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + searchBarViewModel: SearchBarViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val lifeCycleOwner = LocalLifecycleOwner.current + val lifeCycleOwner = LocalLifecycleOwner.current - WatchAccountForSearchScreen(accountViewModel) + WatchAccountForSearchScreen(accountViewModel) - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Search Start") - NostrSearchEventOrUserDataSource.start() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Search Stop") - NostrSearchEventOrUserDataSource.clear() - NostrSearchEventOrUserDataSource.stop() - } + DisposableEffect(lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Search Start") + NostrSearchEventOrUserDataSource.start() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Search Stop") + NostrSearchEventOrUserDataSource.clear() + NostrSearchEventOrUserDataSource.stop() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + val listState = rememberLazyListState() - val listState = rememberLazyListState() - - Column(Modifier.fillMaxSize()) { - SearchBar(searchBarViewModel, listState) - DisplaySearchResults(searchBarViewModel, listState, nav, accountViewModel) - } + Column(Modifier.fillMaxSize()) { + SearchBar(searchBarViewModel, listState) + DisplaySearchResults(searchBarViewModel, listState, nav, accountViewModel) + } } @Composable fun WatchAccountForSearchScreen(accountViewModel: AccountViewModel) { - LaunchedEffect(accountViewModel) { - launch(Dispatchers.IO) { NostrSearchEventOrUserDataSource.start() } - } + LaunchedEffect(accountViewModel) { + launch(Dispatchers.IO) { NostrSearchEventOrUserDataSource.start() } + } } @Stable class SearchBarViewModel(val account: Account) : ViewModel() { - var searchValue by mutableStateOf("") + var searchValue by mutableStateOf("") - private var _searchResultsUsers = MutableStateFlow>(emptyList()) - private var _searchResultsNotes = MutableStateFlow>(emptyList()) - private var _searchResultsChannels = MutableStateFlow>(emptyList()) - private var _hashtagResults = MutableStateFlow>(emptyList()) + private var _searchResultsUsers = MutableStateFlow>(emptyList()) + private var _searchResultsNotes = MutableStateFlow>(emptyList()) + private var _searchResultsChannels = MutableStateFlow>(emptyList()) + private var _hashtagResults = MutableStateFlow>(emptyList()) - val searchResultsUsers = _searchResultsUsers.asStateFlow() - val searchResultsNotes = _searchResultsNotes.asStateFlow() - val searchResultsChannels = _searchResultsChannels.asStateFlow() - val hashtagResults = _hashtagResults.asStateFlow() + val searchResultsUsers = _searchResultsUsers.asStateFlow() + val searchResultsNotes = _searchResultsNotes.asStateFlow() + val searchResultsChannels = _searchResultsChannels.asStateFlow() + val hashtagResults = _hashtagResults.asStateFlow() - val isSearching by derivedStateOf { searchValue.isNotBlank() } + val isSearching by derivedStateOf { searchValue.isNotBlank() } - fun updateSearchValue(newValue: String) { - searchValue = newValue - } - - private suspend fun runSearch() { - if (searchValue.isBlank()) { - _hashtagResults.value = emptyList() - _searchResultsUsers.value = emptyList() - _searchResultsChannels.value = emptyList() - _searchResultsNotes.value = emptyList() - return + fun updateSearchValue(newValue: String) { + searchValue = newValue } - _hashtagResults.emit(findHashtags(searchValue)) - _searchResultsUsers.emit( - LocalCache.findUsersStartingWith(searchValue) - .sortedWith(compareBy({ account.isFollowing(it) }, { it.toBestDisplayName() })) - .reversed(), - ) - _searchResultsNotes.emit( - LocalCache.findNotesStartingWith(searchValue) - .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .reversed(), - ) - _searchResultsChannels.emit(LocalCache.findChannelsStartingWith(searchValue)) - } + private suspend fun runSearch() { + if (searchValue.isBlank()) { + _hashtagResults.value = emptyList() + _searchResultsUsers.value = emptyList() + _searchResultsChannels.value = emptyList() + _searchResultsNotes.value = emptyList() + return + } - fun clear() { - searchValue = "" - _searchResultsUsers.value = emptyList() - _searchResultsChannels.value = emptyList() - _searchResultsNotes.value = emptyList() - _searchResultsChannels.value = emptyList() - } - - private val bundler = BundledUpdate(250, Dispatchers.IO) - - fun invalidateData() { - bundler.invalidate { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - runSearch() + _hashtagResults.emit(findHashtags(searchValue)) + _searchResultsUsers.emit( + LocalCache.findUsersStartingWith(searchValue) + .sortedWith(compareBy({ account.isFollowing(it) }, { it.toBestDisplayName() })) + .reversed(), + ) + _searchResultsNotes.emit( + LocalCache.findNotesStartingWith(searchValue) + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + .reversed(), + ) + _searchResultsChannels.emit(LocalCache.findChannelsStartingWith(searchValue)) } - } - override fun onCleared() { - bundler.cancel() - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - super.onCleared() - } - - fun isSearchingFun() = searchValue.isNotBlank() - - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create( - modelClass: Class - ): SearchBarViewModel { - return SearchBarViewModel(account) as SearchBarViewModel + fun clear() { + searchValue = "" + _searchResultsUsers.value = emptyList() + _searchResultsChannels.value = emptyList() + _searchResultsNotes.value = emptyList() + _searchResultsChannels.value = emptyList() + } + + private val bundler = BundledUpdate(250, Dispatchers.IO) + + fun invalidateData() { + bundler.invalidate { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + runSearch() + } + } + + override fun onCleared() { + bundler.cancel() + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + super.onCleared() + } + + fun isSearchingFun() = searchValue.isNotBlank() + + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): SearchBarViewModel { + return SearchBarViewModel(account) as SearchBarViewModel + } } - } } @OptIn(FlowPreview::class) @Composable private fun SearchBar( - searchBarViewModel: SearchBarViewModel, - listState: LazyListState, + searchBarViewModel: SearchBarViewModel, + listState: LazyListState, ) { - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - // Create a channel for processing search queries. - val searchTextChanges = remember { CoroutineChannel(CoroutineChannel.CONFLATED) } + // Create a channel for processing search queries. + val searchTextChanges = remember { CoroutineChannel(CoroutineChannel.CONFLATED) } - LaunchedEffect(Unit) { - launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { - checkNotInMainThread() + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { + checkNotInMainThread() - if (searchBarViewModel.isSearchingFun()) { - searchBarViewModel.invalidateData() - } - } - } - } - - LaunchedEffect(Unit) { - // Wait for text changes to stop for 300 ms before firing off search. - withContext(Dispatchers.IO) { - searchTextChanges - .receiveAsFlow() - .filter { it.isNotBlank() } - .distinctUntilChanged() - .debounce(300) - .collectLatest { - if (it.length >= 2) { - NostrSearchEventOrUserDataSource.search(it.trim()) - } - - searchBarViewModel.invalidateData() - - // makes sure to show the top of the search - launch(Dispatchers.Main) { listState.animateScrollToItem(0) } + if (searchBarViewModel.isSearchingFun()) { + searchBarViewModel.invalidateData() + } + } } } - } - DisposableEffect(Unit) { onDispose { NostrSearchEventOrUserDataSource.clear() } } + LaunchedEffect(Unit) { + // Wait for text changes to stop for 300 ms before firing off search. + withContext(Dispatchers.IO) { + searchTextChanges + .receiveAsFlow() + .filter { it.isNotBlank() } + .distinctUntilChanged() + .debounce(300) + .collectLatest { + if (it.length >= 2) { + NostrSearchEventOrUserDataSource.search(it.trim()) + } - // LAST ROW - SearchTextField(searchBarViewModel) { - scope.launch(Dispatchers.IO) { searchTextChanges.trySend(it) } - } + searchBarViewModel.invalidateData() + + // makes sure to show the top of the search + launch(Dispatchers.Main) { listState.animateScrollToItem(0) } + } + } + } + + DisposableEffect(Unit) { onDispose { NostrSearchEventOrUserDataSource.clear() } } + + // LAST ROW + SearchTextField(searchBarViewModel) { + scope.launch(Dispatchers.IO) { searchTextChanges.trySend(it) } + } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun SearchTextField( - searchBarViewModel: SearchBarViewModel, - onTextChanges: (String) -> Unit, + searchBarViewModel: SearchBarViewModel, + onTextChanges: (String) -> Unit, ) { - Row( - modifier = Modifier.padding(10.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - TextField( - value = searchBarViewModel.searchValue, - onValueChange = { - searchBarViewModel.updateSearchValue(it) - onTextChanges(it) - }, - shape = RoundedCornerShape(25.dp), - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - leadingIcon = { SearchIcon(modifier = Size20Modifier, Color.Unspecified) }, - modifier = Modifier.weight(1f, true).defaultMinSize(minHeight = 20.dp), - placeholder = { - Text( - text = stringResource(R.string.npub_hex_username), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - trailingIcon = { - if (searchBarViewModel.isSearching) { - IconButton( - onClick = { - searchBarViewModel.clear() - NostrSearchEventOrUserDataSource.clear() + Row( + modifier = Modifier.padding(10.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TextField( + value = searchBarViewModel.searchValue, + onValueChange = { + searchBarViewModel.updateSearchValue(it) + onTextChanges(it) }, - ) { - ClearTextIcon() - } - } - }, - singleLine = true, - colors = - TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), - ) - } + shape = RoundedCornerShape(25.dp), + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + leadingIcon = { SearchIcon(modifier = Size20Modifier, Color.Unspecified) }, + modifier = Modifier.weight(1f, true).defaultMinSize(minHeight = 20.dp), + placeholder = { + Text( + text = stringResource(R.string.npub_hex_username), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + if (searchBarViewModel.isSearching) { + IconButton( + onClick = { + searchBarViewModel.clear() + NostrSearchEventOrUserDataSource.clear() + }, + ) { + ClearTextIcon() + } + } + }, + singleLine = true, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + } } @Composable private fun DisplaySearchResults( - searchBarViewModel: SearchBarViewModel, - listState: LazyListState, - nav: (String) -> Unit, - accountViewModel: AccountViewModel, + searchBarViewModel: SearchBarViewModel, + listState: LazyListState, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - if (!searchBarViewModel.isSearching) { - return - } - - val hashTags by searchBarViewModel.hashtagResults.collectAsStateWithLifecycle() - val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle() - val channels by searchBarViewModel.searchResultsChannels.collectAsStateWithLifecycle() - val notes by searchBarViewModel.searchResultsNotes.collectAsStateWithLifecycle() - - val hasNewMessages = remember { mutableStateOf(false) } - - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } - - LazyColumn( - modifier = Modifier.fillMaxHeight(), - contentPadding = FeedPadding, - state = listState, - ) { - itemsIndexed( - hashTags, - key = { _, item -> "#$item" }, - ) { _, item -> - HashtagLine(item) { nav("Hashtag/$item") } + if (!searchBarViewModel.isSearching) { + return } - itemsIndexed( - users, - key = { _, item -> "u" + item.pubkeyHex }, - ) { _, item -> - UserCompose(item, accountViewModel = accountViewModel, nav = nav) - } + val hashTags by searchBarViewModel.hashtagResults.collectAsStateWithLifecycle() + val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle() + val channels by searchBarViewModel.searchResultsChannels.collectAsStateWithLifecycle() + val notes by searchBarViewModel.searchResultsNotes.collectAsStateWithLifecycle() - itemsIndexed( - channels, - key = { _, item -> "c" + item.idHex }, - ) { _, item -> - ChannelName( - channelIdHex = item.idHex, - channelPicture = item.profilePicture(), - channelTitle = { - Text( - item.toBestDisplayName(), - fontWeight = FontWeight.Bold, - ) - }, - channelLastTime = null, - channelLastContent = item.summary(), - hasNewMessages = hasNewMessages, - loadProfilePicture = automaticallyShowProfilePicture, - onClick = { nav("Channel/${item.idHex}") }, - ) - } + val hasNewMessages = remember { mutableStateOf(false) } - itemsIndexed( - notes, - key = { _, item -> "n" + item.idHex }, - ) { _, item -> - NoteCompose( - item, - accountViewModel = accountViewModel, - nav = nav, - ) + val automaticallyShowProfilePicture = + remember { + accountViewModel.settings.showProfilePictures.value + } + + LazyColumn( + modifier = Modifier.fillMaxHeight(), + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed( + hashTags, + key = { _, item -> "#$item" }, + ) { _, item -> + HashtagLine(item) { nav("Hashtag/$item") } + } + + itemsIndexed( + users, + key = { _, item -> "u" + item.pubkeyHex }, + ) { _, item -> + UserCompose(item, accountViewModel = accountViewModel, nav = nav) + } + + itemsIndexed( + channels, + key = { _, item -> "c" + item.idHex }, + ) { _, item -> + ChannelName( + channelIdHex = item.idHex, + channelPicture = item.profilePicture(), + channelTitle = { + Text( + item.toBestDisplayName(), + fontWeight = FontWeight.Bold, + ) + }, + channelLastTime = null, + channelLastContent = item.summary(), + hasNewMessages = hasNewMessages, + loadProfilePicture = automaticallyShowProfilePicture, + onClick = { nav("Channel/${item.idHex}") }, + ) + } + + itemsIndexed( + notes, + key = { _, item -> "n" + item.idHex }, + ) { _, item -> + NoteCompose( + item, + accountViewModel = accountViewModel, + nav = nav, + ) + } } - } } @Composable fun HashtagLine( - tag: String, - onClick: () -> Unit, + tag: String, + onClick: () -> Unit, ) { - Column( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), - ) { - Row( - modifier = - Modifier.padding( - start = 12.dp, - end = 12.dp, - top = 10.dp, - ), + Column( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - "Search hashtag: #$tag", - fontWeight = FontWeight.Bold, - ) - } - } + Row( + modifier = + Modifier.padding( + start = 12.dp, + end = 12.dp, + top = 10.dp, + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + "Search hashtag: #$tag", + fontWeight = FontWeight.Bold, + ) + } + } - Divider( - modifier = Modifier.padding(top = 10.dp), - thickness = DividerThickness, - ) - } + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = DividerThickness, + ) + } } @Composable fun UserLine( - baseUser: User, - accountViewModel: AccountViewModel, - onClick: () -> Unit, + baseUser: User, + accountViewModel: AccountViewModel, + onClick: () -> Unit, ) { - Column( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), - ) { - Row( - modifier = - Modifier.padding( - start = 12.dp, - end = 12.dp, - top = 10.dp, - ), + Column( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), ) { - ClickableUserPicture(baseUser, 55.dp, accountViewModel, Modifier, null) + Row( + modifier = + Modifier.padding( + start = 12.dp, + end = 12.dp, + top = 10.dp, + ), + ) { + ClickableUserPicture(baseUser, 55.dp, accountViewModel, Modifier, null) - Column( - modifier = Modifier.padding(start = 10.dp).weight(1f), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser) } + Column( + modifier = Modifier.padding(start = 10.dp).weight(1f), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser) } - AboutDisplay(baseUser) - } + AboutDisplay(baseUser) + } + } + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = DividerThickness, + ) } - - Divider( - modifier = Modifier.padding(top = 10.dp), - thickness = DividerThickness, - ) - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SettingsScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SettingsScreen.kt index 3631e3f15..7b937590e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SettingsScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SettingsScreen.kt @@ -58,7 +58,6 @@ import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size20dp -import java.io.IOException import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentListOf @@ -66,219 +65,220 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParserException +import java.io.IOException fun Context.getLocaleListFromXml(): LocaleListCompat { - val tagsList = mutableListOf() - try { - val xpp: XmlPullParser = resources.getXml(R.xml.locales_config) - while (xpp.eventType != XmlPullParser.END_DOCUMENT) { - if (xpp.eventType == XmlPullParser.START_TAG) { - if (xpp.name == "locale") { - tagsList.add(xpp.getAttributeValue(0)) + val tagsList = mutableListOf() + try { + val xpp: XmlPullParser = resources.getXml(R.xml.locales_config) + while (xpp.eventType != XmlPullParser.END_DOCUMENT) { + if (xpp.eventType == XmlPullParser.START_TAG) { + if (xpp.name == "locale") { + tagsList.add(xpp.getAttributeValue(0)) + } + } + xpp.next() } - } - xpp.next() + } catch (e: XmlPullParserException) { + e.printStackTrace() + } catch (e: IOException) { + e.printStackTrace() } - } catch (e: XmlPullParserException) { - e.printStackTrace() - } catch (e: IOException) { - e.printStackTrace() - } - return LocaleListCompat.forLanguageTags(tagsList.joinToString(",")) + return LocaleListCompat.forLanguageTags(tagsList.joinToString(",")) } fun Context.getLangPreferenceDropdownEntries(): ImmutableMap { - val localeList = getLocaleListFromXml() - val map = mutableMapOf() + val localeList = getLocaleListFromXml() + val map = mutableMapOf() - for (a in 0 until localeList.size()) { - localeList[a].let { - map.put( - it!!.getDisplayName(it).replaceFirstChar { char -> char.uppercase() }, - it.toLanguageTag(), - ) + for (a in 0 until localeList.size()) { + localeList[a].let { + map.put( + it!!.getDisplayName(it).replaceFirstChar { char -> char.uppercase() }, + it.toLanguageTag(), + ) + } } - } - return map.toImmutableMap() + return map.toImmutableMap() } fun getLanguageIndex( - languageEntries: ImmutableMap, - sharedPreferencesViewModel: SharedPreferencesViewModel, + languageEntries: ImmutableMap, + sharedPreferencesViewModel: SharedPreferencesViewModel, ): Int { - val language = sharedPreferencesViewModel.sharedPrefs.language - var languageIndex = -1 - if (language != null) { - languageIndex = languageEntries.values.toTypedArray().indexOf(language) - } else { - languageIndex = languageEntries.values.toTypedArray().indexOf(Locale.current.toLanguageTag()) - } - if (languageIndex == -1) { - languageIndex = languageEntries.values.toTypedArray().indexOf(Locale.current.language) - } - if (languageIndex == -1) languageIndex = languageEntries.values.toTypedArray().indexOf("en") - return languageIndex + val language = sharedPreferencesViewModel.sharedPrefs.language + var languageIndex = -1 + if (language != null) { + languageIndex = languageEntries.values.toTypedArray().indexOf(language) + } else { + languageIndex = languageEntries.values.toTypedArray().indexOf(Locale.current.toLanguageTag()) + } + if (languageIndex == -1) { + languageIndex = languageEntries.values.toTypedArray().indexOf(Locale.current.language) + } + if (languageIndex == -1) languageIndex = languageEntries.values.toTypedArray().indexOf("en") + return languageIndex } @Composable fun SettingsScreen(sharedPreferencesViewModel: SharedPreferencesViewModel) { - val selectedItens = - persistentListOf( - TitleExplainer(stringResource(ConnectivityType.ALWAYS.resourceId)), - TitleExplainer(stringResource(ConnectivityType.WIFI_ONLY.resourceId)), - TitleExplainer(stringResource(ConnectivityType.NEVER.resourceId)), - ) + val selectedItens = + persistentListOf( + TitleExplainer(stringResource(ConnectivityType.ALWAYS.resourceId)), + TitleExplainer(stringResource(ConnectivityType.WIFI_ONLY.resourceId)), + TitleExplainer(stringResource(ConnectivityType.NEVER.resourceId)), + ) - val themeItens = - persistentListOf( - TitleExplainer(stringResource(ThemeType.SYSTEM.resourceId)), - TitleExplainer(stringResource(ThemeType.LIGHT.resourceId)), - TitleExplainer(stringResource(ThemeType.DARK.resourceId)), - ) + val themeItens = + persistentListOf( + TitleExplainer(stringResource(ThemeType.SYSTEM.resourceId)), + TitleExplainer(stringResource(ThemeType.LIGHT.resourceId)), + TitleExplainer(stringResource(ThemeType.DARK.resourceId)), + ) - val booleanItems = - persistentListOf( - TitleExplainer(stringResource(ConnectivityType.ALWAYS.resourceId)), - TitleExplainer(stringResource(ConnectivityType.NEVER.resourceId)), - ) + val booleanItems = + persistentListOf( + TitleExplainer(stringResource(ConnectivityType.ALWAYS.resourceId)), + TitleExplainer(stringResource(ConnectivityType.NEVER.resourceId)), + ) - val showImagesIndex = sharedPreferencesViewModel.sharedPrefs.automaticallyShowImages.screenCode - val videoIndex = sharedPreferencesViewModel.sharedPrefs.automaticallyStartPlayback.screenCode - val linkIndex = sharedPreferencesViewModel.sharedPrefs.automaticallyShowUrlPreview.screenCode - val hideNavBarsIndex = - sharedPreferencesViewModel.sharedPrefs.automaticallyHideNavigationBars.screenCode - val profilePictureIndex = - sharedPreferencesViewModel.sharedPrefs.automaticallyShowProfilePictures.screenCode - val themeIndex = sharedPreferencesViewModel.sharedPrefs.theme.screenCode + val showImagesIndex = sharedPreferencesViewModel.sharedPrefs.automaticallyShowImages.screenCode + val videoIndex = sharedPreferencesViewModel.sharedPrefs.automaticallyStartPlayback.screenCode + val linkIndex = sharedPreferencesViewModel.sharedPrefs.automaticallyShowUrlPreview.screenCode + val hideNavBarsIndex = + sharedPreferencesViewModel.sharedPrefs.automaticallyHideNavigationBars.screenCode + val profilePictureIndex = + sharedPreferencesViewModel.sharedPrefs.automaticallyShowProfilePictures.screenCode + val themeIndex = sharedPreferencesViewModel.sharedPrefs.theme.screenCode - val context = LocalContext.current + val context = LocalContext.current - val languageEntries = remember { context.getLangPreferenceDropdownEntries() } - val languageList = remember { languageEntries.keys.map { TitleExplainer(it) }.toImmutableList() } - val languageIndex = getLanguageIndex(languageEntries, sharedPreferencesViewModel) + val languageEntries = remember { context.getLangPreferenceDropdownEntries() } + val languageList = remember { languageEntries.keys.map { TitleExplainer(it) }.toImmutableList() } + val languageIndex = getLanguageIndex(languageEntries, sharedPreferencesViewModel) - Column( - Modifier.fillMaxSize() - .padding(top = Size10dp, start = Size20dp, end = Size20dp) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - SettingsRow( - R.string.language, - R.string.language_description, - languageList, - languageIndex, + Column( + Modifier.fillMaxSize() + .padding(top = Size10dp, start = Size20dp, end = Size20dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, ) { - sharedPreferencesViewModel.updateLanguage(languageEntries[languageList[it].title]) + SettingsRow( + R.string.language, + R.string.language_description, + languageList, + languageIndex, + ) { + sharedPreferencesViewModel.updateLanguage(languageEntries[languageList[it].title]) + } + + Spacer(modifier = HalfVertSpacer) + + SettingsRow( + R.string.theme, + R.string.theme_description, + themeItens, + themeIndex, + ) { + sharedPreferencesViewModel.updateTheme(parseThemeType(it)) + } + + Spacer(modifier = HalfVertSpacer) + + SettingsRow( + R.string.automatically_load_images_gifs, + R.string.automatically_load_images_gifs_description, + selectedItens, + showImagesIndex, + ) { + sharedPreferencesViewModel.updateAutomaticallyShowImages(parseConnectivityType(it)) + } + + Spacer(modifier = HalfVertSpacer) + + SettingsRow( + R.string.automatically_play_videos, + R.string.automatically_play_videos_description, + selectedItens, + videoIndex, + ) { + sharedPreferencesViewModel.updateAutomaticallyStartPlayback(parseConnectivityType(it)) + } + + Spacer(modifier = HalfVertSpacer) + + SettingsRow( + R.string.automatically_show_url_preview, + R.string.automatically_show_url_preview_description, + selectedItens, + linkIndex, + ) { + sharedPreferencesViewModel.updateAutomaticallyShowUrlPreview(parseConnectivityType(it)) + } + + SettingsRow( + R.string.automatically_show_profile_picture, + R.string.automatically_show_profile_picture_description, + selectedItens, + profilePictureIndex, + ) { + sharedPreferencesViewModel.updateAutomaticallyShowProfilePicture(parseConnectivityType(it)) + } + + Spacer(modifier = HalfVertSpacer) + + SettingsRow( + R.string.automatically_hide_nav_bars, + R.string.automatically_hide_nav_bars_description, + booleanItems, + hideNavBarsIndex, + ) { + sharedPreferencesViewModel.updateAutomaticallyHideNavBars(parseBooleanType(it)) + } + + Spacer(modifier = HalfVertSpacer) + + PushNotificationSettingsRow(sharedPreferencesViewModel) } - - Spacer(modifier = HalfVertSpacer) - - SettingsRow( - R.string.theme, - R.string.theme_description, - themeItens, - themeIndex, - ) { - sharedPreferencesViewModel.updateTheme(parseThemeType(it)) - } - - Spacer(modifier = HalfVertSpacer) - - SettingsRow( - R.string.automatically_load_images_gifs, - R.string.automatically_load_images_gifs_description, - selectedItens, - showImagesIndex, - ) { - sharedPreferencesViewModel.updateAutomaticallyShowImages(parseConnectivityType(it)) - } - - Spacer(modifier = HalfVertSpacer) - - SettingsRow( - R.string.automatically_play_videos, - R.string.automatically_play_videos_description, - selectedItens, - videoIndex, - ) { - sharedPreferencesViewModel.updateAutomaticallyStartPlayback(parseConnectivityType(it)) - } - - Spacer(modifier = HalfVertSpacer) - - SettingsRow( - R.string.automatically_show_url_preview, - R.string.automatically_show_url_preview_description, - selectedItens, - linkIndex, - ) { - sharedPreferencesViewModel.updateAutomaticallyShowUrlPreview(parseConnectivityType(it)) - } - - SettingsRow( - R.string.automatically_show_profile_picture, - R.string.automatically_show_profile_picture_description, - selectedItens, - profilePictureIndex, - ) { - sharedPreferencesViewModel.updateAutomaticallyShowProfilePicture(parseConnectivityType(it)) - } - - Spacer(modifier = HalfVertSpacer) - - SettingsRow( - R.string.automatically_hide_nav_bars, - R.string.automatically_hide_nav_bars_description, - booleanItems, - hideNavBarsIndex, - ) { - sharedPreferencesViewModel.updateAutomaticallyHideNavBars(parseBooleanType(it)) - } - - Spacer(modifier = HalfVertSpacer) - - PushNotificationSettingsRow(sharedPreferencesViewModel) - } } @Composable fun SettingsRow( - name: Int, - description: Int, - selectedItens: ImmutableList, - selectedIndex: Int, - onSelect: (Int) -> Unit, + name: Int, + description: Int, + selectedItens: ImmutableList, + selectedIndex: Int, + onSelect: (Int) -> Unit, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier.weight(2.0f), - verticalArrangement = Arrangement.spacedBy(3.dp), + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), ) { - Text( - text = stringResource(name), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = stringResource(description), - style = MaterialTheme.typography.bodySmall, - color = Color.Gray, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } + Column( + modifier = Modifier.weight(2.0f), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + text = stringResource(name), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(description), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } - TextSpinner( - label = "", - placeholder = selectedItens[selectedIndex].title, - options = selectedItens, - onSelect = onSelect, - modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).weight(1f), - ) - } + TextSpinner( + label = "", + placeholder = selectedItens[selectedIndex].title, + options = selectedItens, + onSelect = onSelect, + modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).weight(1f), + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt index 730f1fa80..fc0eac715 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt @@ -35,50 +35,51 @@ import com.vitorpamplona.amethyst.ui.screen.ThreadFeedView @Composable fun ThreadScreen( - noteId: String?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + noteId: String?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (noteId == null) return + if (noteId == null) return - val lifeCycleOwner = LocalLifecycleOwner.current + val lifeCycleOwner = LocalLifecycleOwner.current - val feedViewModel: NostrThreadFeedViewModel = - viewModel( - key = noteId + "NostrThreadFeedViewModel", - factory = NostrThreadFeedViewModel.Factory(accountViewModel.account, noteId), - ) + val feedViewModel: NostrThreadFeedViewModel = + viewModel( + key = noteId + "NostrThreadFeedViewModel", + factory = NostrThreadFeedViewModel.Factory(accountViewModel.account, noteId), + ) - NostrThreadDataSource.loadThread(noteId) + NostrThreadDataSource.loadThread(noteId) - DisposableEffect(noteId) { - feedViewModel.invalidateData(true) - onDispose { - NostrThreadDataSource.loadThread(null) - NostrThreadDataSource.stop() - } - } - - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Thread Start") - NostrThreadDataSource.loadThread(noteId) - NostrThreadDataSource.start() + DisposableEffect(noteId) { feedViewModel.invalidateData(true) - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Thread Stop") - NostrThreadDataSource.loadThread(null) - NostrThreadDataSource.stop() - } + onDispose { + NostrThreadDataSource.loadThread(null) + NostrThreadDataSource.stop() + } } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + DisposableEffect(lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Thread Start") + NostrThreadDataSource.loadThread(noteId) + NostrThreadDataSource.start() + feedViewModel.invalidateData(true) + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Thread Stop") + NostrThreadDataSource.loadThread(null) + NostrThreadDataSource.stop() + } + } - Column(Modifier.fillMaxHeight()) { - Column { ThreadFeedView(noteId, feedViewModel, accountViewModel, nav) } - } + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } + + Column(Modifier.fillMaxHeight()) { + Column { ThreadFeedView(noteId, feedViewModel, accountViewModel, nav) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt index 43125fa60..b1951014d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -104,403 +104,404 @@ import kotlinx.coroutines.launch @Composable fun VideoScreen( - videoFeedView: NostrVideoFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + videoFeedView: NostrVideoFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val lifeCycleOwner = LocalLifecycleOwner.current + val lifeCycleOwner = LocalLifecycleOwner.current - WatchAccountForVideoScreen(videoFeedView = videoFeedView, accountViewModel = accountViewModel) + WatchAccountForVideoScreen(videoFeedView = videoFeedView, accountViewModel = accountViewModel) - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Video Start") - NostrVideoDataSource.start() - } + DisposableEffect(lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Video Start") + NostrVideoDataSource.start() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } - - Column(Modifier.fillMaxHeight()) { - RenderPage( - videoFeedView = videoFeedView, - pagerStateKey = ScrollStateKeys.VIDEO_SCREEN, - accountViewModel = accountViewModel, - nav = nav, - ) - } + Column(Modifier.fillMaxHeight()) { + RenderPage( + videoFeedView = videoFeedView, + pagerStateKey = ScrollStateKeys.VIDEO_SCREEN, + accountViewModel = accountViewModel, + nav = nav, + ) + } } @Composable fun WatchAccountForVideoScreen( - videoFeedView: NostrVideoFeedViewModel, - accountViewModel: AccountViewModel, + videoFeedView: NostrVideoFeedViewModel, + accountViewModel: AccountViewModel, ) { - val listState by accountViewModel.account.liveStoriesFollowLists.collectAsStateWithLifecycle() - val hiddenUsers = accountViewModel.account.flowHiddenUsers.collectAsStateWithLifecycle() + val listState by accountViewModel.account.liveStoriesFollowLists.collectAsStateWithLifecycle() + val hiddenUsers = accountViewModel.account.flowHiddenUsers.collectAsStateWithLifecycle() - LaunchedEffect(accountViewModel, listState, hiddenUsers) { - NostrVideoDataSource.resetFilters() - videoFeedView.checkKeysInvalidateDataAndSendToTop() - } + LaunchedEffect(accountViewModel, listState, hiddenUsers) { + NostrVideoDataSource.resetFilters() + videoFeedView.checkKeysInvalidateDataAndSendToTop() + } } @OptIn(ExperimentalFoundationApi::class) @Composable public fun WatchScrollToTop( - viewModel: FeedViewModel, - pagerState: PagerState, + viewModel: FeedViewModel, + pagerState: PagerState, ) { - val scrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle() + val scrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle() - LaunchedEffect(scrollToTop) { - if (scrollToTop > 0 && viewModel.scrolltoTopPending) { - pagerState.scrollToPage(page = 0) - viewModel.sentToTop() + LaunchedEffect(scrollToTop) { + if (scrollToTop > 0 && viewModel.scrolltoTopPending) { + pagerState.scrollToPage(page = 0) + viewModel.sentToTop() + } } - } } @Composable fun RenderPage( - videoFeedView: NostrVideoFeedViewModel, - pagerStateKey: String?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + videoFeedView: NostrVideoFeedViewModel, + pagerStateKey: String?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val feedState by videoFeedView.feedContent.collectAsStateWithLifecycle() + val feedState by videoFeedView.feedContent.collectAsStateWithLifecycle() - Crossfade( - targetState = feedState, - animationSpec = tween(durationMillis = 100), - label = "RenderPage", - ) { state -> - when (state) { - is FeedState.Empty -> { - FeedEmpty {} - } - is FeedState.FeedError -> { - FeedError(state.errorMessage) {} - } - is FeedState.Loaded -> { - LoadedState(state, pagerStateKey, videoFeedView, accountViewModel, nav) - } - is FeedState.Loading -> { - LoadingFeed() - } + Crossfade( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + label = "RenderPage", + ) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty {} + } + is FeedState.FeedError -> { + FeedError(state.errorMessage) {} + } + is FeedState.Loaded -> { + LoadedState(state, pagerStateKey, videoFeedView, accountViewModel, nav) + } + is FeedState.Loading -> { + LoadingFeed() + } + } } - } } @Composable @OptIn(ExperimentalFoundationApi::class) private fun LoadedState( - state: FeedState.Loaded, - pagerStateKey: String?, - videoFeedView: NostrVideoFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + state: FeedState.Loaded, + pagerStateKey: String?, + videoFeedView: NostrVideoFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val pagerState = - if (pagerStateKey != null) { - rememberForeverPagerState(pagerStateKey) { state.feed.value.size } - } else { - rememberPagerState { state.feed.value.size } + val pagerState = + if (pagerStateKey != null) { + rememberForeverPagerState(pagerStateKey) { state.feed.value.size } + } else { + rememberPagerState { state.feed.value.size } + } + + WatchScrollToTop(videoFeedView, pagerState) + + RefresheableView(viewModel = videoFeedView) { + SlidingCarousel( + state.feed, + pagerState, + state.showHidden.value, + accountViewModel, + nav, + ) } - - WatchScrollToTop(videoFeedView, pagerState) - - RefresheableView(viewModel = videoFeedView) { - SlidingCarousel( - state.feed, - pagerState, - state.showHidden.value, - accountViewModel, - nav, - ) - } } @OptIn(ExperimentalFoundationApi::class) @Composable fun SlidingCarousel( - feed: MutableState>, - pagerState: PagerState, - showHidden: Boolean, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + feed: MutableState>, + pagerState: PagerState, + showHidden: Boolean, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - VerticalPager( - state = pagerState, - beyondBoundsPageCount = 1, - modifier = Modifier.fillMaxSize(), - key = { index -> feed.value.getOrNull(index)?.idHex ?: "$index" }, - ) { index -> - feed.value.getOrNull(index)?.let { note -> - LoadedVideoCompose(note, showHidden, accountViewModel, nav) + VerticalPager( + state = pagerState, + beyondBoundsPageCount = 1, + modifier = Modifier.fillMaxSize(), + key = { index -> feed.value.getOrNull(index)?.idHex ?: "$index" }, + ) { index -> + feed.value.getOrNull(index)?.let { note -> + LoadedVideoCompose(note, showHidden, accountViewModel, nav) + } } - } } @Composable fun LoadedVideoCompose( - note: Note, - showHidden: Boolean, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + showHidden: Boolean, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var state by - remember(note) { - mutableStateOf( - AccountViewModel.NoteComposeReportState(), - ) + var state by + remember(note) { + mutableStateOf( + AccountViewModel.NoteComposeReportState(), + ) + } + + if (!showHidden) { + val scope = rememberCoroutineScope() + + WatchForReports(note, accountViewModel) { newState -> + if (state != newState) { + scope.launch(Dispatchers.Main) { state = newState } + } + } } - if (!showHidden) { - val scope = rememberCoroutineScope() - - WatchForReports(note, accountViewModel) { newState -> - if (state != newState) { - scope.launch(Dispatchers.Main) { state = newState } - } + Crossfade(targetState = state, label = "LoadedVideoCompose") { + RenderReportState( + it, + note, + accountViewModel, + nav, + ) } - } - - Crossfade(targetState = state, label = "LoadedVideoCompose") { - RenderReportState( - it, - note, - accountViewModel, - nav, - ) - } } @Composable fun RenderReportState( - state: AccountViewModel.NoteComposeReportState, - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + state: AccountViewModel.NoteComposeReportState, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var showReportedNote by remember { mutableStateOf(false) } + var showReportedNote by remember { mutableStateOf(false) } - Crossfade(targetState = (!state.isAcceptable || state.isHiddenAuthor) && !showReportedNote) { - showHiddenNote -> - if (showHiddenNote) { - Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) { - HiddenNote( - state.relevantReports, - state.isHiddenAuthor, - accountViewModel, - Modifier.fillMaxWidth(), - false, - nav, - onClick = { showReportedNote = true }, - ) - } - } else { - RenderVideoOrPictureNote( - note, - accountViewModel, - nav, - ) + Crossfade(targetState = (!state.isAcceptable || state.isHiddenAuthor) && !showReportedNote) { + showHiddenNote -> + if (showHiddenNote) { + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) { + HiddenNote( + state.relevantReports, + state.isHiddenAuthor, + accountViewModel, + Modifier.fillMaxWidth(), + false, + nav, + onClick = { showReportedNote = true }, + ) + } + } else { + RenderVideoOrPictureNote( + note, + accountViewModel, + nav, + ) + } } - } } @Composable private fun RenderVideoOrPictureNote( - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Column(remember { Modifier.fillMaxSize(1f) }, verticalArrangement = Arrangement.Center) { - Row(remember { Modifier.weight(1f) }, verticalAlignment = Alignment.CenterVertically) { - val noteEvent = remember { note.event } - if (noteEvent is FileHeaderEvent) { - FileHeaderDisplay(note, false, accountViewModel) - } else if (noteEvent is FileStorageHeaderEvent) { - FileStorageHeaderDisplay(note, false, accountViewModel) - } - } - } - - Row(verticalAlignment = Alignment.Bottom, modifier = remember { Modifier.fillMaxSize(1f) }) { - Column(remember { Modifier.weight(1f) }) { - RenderAuthorInformation(note, nav, accountViewModel) + Column(remember { Modifier.fillMaxSize(1f) }, verticalArrangement = Arrangement.Center) { + Row(remember { Modifier.weight(1f) }, verticalAlignment = Alignment.CenterVertically) { + val noteEvent = remember { note.event } + if (noteEvent is FileHeaderEvent) { + FileHeaderDisplay(note, false, accountViewModel) + } else if (noteEvent is FileStorageHeaderEvent) { + FileStorageHeaderDisplay(note, false, accountViewModel) + } + } } - Column( - remember { Modifier.width(65.dp).padding(bottom = 10.dp) }, - verticalArrangement = Arrangement.Center, - ) { - Row(horizontalArrangement = Arrangement.Center) { - ReactionsColumn(note, accountViewModel, nav) - } + Row(verticalAlignment = Alignment.Bottom, modifier = remember { Modifier.fillMaxSize(1f) }) { + Column(remember { Modifier.weight(1f) }) { + RenderAuthorInformation(note, nav, accountViewModel) + } + + Column( + remember { Modifier.width(65.dp).padding(bottom = 10.dp) }, + verticalArrangement = Arrangement.Center, + ) { + Row(horizontalArrangement = Arrangement.Center) { + ReactionsColumn(note, accountViewModel, nav) + } + } } - } } @Composable private fun RenderAuthorInformation( - note: Note, - nav: (String) -> Unit, - accountViewModel: AccountViewModel, + note: Note, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - Row(remember { Modifier.padding(10.dp) }, verticalAlignment = Alignment.Bottom) { - Column(remember { Modifier.size(55.dp) }, verticalArrangement = Arrangement.Center) { - NoteAuthorPicture(note, nav, accountViewModel, 55.dp) - } + Row(remember { Modifier.padding(10.dp) }, verticalAlignment = Alignment.Bottom) { + Column(remember { Modifier.size(55.dp) }, verticalArrangement = Arrangement.Center) { + NoteAuthorPicture(note, nav, accountViewModel, 55.dp) + } - Column( - remember { Modifier.padding(start = 10.dp, end = 10.dp).height(65.dp).weight(1f) }, - verticalArrangement = Arrangement.Center, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - NoteUsernameDisplay(note, remember { Modifier.weight(1f) }) - VideoUserOptionAction(note, accountViewModel) - } - Row(verticalAlignment = Alignment.CenterVertically) { - ObserveDisplayNip05Status( - remember { note.author!! }, - remember { Modifier.weight(1f) }, - accountViewModel, - nav = nav, - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(top = 2.dp), - ) { - RelayBadges(baseNote = note, accountViewModel, nav) - } + Column( + remember { Modifier.padding(start = 10.dp, end = 10.dp).height(65.dp).weight(1f) }, + verticalArrangement = Arrangement.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + NoteUsernameDisplay(note, remember { Modifier.weight(1f) }) + VideoUserOptionAction(note, accountViewModel) + } + Row(verticalAlignment = Alignment.CenterVertically) { + ObserveDisplayNip05Status( + remember { note.author!! }, + remember { Modifier.weight(1f) }, + accountViewModel, + nav = nav, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 2.dp), + ) { + RelayBadges(baseNote = note, accountViewModel, nav) + } + } } - } } @Composable private fun VideoUserOptionAction( - note: Note, - accountViewModel: AccountViewModel, + note: Note, + accountViewModel: AccountViewModel, ) { - val popupExpanded = remember { mutableStateOf(false) } - val enablePopup = remember { { popupExpanded.value = true } } + val popupExpanded = remember { mutableStateOf(false) } + val enablePopup = remember { { popupExpanded.value = true } } - IconButton( - modifier = remember { Modifier.size(22.dp) }, - onClick = enablePopup, - ) { - Icon( - imageVector = Icons.Default.MoreVert, - null, - modifier = remember { Modifier.size(20.dp) }, - tint = MaterialTheme.colorScheme.placeholderText, - ) + IconButton( + modifier = remember { Modifier.size(22.dp) }, + onClick = enablePopup, + ) { + Icon( + imageVector = Icons.Default.MoreVert, + null, + modifier = remember { Modifier.size(20.dp) }, + tint = MaterialTheme.colorScheme.placeholderText, + ) - NoteDropDownMenu( - note, - popupExpanded, - accountViewModel, - ) - } + NoteDropDownMenu( + note, + popupExpanded, + accountViewModel, + ) + } } @OptIn(ExperimentalLayoutApi::class) @Composable private fun RelayBadges( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteRelays by baseNote.live().relayInfo.observeAsState(baseNote.relays) + val noteRelays by baseNote.live().relayInfo.observeAsState(baseNote.relays) - FlowRow { noteRelays?.forEach { relayInfo -> RenderRelay(relayInfo, accountViewModel, nav) } } + FlowRow { noteRelays?.forEach { relayInfo -> RenderRelay(relayInfo, accountViewModel, nav) } } } @Composable fun ReactionsColumn( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var wantsToReplyTo by remember { mutableStateOf(null) } + var wantsToReplyTo by remember { mutableStateOf(null) } - var wantsToQuote by remember { mutableStateOf(null) } + var wantsToQuote by remember { mutableStateOf(null) } - if (wantsToReplyTo != null) { - NewPostView( - onClose = { wantsToReplyTo = null }, - baseReplyTo = wantsToReplyTo, - quote = null, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (wantsToQuote != null) { - NewPostView( - onClose = { wantsToQuote = null }, - baseReplyTo = null, - quote = wantsToQuote, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(bottom = 75.dp, end = 20.dp), - ) { - ReplyReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onBackground, - accountViewModel = accountViewModel, - iconSizeModifier = Size40Modifier, - ) { - routeFor( - baseNote, - accountViewModel.userProfile(), + if (wantsToReplyTo != null) { + NewPostView( + onClose = { wantsToReplyTo = null }, + baseReplyTo = wantsToReplyTo, + quote = null, + accountViewModel = accountViewModel, + nav = nav, ) - ?.let { nav(it) } } - BoostReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onBackground, - accountViewModel = accountViewModel, - iconSizeModifier = Size40Modifier, - iconSize = Size40dp, + + if (wantsToQuote != null) { + NewPostView( + onClose = { wantsToQuote = null }, + baseReplyTo = null, + quote = wantsToQuote, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(bottom = 75.dp, end = 20.dp), ) { - wantsToQuote = baseNote + ReplyReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onBackground, + accountViewModel = accountViewModel, + iconSizeModifier = Size40Modifier, + ) { + routeFor( + baseNote, + accountViewModel.userProfile(), + ) + ?.let { nav(it) } + } + BoostReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onBackground, + accountViewModel = accountViewModel, + iconSizeModifier = Size40Modifier, + iconSize = Size40dp, + ) { + wantsToQuote = baseNote + } + LikeReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onBackground, + accountViewModel = accountViewModel, + nav = nav, + iconSize = Size40dp, + heartSizeModifier = Size35Modifier, + 28.sp, + ) + ZapReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onBackground, + accountViewModel = accountViewModel, + iconSize = Size40dp, + iconSizeModifier = Size40Modifier, + animationSize = Size35dp, + nav = nav, + ) + ViewCountReaction( + note = baseNote, + grayTint = MaterialTheme.colorScheme.onBackground, + barChartModifier = Size39Modifier, + viewCountColorFilter = MaterialTheme.colorScheme.onBackgroundColorFilter, + ) } - LikeReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onBackground, - accountViewModel = accountViewModel, - nav = nav, - iconSize = Size40dp, - heartSizeModifier = Size35Modifier, - 28.sp, - ) - ZapReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onBackground, - accountViewModel = accountViewModel, - iconSize = Size40dp, - iconSizeModifier = Size40Modifier, - animationSize = Size35dp, - nav = nav, - ) - ViewCountReaction( - note = baseNote, - grayTint = MaterialTheme.colorScheme.onBackground, - barChartModifier = Size39Modifier, - viewCountColorFilter = MaterialTheme.colorScheme.onBackgroundColorFilter, - ) - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt index b692241f5..6f96b05e4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt @@ -101,394 +101,395 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.signers.ExternalSignerLauncher import com.vitorpamplona.quartz.signers.SignerType -import java.util.UUID import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.util.UUID @OptIn(ExperimentalComposeUiApi::class) @Composable fun LoginPage( - accountViewModel: AccountStateViewModel, - isFirstLogin: Boolean, + accountViewModel: AccountStateViewModel, + isFirstLogin: Boolean, ) { - val key = remember { mutableStateOf(TextFieldValue("")) } - var errorMessage by remember { mutableStateOf("") } - val acceptedTerms = remember { mutableStateOf(!isFirstLogin) } - var termsAcceptanceIsRequired by remember { mutableStateOf("") } + val key = remember { mutableStateOf(TextFieldValue("")) } + var errorMessage by remember { mutableStateOf("") } + val acceptedTerms = remember { mutableStateOf(!isFirstLogin) } + var termsAcceptanceIsRequired by remember { mutableStateOf("") } - val uri = LocalUriHandler.current - val context = LocalContext.current - var dialogOpen by remember { mutableStateOf(false) } - val useProxy = remember { mutableStateOf(false) } - val proxyPort = remember { mutableStateOf("9050") } - var connectOrbotDialogOpen by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - var loginWithExternalSigner by remember { mutableStateOf(false) } + val uri = LocalUriHandler.current + val context = LocalContext.current + var dialogOpen by remember { mutableStateOf(false) } + val useProxy = remember { mutableStateOf(false) } + val proxyPort = remember { mutableStateOf("9050") } + var connectOrbotDialogOpen by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + var loginWithExternalSigner by remember { mutableStateOf(false) } - if (loginWithExternalSigner) { - val externalSignerLauncher = remember { ExternalSignerLauncher("", signerPackageName = "") } - val id = remember { UUID.randomUUID().toString() } + if (loginWithExternalSigner) { + val externalSignerLauncher = remember { ExternalSignerLauncher("", signerPackageName = "") } + val id = remember { UUID.randomUUID().toString() } - val launcher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - onResult = { result -> - if (result.resultCode != Activity.RESULT_OK) { - scope.launch(Dispatchers.Main) { - Toast.makeText( - Amethyst.instance, - "Sign request rejected", - Toast.LENGTH_SHORT, - ) - .show() + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + onResult = { result -> + if (result.resultCode != Activity.RESULT_OK) { + scope.launch(Dispatchers.Main) { + Toast.makeText( + Amethyst.instance, + "Sign request rejected", + Toast.LENGTH_SHORT, + ) + .show() + } + } else { + result.data?.let { externalSignerLauncher.newResult(it) } + } + }, + ) + + val activity = getActivity() as MainActivity + + DisposableEffect(launcher, activity, externalSignerLauncher) { + externalSignerLauncher.registerLauncher( + launcher = { + try { + activity.prepareToLaunchSigner() + launcher.launch(it) + } catch (e: Exception) { + Log.e("Signer", "Error opening Signer app", e) + scope.launch(Dispatchers.Main) { + Toast.makeText( + Amethyst.instance, + R.string.error_opening_external_signer, + Toast.LENGTH_SHORT, + ) + .show() + } + } + }, + contentResolver = { Amethyst.instance.contentResolver }, + ) + onDispose { externalSignerLauncher.clearLauncher() } + } + + LaunchedEffect(loginWithExternalSigner, externalSignerLauncher) { + externalSignerLauncher.openSignerApp( + "", + SignerType.GET_PUBLIC_KEY, + "", + id, + ) { result -> + val split = result.split("-") + val pubkey = split.first() + val packageName = if (split.size > 1) split[1] else "" + key.value = TextFieldValue(pubkey) + if (!acceptedTerms.value) { + termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required) + } + + if (key.value.text.isBlank()) { + errorMessage = context.getString(R.string.key_is_required) + } + + if (acceptedTerms.value && key.value.text.isNotBlank()) { + accountViewModel.login( + key.value.text, + useProxy.value, + proxyPort.value.toInt(), + true, + packageName, + ) { + errorMessage = context.getString(R.string.invalid_key) + } + } } - } else { - result.data?.let { externalSignerLauncher.newResult(it) } - } - }, - ) - - val activity = getActivity() as MainActivity - - DisposableEffect(launcher, activity, externalSignerLauncher) { - externalSignerLauncher.registerLauncher( - launcher = { - try { - activity.prepareToLaunchSigner() - launcher.launch(it) - } catch (e: Exception) { - Log.e("Signer", "Error opening Signer app", e) - scope.launch(Dispatchers.Main) { - Toast.makeText( - Amethyst.instance, - R.string.error_opening_external_signer, - Toast.LENGTH_SHORT, - ) - .show() - } - } - }, - contentResolver = { Amethyst.instance.contentResolver }, - ) - onDispose { externalSignerLauncher.clearLauncher() } + } } - LaunchedEffect(loginWithExternalSigner, externalSignerLauncher) { - externalSignerLauncher.openSignerApp( - "", - SignerType.GET_PUBLIC_KEY, - "", - id, - ) { result -> - val split = result.split("-") - val pubkey = split.first() - val packageName = if (split.size > 1) split[1] else "" - key.value = TextFieldValue(pubkey) - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required) - } - - if (key.value.text.isBlank()) { - errorMessage = context.getString(R.string.key_is_required) - } - - if (acceptedTerms.value && key.value.text.isNotBlank()) { - accountViewModel.login( - key.value.text, - useProxy.value, - proxyPort.value.toInt(), - true, - packageName, - ) { - errorMessage = context.getString(R.string.invalid_key) - } - } - } - } - } - - Column( - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.SpaceBetween, - ) { - // The first child is glued to the top. - // Hence we have nothing at the top, an empty box is used. - Box(modifier = Modifier.height(0.dp)) - - // The second child, this column, is centered vertically. Column( - modifier = Modifier.padding(20.dp).fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.SpaceBetween, ) { - Image( - painterResource(id = R.drawable.amethyst), - contentDescription = stringResource(R.string.app_logo), - modifier = Modifier.size(200.dp), - contentScale = ContentScale.Inside, - ) + // The first child is glued to the top. + // Hence we have nothing at the top, an empty box is used. + Box(modifier = Modifier.height(0.dp)) - Spacer(modifier = Modifier.height(40.dp)) - - var showPassword by remember { mutableStateOf(false) } - - val autofillNode = - AutofillNode( - autofillTypes = listOf(AutofillType.Password), - onFill = { key.value = TextFieldValue(it) }, - ) - val autofill = LocalAutofill.current - LocalAutofillTree.current += autofillNode - - OutlinedTextField( - modifier = - Modifier.onGloballyPositioned { coordinates -> - autofillNode.boundingBox = coordinates.boundsInWindow() - } - .onFocusChanged { focusState -> - autofill?.run { - if (focusState.isFocused) { - requestAutofillForNode(autofillNode) - } else { - cancelAutofillForNode(autofillNode) - } - } - }, - value = key.value, - onValueChange = { key.value = it }, - keyboardOptions = - KeyboardOptions( - autoCorrect = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, - ), - placeholder = { - Text( - text = stringResource(R.string.nsec_npub_hex_private_key), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - trailingIcon = { - Row { - IconButton(onClick = { showPassword = !showPassword }) { - Icon( - imageVector = - if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = - if (showPassword) { - stringResource(R.string.show_password) - } else { - stringResource( - R.string.hide_password, - ) - }, - ) - } - } - }, - leadingIcon = { - if (dialogOpen) { - SimpleQrCodeScanner { - dialogOpen = false - if (!it.isNullOrEmpty()) { - key.value = TextFieldValue(it) - } - } - } - IconButton(onClick = { dialogOpen = true }) { - Icon( - painter = painterResource(R.drawable.ic_qrcode), - null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } - }, - visualTransformation = - if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), - keyboardActions = - KeyboardActions( - onGo = { - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = - context.getString(R.string.acceptance_of_terms_is_required) - } - - if (key.value.text.isBlank()) { - errorMessage = context.getString(R.string.key_is_required) - } - - if (acceptedTerms.value && key.value.text.isNotBlank()) { - accountViewModel.login(key.value.text, useProxy.value, proxyPort.value.toInt()) { - errorMessage = context.getString(R.string.invalid_key) - } - } - }, - ), - ) - if (errorMessage.isNotBlank()) { - Text( - text = errorMessage, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - - Spacer(modifier = Modifier.height(20.dp)) - - if (isFirstLogin) { - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = acceptedTerms.value, - onCheckedChange = { acceptedTerms.value = it }, - ) - - val regularText = SpanStyle(color = MaterialTheme.colorScheme.onBackground) - - val clickableTextStyle = SpanStyle(color = MaterialTheme.colorScheme.primary) - - val annotatedTermsString = buildAnnotatedString { - withStyle(regularText) { append(stringResource(R.string.i_accept_the)) } - - withStyle(clickableTextStyle) { - pushStringAnnotation("openTerms", "") - append(stringResource(R.string.terms_of_use)) - } - } - - ClickableText( - text = annotatedTermsString, - ) { spanOffset -> - annotatedTermsString.getStringAnnotations(spanOffset, spanOffset).firstOrNull()?.also { - span -> - if (span.tag == "openTerms") { - runCatching { - uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") - } - } - } - } - } - - if (termsAcceptanceIsRequired.isNotBlank()) { - Text( - text = termsAcceptanceIsRequired, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - } - - if (PackageUtils.isOrbotInstalled(context)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = useProxy.value, - onCheckedChange = { - if (it) { - connectOrbotDialogOpen = true - } - }, - ) - - Text(stringResource(R.string.connect_via_tor)) - } - - if (connectOrbotDialogOpen) { - ConnectOrbotDialog( - onClose = { connectOrbotDialogOpen = false }, - onPost = { - connectOrbotDialogOpen = false - useProxy.value = true - }, - onError = { - scope.launch { - Toast.makeText( - context, - it, - Toast.LENGTH_LONG, - ) - .show() - } - }, - proxyPort, - ) - } - } - - Spacer(modifier = Modifier.height(20.dp)) - - Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) { - Button( - enabled = acceptedTerms.value, - onClick = { - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = - context.getString(R.string.acceptance_of_terms_is_required) - } - - if (key.value.text.isBlank()) { - errorMessage = context.getString(R.string.key_is_required) - } - - if (acceptedTerms.value && key.value.text.isNotBlank()) { - accountViewModel.login(key.value.text, useProxy.value, proxyPort.value.toInt()) { - errorMessage = context.getString(R.string.invalid_key) - } - } - }, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier.height(50.dp), + // The second child, this column, is centered vertically. + Column( + modifier = Modifier.padding(20.dp).fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Text( - text = stringResource(R.string.login), - modifier = Modifier.padding(horizontal = 40.dp), - ) - } - } - - if (PackageUtils.isAmberInstalled(context)) { - Box(modifier = Modifier.padding(40.dp, 40.dp, 40.dp, 0.dp)) { - Button( - enabled = acceptedTerms.value, - onClick = { - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = - context.getString(R.string.acceptance_of_terms_is_required) - return@Button - } - - loginWithExternalSigner = true - return@Button - }, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier.height(50.dp), - ) { - Text( - text = stringResource(R.string.login_with_external_signer), - modifier = Modifier.padding(horizontal = 40.dp), + Image( + painterResource(id = R.drawable.amethyst), + contentDescription = stringResource(R.string.app_logo), + modifier = Modifier.size(200.dp), + contentScale = ContentScale.Inside, ) - } - } - } - } - // The last child is glued to the bottom. - ClickableText( - text = AnnotatedString(stringResource(R.string.generate_a_new_key)), - modifier = Modifier.padding(20.dp).fillMaxWidth(), - onClick = { - if (acceptedTerms.value) { - accountViewModel.newKey(useProxy.value, proxyPort.value.toInt()) - } else { - termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required) + Spacer(modifier = Modifier.height(40.dp)) + + var showPassword by remember { mutableStateOf(false) } + + val autofillNode = + AutofillNode( + autofillTypes = listOf(AutofillType.Password), + onFill = { key.value = TextFieldValue(it) }, + ) + val autofill = LocalAutofill.current + LocalAutofillTree.current += autofillNode + + OutlinedTextField( + modifier = + Modifier.onGloballyPositioned { coordinates -> + autofillNode.boundingBox = coordinates.boundsInWindow() + } + .onFocusChanged { focusState -> + autofill?.run { + if (focusState.isFocused) { + requestAutofillForNode(autofillNode) + } else { + cancelAutofillForNode(autofillNode) + } + } + }, + value = key.value, + onValueChange = { key.value = it }, + keyboardOptions = + KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + placeholder = { + Text( + text = stringResource(R.string.nsec_npub_hex_private_key), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + Row { + IconButton(onClick = { showPassword = !showPassword }) { + Icon( + imageVector = + if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = + if (showPassword) { + stringResource(R.string.show_password) + } else { + stringResource( + R.string.hide_password, + ) + }, + ) + } + } + }, + leadingIcon = { + if (dialogOpen) { + SimpleQrCodeScanner { + dialogOpen = false + if (!it.isNullOrEmpty()) { + key.value = TextFieldValue(it) + } + } + } + IconButton(onClick = { dialogOpen = true }) { + Icon( + painter = painterResource(R.drawable.ic_qrcode), + null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + visualTransformation = + if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + keyboardActions = + KeyboardActions( + onGo = { + if (!acceptedTerms.value) { + termsAcceptanceIsRequired = + context.getString(R.string.acceptance_of_terms_is_required) + } + + if (key.value.text.isBlank()) { + errorMessage = context.getString(R.string.key_is_required) + } + + if (acceptedTerms.value && key.value.text.isNotBlank()) { + accountViewModel.login(key.value.text, useProxy.value, proxyPort.value.toInt()) { + errorMessage = context.getString(R.string.invalid_key) + } + } + }, + ), + ) + if (errorMessage.isNotBlank()) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + if (isFirstLogin) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = acceptedTerms.value, + onCheckedChange = { acceptedTerms.value = it }, + ) + + val regularText = SpanStyle(color = MaterialTheme.colorScheme.onBackground) + + val clickableTextStyle = SpanStyle(color = MaterialTheme.colorScheme.primary) + + val annotatedTermsString = + buildAnnotatedString { + withStyle(regularText) { append(stringResource(R.string.i_accept_the)) } + + withStyle(clickableTextStyle) { + pushStringAnnotation("openTerms", "") + append(stringResource(R.string.terms_of_use)) + } + } + + ClickableText( + text = annotatedTermsString, + ) { spanOffset -> + annotatedTermsString.getStringAnnotations(spanOffset, spanOffset).firstOrNull()?.also { + span -> + if (span.tag == "openTerms") { + runCatching { + uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") + } + } + } + } + } + + if (termsAcceptanceIsRequired.isNotBlank()) { + Text( + text = termsAcceptanceIsRequired, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + + if (PackageUtils.isOrbotInstalled(context)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = useProxy.value, + onCheckedChange = { + if (it) { + connectOrbotDialogOpen = true + } + }, + ) + + Text(stringResource(R.string.connect_via_tor)) + } + + if (connectOrbotDialogOpen) { + ConnectOrbotDialog( + onClose = { connectOrbotDialogOpen = false }, + onPost = { + connectOrbotDialogOpen = false + useProxy.value = true + }, + onError = { + scope.launch { + Toast.makeText( + context, + it, + Toast.LENGTH_LONG, + ) + .show() + } + }, + proxyPort, + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) { + Button( + enabled = acceptedTerms.value, + onClick = { + if (!acceptedTerms.value) { + termsAcceptanceIsRequired = + context.getString(R.string.acceptance_of_terms_is_required) + } + + if (key.value.text.isBlank()) { + errorMessage = context.getString(R.string.key_is_required) + } + + if (acceptedTerms.value && key.value.text.isNotBlank()) { + accountViewModel.login(key.value.text, useProxy.value, proxyPort.value.toInt()) { + errorMessage = context.getString(R.string.invalid_key) + } + } + }, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(50.dp), + ) { + Text( + text = stringResource(R.string.login), + modifier = Modifier.padding(horizontal = 40.dp), + ) + } + } + + if (PackageUtils.isAmberInstalled(context)) { + Box(modifier = Modifier.padding(40.dp, 40.dp, 40.dp, 0.dp)) { + Button( + enabled = acceptedTerms.value, + onClick = { + if (!acceptedTerms.value) { + termsAcceptanceIsRequired = + context.getString(R.string.acceptance_of_terms_is_required) + return@Button + } + + loginWithExternalSigner = true + return@Button + }, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(50.dp), + ) { + Text( + text = stringResource(R.string.login_with_external_signer), + modifier = Modifier.padding(horizontal = 40.dp), + ) + } + } + } } - }, - style = - TextStyle( - fontSize = Font14SP, - textDecoration = TextDecoration.Underline, - color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center, - ), - ) - } + + // The last child is glued to the bottom. + ClickableText( + text = AnnotatedString(stringResource(R.string.generate_a_new_key)), + modifier = Modifier.padding(20.dp).fillMaxWidth(), + onClick = { + if (acceptedTerms.value) { + accountViewModel.newKey(useProxy.value, proxyPort.value.toInt()) + } else { + termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required) + } + }, + style = + TextStyle( + fontSize = Font14SP, + textDecoration = TextDecoration.Underline, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + ), + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index ddb38ffc6..67f1cb8ac 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -36,11 +36,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp val Shapes = - Shapes( - small = RoundedCornerShape(4.dp), - medium = RoundedCornerShape(4.dp), - large = RoundedCornerShape(0.dp), - ) + Shapes( + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(4.dp), + large = RoundedCornerShape(0.dp), + ) val RippleRadius45dp = 45.dp // Ripple should be +10.dp over the component size @@ -164,7 +164,7 @@ val PinBottomIconSize = Modifier.size(70.dp).padding(10.dp) val NIP05IconSize = Modifier.size(13.dp).padding(top = 1.dp, start = 1.dp, end = 1.dp) val EditFieldModifier = - Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp).fillMaxWidth() + Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp).fillMaxWidth() val EditFieldTrailingIconModifier = Modifier.height(26.dp).padding(start = 5.dp, end = 0.dp) val EditFieldLeadingIconModifier = Modifier.height(32.dp).padding(start = 2.dp) @@ -174,16 +174,16 @@ val ButtonPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp) val ChatPaddingInnerQuoteModifier = Modifier.padding(top = 10.dp, end = 5.dp) val ChatPaddingModifier = - Modifier.fillMaxWidth(1f) - .padding( - start = 12.dp, - end = 12.dp, - top = 5.dp, - bottom = 5.dp, - ) + Modifier.fillMaxWidth(1f) + .padding( + start = 12.dp, + end = 12.dp, + top = 5.dp, + bottom = 5.dp, + ) val profileContentHeaderModifier = - Modifier.fillMaxWidth().padding(top = 70.dp, start = Size25dp, end = Size25dp) + Modifier.fillMaxWidth().padding(top = 70.dp, start = Size25dp, end = Size25dp) val bannerModifier = Modifier.fillMaxWidth().height(120.dp) val drawerSpacing = Modifier.padding(top = Size10dp, start = Size25dp, end = Size25dp) @@ -193,25 +193,25 @@ val IconRowModifier = Modifier.fillMaxWidth().padding(vertical = 15.dp, horizont val emptyLineItemModifier = Modifier.height(Size75dp).fillMaxWidth() val normalNoteModifier = - Modifier.fillMaxWidth() - .padding( - start = 12.dp, - end = 12.dp, - top = 0.dp, - ) + Modifier.fillMaxWidth() + .padding( + start = 12.dp, + end = 12.dp, + top = 0.dp, + ) val normalWithTopMarginNoteModifier = - Modifier.fillMaxWidth() - .padding( - start = 12.dp, - end = 12.dp, - top = 10.dp, - ) + Modifier.fillMaxWidth() + .padding( + start = 12.dp, + end = 12.dp, + top = 10.dp, + ) val boostedNoteModifier = - Modifier.fillMaxWidth() - .padding( - start = 0.dp, - end = 0.dp, - top = 0.dp, - ) + Modifier.fillMaxWidth() + .padding( + start = 0.dp, + end = 0.dp, + top = 0.dp, + ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index d8fef596f..8b2d21198 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -58,22 +58,22 @@ import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel private val DarkColorPalette = - darkColorScheme( - primary = Purple200, - secondary = Teal200, - tertiary = Teal200, - background = Color(0xFF000000), - surface = Color(0xFF000000), - surfaceVariant = Color(red = 29, green = 26, blue = 34), - ) + darkColorScheme( + primary = Purple200, + secondary = Teal200, + tertiary = Teal200, + background = Color(0xFF000000), + surface = Color(0xFF000000), + surfaceVariant = Color(red = 29, green = 26, blue = 34), + ) private val LightColorPalette = - lightColorScheme( - primary = Purple500, - secondary = Teal200, - tertiary = Teal200, - surfaceVariant = Color(red = 250, green = 245, blue = 252), - ) + lightColorScheme( + primary = Purple500, + secondary = Teal200, + tertiary = Teal200, + surfaceVariant = Color(red = 250, green = 245, blue = 252), + ) private val DarkNewItemBackground = DarkColorPalette.primary.copy(0.12f) private val LightNewItemBackground = LightColorPalette.primary.copy(0.12f) @@ -82,9 +82,9 @@ private val DarkSelectedNote = DarkNewItemBackground.compositeOver(DarkColorPale private val LightSelectedNote = LightNewItemBackground.compositeOver(LightColorPalette.background) private val DarkButtonBackground = - DarkColorPalette.primary.copy(alpha = 0.32f).compositeOver(DarkColorPalette.background) + DarkColorPalette.primary.copy(alpha = 0.32f).compositeOver(DarkColorPalette.background) private val LightButtonBackground = - LightColorPalette.primary.copy(alpha = 0.32f).compositeOver(LightColorPalette.background) + LightColorPalette.primary.copy(alpha = 0.32f).compositeOver(LightColorPalette.background) private val DarkLessImportantLink = DarkColorPalette.primary.copy(alpha = 0.52f) private val LightLessImportantLink = LightColorPalette.primary.copy(alpha = 0.52f) @@ -117,9 +117,9 @@ private val DarkReplyItemBackground = DarkColorPalette.onSurface.copy(alpha = 0. private val LightReplyItemBackground = LightColorPalette.onSurface.copy(alpha = 0.05f) private val DarkZapraiserBackground = - BitcoinOrange.copy(0.52f).compositeOver(DarkColorPalette.background) + BitcoinOrange.copy(0.52f).compositeOver(DarkColorPalette.background) private val LightZapraiserBackground = - BitcoinOrange.copy(0.52f).compositeOver(LightColorPalette.background) + BitcoinOrange.copy(0.52f).compositeOver(LightColorPalette.background) private val DarkOverPictureBackground = DarkColorPalette.background.copy(0.62f) private val LightOverPictureBackground = LightColorPalette.background.copy(0.62f) @@ -128,293 +128,293 @@ val RepostPictureBorderDark = Modifier.border(2.dp, DarkColorPalette.background, val RepostPictureBorderLight = Modifier.border(2.dp, LightColorPalette.background, CircleShape) val DarkImageModifier = - Modifier.fillMaxWidth().clip(shape = QuoteBorder).border(1.dp, DarkSubtleBorder, QuoteBorder) + Modifier.fillMaxWidth().clip(shape = QuoteBorder).border(1.dp, DarkSubtleBorder, QuoteBorder) val LightImageModifier = - Modifier.fillMaxWidth().clip(shape = QuoteBorder).border(1.dp, LightSubtleBorder, QuoteBorder) + Modifier.fillMaxWidth().clip(shape = QuoteBorder).border(1.dp, LightSubtleBorder, QuoteBorder) val DarkProfile35dpModifier = Modifier.size(Size35dp).clip(shape = CircleShape) val LightProfile35dpModifier = Modifier.fillMaxWidth().clip(shape = CircleShape) val DarkReplyBorderModifier = - Modifier.padding(top = 5.dp) - .fillMaxWidth() - .clip(shape = QuoteBorder) - .border(1.dp, DarkSubtleBorder, QuoteBorder) + Modifier.padding(top = 5.dp) + .fillMaxWidth() + .clip(shape = QuoteBorder) + .border(1.dp, DarkSubtleBorder, QuoteBorder) val LightReplyBorderModifier = - Modifier.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp) - .fillMaxWidth() - .clip(shape = QuoteBorder) - .border(1.dp, LightSubtleBorder, QuoteBorder) + Modifier.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp) + .fillMaxWidth() + .clip(shape = QuoteBorder) + .border(1.dp, LightSubtleBorder, QuoteBorder) val DarkInnerPostBorderModifier = - Modifier.padding(top = 5.dp) - .fillMaxWidth() - .clip(shape = QuoteBorder) - .border(1.dp, DarkSubtleBorder, QuoteBorder) + Modifier.padding(top = 5.dp) + .fillMaxWidth() + .clip(shape = QuoteBorder) + .border(1.dp, DarkSubtleBorder, QuoteBorder) val LightInnerPostBorderModifier = - Modifier.padding(top = 5.dp) - .fillMaxWidth() - .clip(shape = QuoteBorder) - .border(1.dp, LightSubtleBorder, QuoteBorder) + Modifier.padding(top = 5.dp) + .fillMaxWidth() + .clip(shape = QuoteBorder) + .border(1.dp, LightSubtleBorder, QuoteBorder) val DarkChannelNotePictureModifier = - Modifier.size(30.dp) - .clip(shape = CircleShape) - .background(DarkColorPalette.background) - .border(2.dp, DarkColorPalette.background, CircleShape) + Modifier.size(30.dp) + .clip(shape = CircleShape) + .background(DarkColorPalette.background) + .border(2.dp, DarkColorPalette.background, CircleShape) val LightChannelNotePictureModifier = - Modifier.size(30.dp) - .clip(shape = CircleShape) - .background(LightColorPalette.background) - .border(2.dp, LightColorPalette.background, CircleShape) + Modifier.size(30.dp) + .clip(shape = CircleShape) + .background(LightColorPalette.background) + .border(2.dp, LightColorPalette.background, CircleShape) val LightRelayIconModifier = - Modifier.size(Size13dp).clip(shape = CircleShape).background(LightColorPalette.background) + Modifier.size(Size13dp).clip(shape = CircleShape).background(LightColorPalette.background) val DarkRelayIconModifier = - Modifier.size(Size13dp).clip(shape = CircleShape).background(DarkColorPalette.background) + Modifier.size(Size13dp).clip(shape = CircleShape).background(DarkColorPalette.background) val LightLargeRelayIconModifier = - Modifier.size(Size55dp).clip(shape = CircleShape).background(LightColorPalette.background) + Modifier.size(Size55dp).clip(shape = CircleShape).background(LightColorPalette.background) val DarkLargeRelayIconModifier = - Modifier.size(Size55dp).clip(shape = CircleShape).background(DarkColorPalette.background) + Modifier.size(Size55dp).clip(shape = CircleShape).background(DarkColorPalette.background) val RichTextDefaults = RichTextStyle().resolveDefaults() val MarkDownStyleOnDark = - RichTextDefaults.copy( - paragraphSpacing = DefaultParagraphSpacing, - headingStyle = DefaultHeadingStyle, - listStyle = - RichTextDefaults.listStyle?.copy( - itemSpacing = 10.sp, - ), - codeBlockStyle = - RichTextDefaults.codeBlockStyle?.copy( - textStyle = - TextStyle( - fontFamily = FontFamily.Monospace, - fontSize = Font14SP, - ), - modifier = - Modifier.padding(0.dp) - .fillMaxWidth() - .clip(shape = QuoteBorder) - .border(1.dp, DarkSubtleBorder, QuoteBorder) - .background(DarkColorPalette.onSurface.copy(alpha = 0.05f)), - ), - stringStyle = - RichTextDefaults.stringStyle?.copy( - linkStyle = - SpanStyle( - color = DarkColorPalette.primary, - ), - codeStyle = - SpanStyle( - fontFamily = FontFamily.Monospace, - fontSize = Font14SP, - background = DarkColorPalette.onSurface.copy(alpha = 0.22f), - ), - ), - ) + RichTextDefaults.copy( + paragraphSpacing = DefaultParagraphSpacing, + headingStyle = DefaultHeadingStyle, + listStyle = + RichTextDefaults.listStyle?.copy( + itemSpacing = 10.sp, + ), + codeBlockStyle = + RichTextDefaults.codeBlockStyle?.copy( + textStyle = + TextStyle( + fontFamily = FontFamily.Monospace, + fontSize = Font14SP, + ), + modifier = + Modifier.padding(0.dp) + .fillMaxWidth() + .clip(shape = QuoteBorder) + .border(1.dp, DarkSubtleBorder, QuoteBorder) + .background(DarkColorPalette.onSurface.copy(alpha = 0.05f)), + ), + stringStyle = + RichTextDefaults.stringStyle?.copy( + linkStyle = + SpanStyle( + color = DarkColorPalette.primary, + ), + codeStyle = + SpanStyle( + fontFamily = FontFamily.Monospace, + fontSize = Font14SP, + background = DarkColorPalette.onSurface.copy(alpha = 0.22f), + ), + ), + ) val MarkDownStyleOnLight = - RichTextDefaults.copy( - paragraphSpacing = DefaultParagraphSpacing, - headingStyle = DefaultHeadingStyle, - listStyle = - RichTextDefaults.listStyle?.copy( - itemSpacing = 10.sp, - ), - codeBlockStyle = - RichTextDefaults.codeBlockStyle?.copy( - textStyle = - TextStyle( - fontFamily = FontFamily.Monospace, - fontSize = Font14SP, - ), - modifier = - Modifier.padding(0.dp) - .fillMaxWidth() - .clip(shape = QuoteBorder) - .border(1.dp, LightSubtleBorder, QuoteBorder) - .background(DarkColorPalette.onSurface.copy(alpha = 0.05f)), - ), - stringStyle = - RichTextDefaults.stringStyle?.copy( - linkStyle = - SpanStyle( - color = LightColorPalette.primary, - ), - codeStyle = - SpanStyle( - fontFamily = FontFamily.Monospace, - fontSize = Font14SP, - background = LightColorPalette.onSurface.copy(alpha = 0.22f), - ), - ), - ) + RichTextDefaults.copy( + paragraphSpacing = DefaultParagraphSpacing, + headingStyle = DefaultHeadingStyle, + listStyle = + RichTextDefaults.listStyle?.copy( + itemSpacing = 10.sp, + ), + codeBlockStyle = + RichTextDefaults.codeBlockStyle?.copy( + textStyle = + TextStyle( + fontFamily = FontFamily.Monospace, + fontSize = Font14SP, + ), + modifier = + Modifier.padding(0.dp) + .fillMaxWidth() + .clip(shape = QuoteBorder) + .border(1.dp, LightSubtleBorder, QuoteBorder) + .background(DarkColorPalette.onSurface.copy(alpha = 0.05f)), + ), + stringStyle = + RichTextDefaults.stringStyle?.copy( + linkStyle = + SpanStyle( + color = LightColorPalette.primary, + ), + codeStyle = + SpanStyle( + fontFamily = FontFamily.Monospace, + fontSize = Font14SP, + background = LightColorPalette.onSurface.copy(alpha = 0.22f), + ), + ), + ) val ColorScheme.isLight: Boolean - get() = primary == Purple500 + get() = primary == Purple500 val ColorScheme.newItemBackgroundColor: Color - get() = if (isLight) LightNewItemBackground else DarkNewItemBackground + get() = if (isLight) LightNewItemBackground else DarkNewItemBackground val ColorScheme.replyBackground: Color - get() = if (isLight) LightReplyItemBackground else DarkReplyItemBackground + get() = if (isLight) LightReplyItemBackground else DarkReplyItemBackground val ColorScheme.selectedNote: Color - get() = if (isLight) LightSelectedNote else DarkSelectedNote + get() = if (isLight) LightSelectedNote else DarkSelectedNote val ColorScheme.secondaryButtonBackground: Color - get() = if (isLight) LightButtonBackground else DarkButtonBackground + get() = if (isLight) LightButtonBackground else DarkButtonBackground val ColorScheme.lessImportantLink: Color - get() = if (isLight) LightLessImportantLink else DarkLessImportantLink + get() = if (isLight) LightLessImportantLink else DarkLessImportantLink val ColorScheme.zapraiserBackground: Color - get() = if (isLight) LightZapraiserBackground else DarkZapraiserBackground + get() = if (isLight) LightZapraiserBackground else DarkZapraiserBackground val ColorScheme.mediumImportanceLink: Color - get() = if (isLight) LightMediumImportantLink else DarkMediumImportantLink + get() = if (isLight) LightMediumImportantLink else DarkMediumImportantLink val ColorScheme.veryImportantLink: Color - get() = if (isLight) LightVeryImportantLink else DarkVeryImportantLink + get() = if (isLight) LightVeryImportantLink else DarkVeryImportantLink val ColorScheme.placeholderText: Color - get() = if (isLight) LightPlaceholderText else DarkPlaceholderText + get() = if (isLight) LightPlaceholderText else DarkPlaceholderText val ColorScheme.nip05: Color - get() = if (isLight) Nip05EmailColorLight else Nip05EmailColorDark + get() = if (isLight) Nip05EmailColorLight else Nip05EmailColorDark val ColorScheme.placeholderTextColorFilter: ColorFilter - get() = if (isLight) LightPlaceholderTextColorFilter else DarkPlaceholderTextColorFilter + get() = if (isLight) LightPlaceholderTextColorFilter else DarkPlaceholderTextColorFilter val ColorScheme.onBackgroundColorFilter: ColorFilter - get() = if (isLight) LightOnBackgroundColorFilter else DarkOnBackgroundColorFilter + get() = if (isLight) LightOnBackgroundColorFilter else DarkOnBackgroundColorFilter val ColorScheme.grayText: Color - get() = if (isLight) LightGrayText else DarkGrayText + get() = if (isLight) LightGrayText else DarkGrayText val ColorScheme.subtleBorder: Color - get() = if (isLight) LightSubtleBorder else DarkSubtleBorder + get() = if (isLight) LightSubtleBorder else DarkSubtleBorder val ColorScheme.subtleButton: Color - get() = if (isLight) LightSubtleButton else DarkSubtleButton + get() = if (isLight) LightSubtleButton else DarkSubtleButton val ColorScheme.overPictureBackground: Color - get() = if (isLight) LightOverPictureBackground else DarkOverPictureBackground + get() = if (isLight) LightOverPictureBackground else DarkOverPictureBackground val ColorScheme.bitcoinColor: Color - get() = if (isLight) BitcoinLight else BitcoinDark + get() = if (isLight) BitcoinLight else BitcoinDark val ColorScheme.warningColor: Color - get() = if (isLight) LightWarningColor else DarkWarningColor + get() = if (isLight) LightWarningColor else DarkWarningColor val ColorScheme.allGoodColor: Color - get() = if (isLight) LightAllGoodColor else DarkAllGoodColor + get() = if (isLight) LightAllGoodColor else DarkAllGoodColor val ColorScheme.markdownStyle: RichTextStyle - get() = if (isLight) MarkDownStyleOnLight else MarkDownStyleOnDark + get() = if (isLight) MarkDownStyleOnLight else MarkDownStyleOnDark val ColorScheme.repostProfileBorder: Modifier - get() = if (isLight) RepostPictureBorderLight else RepostPictureBorderDark + get() = if (isLight) RepostPictureBorderLight else RepostPictureBorderDark val ColorScheme.imageModifier: Modifier - get() = if (isLight) LightImageModifier else DarkImageModifier + get() = if (isLight) LightImageModifier else DarkImageModifier val ColorScheme.profile35dpModifier: Modifier - get() = if (isLight) LightProfile35dpModifier else DarkProfile35dpModifier + get() = if (isLight) LightProfile35dpModifier else DarkProfile35dpModifier val ColorScheme.replyModifier: Modifier - get() = if (isLight) LightReplyBorderModifier else DarkReplyBorderModifier + get() = if (isLight) LightReplyBorderModifier else DarkReplyBorderModifier val ColorScheme.innerPostModifier: Modifier - get() = if (isLight) LightInnerPostBorderModifier else DarkInnerPostBorderModifier + get() = if (isLight) LightInnerPostBorderModifier else DarkInnerPostBorderModifier val ColorScheme.channelNotePictureModifier: Modifier - get() = if (isLight) LightChannelNotePictureModifier else DarkChannelNotePictureModifier + get() = if (isLight) LightChannelNotePictureModifier else DarkChannelNotePictureModifier val ColorScheme.relayIconModifier: Modifier - get() = if (isLight) LightRelayIconModifier else DarkRelayIconModifier + get() = if (isLight) LightRelayIconModifier else DarkRelayIconModifier val ColorScheme.largeRelayIconModifier: Modifier - get() = if (isLight) LightLargeRelayIconModifier else DarkLargeRelayIconModifier + get() = if (isLight) LightLargeRelayIconModifier else DarkLargeRelayIconModifier val ColorScheme.chartStyle: ChartStyle - get() { - val defaultColors = if (isLight) DefaultColors.Light else DefaultColors.Dark - return ChartStyle.fromColors( - axisLabelColor = Color(defaultColors.axisLabelColor), - axisGuidelineColor = Color(defaultColors.axisGuidelineColor), - axisLineColor = Color(defaultColors.axisLineColor), - entityColors = - listOf( - defaultColors.entity1Color, - defaultColors.entity2Color, - defaultColors.entity3Color, - ) - .map(::Color), - elevationOverlayColor = Color(defaultColors.elevationOverlayColor), - ) - } + get() { + val defaultColors = if (isLight) DefaultColors.Light else DefaultColors.Dark + return ChartStyle.fromColors( + axisLabelColor = Color(defaultColors.axisLabelColor), + axisGuidelineColor = Color(defaultColors.axisGuidelineColor), + axisLineColor = Color(defaultColors.axisLineColor), + entityColors = + listOf( + defaultColors.entity1Color, + defaultColors.entity2Color, + defaultColors.entity3Color, + ) + .map(::Color), + elevationOverlayColor = Color(defaultColors.elevationOverlayColor), + ) + } @Composable fun AmethystTheme( - sharedPrefsViewModel: SharedPreferencesViewModel, - content: @Composable () -> Unit, + sharedPrefsViewModel: SharedPreferencesViewModel, + content: @Composable () -> Unit, ) { - val darkTheme = - when (sharedPrefsViewModel.sharedPrefs.theme) { - ThemeType.DARK -> true - ThemeType.LIGHT -> false - else -> isSystemInDarkTheme() - } - val colors = if (darkTheme) DarkColorPalette else LightColorPalette + val darkTheme = + when (sharedPrefsViewModel.sharedPrefs.theme) { + ThemeType.DARK -> true + ThemeType.LIGHT -> false + else -> isSystemInDarkTheme() + } + val colors = if (darkTheme) DarkColorPalette else LightColorPalette - MaterialTheme( - colorScheme = colors, - typography = Typography, - shapes = Shapes, - content = content, - ) + MaterialTheme( + colorScheme = colors, + typography = Typography, + shapes = Shapes, + content = content, + ) - val view = LocalView.current - if (!view.isInEditMode) { - SideEffect { - val window = (view.context as Activity).window - val insetsController = WindowCompat.getInsetsController(window, view) - if (darkTheme) { - window.statusBarColor = colors.background.toArgb() - } else { - window.statusBarColor = colors.primary.toArgb() - } - window.navigationBarColor = colors.background.toArgb() - insetsController.isAppearanceLightNavigationBars = !darkTheme + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + val insetsController = WindowCompat.getInsetsController(window, view) + if (darkTheme) { + window.statusBarColor = colors.background.toArgb() + } else { + window.statusBarColor = colors.primary.toArgb() + } + window.navigationBarColor = colors.background.toArgb() + insetsController.isAppearanceLightNavigationBars = !darkTheme + } } - } } @Composable fun ThemeComparison( - onDark: @Composable () -> Unit, - onLight: @Composable () -> Unit, + onDark: @Composable () -> Unit, + onLight: @Composable () -> Unit, ) { - Column { - val darkTheme: SharedPreferencesViewModel = viewModel() - darkTheme.updateTheme(ThemeType.DARK) - AmethystTheme(darkTheme) { Surface(color = MaterialTheme.colorScheme.background) { onDark() } } + Column { + val darkTheme: SharedPreferencesViewModel = viewModel() + darkTheme.updateTheme(ThemeType.DARK) + AmethystTheme(darkTheme) { Surface(color = MaterialTheme.colorScheme.background) { onDark() } } - val lightTheme: SharedPreferencesViewModel = viewModel() - lightTheme.updateTheme(ThemeType.LIGHT) - AmethystTheme(lightTheme) { - Surface(color = MaterialTheme.colorScheme.background) { onLight() } + val lightTheme: SharedPreferencesViewModel = viewModel() + lightTheme.updateTheme(ThemeType.LIGHT) + AmethystTheme(lightTheme) { + Surface(color = MaterialTheme.colorScheme.background) { onLight() } + } } - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt index 9bfc045cb..482985f5c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt @@ -31,13 +31,13 @@ import com.halilibo.richtext.ui.HeadingStyle // Set of Material typography styles to start with val Typography = - Typography( - bodyLarge = - TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - ), + Typography( + bodyLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + ), /* Other default text styles to override button = TextStyle( fontFamily = FontFamily.Default, @@ -50,7 +50,7 @@ val Typography = fontSize = 12.sp ) */ - ) + ) val Font12SP = 12.sp val Font14SP = 14.sp @@ -62,34 +62,34 @@ val MarkdownTextStyle = TextStyle(lineHeight = 1.30.em) val DefaultParagraphSpacing: TextUnit = 16.sp internal val DefaultHeadingStyle: HeadingStyle = { level, textStyle -> - when (level) { - 0 -> - Typography.displayLarge.copy( - fontSize = 32.sp, - lineHeight = 40.sp, - ) - 1 -> - Typography.displayMedium.copy( - fontSize = 28.sp, - lineHeight = 36.sp, - ) - 2 -> - Typography.displaySmall.copy( - fontSize = 24.sp, - lineHeight = 32.sp, - ) - 3 -> - Typography.headlineLarge.copy( - fontSize = 22.sp, - lineHeight = 26.sp, - ) - 4 -> - Typography.headlineMedium.copy( - fontSize = 20.sp, - lineHeight = 24.sp, - ) - 5 -> Typography.headlineSmall - 6 -> Typography.titleLarge - else -> textStyle - } + when (level) { + 0 -> + Typography.displayLarge.copy( + fontSize = 32.sp, + lineHeight = 40.sp, + ) + 1 -> + Typography.displayMedium.copy( + fontSize = 28.sp, + lineHeight = 36.sp, + ) + 2 -> + Typography.displaySmall.copy( + fontSize = 24.sp, + lineHeight = 32.sp, + ) + 3 -> + Typography.headlineLarge.copy( + fontSize = 22.sp, + lineHeight = 26.sp, + ) + 4 -> + Typography.headlineMedium.copy( + fontSize = 20.sp, + lineHeight = 24.sp, + ) + 5 -> Typography.headlineSmall + 6 -> Typography.titleLarge + else -> textStyle + } } diff --git a/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt b/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt index 76ebba883..4d9bcc839 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt @@ -38,176 +38,178 @@ import java.util.regex.Pattern @Immutable data class ResultOrError( - val result: String?, - val sourceLang: String?, - val targetLang: String?, + val result: String?, + val sourceLang: String?, + val targetLang: String?, ) object LanguageTranslatorService { - var executorService = Executors.newScheduledThreadPool(5) + var executorService = Executors.newScheduledThreadPool(5) - private val options = - LanguageIdentificationOptions.Builder() - .setExecutor(executorService) - .setConfidenceThreshold(0.6f) - .build() - private val languageIdentification = LanguageIdentification.getClient(options) - val lnRegex = Pattern.compile("\\blnbc[a-z0-9]+\\b", Pattern.CASE_INSENSITIVE) - val tagRegex = - Pattern.compile( - "(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)", - Pattern.CASE_INSENSITIVE, - ) + private val options = + LanguageIdentificationOptions.Builder() + .setExecutor(executorService) + .setConfidenceThreshold(0.6f) + .build() + private val languageIdentification = LanguageIdentification.getClient(options) + val lnRegex = Pattern.compile("\\blnbc[a-z0-9]+\\b", Pattern.CASE_INSENSITIVE) + val tagRegex = + Pattern.compile( + "(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)", + Pattern.CASE_INSENSITIVE, + ) - private val translators = - object : LruCache(3) { - override fun create(options: TranslatorOptions): Translator { - return Translation.getClient(options) - } + private val translators = + object : LruCache(3) { + override fun create(options: TranslatorOptions): Translator { + return Translation.getClient(options) + } - override fun entryRemoved( - evicted: Boolean, - key: TranslatorOptions, - oldValue: Translator, - newValue: Translator?, - ) { - oldValue.close() - } - } - - fun clear() { - translators.evictAll() - } - - fun identifyLanguage(text: String): Task { - return languageIdentification.identifyLanguage(text) - } - - fun translate( - text: String, - source: String, - target: String, - ): Task { - checkNotInMainThread() - val sourceLangCode = TranslateLanguage.fromLanguageTag(source) - val targetLangCode = TranslateLanguage.fromLanguageTag(target) - - if (sourceLangCode == null || targetLangCode == null) { - return Tasks.forCanceled() - } - - val options = - TranslatorOptions.Builder() - .setExecutor(executorService) - .setSourceLanguage(sourceLangCode) - .setTargetLanguage(targetLangCode) - .build() - - val translator = translators[options] - - return translator.downloadModelIfNeeded().onSuccessTask(executorService) { - checkNotInMainThread() - - val tasks = mutableListOf>() - val dict = lnDictionary(text) + urlDictionary(text) + tagDictionary(text) - - for (paragraph in encodeDictionary(text, dict).split("\n")) { - tasks.add(translator.translate(paragraph)) - } - - Tasks.whenAll(tasks).continueWith(executorService) { - checkNotInMainThread() - - val results: MutableList = ArrayList() - for (task in tasks) { - val fixedText = - task.result.replace("# [", "#[") // fixes tags that always return with a space - results.add(decodeDictionary(fixedText, dict)) + override fun entryRemoved( + evicted: Boolean, + key: TranslatorOptions, + oldValue: Translator, + newValue: Translator?, + ) { + oldValue.close() + } } - ResultOrError(results.joinToString("\n"), source, target) - } + + fun clear() { + translators.evictAll() } - } - private fun encodeDictionary( - text: String, - dict: Map, - ): String { - var newText = text - for (pair in dict) { - newText = newText.replace(pair.value, pair.key, true) + fun identifyLanguage(text: String): Task { + return languageIdentification.identifyLanguage(text) } - return newText - } - private fun decodeDictionary( - text: String, - dict: Map, - ): String { - var newText = text - for (pair in dict) { - newText = newText.replace(pair.key, pair.value, true) + fun translate( + text: String, + source: String, + target: String, + ): Task { + checkNotInMainThread() + val sourceLangCode = TranslateLanguage.fromLanguageTag(source) + val targetLangCode = TranslateLanguage.fromLanguageTag(target) + + if (sourceLangCode == null || targetLangCode == null) { + return Tasks.forCanceled() + } + + val options = + TranslatorOptions.Builder() + .setExecutor(executorService) + .setSourceLanguage(sourceLangCode) + .setTargetLanguage(targetLangCode) + .build() + + val translator = translators[options] + + return translator.downloadModelIfNeeded().onSuccessTask(executorService) { + checkNotInMainThread() + + val tasks = mutableListOf>() + val dict = lnDictionary(text) + urlDictionary(text) + tagDictionary(text) + + for (paragraph in encodeDictionary(text, dict).split("\n")) { + tasks.add(translator.translate(paragraph)) + } + + Tasks.whenAll(tasks).continueWith(executorService) { + checkNotInMainThread() + + val results: MutableList = ArrayList() + for (task in tasks) { + val fixedText = + task.result.replace("# [", "#[") // fixes tags that always return with a space + results.add(decodeDictionary(fixedText, dict)) + } + ResultOrError(results.joinToString("\n"), source, target) + } + } } - return newText - } - private fun tagDictionary(text: String): Map { - val matcher = tagRegex.matcher(text) - val returningList = mutableMapOf() - var counter = 0 - while (matcher.find()) { - try { - val tag = matcher.group() - val short = "A$counter" - counter++ - returningList.put(short, tag) - } catch (_: Exception) {} + private fun encodeDictionary( + text: String, + dict: Map, + ): String { + var newText = text + for (pair in dict) { + newText = newText.replace(pair.value, pair.key, true) + } + return newText } - return returningList - } - private fun lnDictionary(text: String): Map { - val matcher = lnRegex.matcher(text) - val returningList = mutableMapOf() - var counter = 0 - while (matcher.find()) { - try { - val lnInvoice = matcher.group() - val short = "A$counter" - counter++ - returningList.put(short, lnInvoice) - } catch (_: Exception) {} + private fun decodeDictionary( + text: String, + dict: Map, + ): String { + var newText = text + for (pair in dict) { + newText = newText.replace(pair.key, pair.value, true) + } + return newText } - return returningList - } - private fun urlDictionary(text: String): Map { - val parser = UrlDetector(text, UrlDetectorOptions.Default) - val urlsInText = parser.detect() - - var counter = 0 - - return urlsInText - .filter { !it.originalUrl.contains("๏ผŒ") && !it.originalUrl.contains("ใ€‚") } - .associate { - counter++ - "A$counter" to it.originalUrl - } - } - - fun autoTranslate( - text: String, - dontTranslateFrom: Set, - translateTo: String, - ): Task { - return identifyLanguage(text).onSuccessTask(executorService) { - if (it.equals(translateTo, true)) { - Tasks.forCanceled() - } else if (it != "und" && !dontTranslateFrom.contains(it)) { - translate(text, it, translateTo) - } else { - Tasks.forCanceled() - } + private fun tagDictionary(text: String): Map { + val matcher = tagRegex.matcher(text) + val returningList = mutableMapOf() + var counter = 0 + while (matcher.find()) { + try { + val tag = matcher.group() + val short = "A$counter" + counter++ + returningList.put(short, tag) + } catch (_: Exception) { + } + } + return returningList + } + + private fun lnDictionary(text: String): Map { + val matcher = lnRegex.matcher(text) + val returningList = mutableMapOf() + var counter = 0 + while (matcher.find()) { + try { + val lnInvoice = matcher.group() + val short = "A$counter" + counter++ + returningList.put(short, lnInvoice) + } catch (_: Exception) { + } + } + return returningList + } + + private fun urlDictionary(text: String): Map { + val parser = UrlDetector(text, UrlDetectorOptions.Default) + val urlsInText = parser.detect() + + var counter = 0 + + return urlsInText + .filter { !it.originalUrl.contains("๏ผŒ") && !it.originalUrl.contains("ใ€‚") } + .associate { + counter++ + "A$counter" to it.originalUrl + } + } + + fun autoTranslate( + text: String, + dontTranslateFrom: Set, + translateTo: String, + ): Task { + return identifyLanguage(text).onSuccessTask(executorService) { + if (it.equals(translateTo, true)) { + Tasks.forCanceled() + } else if (it != "und" && !dontTranslateFrom.contains(it)) { + translate(text, it, translateTo) + } else { + Tasks.forCanceled() + } + } } - } } diff --git a/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt index 456c0d0a5..a3dd0067f 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt @@ -31,65 +31,65 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrC import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.GiftWrapEvent -import kotlin.time.measureTimedValue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import kotlin.time.measureTimedValue class PushNotificationReceiverService : FirebaseMessagingService() { - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val eventCache = LruCache(100) + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val eventCache = LruCache(100) - // this is called when a message is received - override fun onMessageReceived(remoteMessage: RemoteMessage) { - Log.d("Time", "Notification received $remoteMessage") - scope.launch(Dispatchers.IO) { - val (value, elapsed) = - measureTimedValue { parseMessage(remoteMessage.data)?.let { receiveIfNew(it) } } - Log.d("Time", "Notification processed in $elapsed") + // this is called when a message is received + override fun onMessageReceived(remoteMessage: RemoteMessage) { + Log.d("Time", "Notification received $remoteMessage") + scope.launch(Dispatchers.IO) { + val (value, elapsed) = + measureTimedValue { parseMessage(remoteMessage.data)?.let { receiveIfNew(it) } } + Log.d("Time", "Notification processed in $elapsed") + } } - } - private suspend fun parseMessage(params: Map): GiftWrapEvent? { - params["encryptedEvent"]?.let { eventStr -> - (Event.fromJson(eventStr) as? GiftWrapEvent)?.let { - return it - } + private suspend fun parseMessage(params: Map): GiftWrapEvent? { + params["encryptedEvent"]?.let { eventStr -> + (Event.fromJson(eventStr) as? GiftWrapEvent)?.let { + return it + } + } + return null } - return null - } - private suspend fun receiveIfNew(event: GiftWrapEvent) { - if (eventCache.get(event.id) == null) { - eventCache.put(event.id, event.id) - EventNotificationConsumer(applicationContext).consume(event) + private suspend fun receiveIfNew(event: GiftWrapEvent) { + if (eventCache.get(event.id) == null) { + eventCache.put(event.id, event.id) + EventNotificationConsumer(applicationContext).consume(event) + } } - } - override fun onCreate() { - super.onCreate() - Log.d("Lifetime Event", "PushNotificationReceiverService.onCreate") - } - - override fun onDestroy() { - Log.d("Lifetime Event", "PushNotificationReceiverService.onDestroy") - - scope.cancel() - super.onDestroy() - } - - override fun onNewToken(token: String) { - scope.launch(Dispatchers.IO) { - RegisterAccounts(LocalPreferences.allSavedAccounts()).go(token) - notificationManager().getOrCreateZapChannel(applicationContext) - notificationManager().getOrCreateDMChannel(applicationContext) + override fun onCreate() { + super.onCreate() + Log.d("Lifetime Event", "PushNotificationReceiverService.onCreate") } - } - fun notificationManager(): NotificationManager { - return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) - as NotificationManager - } + override fun onDestroy() { + Log.d("Lifetime Event", "PushNotificationReceiverService.onDestroy") + + scope.cancel() + super.onDestroy() + } + + override fun onNewToken(token: String) { + scope.launch(Dispatchers.IO) { + RegisterAccounts(LocalPreferences.allSavedAccounts()).go(token) + notificationManager().getOrCreateZapChannel(applicationContext) + notificationManager().getOrCreateDMChannel(applicationContext) + } + } + + fun notificationManager(): NotificationManager { + return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) + as NotificationManager + } } diff --git a/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt index 2810af689..cd1aadf00 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -27,18 +27,18 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.tasks.await object PushNotificationUtils { - var hasInit: Boolean = false + var hasInit: Boolean = false - suspend fun init(accounts: List) = - with(Dispatchers.IO) { - if (hasInit) { - return@with - } - // get user notification token provided by firebase - try { - RegisterAccounts(accounts).go(FirebaseMessaging.getInstance().token.await()) - } catch (e: Exception) { - Log.e("Firebase token", "failed to get firebase token", e) - } - } + suspend fun init(accounts: List) = + with(Dispatchers.IO) { + if (hasInit) { + return@with + } + // get user notification token provided by firebase + try { + RegisterAccounts(accounts).go(FirebaseMessaging.getInstance().token.await()) + } catch (e: Exception) { + Log.e("Firebase token", "failed to get firebase token", e) + } + } } diff --git a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt index 769e03b9e..74d0ae0bd 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.CheckifItNeedsToRequestNoti @OptIn(ExperimentalPermissionsApi::class) @Composable fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesViewModel) { - CheckifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel) + CheckifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel) } @Composable diff --git a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index a95266ef4..cc2e2c5b2 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -62,316 +62,317 @@ import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.quartz.events.ImmutableListOfLists -import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.util.Locale @Composable fun TranslatableRichTextViewer( - content: String, - canPreview: Boolean, - modifier: Modifier = Modifier, - tags: ImmutableListOfLists, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + content: String, + canPreview: Boolean, + modifier: Modifier = Modifier, + tags: ImmutableListOfLists, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var translatedTextState by - remember(content) { mutableStateOf(TranslationConfig(content, null, null, false)) } + var translatedTextState by + remember(content) { mutableStateOf(TranslationConfig(content, null, null, false)) } - TranslateAndWatchLanguageChanges(content, accountViewModel) { result -> - if ( - !translatedTextState.result.equals(result.result, true) || - translatedTextState.sourceLang != result.sourceLang || - translatedTextState.targetLang != result.targetLang - ) { - translatedTextState = result + TranslateAndWatchLanguageChanges(content, accountViewModel) { result -> + if ( + !translatedTextState.result.equals(result.result, true) || + translatedTextState.sourceLang != result.sourceLang || + translatedTextState.targetLang != result.targetLang + ) { + translatedTextState = result + } } - } - Crossfade(targetState = translatedTextState) { - RenderText( - it, - content, - canPreview, - modifier, - tags, - backgroundColor, - accountViewModel, - nav, - ) - } + Crossfade(targetState = translatedTextState) { + RenderText( + it, + content, + canPreview, + modifier, + tags, + backgroundColor, + accountViewModel, + nav, + ) + } } @Composable private fun RenderText( - translatedTextState: TranslationConfig, - content: String, - canPreview: Boolean, - modifier: Modifier, - tags: ImmutableListOfLists, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, + translatedTextState: TranslationConfig, + content: String, + canPreview: Boolean, + modifier: Modifier, + tags: ImmutableListOfLists, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var showOriginal by - remember(translatedTextState) { mutableStateOf(translatedTextState.showOriginal) } + var showOriginal by + remember(translatedTextState) { mutableStateOf(translatedTextState.showOriginal) } - val toBeViewed by - remember(translatedTextState) { - derivedStateOf { if (showOriginal) content else translatedTextState.result ?: content } + val toBeViewed by + remember(translatedTextState) { + derivedStateOf { if (showOriginal) content else translatedTextState.result ?: content } + } + + Column { + ExpandableRichTextViewer( + toBeViewed, + canPreview, + modifier, + tags, + backgroundColor, + accountViewModel, + nav, + ) + + if ( + translatedTextState.sourceLang != null && + translatedTextState.targetLang != null && + translatedTextState.sourceLang != translatedTextState.targetLang + ) { + TranslationMessage( + translatedTextState.sourceLang, + translatedTextState.targetLang, + accountViewModel, + ) { + showOriginal = it + } + } } - - Column { - ExpandableRichTextViewer( - toBeViewed, - canPreview, - modifier, - tags, - backgroundColor, - accountViewModel, - nav, - ) - - if ( - translatedTextState.sourceLang != null && - translatedTextState.targetLang != null && - translatedTextState.sourceLang != translatedTextState.targetLang - ) { - TranslationMessage( - translatedTextState.sourceLang, - translatedTextState.targetLang, - accountViewModel, - ) { - showOriginal = it - } - } - } } @Composable private fun TranslationMessage( - source: String, - target: String, - accountViewModel: AccountViewModel, - onChangeWhatToShow: (Boolean) -> Unit, + source: String, + target: String, + accountViewModel: AccountViewModel, + onChangeWhatToShow: (Boolean) -> Unit, ) { - var langSettingsPopupExpanded by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() + var langSettingsPopupExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() - Row( - modifier = Modifier.fillMaxWidth().padding(top = 5.dp), - ) { - val clickableTextStyle = SpanStyle(color = MaterialTheme.colorScheme.lessImportantLink) - - val annotatedTranslationString = buildAnnotatedString { - withStyle(clickableTextStyle) { - pushStringAnnotation("langSettings", true.toString()) - append(stringResource(R.string.translations_auto)) - } - - append("-${stringResource(R.string.translations_translated_from)} ") - - withStyle(clickableTextStyle) { - pushStringAnnotation("showOriginal", true.toString()) - append(Locale(source).displayName) - } - - append(" ${stringResource(R.string.translations_to)} ") - - withStyle(clickableTextStyle) { - pushStringAnnotation("showOriginal", false.toString()) - append(Locale(target).displayName) - } - } - - ClickableText( - text = annotatedTranslationString, - style = - LocalTextStyle.current.copy( - color = - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ), - ), - overflow = TextOverflow.Visible, - maxLines = 3, - ) { spanOffset -> - annotatedTranslationString.getStringAnnotations(spanOffset, spanOffset).firstOrNull()?.also { - span -> - if (span.tag == "showOriginal") { - onChangeWhatToShow(span.item.toBoolean()) - } else { - langSettingsPopupExpanded = !langSettingsPopupExpanded - } - } - } - - DropdownMenu( - expanded = langSettingsPopupExpanded, - onDismissRequest = { langSettingsPopupExpanded = false }, + Row( + modifier = Modifier.fillMaxWidth().padding(top = 5.dp), ) { - DropdownMenuItem( - text = { - if (source in accountViewModel.account.dontTranslateFrom) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - } else { - Spacer(modifier = Modifier.size(24.dp)) - } + val clickableTextStyle = SpanStyle(color = MaterialTheme.colorScheme.lessImportantLink) - Spacer(modifier = Modifier.size(10.dp)) + val annotatedTranslationString = + buildAnnotatedString { + withStyle(clickableTextStyle) { + pushStringAnnotation("langSettings", true.toString()) + append(stringResource(R.string.translations_auto)) + } - Text( - stringResource( - R.string.translations_never_translate_from_lang, - Locale(source).displayName, - ), - ) - }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.dontTranslateFrom(source) - langSettingsPopupExpanded = false - } - }, - ) - Divider() - DropdownMenuItem( - text = { - if (accountViewModel.account.preferenceBetween(source, target) == source) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - } else { - Spacer(modifier = Modifier.size(24.dp)) - } + append("-${stringResource(R.string.translations_translated_from)} ") - Spacer(modifier = Modifier.size(10.dp)) + withStyle(clickableTextStyle) { + pushStringAnnotation("showOriginal", true.toString()) + append(Locale(source).displayName) + } - Text( - stringResource( - R.string.translations_show_in_lang_first, - Locale(source).displayName, - ), - ) - }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.prefer(source, target, source) - langSettingsPopupExpanded = false - } - }, - ) - DropdownMenuItem( - text = { - if (accountViewModel.account.preferenceBetween(source, target) == target) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - } else { - Spacer(modifier = Modifier.size(24.dp)) - } + append(" ${stringResource(R.string.translations_to)} ") - Spacer(modifier = Modifier.size(10.dp)) + withStyle(clickableTextStyle) { + pushStringAnnotation("showOriginal", false.toString()) + append(Locale(target).displayName) + } + } - Text( - stringResource( - R.string.translations_show_in_lang_first, - Locale(target).displayName, - ), - ) - }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.prefer(source, target, target) - langSettingsPopupExpanded = false - } - }, - ) - Divider() - - val languageList = ConfigurationCompat.getLocales(Resources.getSystem().configuration) - for (i in 0 until languageList.size()) { - languageList.get(i)?.let { lang -> - DropdownMenuItem( - text = { - if (lang.language in accountViewModel.account.translateTo) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - } else { - Spacer(modifier = Modifier.size(24.dp)) - } - - Spacer(modifier = Modifier.size(10.dp)) - - Text( - stringResource( - R.string.translations_always_translate_to_lang, - lang.displayName, + ClickableText( + text = annotatedTranslationString, + style = + LocalTextStyle.current.copy( + color = + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.32f, + ), ), - ) - }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.translateTo(lang) - langSettingsPopupExpanded = false - } - }, - ) + overflow = TextOverflow.Visible, + maxLines = 3, + ) { spanOffset -> + annotatedTranslationString.getStringAnnotations(spanOffset, spanOffset).firstOrNull()?.also { + span -> + if (span.tag == "showOriginal") { + onChangeWhatToShow(span.item.toBoolean()) + } else { + langSettingsPopupExpanded = !langSettingsPopupExpanded + } + } + } + + DropdownMenu( + expanded = langSettingsPopupExpanded, + onDismissRequest = { langSettingsPopupExpanded = false }, + ) { + DropdownMenuItem( + text = { + if (source in accountViewModel.account.dontTranslateFrom) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) + } + + Spacer(modifier = Modifier.size(10.dp)) + + Text( + stringResource( + R.string.translations_never_translate_from_lang, + Locale(source).displayName, + ), + ) + }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.dontTranslateFrom(source) + langSettingsPopupExpanded = false + } + }, + ) + Divider() + DropdownMenuItem( + text = { + if (accountViewModel.account.preferenceBetween(source, target) == source) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) + } + + Spacer(modifier = Modifier.size(10.dp)) + + Text( + stringResource( + R.string.translations_show_in_lang_first, + Locale(source).displayName, + ), + ) + }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.prefer(source, target, source) + langSettingsPopupExpanded = false + } + }, + ) + DropdownMenuItem( + text = { + if (accountViewModel.account.preferenceBetween(source, target) == target) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) + } + + Spacer(modifier = Modifier.size(10.dp)) + + Text( + stringResource( + R.string.translations_show_in_lang_first, + Locale(target).displayName, + ), + ) + }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.prefer(source, target, target) + langSettingsPopupExpanded = false + } + }, + ) + Divider() + + val languageList = ConfigurationCompat.getLocales(Resources.getSystem().configuration) + for (i in 0 until languageList.size()) { + languageList.get(i)?.let { lang -> + DropdownMenuItem( + text = { + if (lang.language in accountViewModel.account.translateTo) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) + } + + Spacer(modifier = Modifier.size(10.dp)) + + Text( + stringResource( + R.string.translations_always_translate_to_lang, + lang.displayName, + ), + ) + }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.translateTo(lang) + langSettingsPopupExpanded = false + } + }, + ) + } + } } - } } - } } @Composable fun TranslateAndWatchLanguageChanges( - content: String, - accountViewModel: AccountViewModel, - onTranslated: (TranslationConfig) -> Unit, + content: String, + accountViewModel: AccountViewModel, + onTranslated: (TranslationConfig) -> Unit, ) { - val accountState by accountViewModel.accountLanguagesLiveData.observeAsState() + val accountState by accountViewModel.accountLanguagesLiveData.observeAsState() - LaunchedEffect(accountState) { - // This takes some time. Launches as a Composition scope to make sure this gets cancel if this - // item gets out of view. - launch(Dispatchers.IO) { - LanguageTranslatorService.autoTranslate( - content, - accountViewModel.account.dontTranslateFrom, - accountViewModel.account.translateTo, - ) - .addOnCompleteListener { task -> - if (task.isSuccessful && !content.equals(task.result.result, true)) { - if (task.result.sourceLang != null && task.result.targetLang != null) { - val preference = - accountViewModel.account.preferenceBetween( - task.result.sourceLang!!, - task.result.targetLang!!, - ) - val newConfig = - TranslationConfig( - result = task.result.result, - sourceLang = task.result.sourceLang, - targetLang = task.result.targetLang, - showOriginal = preference == task.result.sourceLang, - ) + LaunchedEffect(accountState) { + // This takes some time. Launches as a Composition scope to make sure this gets cancel if this + // item gets out of view. + launch(Dispatchers.IO) { + LanguageTranslatorService.autoTranslate( + content, + accountViewModel.account.dontTranslateFrom, + accountViewModel.account.translateTo, + ) + .addOnCompleteListener { task -> + if (task.isSuccessful && !content.equals(task.result.result, true)) { + if (task.result.sourceLang != null && task.result.targetLang != null) { + val preference = + accountViewModel.account.preferenceBetween( + task.result.sourceLang!!, + task.result.targetLang!!, + ) + val newConfig = + TranslationConfig( + result = task.result.result, + sourceLang = task.result.sourceLang, + targetLang = task.result.targetLang, + showOriginal = preference == task.result.sourceLang, + ) - onTranslated(newConfig) - } - } + onTranslated(newConfig) + } + } + } } } - } } diff --git a/app/src/test/java/com/vitorpamplona/amethyst/CharsetTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/CharsetTest.kt index 47c67de5c..84c92f23c 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/CharsetTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/CharsetTest.kt @@ -25,79 +25,79 @@ import org.junit.Assert import org.junit.Test class CharsetTest { - @Test - fun testASCIIChar() { - Assert.assertEquals("H", "Hi".firstFullChar()) - } + @Test + fun testASCIIChar() { + Assert.assertEquals("H", "Hi".firstFullChar()) + } - @Test - fun testUTF16JoinChar() { - Assert.assertEquals("\uD83C\uDF48", "\uD83C\uDF48Hi".firstFullChar()) - } + @Test + fun testUTF16JoinChar() { + Assert.assertEquals("\uD83C\uDF48", "\uD83C\uDF48Hi".firstFullChar()) + } - @Test - fun testUTF32JoinChar() { - Assert.assertEquals("\uD83E\uDDD1\uD83C\uDFFE", "\uD83E\uDDD1\uD83C\uDFFEHi".firstFullChar()) - } + @Test + fun testUTF32JoinChar() { + Assert.assertEquals("\uD83E\uDDD1\uD83C\uDFFE", "\uD83E\uDDD1\uD83C\uDFFEHi".firstFullChar()) + } - @Test - fun testUTF32JoinChar2() { - Assert.assertEquals("\uD83E\uDDD1\uD83C\uDFFE", "\uD83E\uDDD1\uD83C\uDFFEHi".firstFullChar()) - } + @Test + fun testUTF32JoinChar2() { + Assert.assertEquals("\uD83E\uDDD1\uD83C\uDFFE", "\uD83E\uDDD1\uD83C\uDFFEHi".firstFullChar()) + } - @Test - fun testAsciiWithUTF32Char() { - Assert.assertEquals("H", "Hi\uD83E\uDDD1\uD83C\uDFFEHi".firstFullChar()) - } + @Test + fun testAsciiWithUTF32Char() { + Assert.assertEquals("H", "Hi\uD83E\uDDD1\uD83C\uDFFEHi".firstFullChar()) + } - @Test - fun testBlank() { - Assert.assertEquals("", "".firstFullChar()) - } + @Test + fun testBlank() { + Assert.assertEquals("", "".firstFullChar()) + } - @Test - fun testSpecialChars() { - Assert.assertEquals("=", "=x".firstFullChar()) - } + @Test + fun testSpecialChars() { + Assert.assertEquals("=", "=x".firstFullChar()) + } - @Test - fun test5CharEmoji() { - Assert.assertEquals( - "\uD83D\uDC68\u200D\uD83D\uDCBB", - "\uD83D\uDC68\u200D\uD83D\uDCBBadsfasdf".firstFullChar(), - ) - } + @Test + fun test5CharEmoji() { + Assert.assertEquals( + "\uD83D\uDC68\u200D\uD83D\uDCBB", + "\uD83D\uDC68\u200D\uD83D\uDCBBadsfasdf".firstFullChar(), + ) + } - @Test - fun testFamily() { - Assert.assertEquals( - "\uD83D\uDC68\u200d\uD83D\uDC69\u200d\uD83D\uDC67\u200d\uD83D\uDC67", - "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC67adsfasdf".firstFullChar(), - ) - } + @Test + fun testFamily() { + Assert.assertEquals( + "\uD83D\uDC68\u200d\uD83D\uDC69\u200d\uD83D\uDC67\u200d\uD83D\uDC67", + "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC67adsfasdf".firstFullChar(), + ) + } - @Test - fun testTeacher() { - Assert.assertEquals( - "\uD83E\uDDD1\uD83C\uDFFF\u200D\uD83C\uDFEB", - "\uD83E\uDDD1\uD83C\uDFFF\u200D\uD83C\uDFEBasdf".firstFullChar(), - ) - } + @Test + fun testTeacher() { + Assert.assertEquals( + "\uD83E\uDDD1\uD83C\uDFFF\u200D\uD83C\uDFEB", + "\uD83E\uDDD1\uD83C\uDFFF\u200D\uD83C\uDFEBasdf".firstFullChar(), + ) + } - @Test - fun testVariation() { - Assert.assertEquals( - "\uD83D\uDC68\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68", - "\uD83D\uDC68\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68ddd".firstFullChar(), - ) - } + @Test + fun testVariation() { + Assert.assertEquals( + "\uD83D\uDC68\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68", + "\uD83D\uDC68\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68ddd".firstFullChar(), + ) + } - @Test - fun testMultipleEmoji() { - Assert.assertEquals( - "\uD83E\uDEC2\uD83E\uDEC2", - "\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2" - .firstFullChar(), - ) - } + @Test + fun testMultipleEmoji() { + Assert.assertEquals( + "\uD83E\uDEC2\uD83E\uDEC2", + "\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2" + .firstFullChar(), + ) + } } diff --git a/app/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt index 00ef2ad85..e3fd477e9 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt @@ -34,156 +34,156 @@ import org.junit.Test * See [testing documentation](http://d.android.com/tools/testing). */ class NewMessageTaggerKeyParseTest { - val dao: Dao = - object : Dao { - override suspend fun getOrCreateUser(hex: String): User { - return User(hex) - } + val dao: Dao = + object : Dao { + override suspend fun getOrCreateUser(hex: String): User { + return User(hex) + } - override suspend fun getOrCreateNote(hex: String): Note { - return Note(hex) - } + override suspend fun getOrCreateNote(hex: String): Note { + return Note(hex) + } - override suspend fun checkGetOrCreateAddressableNote(hex: String): Note? { - return Note(hex) - } + override suspend fun checkGetOrCreateAddressableNote(hex: String): Note? { + return Note(hex) + } + } + + @Test + fun keyParseTestNote() { + val result = + NewMessageTagger(message = "", dao = dao) + .parseDirtyWordForKey("note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn") + assertEquals(Nip19.Type.NOTE, result?.key?.type) + assertEquals( + "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", + result?.key?.hex, + ) + assertEquals("", result?.restOfWord) } - @Test - fun keyParseTestNote() { - val result = - NewMessageTagger(message = "", dao = dao) - .parseDirtyWordForKey("note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn") - assertEquals(Nip19.Type.NOTE, result?.key?.type) - assertEquals( - "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", - result?.key?.hex, - ) - assertEquals("", result?.restOfWord) - } - - @Test - fun keyParseTestPub() { - val result = - NewMessageTagger(message = "", dao = dao) - .parseDirtyWordForKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z") - assertEquals(Nip19.Type.USER, result?.key?.type) - assertEquals( - "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", - result?.key?.hex, - ) - assertEquals("", result?.restOfWord) - } - - @Test - fun keyParseTestNoteWithExtraChars() { - val result = - NewMessageTagger(message = "", dao = dao) - .parseDirtyWordForKey("note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") - assertEquals(Nip19.Type.NOTE, result?.key?.type) - assertEquals( - "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", - result?.key?.hex, - ) - assertEquals(",", result?.restOfWord) - } - - @Test - fun keyParseTestPubWithExtraChars() { - val result = - NewMessageTagger(message = "", dao = dao) - .parseDirtyWordForKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,") - assertEquals(Nip19.Type.USER, result?.key?.type) - assertEquals( - "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", - result?.key?.hex, - ) - assertEquals(",", result?.restOfWord) - } - - @Test - fun keyParseTestNoteWithExtraCharsAndAt() { - val result = - NewMessageTagger(message = "", dao = dao) - .parseDirtyWordForKey("@note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") - assertEquals(Nip19.Type.NOTE, result?.key?.type) - assertEquals( - "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", - result?.key?.hex, - ) - assertEquals(",", result?.restOfWord) - } - - @Test - fun keyParseTestPubWithExtraCharsAndAt() { - val result = - NewMessageTagger(message = "", dao = dao) - .parseDirtyWordForKey("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,") - assertEquals(Nip19.Type.USER, result?.key?.type) - assertEquals( - "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", - result?.key?.hex, - ) - assertEquals(",", result?.restOfWord) - } - - @Test - fun keyParseTestNoteWithExtraCharsAndNostrPrefix() { - val result = - NewMessageTagger(message = "", dao = dao) - .parseDirtyWordForKey( - "nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,", + @Test + fun keyParseTestPub() { + val result = + NewMessageTagger(message = "", dao = dao) + .parseDirtyWordForKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z") + assertEquals(Nip19.Type.USER, result?.key?.type) + assertEquals( + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + result?.key?.hex, ) - assertEquals(Nip19.Type.NOTE, result?.key?.type) - assertEquals( - "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", - result?.key?.hex, - ) - assertEquals(",", result?.restOfWord) - } + assertEquals("", result?.restOfWord) + } - @Test - fun keyParseTestPubWithExtraCharsAndNostrPrefix() { - val result = - NewMessageTagger(message = "", dao = dao) - .parseDirtyWordForKey( - "nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,", + @Test + fun keyParseTestNoteWithExtraChars() { + val result = + NewMessageTagger(message = "", dao = dao) + .parseDirtyWordForKey("note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") + assertEquals(Nip19.Type.NOTE, result?.key?.type) + assertEquals( + "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", + result?.key?.hex, ) - assertEquals(Nip19.Type.USER, result?.key?.type) - assertEquals( - "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", - result?.key?.hex, - ) - assertEquals(",", result?.restOfWord) - } + assertEquals(",", result?.restOfWord) + } - @Test - fun keyParseTestUppercaseNoteWithExtraCharsAndNostrPrefix() { - val result = - NewMessageTagger(message = "", dao = dao) - .parseDirtyWordForKey( - "Nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,", + @Test + fun keyParseTestPubWithExtraChars() { + val result = + NewMessageTagger(message = "", dao = dao) + .parseDirtyWordForKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,") + assertEquals(Nip19.Type.USER, result?.key?.type) + assertEquals( + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + result?.key?.hex, ) - assertEquals(Nip19.Type.NOTE, result?.key?.type) - assertEquals( - "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", - result?.key?.hex, - ) - assertEquals(",", result?.restOfWord) - } + assertEquals(",", result?.restOfWord) + } - @Test - fun keyParseTestUppercasePubWithExtraCharsAndNostrPrefix() { - val result = - NewMessageTagger(message = "", dao = dao) - .parseDirtyWordForKey( - "nOstr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,", + @Test + fun keyParseTestNoteWithExtraCharsAndAt() { + val result = + NewMessageTagger(message = "", dao = dao) + .parseDirtyWordForKey("@note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") + assertEquals(Nip19.Type.NOTE, result?.key?.type) + assertEquals( + "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", + result?.key?.hex, ) - assertEquals(Nip19.Type.USER, result?.key?.type) - assertEquals( - "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", - result?.key?.hex, - ) - assertEquals(",", result?.restOfWord) - } + assertEquals(",", result?.restOfWord) + } + + @Test + fun keyParseTestPubWithExtraCharsAndAt() { + val result = + NewMessageTagger(message = "", dao = dao) + .parseDirtyWordForKey("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,") + assertEquals(Nip19.Type.USER, result?.key?.type) + assertEquals( + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + result?.key?.hex, + ) + assertEquals(",", result?.restOfWord) + } + + @Test + fun keyParseTestNoteWithExtraCharsAndNostrPrefix() { + val result = + NewMessageTagger(message = "", dao = dao) + .parseDirtyWordForKey( + "nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,", + ) + assertEquals(Nip19.Type.NOTE, result?.key?.type) + assertEquals( + "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", + result?.key?.hex, + ) + assertEquals(",", result?.restOfWord) + } + + @Test + fun keyParseTestPubWithExtraCharsAndNostrPrefix() { + val result = + NewMessageTagger(message = "", dao = dao) + .parseDirtyWordForKey( + "nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,", + ) + assertEquals(Nip19.Type.USER, result?.key?.type) + assertEquals( + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + result?.key?.hex, + ) + assertEquals(",", result?.restOfWord) + } + + @Test + fun keyParseTestUppercaseNoteWithExtraCharsAndNostrPrefix() { + val result = + NewMessageTagger(message = "", dao = dao) + .parseDirtyWordForKey( + "Nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,", + ) + assertEquals(Nip19.Type.NOTE, result?.key?.type) + assertEquals( + "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", + result?.key?.hex, + ) + assertEquals(",", result?.restOfWord) + } + + @Test + fun keyParseTestUppercasePubWithExtraCharsAndNostrPrefix() { + val result = + NewMessageTagger(message = "", dao = dao) + .parseDirtyWordForKey( + "nOstr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,", + ) + assertEquals(Nip19.Type.USER, result?.key?.type) + assertEquals( + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + result?.key?.hex, + ) + assertEquals(",", result?.restOfWord) + } } diff --git a/app/src/test/java/com/vitorpamplona/amethyst/SplitterTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/SplitterTest.kt index 9f5f6bee3..7912d7703 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/SplitterTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/SplitterTest.kt @@ -37,96 +37,97 @@ import org.junit.Before import org.junit.Test class SplitterTest { - @SpyK var mySplit = Split() + @SpyK var mySplit = Split() - @Before - fun setUp() { - mockkStatic(Looper::class) - every { Looper.myLooper() } returns mockk() - every { Looper.getMainLooper() } returns mockk() - MockKAnnotations.init(this) - } + @Before + fun setUp() { + mockkStatic(Looper::class) + every { Looper.myLooper() } returns mockk() + every { Looper.getMainLooper() } returns mockk() + MockKAnnotations.init(this) + } - @After - fun tearDown() { - unmockkAll() - } + @After + fun tearDown() { + unmockkAll() + } - @Test - fun testSplit() = runBlocking { - val vitor = mySplit.addItem("Vitor") + @Test + fun testSplit() = + runBlocking { + val vitor = mySplit.addItem("Vitor") - assertEquals(1f, mySplit.items[vitor].percentage, 0.01f) - assertTrue(mySplit.isEqualSplit()) + assertEquals(1f, mySplit.items[vitor].percentage, 0.01f) + assertTrue(mySplit.isEqualSplit()) - val pablo = mySplit.addItem("Pablo") + val pablo = mySplit.addItem("Pablo") - assertEquals(0.5f, mySplit.items[pablo].percentage, 0.01f) - assertEquals(0.5f, mySplit.items[vitor].percentage, 0.01f) - assertTrue(mySplit.isEqualSplit()) + assertEquals(0.5f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.5f, mySplit.items[vitor].percentage, 0.01f) + assertTrue(mySplit.isEqualSplit()) - val gigi = mySplit.addItem("Gigi") + val gigi = mySplit.addItem("Gigi") - assertEquals(0.33f, mySplit.items[vitor].percentage, 0.01f) - assertEquals(0.33f, mySplit.items[pablo].percentage, 0.01f) - assertEquals(0.33f, mySplit.items[gigi].percentage, 0.01f) - assertTrue(mySplit.isEqualSplit()) + assertEquals(0.33f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.33f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.33f, mySplit.items[gigi].percentage, 0.01f) + assertTrue(mySplit.isEqualSplit()) - mySplit.updatePercentage(vitor, 0.5f) + mySplit.updatePercentage(vitor, 0.5f) - assertEquals(0.5f, mySplit.items[vitor].percentage, 0.01f) - assertEquals(0.33f, mySplit.items[pablo].percentage, 0.01f) - assertEquals(0.16f, mySplit.items[gigi].percentage, 0.01f) - assertFalse(mySplit.isEqualSplit()) + assertEquals(0.5f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.33f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.16f, mySplit.items[gigi].percentage, 0.01f) + assertFalse(mySplit.isEqualSplit()) - mySplit.updatePercentage(vitor, 0.95f) + mySplit.updatePercentage(vitor, 0.95f) - assertEquals(0.95f, mySplit.items[vitor].percentage, 0.01f) - assertEquals(0.05f, mySplit.items[pablo].percentage, 0.01f) - assertEquals(0.0f, mySplit.items[gigi].percentage, 0.01f) - assertFalse(mySplit.isEqualSplit()) + assertEquals(0.95f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.05f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.0f, mySplit.items[gigi].percentage, 0.01f) + assertFalse(mySplit.isEqualSplit()) - mySplit.updatePercentage(vitor, 0.15f) + mySplit.updatePercentage(vitor, 0.15f) - assertEquals(0.15f, mySplit.items[vitor].percentage, 0.01f) - assertEquals(0.05f, mySplit.items[pablo].percentage, 0.01f) - assertEquals(0.80f, mySplit.items[gigi].percentage, 0.01f) - assertFalse(mySplit.isEqualSplit()) + assertEquals(0.15f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.05f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.80f, mySplit.items[gigi].percentage, 0.01f) + assertFalse(mySplit.isEqualSplit()) - mySplit.updatePercentage(pablo, 0.95f) + mySplit.updatePercentage(pablo, 0.95f) - assertEquals(0.05f, mySplit.items[vitor].percentage, 0.01f) - assertEquals(0.95f, mySplit.items[pablo].percentage, 0.01f) - assertEquals(0.00f, mySplit.items[gigi].percentage, 0.01f) + assertEquals(0.05f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.95f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.00f, mySplit.items[gigi].percentage, 0.01f) - mySplit.updatePercentage(gigi, 1f) + mySplit.updatePercentage(gigi, 1f) - assertEquals(0.00f, mySplit.items[vitor].percentage, 0.01f) - assertEquals(0.00f, mySplit.items[pablo].percentage, 0.01f) - assertEquals(1.00f, mySplit.items[gigi].percentage, 0.01f) + assertEquals(0.00f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.00f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(1.00f, mySplit.items[gigi].percentage, 0.01f) - mySplit.updatePercentage(vitor, 0.5f) + mySplit.updatePercentage(vitor, 0.5f) - assertEquals(0.50f, mySplit.items[vitor].percentage, 0.01f) - assertEquals(0.00f, mySplit.items[pablo].percentage, 0.01f) - assertEquals(0.50f, mySplit.items[gigi].percentage, 0.01f) + assertEquals(0.50f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.00f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.50f, mySplit.items[gigi].percentage, 0.01f) - mySplit.updatePercentage(pablo, 0.3f) + mySplit.updatePercentage(pablo, 0.3f) - assertEquals(0.50f, mySplit.items[vitor].percentage, 0.01f) - assertEquals(0.30f, mySplit.items[pablo].percentage, 0.01f) - assertEquals(0.20f, mySplit.items[gigi].percentage, 0.01f) + assertEquals(0.50f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.30f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.20f, mySplit.items[gigi].percentage, 0.01f) - mySplit.updatePercentage(gigi, 1f) + mySplit.updatePercentage(gigi, 1f) - assertEquals(0.00f, mySplit.items[vitor].percentage, 0.01f) - assertEquals(0.00f, mySplit.items[pablo].percentage, 0.01f) - assertEquals(1.00f, mySplit.items[gigi].percentage, 0.01f) + assertEquals(0.00f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.00f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(1.00f, mySplit.items[gigi].percentage, 0.01f) - mySplit.updatePercentage(gigi, 0.5f) + mySplit.updatePercentage(gigi, 0.5f) - assertEquals(0.00f, mySplit.items[vitor].percentage, 0.01f) - assertEquals(0.50f, mySplit.items[pablo].percentage, 0.01f) - assertEquals(0.50f, mySplit.items[gigi].percentage, 0.01f) - } + assertEquals(0.00f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.50f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.50f, mySplit.items[gigi].percentage, 0.01f) + } } diff --git a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifierTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifierTest.kt index 0303bcd8a..1e2abe7ed 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifierTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifierTest.kt @@ -37,108 +37,110 @@ import org.junit.Before import org.junit.Test class Nip05NostrAddressVerifierTest { - companion object { - private val ALL_UPPER_CASE_USER_NAME = "ONETWO" - private val ALL_LOWER_CASE_USER_NAME = "onetwo" - } + companion object { + private val ALL_UPPER_CASE_USER_NAME = "ONETWO" + private val ALL_LOWER_CASE_USER_NAME = "onetwo" + } - @SpyK var nip05Verifier = Nip05NostrAddressVerifier() + @SpyK var nip05Verifier = Nip05NostrAddressVerifier() - @Before - fun setUp() { - mockkStatic(Looper::class) - every { Looper.myLooper() } returns mockk() - every { Looper.getMainLooper() } returns mockk() - MockKAnnotations.init(this) - } + @Before + fun setUp() { + mockkStatic(Looper::class) + every { Looper.myLooper() } returns mockk() + every { Looper.getMainLooper() } returns mockk() + MockKAnnotations.init(this) + } - @Test - fun `test with matching case on user name`() = runBlocking { - // Set-up - val userNameToTest = ALL_UPPER_CASE_USER_NAME - val expectedPubKey = "ca29c211f1c72d5b6622268ff43d2288ea2b2cb5b9aa196ff9f1704fc914b71b" + @Test + fun `test with matching case on user name`() = + runBlocking { + // Set-up + val userNameToTest = ALL_UPPER_CASE_USER_NAME + val expectedPubKey = "ca29c211f1c72d5b6622268ff43d2288ea2b2cb5b9aa196ff9f1704fc914b71b" - val nostrJson = - "{\n" + " \"names\": {\n" + " \"$userNameToTest\": \"$expectedPubKey\" \n" + " }\n" + "}" + val nostrJson = + "{\n" + " \"names\": {\n" + " \"$userNameToTest\": \"$expectedPubKey\" \n" + " }\n" + "}" - coEvery { nip05Verifier.fetchNip05Json(any(), any(), any()) } answers - { - secondArg<(String) -> Unit>().invoke(nostrJson) - } + coEvery { nip05Verifier.fetchNip05Json(any(), any(), any()) } answers + { + secondArg<(String) -> Unit>().invoke(nostrJson) + } - val nip05 = "$userNameToTest@domain.com" - var actualPubkeyHex = "" + val nip05 = "$userNameToTest@domain.com" + var actualPubkeyHex = "" - // Execution - nip05Verifier.verifyNip05( - nip05, - onSuccess = { actualPubkeyHex = it }, - onError = { fail("Test failure") }, - ) + // Execution + nip05Verifier.verifyNip05( + nip05, + onSuccess = { actualPubkeyHex = it }, + onError = { fail("Test failure") }, + ) - // Verification - assertEquals(expectedPubKey, actualPubkeyHex) - } + // Verification + assertEquals(expectedPubKey, actualPubkeyHex) + } - @Test - fun `test with NOT matching case on user name`() = runBlocking { - // Set-up - val expectedPubKey = "ca29c211f1c72d5b6622268ff43d2288ea2b2cb5b9aa196ff9f1704fc914b71b" + @Test + fun `test with NOT matching case on user name`() = + runBlocking { + // Set-up + val expectedPubKey = "ca29c211f1c72d5b6622268ff43d2288ea2b2cb5b9aa196ff9f1704fc914b71b" - val nostrJson = - "{\n" + - " \"names\": {\n" + - " \"$ALL_UPPER_CASE_USER_NAME\": \"$expectedPubKey\" \n" + - " }\n" + - "}" - coEvery { nip05Verifier.fetchNip05Json(any(), any(), any()) } answers - { - secondArg<(String) -> Unit>().invoke(nostrJson) - } + val nostrJson = + "{\n" + + " \"names\": {\n" + + " \"$ALL_UPPER_CASE_USER_NAME\": \"$expectedPubKey\" \n" + + " }\n" + + "}" + coEvery { nip05Verifier.fetchNip05Json(any(), any(), any()) } answers + { + secondArg<(String) -> Unit>().invoke(nostrJson) + } - val nip05 = "$ALL_LOWER_CASE_USER_NAME@domain.com" - var actualPubkeyHex = "" + val nip05 = "$ALL_LOWER_CASE_USER_NAME@domain.com" + var actualPubkeyHex = "" - // Execution - nip05Verifier.verifyNip05( - nip05, - onSuccess = { actualPubkeyHex = it }, - onError = { fail("Test failure") }, - ) + // Execution + nip05Verifier.verifyNip05( + nip05, + onSuccess = { actualPubkeyHex = it }, + onError = { fail("Test failure") }, + ) - // Verification - assertEquals(expectedPubKey, actualPubkeyHex) - } + // Verification + assertEquals(expectedPubKey, actualPubkeyHex) + } - @After - fun tearDown() { - unmockkAll() - } + @After + fun tearDown() { + unmockkAll() + } - @Test - fun `execute assemble url with invalid value returns null`() { - // given - val nip05address = "this@that@that.com" + @Test + fun `execute assemble url with invalid value returns null`() { + // given + val nip05address = "this@that@that.com" - // when - val actualValue = nip05Verifier.assembleUrl(nip05address) + // when + val actualValue = nip05Verifier.assembleUrl(nip05address) - // then - assertNull(actualValue) - } + // then + assertNull(actualValue) + } - @Test - fun `execute assemble url with valid value returns nip05 url`() { - // given - val userName = "TheUser" - val domain = "domain.com" - val nip05address = "$userName@$domain" - val expectedValue = "https://$domain/.well-known/nostr.json?name=$userName" + @Test + fun `execute assemble url with valid value returns nip05 url`() { + // given + val userName = "TheUser" + val domain = "domain.com" + val nip05address = "$userName@$domain" + val expectedValue = "https://$domain/.well-known/nostr.json?name=$userName" - // when - val actualValue = nip05Verifier.assembleUrl(nip05address) + // when + val actualValue = nip05Verifier.assembleUrl(nip05address) - // then - assertEquals(expectedValue, actualValue) - } + // then + assertEquals(expectedValue, actualValue) + } } diff --git a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip30Test.kt b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip30Test.kt index d36f73566..071119be9 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip30Test.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip30Test.kt @@ -24,55 +24,55 @@ import junit.framework.TestCase.assertEquals import org.junit.Test class Nip30Test { - @Test() - fun parseEmoji() { - val input = "Alex Gleason :soapbox:" + @Test() + fun parseEmoji() { + val input = "Alex Gleason :soapbox:" - assertEquals( - listOf("Alex Gleason ", ":soapbox:", ""), - Nip30CustomEmoji().buildArray(input), - ) - } + assertEquals( + listOf("Alex Gleason ", ":soapbox:", ""), + Nip30CustomEmoji().buildArray(input), + ) + } - @Test() - fun parseEmojiInverted() { - val input = ":soapbox:Alex Gleason" + @Test() + fun parseEmojiInverted() { + val input = ":soapbox:Alex Gleason" - assertEquals( - listOf("", ":soapbox:", "Alex Gleason"), - Nip30CustomEmoji().buildArray(input), - ) - } + assertEquals( + listOf("", ":soapbox:", "Alex Gleason"), + Nip30CustomEmoji().buildArray(input), + ) + } - @Test() - fun parseEmoji2() { - val input = "Hello :gleasonator: \uD83D\uDE02 :ablobcatrainbow: :disputed: yolo" + @Test() + fun parseEmoji2() { + val input = "Hello :gleasonator: \uD83D\uDE02 :ablobcatrainbow: :disputed: yolo" - assertEquals( - listOf("Hello ", ":gleasonator:", " ๐Ÿ˜‚ ", ":ablobcatrainbow:", " ", ":disputed:", " yolo"), - Nip30CustomEmoji().buildArray(input), - ) + assertEquals( + listOf("Hello ", ":gleasonator:", " ๐Ÿ˜‚ ", ":ablobcatrainbow:", " ", ":disputed:", " yolo"), + Nip30CustomEmoji().buildArray(input), + ) - println(Nip30CustomEmoji().buildArray(input).joinToString(",")) - } + println(Nip30CustomEmoji().buildArray(input).joinToString(",")) + } - @Test() - fun parseEmoji3() { - val input = "hello vitor: how can I help:" + @Test() + fun parseEmoji3() { + val input = "hello vitor: how can I help:" - assertEquals( - listOf("hello vitor: how can I help:"), - Nip30CustomEmoji().buildArray(input), - ) - } + assertEquals( + listOf("hello vitor: how can I help:"), + Nip30CustomEmoji().buildArray(input), + ) + } - @Test() - fun parseJapanese() { - val input = "\uD883\uDEDE\uD883\uDEDE้บบใฎ:x30EDE:ใ€‚:\uD883\uDEDE:(Violation of NIP-30)" + @Test() + fun parseJapanese() { + val input = "\uD883\uDEDE\uD883\uDEDE้บบใฎ:x30EDE:ใ€‚:\uD883\uDEDE:(Violation of NIP-30)" - assertEquals( - listOf("\uD883\uDEDE\uD883\uDEDE้บบใฎ", ":x30EDE:", "ใ€‚:\uD883\uDEDE:(Violation of NIP-30)"), - Nip30CustomEmoji().buildArray(input), - ) - } + assertEquals( + listOf("\uD883\uDEDE\uD883\uDEDE้บบใฎ", ":x30EDE:", "ใ€‚:\uD883\uDEDE:(Violation of NIP-30)"), + Nip30CustomEmoji().buildArray(input), + ) + } } diff --git a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt index dbe29dd65..307294ebf 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt @@ -24,8 +24,8 @@ import junit.framework.TestCase.assertEquals import org.junit.Test class Nip96Test { - val json = - """ + val json = + """ { "api_url": "https://nostr.build/api/v2/nip96/upload", "download_url": "https://media.nostr.build", @@ -113,28 +113,28 @@ class Nip96Test { } } """ - .trimIndent() + .trimIndent() - @Test() - fun parseNostrBuild() { - val info = Nip96Retriever().parse(json) + @Test() + fun parseNostrBuild() { + val info = Nip96Retriever().parse(json) - assertEquals("https://nostr.build/api/v2/nip96/upload", info.apiUrl) - assertEquals("https://media.nostr.build", info.downloadUrl) - assertEquals(listOf(94, 96, 98), info.supportedNips) - assertEquals("https://nostr.build/tos/", info.tosUrl) - assertEquals(listOf("image/*", "video/*", "audio/*"), info.contentTypes) + assertEquals("https://nostr.build/api/v2/nip96/upload", info.apiUrl) + assertEquals("https://media.nostr.build", info.downloadUrl) + assertEquals(listOf(94, 96, 98), info.supportedNips) + assertEquals("https://nostr.build/tos/", info.tosUrl) + assertEquals(listOf("image/*", "video/*", "audio/*"), info.contentTypes) - assertEquals(listOf("creator", "free", "professional"), info.plans.keys.sorted()) + assertEquals(listOf("creator", "free", "professional"), info.plans.keys.sorted()) - assertEquals("Free", info.plans["free"]?.name) - assertEquals(true, info.plans["free"]?.isNip98Required) - assertEquals("https://nostr.build", info.plans["free"]?.url) - assertEquals(26214400L, info.plans["free"]?.maxByteSize) - assertEquals(listOf(0, 0), info.plans["free"]?.fileExpiration) - assertEquals(listOf("image", "video"), info.plans["free"]?.mediaTransformations?.keys?.sorted()) + assertEquals("Free", info.plans["free"]?.name) + assertEquals(true, info.plans["free"]?.isNip98Required) + assertEquals("https://nostr.build", info.plans["free"]?.url) + assertEquals(26214400L, info.plans["free"]?.maxByteSize) + assertEquals(listOf(0, 0), info.plans["free"]?.fileExpiration) + assertEquals(listOf("image", "video"), info.plans["free"]?.mediaTransformations?.keys?.sorted()) - assertEquals(26843545600L, info.plans["creator"]?.maxByteSize) - assertEquals(10737418240L, info.plans["professional"]?.maxByteSize) - } + assertEquals(26843545600L, info.plans["creator"]?.maxByteSize) + assertEquals(10737418240L, info.plans["professional"]?.maxByteSize) + } } diff --git a/app/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserZapsTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserZapsTest.kt index 7aaf15d73..e2e2cebe2 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserZapsTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserZapsTest.kt @@ -26,54 +26,54 @@ import com.vitorpamplona.quartz.events.LnZapEventInterface import com.vitorpamplona.quartz.events.zaps.UserZaps import io.mockk.every import io.mockk.mockk -import java.math.BigDecimal import org.junit.Assert import org.junit.Test +import java.math.BigDecimal class UserZapsTest { - @Test - fun nothing() { - Assert.assertEquals(1, 1) - } + @Test + fun nothing() { + Assert.assertEquals(1, 1) + } - @Test - fun user_without_zaps() { - val actual = UserZaps.forProfileFeed(zaps = null) + @Test + fun user_without_zaps() { + val actual = UserZaps.forProfileFeed(zaps = null) - Assert.assertEquals(emptyList>(), actual) - } + Assert.assertEquals(emptyList>(), actual) + } - @Test - fun avoid_duplicates_with_same_zap_request() { - val zapRequest = mockk() + @Test + fun avoid_duplicates_with_same_zap_request() { + val zapRequest = mockk() - val zaps: Map = - mapOf( - zapRequest to mockZapNoteWith("user-1", amount = 100), - zapRequest to mockZapNoteWith("user-1", amount = 200), - ) + val zaps: Map = + mapOf( + zapRequest to mockZapNoteWith("user-1", amount = 100), + zapRequest to mockZapNoteWith("user-1", amount = 200), + ) - val actual = UserZaps.forProfileFeed(zaps) + val actual = UserZaps.forProfileFeed(zaps) - Assert.assertEquals(1, actual.count()) - Assert.assertEquals(zapRequest, actual.first().zapRequest) - Assert.assertEquals( - BigDecimal(200), - (actual.first().zapEvent.event as LnZapEventInterface).amount(), - ) - } + Assert.assertEquals(1, actual.count()) + Assert.assertEquals(zapRequest, actual.first().zapRequest) + Assert.assertEquals( + BigDecimal(200), + (actual.first().zapEvent.event as LnZapEventInterface).amount(), + ) + } - private fun mockZapNoteWith( - pubkey: HexKey, - amount: Int, - ): Note { - val lnZapEvent = mockk() - every { lnZapEvent.amount() } returns amount.toBigDecimal() - every { lnZapEvent.pubKey() } returns pubkey + private fun mockZapNoteWith( + pubkey: HexKey, + amount: Int, + ): Note { + val lnZapEvent = mockk() + every { lnZapEvent.amount() } returns amount.toBigDecimal() + every { lnZapEvent.pubKey() } returns pubkey - val zapNote = mockk() - every { zapNote.event } returns lnZapEvent + val zapNote = mockk() + every { zapNote.event } returns lnZapEvent - return zapNote - } + return zapNote + } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BechBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BechBenchmark.kt index 717065257..eecc0213f 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BechBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BechBenchmark.kt @@ -33,58 +33,58 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class BechBenchmark { - @get:Rule val benchmarkRule = BenchmarkRule() + @get:Rule val benchmarkRule = BenchmarkRule() - @Test - fun npubEncoding() { - val myUser = Hex.decode("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") - benchmarkRule.measureRepeated { - assertEquals( - "npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", - myUser.toNpub(), - ) + @Test + fun npubEncoding() { + val myUser = Hex.decode("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") + benchmarkRule.measureRepeated { + assertEquals( + "npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", + myUser.toNpub(), + ) + } } - } - @Test - fun npubDecoding() { - val myUser = "npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z" - val expected = - listOf( - 70, - 12, - 37, - -26, - -126, - -3, - -89, - -125, - 43, - 82, - -47, - -14, - 45, - 61, - 34, - -77, - 23, - 109, - -105, - 47, - 96, - -36, - -36, - 50, - 18, - -19, - -116, - -110, - -17, - -123, - 6, - 92, - ) - .map { it.toByte() } - benchmarkRule.measureRepeated { assertEquals(expected, myUser.bechToBytes().toList()) } - } + @Test + fun npubDecoding() { + val myUser = "npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z" + val expected = + listOf( + 70, + 12, + 37, + -26, + -126, + -3, + -89, + -125, + 43, + 82, + -47, + -14, + 45, + 61, + 34, + -77, + 23, + 109, + -105, + 47, + 96, + -36, + -36, + 50, + 18, + -19, + -116, + -110, + -17, + -123, + 6, + 92, + ) + .map { it.toByte() } + benchmarkRule.measureRepeated { assertEquals(expected, myUser.bechToBytes().toList()) } + } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ContainsBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ContainsBenchmark.kt index 75063b172..b0063d14d 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ContainsBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ContainsBenchmark.kt @@ -32,71 +32,71 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ContainsBenchmark { - @get:Rule val benchmarkRule = BenchmarkRule() + @get:Rule val benchmarkRule = BenchmarkRule() - private val test = - """Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of "de Finibus Bonorum et Malorum" (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, "Lorem ipsum dolor sit amet..", comes from a line in section 1.10.32. + private val test = + """Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of "de Finibus Bonorum et Malorum" (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, "Lorem ipsum dolor sit amet..", comes from a line in section 1.10.32. The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from "de Finibus Bonorum et Malorum" by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham. """ - .intern() + .intern() - val atTheMiddle = DualCase("Lorem Ipsum".lowercase(), "Lorem Ipsum".uppercase()) - val atTheBeginning = DualCase("contrAry".lowercase(), "contrAry".uppercase()) + val atTheMiddle = DualCase("Lorem Ipsum".lowercase(), "Lorem Ipsum".uppercase()) + val atTheBeginning = DualCase("contrAry".lowercase(), "contrAry".uppercase()) - val atTheEndCase = DualCase("h. rackham".lowercase(), "h. rackham".uppercase()) + val atTheEndCase = DualCase("h. rackham".lowercase(), "h. rackham".uppercase()) - val lastCase = - listOf( - DualCase("my mom".lowercase(), "my mom".uppercase()), - DualCase("my dad".lowercase(), "my dad".uppercase()), - DualCase("h. rackham".lowercase(), "h. rackham".uppercase()), - ) + val lastCase = + listOf( + DualCase("my mom".lowercase(), "my mom".uppercase()), + DualCase("my dad".lowercase(), "my dad".uppercase()), + DualCase("h. rackham".lowercase(), "h. rackham".uppercase()), + ) - @Test - fun middleCaseKotlin() { - benchmarkRule.measureRepeated { assertTrue(test.contains(atTheMiddle.lowercase, true)) } - } - - @Test - fun middleCaseOurs() { - val list = listOf(atTheMiddle) - benchmarkRule.measureRepeated { assertTrue(test.containsAny(list)) } - } - - @Test - fun atTheBeginningKotlin() { - benchmarkRule.measureRepeated { assertTrue(test.contains(atTheBeginning.lowercase, true)) } - } - - @Test - fun atTheBeginningOurs() { - val list = listOf(atTheBeginning) - benchmarkRule.measureRepeated { assertTrue(test.containsAny(list)) } - } - - @Test - fun atTheEndKotlin() { - benchmarkRule.measureRepeated { assertTrue(test.contains(atTheEndCase.lowercase, true)) } - } - - @Test - fun atTheEndOurs() { - val list = listOf(atTheEndCase) - benchmarkRule.measureRepeated { assertTrue(test.containsAny(list)) } - } - - @Test - fun theLastAtTheEndKotlin() { - benchmarkRule.measureRepeated { - assertTrue( - lastCase.any { test.contains(it.lowercase, true) }, - ) + @Test + fun middleCaseKotlin() { + benchmarkRule.measureRepeated { assertTrue(test.contains(atTheMiddle.lowercase, true)) } } - } - @Test - fun theLastAtTheEndOurs() { - benchmarkRule.measureRepeated { assertTrue(test.containsAny(lastCase)) } - } + @Test + fun middleCaseOurs() { + val list = listOf(atTheMiddle) + benchmarkRule.measureRepeated { assertTrue(test.containsAny(list)) } + } + + @Test + fun atTheBeginningKotlin() { + benchmarkRule.measureRepeated { assertTrue(test.contains(atTheBeginning.lowercase, true)) } + } + + @Test + fun atTheBeginningOurs() { + val list = listOf(atTheBeginning) + benchmarkRule.measureRepeated { assertTrue(test.containsAny(list)) } + } + + @Test + fun atTheEndKotlin() { + benchmarkRule.measureRepeated { assertTrue(test.contains(atTheEndCase.lowercase, true)) } + } + + @Test + fun atTheEndOurs() { + val list = listOf(atTheEndCase) + benchmarkRule.measureRepeated { assertTrue(test.containsAny(list)) } + } + + @Test + fun theLastAtTheEndKotlin() { + benchmarkRule.measureRepeated { + assertTrue( + lastCase.any { test.contains(it.lowercase, true) }, + ) + } + } + + @Test + fun theLastAtTheEndOurs() { + benchmarkRule.measureRepeated { assertTrue(test.containsAny(lastCase)) } + } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/CryptoBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/CryptoBenchmark.kt index 0e00b526b..82b1bbb7a 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/CryptoBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/CryptoBenchmark.kt @@ -32,76 +32,76 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class CryptoBenchmark { - @get:Rule val benchmarkRule = BenchmarkRule() + @get:Rule val benchmarkRule = BenchmarkRule() - @Test - fun getSharedKeyNip04() { - val keyPair1 = KeyPair() - val keyPair2 = KeyPair() + @Test + fun getSharedKeyNip04() { + val keyPair1 = KeyPair() + val keyPair2 = KeyPair() - benchmarkRule.measureRepeated { - assertNotNull(CryptoUtils.getSharedSecretNIP04(keyPair1.privKey!!, keyPair2.pubKey)) + benchmarkRule.measureRepeated { + assertNotNull(CryptoUtils.getSharedSecretNIP04(keyPair1.privKey!!, keyPair2.pubKey)) + } } - } - @Test - fun getSharedKeyNip44() { - val keyPair1 = KeyPair() - val keyPair2 = KeyPair() + @Test + fun getSharedKeyNip44() { + val keyPair1 = KeyPair() + val keyPair2 = KeyPair() - benchmarkRule.measureRepeated { - assertNotNull(CryptoUtils.getSharedSecretNIP44v1(keyPair1.privKey!!, keyPair2.pubKey)) + benchmarkRule.measureRepeated { + assertNotNull(CryptoUtils.getSharedSecretNIP44v1(keyPair1.privKey!!, keyPair2.pubKey)) + } } - } - @Test - fun computeSharedKeyNip04() { - val keyPair1 = KeyPair() - val keyPair2 = KeyPair() + @Test + fun computeSharedKeyNip04() { + val keyPair1 = KeyPair() + val keyPair2 = KeyPair() - benchmarkRule.measureRepeated { - assertNotNull(CryptoUtils.computeSharedSecretNIP04(keyPair1.privKey!!, keyPair2.pubKey)) + benchmarkRule.measureRepeated { + assertNotNull(CryptoUtils.computeSharedSecretNIP04(keyPair1.privKey!!, keyPair2.pubKey)) + } } - } - @Test - fun computeSharedKeyNip44() { - val keyPair1 = KeyPair() - val keyPair2 = KeyPair() + @Test + fun computeSharedKeyNip44() { + val keyPair1 = KeyPair() + val keyPair2 = KeyPair() - benchmarkRule.measureRepeated { - assertNotNull(CryptoUtils.computeSharedSecretNIP44v1(keyPair1.privKey!!, keyPair2.pubKey)) + benchmarkRule.measureRepeated { + assertNotNull(CryptoUtils.computeSharedSecretNIP44v1(keyPair1.privKey!!, keyPair2.pubKey)) + } } - } - @Test - fun random() { - benchmarkRule.measureRepeated { assertNotNull(CryptoUtils.random(1000)) } - } - - @Test - fun sha256() { - val keyPair = KeyPair() - - benchmarkRule.measureRepeated { assertNotNull(CryptoUtils.sha256(keyPair.pubKey)) } - } - - @Test - fun sign() { - val keyPair = KeyPair() - val msg = CryptoUtils.sha256(CryptoUtils.random(1000)) - - benchmarkRule.measureRepeated { assertNotNull(CryptoUtils.sign(msg, keyPair.privKey!!)) } - } - - @Test - fun verify() { - val keyPair = KeyPair() - val msg = CryptoUtils.sha256(CryptoUtils.random(1000)) - val signature = CryptoUtils.sign(msg, keyPair.privKey!!) - - benchmarkRule.measureRepeated { - assertNotNull(CryptoUtils.verifySignature(signature, msg, keyPair.pubKey)) + @Test + fun random() { + benchmarkRule.measureRepeated { assertNotNull(CryptoUtils.random(1000)) } + } + + @Test + fun sha256() { + val keyPair = KeyPair() + + benchmarkRule.measureRepeated { assertNotNull(CryptoUtils.sha256(keyPair.pubKey)) } + } + + @Test + fun sign() { + val keyPair = KeyPair() + val msg = CryptoUtils.sha256(CryptoUtils.random(1000)) + + benchmarkRule.measureRepeated { assertNotNull(CryptoUtils.sign(msg, keyPair.privKey!!)) } + } + + @Test + fun verify() { + val keyPair = KeyPair() + val msg = CryptoUtils.sha256(CryptoUtils.random(1000)) + val signature = CryptoUtils.sign(msg, keyPair.privKey!!) + + benchmarkRule.measureRepeated { + assertNotNull(CryptoUtils.verifySignature(signature, msg, keyPair.pubKey)) + } } - } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/EventBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/EventBenchmark.kt index cba07b542..8dd355168 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/EventBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/EventBenchmark.kt @@ -39,11 +39,11 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) class EventBenchmark { - val payload1 = - "[\"EVENT\",\"40b9\",{\"id\":\"48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf\",\"kind\":1,\"pubkey\":\"3d842afecd5e293f28b6627933704a3fb8ce153aa91d790ab11f6a752d44a42d\",\"created_at\":1677940007,\"content\":\"I got asked about follower count again today. Why does my follower count go down when I delete public relays (in our list) and replace them with filter.nostr.wine? \\n\\nIโ€™ll give you one final explanation to rule them all. First, letโ€™s go over how clients calculate your follower count.\\n\\n1. Your client sends a request to all your connected relays asking for accounts who follow you\\n2. Relays answer back with the events requested\\n3. The client aggregates the event total and displays it\\n\\nEach relay has a set limit on how many stored events it will return per request. For some relays itโ€™s 500, others 1000, some as high as 5000. Letโ€™s say for simplicity that all your public relays use 500 as their limit. If you ask 10 relays for your followers the max possible answer you can get is 5000. That wonโ€™t change if you have 20,000 followers or 100,000. You may get back a โ€œdifferentโ€ 5000 each time, but youโ€™ll still cap out at 5000 because that is the most events your client will receive.\u2028\u2028Our limit on filter.nostr.wine is 2000 events. If you replace 10 public relays with only filter.nostr.wine, the MOST followers you will ever get back from our filter relay is 2000. That doesnโ€™t mean you only have 2000 followers or that your reach is reduced in any way.\\n\\nAs long as you are writing to and reading from the same public relays, neither your reach nor any content was lost. That concludes my TED talk. I hope you all have a fantastic day and weekend.\",\"tags\":[],\"sig\":\"dcaf8ab98bb9179017b35bd814092850d1062b26c263dff89fb1ae8c019a324139d1729012d9d05ff0a517f76b1117d869b2cc7d36bea8aa5f4b94c5e2548aa8\"}]" + val payload1 = + "[\"EVENT\",\"40b9\",{\"id\":\"48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf\",\"kind\":1,\"pubkey\":\"3d842afecd5e293f28b6627933704a3fb8ce153aa91d790ab11f6a752d44a42d\",\"created_at\":1677940007,\"content\":\"I got asked about follower count again today. Why does my follower count go down when I delete public relays (in our list) and replace them with filter.nostr.wine? \\n\\nIโ€™ll give you one final explanation to rule them all. First, letโ€™s go over how clients calculate your follower count.\\n\\n1. Your client sends a request to all your connected relays asking for accounts who follow you\\n2. Relays answer back with the events requested\\n3. The client aggregates the event total and displays it\\n\\nEach relay has a set limit on how many stored events it will return per request. For some relays itโ€™s 500, others 1000, some as high as 5000. Letโ€™s say for simplicity that all your public relays use 500 as their limit. If you ask 10 relays for your followers the max possible answer you can get is 5000. That wonโ€™t change if you have 20,000 followers or 100,000. You may get back a โ€œdifferentโ€ 5000 each time, but youโ€™ll still cap out at 5000 because that is the most events your client will receive.\u2028\u2028Our limit on filter.nostr.wine is 2000 events. If you replace 10 public relays with only filter.nostr.wine, the MOST followers you will ever get back from our filter relay is 2000. That doesnโ€™t mean you only have 2000 followers or that your reach is reduced in any way.\\n\\nAs long as you are writing to and reading from the same public relays, neither your reach nor any content was lost. That concludes my TED talk. I hope you all have a fantastic day and weekend.\",\"tags\":[],\"sig\":\"dcaf8ab98bb9179017b35bd814092850d1062b26c263dff89fb1ae8c019a324139d1729012d9d05ff0a517f76b1117d869b2cc7d36bea8aa5f4b94c5e2548aa8\"}]" - val payload2 = - """ + val payload2 = + """ { "content": "Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ", "created_at": 1683596206, @@ -70,66 +70,66 @@ class EventBenchmark { } """ - @get:Rule val benchmarkRule = BenchmarkRule() + @get:Rule val benchmarkRule = BenchmarkRule() - @Test - fun parseREQString() { - benchmarkRule.measureRepeated { Event.mapper.readTree(payload1) } - } - - @Test - fun parseEvent() { - val msg = Event.mapper.readTree(payload1) - - benchmarkRule.measureRepeated { Event.fromJson(msg[2]) } - } - - @Test - fun checkSignature() { - val msg = Event.mapper.readTree(payload1) - val event = Event.fromJson(msg[2]) - benchmarkRule.measureRepeated { - // Should pass - assertTrue(event.hasVerifiedSignature()) + @Test + fun parseREQString() { + benchmarkRule.measureRepeated { Event.mapper.readTree(payload1) } } - } - @Test - fun checkIDHashPayload1() { - val msg = Event.mapper.readTree(payload1) - val event = Event.fromJson(msg[2]) + @Test + fun parseEvent() { + val msg = Event.mapper.readTree(payload1) - benchmarkRule.measureRepeated { - // Should pass - assertTrue(event.hasCorrectIDHash()) + benchmarkRule.measureRepeated { Event.fromJson(msg[2]) } } - } - @Test - fun checkIDHashPayload2() { - val event = Event.fromJson(payload2) - - benchmarkRule.measureRepeated { - // Should pass - assertTrue(event.hasCorrectIDHash()) + @Test + fun checkSignature() { + val msg = Event.mapper.readTree(payload1) + val event = Event.fromJson(msg[2]) + benchmarkRule.measureRepeated { + // Should pass + assertTrue(event.hasVerifiedSignature()) + } } - } - @Test - fun toMakeJsonForID() { - val event = Event.fromJson(payload2) + @Test + fun checkIDHashPayload1() { + val msg = Event.mapper.readTree(payload1) + val event = Event.fromJson(msg[2]) - benchmarkRule.measureRepeated { assertNotNull(event.makeJsonForId()) } - } - - @Test - fun sha256() { - val event = Event.fromJson(payload2) - val byteArray = event.makeJsonForId().toByteArray() - - benchmarkRule.measureRepeated { - // Should pass - assertNotNull(CryptoUtils.sha256(byteArray)) + benchmarkRule.measureRepeated { + // Should pass + assertTrue(event.hasCorrectIDHash()) + } + } + + @Test + fun checkIDHashPayload2() { + val event = Event.fromJson(payload2) + + benchmarkRule.measureRepeated { + // Should pass + assertTrue(event.hasCorrectIDHash()) + } + } + + @Test + fun toMakeJsonForID() { + val event = Event.fromJson(payload2) + + benchmarkRule.measureRepeated { assertNotNull(event.makeJsonForId()) } + } + + @Test + fun sha256() { + val event = Event.fromJson(payload2) + val byteArray = event.makeJsonForId().toByteArray() + + benchmarkRule.measureRepeated { + // Should pass + assertNotNull(CryptoUtils.sha256(byteArray)) + } } - } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapBenchmark.kt index 656c6a8b3..ecf035d6e 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapBenchmark.kt @@ -30,14 +30,14 @@ import com.vitorpamplona.quartz.events.GiftWrapEvent import com.vitorpamplona.quartz.events.NIP24Factory import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.signers.NostrSignerInternal -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit import junit.framework.TestCase import org.junit.Assert import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit /** * Benchmark, which will execute on an Android device. @@ -47,148 +47,148 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) class GiftWrapBenchmark { - @get:Rule val benchmarkRule = BenchmarkRule() + @get:Rule val benchmarkRule = BenchmarkRule() - fun basePerformanceTest( - message: String, - expectedLength: Int, - ) { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) - - var events: NIP24Factory.Result? = null - val countDownLatch = CountDownLatch(1) - - NIP24Factory().createMsgNIP24( - message, - listOf(receiver.pubKey), - sender, + fun basePerformanceTest( + message: String, + expectedLength: Int, ) { - events = it - countDownLatch.countDown() - } + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + var events: NIP24Factory.Result? = null + val countDownLatch = CountDownLatch(1) - val countDownLatch2 = CountDownLatch(1) - - Assert.assertEquals( - expectedLength, - events!! - .wraps - .map { - println("TEST ${it.toJson()}") - it.toJson() + NIP24Factory().createMsgNIP24( + message, + listOf(receiver.pubKey), + sender, + ) { + events = it + countDownLatch.countDown() } - .joinToString("") - .length, - ) - // Simulate Receiver - events!!.wraps.forEach { - it.checkSignature() + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - val keyToUse = if (it.recipientPubKey() == sender.pubKey) sender else receiver + val countDownLatch2 = CountDownLatch(1) - it.cachedGift(keyToUse) { event -> - event.checkSignature() + Assert.assertEquals( + expectedLength, + events!! + .wraps + .map { + println("TEST ${it.toJson()}") + it.toJson() + } + .joinToString("") + .length, + ) - if (event is SealedGossipEvent) { - event.cachedGossip(keyToUse) { innerData -> - Assert.assertEquals(message, innerData.content) - countDownLatch2.countDown() - } - } else { - Assert.fail("Wrong Event") + // Simulate Receiver + events!!.wraps.forEach { + it.checkSignature() + + val keyToUse = if (it.recipientPubKey() == sender.pubKey) sender else receiver + + it.cachedGift(keyToUse) { event -> + event.checkSignature() + + if (event is SealedGossipEvent) { + event.cachedGossip(keyToUse) { innerData -> + Assert.assertEquals(message, innerData.content) + countDownLatch2.countDown() + } + } else { + Assert.fail("Wrong Event") + } + } } - } + + assertTrue(countDownLatch2.await(1, TimeUnit.SECONDS)) } - assertTrue(countDownLatch2.await(1, TimeUnit.SECONDS)) - } + fun receivePerformanceTest(message: String) { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) - fun receivePerformanceTest(message: String) { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) + var giftWrap: GiftWrapEvent? = null + val countDownLatch = CountDownLatch(1) - var giftWrap: GiftWrapEvent? = null - val countDownLatch = CountDownLatch(1) - - NIP24Factory().createMsgNIP24( - message, - listOf(receiver.pubKey), - sender, - ) { - giftWrap = it.wraps.first() - countDownLatch.countDown() - } - - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - - val keyToUse = if (giftWrap!!.recipientPubKey() == sender.pubKey) sender else receiver - val giftWrapJson = giftWrap!!.toJson() - - // Simulate Receiver - benchmarkRule.measureRepeated { - CryptoUtils.clearCache() - val counter = CountDownLatch(1) - - val wrap = Event.fromJson(giftWrapJson) as GiftWrapEvent - wrap.checkSignature() - - wrap.cachedGift(keyToUse) { seal -> - seal.checkSignature() - - if (seal is SealedGossipEvent) { - seal.cachedGossip(keyToUse) { innerData -> - Assert.assertEquals(message, innerData.content) - counter.countDown() - } - } else { - Assert.fail("Wrong Event") + NIP24Factory().createMsgNIP24( + message, + listOf(receiver.pubKey), + sender, + ) { + giftWrap = it.wraps.first() + countDownLatch.countDown() } - } - TestCase.assertTrue(counter.await(1, TimeUnit.SECONDS)) + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + + val keyToUse = if (giftWrap!!.recipientPubKey() == sender.pubKey) sender else receiver + val giftWrapJson = giftWrap!!.toJson() + + // Simulate Receiver + benchmarkRule.measureRepeated { + CryptoUtils.clearCache() + val counter = CountDownLatch(1) + + val wrap = Event.fromJson(giftWrapJson) as GiftWrapEvent + wrap.checkSignature() + + wrap.cachedGift(keyToUse) { seal -> + seal.checkSignature() + + if (seal is SealedGossipEvent) { + seal.cachedGossip(keyToUse) { innerData -> + Assert.assertEquals(message, innerData.content) + counter.countDown() + } + } else { + Assert.fail("Wrong Event") + } + } + + TestCase.assertTrue(counter.await(1, TimeUnit.SECONDS)) + } } - } - @Test - fun tinyMessageHardCoded() { - benchmarkRule.measureRepeated { basePerformanceTest("Hola, que tal?", 3402) } - } - - @Test - fun regularMessageHardCoded() { - benchmarkRule.measureRepeated { - basePerformanceTest("Hi, honey, can you drop by the market and get some bread?", 3746) + @Test + fun tinyMessageHardCoded() { + benchmarkRule.measureRepeated { basePerformanceTest("Hola, que tal?", 3402) } } - } - @Test - fun longMessageHardCoded() { - benchmarkRule.measureRepeated { - basePerformanceTest( - "My queen, you are nothing short of royalty to me. You possess more beauty in the nail of your pinkie toe than everything else in this world combined. I am astounded by your grace, generosity, and graciousness. I am so lucky to know you. ", - 5114, - ) + @Test + fun regularMessageHardCoded() { + benchmarkRule.measureRepeated { + basePerformanceTest("Hi, honey, can you drop by the market and get some bread?", 3746) + } } - } - @Test - fun receivesTinyMessage() { - receivePerformanceTest("Hola, que tal?") - } + @Test + fun longMessageHardCoded() { + benchmarkRule.measureRepeated { + basePerformanceTest( + "My queen, you are nothing short of royalty to me. You possess more beauty in the nail of your pinkie toe than everything else in this world combined. I am astounded by your grace, generosity, and graciousness. I am so lucky to know you. ", + 5114, + ) + } + } - @Test - fun receivesRegularMessage() { - receivePerformanceTest("Hi, honey, can you drop by the market and get some bread?") - } + @Test + fun receivesTinyMessage() { + receivePerformanceTest("Hola, que tal?") + } - @Test - fun receivesLongMessageHardCoded() { - receivePerformanceTest( - "My queen, you are nothing short of royalty to me. You possess more beauty in the nail of your pinkie toe than everything else in this world combined. I am astounded by your grace, generosity, and graciousness. I am so lucky to know you. ", - ) - } + @Test + fun receivesRegularMessage() { + receivePerformanceTest("Hi, honey, can you drop by the market and get some bread?") + } + + @Test + fun receivesLongMessageHardCoded() { + receivePerformanceTest( + "My queen, you are nothing short of royalty to me. You possess more beauty in the nail of your pinkie toe than everything else in this world combined. I am astounded by your grace, generosity, and graciousness. I am so lucky to know you. ", + ) + } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapReceivingBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapReceivingBenchmark.kt index 229f61ef2..19591c4fd 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapReceivingBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapReceivingBenchmark.kt @@ -33,13 +33,13 @@ import com.vitorpamplona.quartz.events.Gossip import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.signers.NostrSignerInternal -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit /** * Benchmark, which will execute on an Android device. @@ -49,178 +49,178 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) class GiftWrapReceivingBenchmark { - @get:Rule val benchmarkRule = BenchmarkRule() + @get:Rule val benchmarkRule = BenchmarkRule() - fun createWrap( - sender: NostrSigner, - receiver: NostrSigner, - ): GiftWrapEvent { - val countDownLatch = CountDownLatch(1) - var wrap: GiftWrapEvent? = null + fun createWrap( + sender: NostrSigner, + receiver: NostrSigner, + ): GiftWrapEvent { + val countDownLatch = CountDownLatch(1) + var wrap: GiftWrapEvent? = null - ChatMessageEvent.create( - msg = "Hi there! This is a test message", - to = listOf(receiver.pubKey), - subject = "Party Tonight", - replyTos = emptyList(), - mentions = emptyList(), - zapReceiver = null, - markAsSensitive = true, - zapRaiserAmount = 10000, - geohash = null, - signer = sender, - ) { - SealedGossipEvent.create( - event = it, - encryptTo = receiver.pubKey, - signer = sender, - ) { - GiftWrapEvent.create( - event = it, - recipientPubKey = receiver.pubKey, + ChatMessageEvent.create( + msg = "Hi there! This is a test message", + to = listOf(receiver.pubKey), + subject = "Party Tonight", + replyTos = emptyList(), + mentions = emptyList(), + zapReceiver = null, + markAsSensitive = true, + zapRaiserAmount = 10000, + geohash = null, + signer = sender, ) { - wrap = it - countDownLatch.countDown() + SealedGossipEvent.create( + event = it, + encryptTo = receiver.pubKey, + signer = sender, + ) { + GiftWrapEvent.create( + event = it, + recipientPubKey = receiver.pubKey, + ) { + wrap = it + countDownLatch.countDown() + } + } } - } + + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + + return wrap!! } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + fun createSeal( + sender: NostrSigner, + receiver: NostrSigner, + ): SealedGossipEvent { + val countDownLatch = CountDownLatch(1) + var seal: SealedGossipEvent? = null - return wrap!! - } + ChatMessageEvent.create( + msg = "Hi there! This is a test message", + to = listOf(receiver.pubKey), + subject = "Party Tonight", + replyTos = emptyList(), + mentions = emptyList(), + zapReceiver = null, + markAsSensitive = true, + zapRaiserAmount = 10000, + geohash = null, + signer = sender, + ) { + SealedGossipEvent.create( + event = it, + encryptTo = receiver.pubKey, + signer = sender, + ) { + seal = it + countDownLatch.countDown() + } + } - fun createSeal( - sender: NostrSigner, - receiver: NostrSigner, - ): SealedGossipEvent { - val countDownLatch = CountDownLatch(1) - var seal: SealedGossipEvent? = null + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - ChatMessageEvent.create( - msg = "Hi there! This is a test message", - to = listOf(receiver.pubKey), - subject = "Party Tonight", - replyTos = emptyList(), - mentions = emptyList(), - zapReceiver = null, - markAsSensitive = true, - zapRaiserAmount = 10000, - geohash = null, - signer = sender, - ) { - SealedGossipEvent.create( - event = it, - encryptTo = receiver.pubKey, - signer = sender, - ) { - seal = it - countDownLatch.countDown() - } + return seal!! } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + @Test + fun parseWrapFromString() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) - return seal!! - } + val str = createWrap(sender, receiver).toJson() - @Test - fun parseWrapFromString() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) - - val str = createWrap(sender, receiver).toJson() - - benchmarkRule.measureRepeated { Event.fromJson(str) } - } - - @Test - fun checkId() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) - - val wrap = createWrap(sender, receiver) - - benchmarkRule.measureRepeated { wrap.hasCorrectIDHash() } - } - - @Test - fun checkSignature() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) - - val wrap = createWrap(sender, receiver) - - benchmarkRule.measureRepeated { wrap.hasVerifiedSignature() } - } - - @Test - fun decryptWrapEvent() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) - - val wrap = createWrap(sender, receiver) - - benchmarkRule.measureRepeated { - assertNotNull( - CryptoUtils.decryptNIP44v2( - wrap.content, - receiver.keyPair.privKey!!, - wrap.pubKey.hexToByteArray(), - ), - ) + benchmarkRule.measureRepeated { Event.fromJson(str) } } - } - @Test - fun parseWrappedEvent() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) + @Test + fun checkId() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) - val wrap = createWrap(sender, receiver) + val wrap = createWrap(sender, receiver) - val innerJson = - CryptoUtils.decryptNIP44v2( - wrap.content, - receiver.keyPair.privKey!!, - wrap.pubKey.hexToByteArray(), - ) - - benchmarkRule.measureRepeated { assertNotNull(innerJson?.let { Event.fromJson(it) }) } - } - - @Test - fun decryptSealedEvent() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) - - val seal = createSeal(sender, receiver) - - benchmarkRule.measureRepeated { - assertNotNull( - CryptoUtils.decryptNIP44v2( - seal.content, - receiver.keyPair.privKey!!, - seal.pubKey.hexToByteArray(), - ), - ) + benchmarkRule.measureRepeated { wrap.hasCorrectIDHash() } } - } - @Test - fun parseSealedEvent() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) + @Test + fun checkSignature() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) - val seal = createSeal(sender, receiver) + val wrap = createWrap(sender, receiver) - val innerJson = - CryptoUtils.decryptNIP44v2( - seal.content, - receiver.keyPair.privKey!!, - seal.pubKey.hexToByteArray(), - ) + benchmarkRule.measureRepeated { wrap.hasVerifiedSignature() } + } - benchmarkRule.measureRepeated { assertNotNull(innerJson?.let { Gossip.fromJson(it) }) } - } + @Test + fun decryptWrapEvent() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) + + val wrap = createWrap(sender, receiver) + + benchmarkRule.measureRepeated { + assertNotNull( + CryptoUtils.decryptNIP44v2( + wrap.content, + receiver.keyPair.privKey!!, + wrap.pubKey.hexToByteArray(), + ), + ) + } + } + + @Test + fun parseWrappedEvent() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) + + val wrap = createWrap(sender, receiver) + + val innerJson = + CryptoUtils.decryptNIP44v2( + wrap.content, + receiver.keyPair.privKey!!, + wrap.pubKey.hexToByteArray(), + ) + + benchmarkRule.measureRepeated { assertNotNull(innerJson?.let { Event.fromJson(it) }) } + } + + @Test + fun decryptSealedEvent() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) + + val seal = createSeal(sender, receiver) + + benchmarkRule.measureRepeated { + assertNotNull( + CryptoUtils.decryptNIP44v2( + seal.content, + receiver.keyPair.privKey!!, + seal.pubKey.hexToByteArray(), + ), + ) + } + } + + @Test + fun parseSealedEvent() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) + + val seal = createSeal(sender, receiver) + + val innerJson = + CryptoUtils.decryptNIP44v2( + seal.content, + receiver.keyPair.privKey!!, + seal.pubKey.hexToByteArray(), + ) + + benchmarkRule.measureRepeated { assertNotNull(innerJson?.let { Gossip.fromJson(it) }) } + } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapSigningBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapSigningBenchmark.kt index cb0cf28f4..6363da2b9 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapSigningBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapSigningBenchmark.kt @@ -28,12 +28,12 @@ import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.GiftWrapEvent import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.signers.NostrSignerInternal -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit import junit.framework.TestCase.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit /** * Benchmark, which will execute on an Android device. @@ -43,159 +43,159 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) class GiftWrapSigningBenchmark { - @get:Rule val benchmarkRule = BenchmarkRule() + @get:Rule val benchmarkRule = BenchmarkRule() - @Test - fun createMessageEvent() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) + @Test + fun createMessageEvent() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) - benchmarkRule.measureRepeated { - val countDownLatch = CountDownLatch(1) + benchmarkRule.measureRepeated { + val countDownLatch = CountDownLatch(1) - ChatMessageEvent.create( - msg = "Hi there! This is a test message", - to = listOf(receiver.pubKey), - subject = "Party Tonight", - replyTos = emptyList(), - mentions = emptyList(), - zapReceiver = null, - markAsSensitive = true, - zapRaiserAmount = 10000, - geohash = null, - signer = sender, - ) { - countDownLatch.countDown() - } + ChatMessageEvent.create( + msg = "Hi there! This is a test message", + to = listOf(receiver.pubKey), + subject = "Party Tonight", + replyTos = emptyList(), + mentions = emptyList(), + zapReceiver = null, + markAsSensitive = true, + zapRaiserAmount = 10000, + geohash = null, + signer = sender, + ) { + countDownLatch.countDown() + } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - } - } - - @Test - fun sealMessage() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) - - val countDownLatch = CountDownLatch(1) - - var msg: ChatMessageEvent? = null - - ChatMessageEvent.create( - msg = "Hi there! This is a test message", - to = listOf(receiver.pubKey), - subject = "Party Tonight", - replyTos = emptyList(), - mentions = emptyList(), - zapReceiver = null, - markAsSensitive = true, - zapRaiserAmount = 10000, - geohash = null, - signer = sender, - ) { - msg = it - countDownLatch.countDown() - } - - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - - benchmarkRule.measureRepeated { - val countDownLatch2 = CountDownLatch(1) - SealedGossipEvent.create( - event = msg!!, - encryptTo = receiver.pubKey, - signer = sender, - ) { - countDownLatch2.countDown() - } - - assertTrue(countDownLatch2.await(1, TimeUnit.SECONDS)) - } - } - - @Test - fun wrapSeal() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) - - val countDownLatch = CountDownLatch(1) - - var seal: SealedGossipEvent? = null - - ChatMessageEvent.create( - msg = "Hi there! This is a test message", - to = listOf(receiver.pubKey), - subject = "Party Tonight", - replyTos = emptyList(), - mentions = emptyList(), - zapReceiver = null, - markAsSensitive = true, - zapRaiserAmount = 10000, - geohash = null, - signer = sender, - ) { - SealedGossipEvent.create( - event = it, - encryptTo = receiver.pubKey, - signer = sender, - ) { - seal = it - countDownLatch.countDown() - } - } - - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - - benchmarkRule.measureRepeated { - val countDownLatch2 = CountDownLatch(1) - GiftWrapEvent.create( - event = seal!!, - recipientPubKey = receiver.pubKey, - ) { - countDownLatch2.countDown() - } - assertTrue(countDownLatch2.await(1, TimeUnit.SECONDS)) - } - } - - @Test - fun wrapToString() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) - - val countDownLatch = CountDownLatch(1) - - var wrap: GiftWrapEvent? = null - - ChatMessageEvent.create( - msg = "Hi there! This is a test message", - to = listOf(receiver.pubKey), - subject = "Party Tonight", - replyTos = emptyList(), - mentions = emptyList(), - zapReceiver = null, - markAsSensitive = true, - zapRaiserAmount = 10000, - geohash = null, - signer = sender, - ) { - SealedGossipEvent.create( - event = it, - encryptTo = receiver.pubKey, - signer = sender, - ) { - GiftWrapEvent.create( - event = it, - recipientPubKey = receiver.pubKey, - ) { - wrap = it - countDownLatch.countDown() + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) } - } } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + @Test + fun sealMessage() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) - benchmarkRule.measureRepeated { wrap!!.toJson() } - } + val countDownLatch = CountDownLatch(1) + + var msg: ChatMessageEvent? = null + + ChatMessageEvent.create( + msg = "Hi there! This is a test message", + to = listOf(receiver.pubKey), + subject = "Party Tonight", + replyTos = emptyList(), + mentions = emptyList(), + zapReceiver = null, + markAsSensitive = true, + zapRaiserAmount = 10000, + geohash = null, + signer = sender, + ) { + msg = it + countDownLatch.countDown() + } + + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + + benchmarkRule.measureRepeated { + val countDownLatch2 = CountDownLatch(1) + SealedGossipEvent.create( + event = msg!!, + encryptTo = receiver.pubKey, + signer = sender, + ) { + countDownLatch2.countDown() + } + + assertTrue(countDownLatch2.await(1, TimeUnit.SECONDS)) + } + } + + @Test + fun wrapSeal() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) + + val countDownLatch = CountDownLatch(1) + + var seal: SealedGossipEvent? = null + + ChatMessageEvent.create( + msg = "Hi there! This is a test message", + to = listOf(receiver.pubKey), + subject = "Party Tonight", + replyTos = emptyList(), + mentions = emptyList(), + zapReceiver = null, + markAsSensitive = true, + zapRaiserAmount = 10000, + geohash = null, + signer = sender, + ) { + SealedGossipEvent.create( + event = it, + encryptTo = receiver.pubKey, + signer = sender, + ) { + seal = it + countDownLatch.countDown() + } + } + + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + + benchmarkRule.measureRepeated { + val countDownLatch2 = CountDownLatch(1) + GiftWrapEvent.create( + event = seal!!, + recipientPubKey = receiver.pubKey, + ) { + countDownLatch2.countDown() + } + assertTrue(countDownLatch2.await(1, TimeUnit.SECONDS)) + } + } + + @Test + fun wrapToString() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) + + val countDownLatch = CountDownLatch(1) + + var wrap: GiftWrapEvent? = null + + ChatMessageEvent.create( + msg = "Hi there! This is a test message", + to = listOf(receiver.pubKey), + subject = "Party Tonight", + replyTos = emptyList(), + mentions = emptyList(), + zapReceiver = null, + markAsSensitive = true, + zapRaiserAmount = 10000, + geohash = null, + signer = sender, + ) { + SealedGossipEvent.create( + event = it, + encryptTo = receiver.pubKey, + signer = sender, + ) { + GiftWrapEvent.create( + event = it, + recipientPubKey = receiver.pubKey, + ) { + wrap = it + countDownLatch.countDown() + } + } + } + + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + + benchmarkRule.measureRepeated { wrap!!.toJson() } + } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/HexBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/HexBenchmark.kt index fa7e115b6..b641c7574 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/HexBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/HexBenchmark.kt @@ -36,33 +36,33 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) class HexBenchmark { - @get:Rule val benchmarkRule = BenchmarkRule() + @get:Rule val benchmarkRule = BenchmarkRule() - val testHex = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf" + val testHex = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf" - @Test - fun hexDecodeOurs() { - benchmarkRule.measureRepeated { com.vitorpamplona.quartz.encoders.Hex.decode(testHex) } - } - - @Test - fun hexEncodeOurs() { - val bytes = com.vitorpamplona.quartz.encoders.Hex.decode(testHex) - - benchmarkRule.measureRepeated { - assertEquals(testHex, com.vitorpamplona.quartz.encoders.Hex.encode(bytes)) + @Test + fun hexDecodeOurs() { + benchmarkRule.measureRepeated { com.vitorpamplona.quartz.encoders.Hex.decode(testHex) } } - } - @Test - fun hexDecodeBaseSecp() { - benchmarkRule.measureRepeated { fr.acinq.secp256k1.Hex.decode(testHex) } - } + @Test + fun hexEncodeOurs() { + val bytes = com.vitorpamplona.quartz.encoders.Hex.decode(testHex) - @Test - fun hexEncodeBaseSecp() { - val bytes = fr.acinq.secp256k1.Hex.decode(testHex) + benchmarkRule.measureRepeated { + assertEquals(testHex, com.vitorpamplona.quartz.encoders.Hex.encode(bytes)) + } + } - benchmarkRule.measureRepeated { assertEquals(testHex, fr.acinq.secp256k1.Hex.encode(bytes)) } - } + @Test + fun hexDecodeBaseSecp() { + benchmarkRule.measureRepeated { fr.acinq.secp256k1.Hex.decode(testHex) } + } + + @Test + fun hexEncodeBaseSecp() { + val bytes = fr.acinq.secp256k1.Hex.decode(testHex) + + benchmarkRule.measureRepeated { assertEquals(testHex, fr.acinq.secp256k1.Hex.encode(bytes)) } + } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RobohashBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RobohashBenchmark.kt index 12dbd3e1b..bc32301c7 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RobohashBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RobohashBenchmark.kt @@ -37,12 +37,12 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) class RobohashBenchmark { - @get:Rule val benchmarkRule = BenchmarkRule() + @get:Rule val benchmarkRule = BenchmarkRule() - val warmHex = "f4f016c739b8ec0d6313540a8b12cf48a72b485d38338627ec9d427583551f9a" - val testHex = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf" - val resultingSVG = - """ + val warmHex = "f4f016c739b8ec0d6313540a8b12cf48a72b485d38338627ec9d427583551f9a" + val testHex = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf" + val resultingSVG = + """ @@ -164,15 +164,15 @@ class RobohashBenchmark { """ - .trimIndent() + .trimIndent() - @Test - fun createSVG() { - // warm up - Robohash.assemble(warmHex, true) - benchmarkRule.measureRepeated { - val result = Robohash.assemble(testHex, true) - assertEquals(resultingSVG, result) + @Test + fun createSVG() { + // warm up + Robohash.assemble(warmHex, true) + benchmarkRule.measureRepeated { + val result = Robohash.assemble(testHex, true) + assertEquals(resultingSVG, result) + } } - } } diff --git a/build.gradle b/build.gradle index 147c08459..288b9cda8 100644 --- a/build.gradle +++ b/build.gradle @@ -56,7 +56,7 @@ subprojects { targetExclude("$buildDir/**/*.kt") ktlint("1.1.0") - ktfmt().googleStyle() + //ktfmt().googleStyle() licenseHeaderFile rootProject.file('spotless/copyright.kt'), "package|import|class|object|sealed|open|interface|abstract " } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/ChatroomKeyTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/ChatroomKeyTest.kt index 88ecdcd93..6b9506621 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/ChatroomKeyTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/ChatroomKeyTest.kt @@ -29,12 +29,12 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ChatroomKeyTest { - @Test - fun testEquals() { - val k1 = ChatroomKey(persistentSetOf("Key1", "Key2")) - val k2 = ChatroomKey(persistentSetOf("Key1", "Key2")) + @Test + fun testEquals() { + val k1 = ChatroomKey(persistentSetOf("Key1", "Key2")) + val k2 = ChatroomKey(persistentSetOf("Key1", "Key2")) - assertEquals(k1, k2) - assertEquals(k1.hashCode(), k2.hashCode()) - } + assertEquals(k1, k2) + assertEquals(k1.hashCode(), k2.hashCode()) + } } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/CitationTests.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/CitationTests.kt index 019aa0af0..7fff34d93 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/CitationTests.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/CitationTests.kt @@ -29,8 +29,8 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class CitationTests { - val json = - """ + val json = + """ { "content": "Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ", "created_at": 1683596206, @@ -94,18 +94,18 @@ class CitationTests { } """ - @Test - fun parseEvent() { - val event = Event.fromJson(json) as TextNoteEvent + @Test + fun parseEvent() { + val event = Event.fromJson(json) as TextNoteEvent - val expectedCitations = - setOf( - "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168", - "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", - "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0", - "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", - ) + val expectedCitations = + setOf( + "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", + "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + ) - assertEquals(expectedCitations, event.citedUsers()) - } + assertEquals(expectedCitations, event.citedUsers()) + } } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/CryptoUtilsTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/CryptoUtilsTest.kt index 5db4fd3a7..b7e6f3132 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/CryptoUtilsTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/CryptoUtilsTest.kt @@ -31,95 +31,95 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class CryptoUtilsTest { - @Test - fun testGetPublicFromPrivateKey() { - val privateKey = - "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray() - val publicKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey() - assertEquals("7d4b8806f1fd713c287235411bf95aa81b7242ead892733ec84b3f2719845be6", publicKey) - } + @Test + fun testGetPublicFromPrivateKey() { + val privateKey = + "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray() + val publicKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey() + assertEquals("7d4b8806f1fd713c287235411bf95aa81b7242ead892733ec84b3f2719845be6", publicKey) + } - @Test - fun testSharedSecretCompatibilityWithCoracle() { - val privateKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561" - val publicKey = "765cd7cf91d3ad07423d114d5a39c61d52b2cdbc18ba055ddbbeec71fbe2aa2f" + @Test + fun testSharedSecretCompatibilityWithCoracle() { + val privateKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561" + val publicKey = "765cd7cf91d3ad07423d114d5a39c61d52b2cdbc18ba055ddbbeec71fbe2aa2f" - val key = - CryptoUtils.getSharedSecretNIP44v1( - privateKey = privateKey.hexToByteArray(), - pubKey = publicKey.hexToByteArray(), - ) + val key = + CryptoUtils.getSharedSecretNIP44v1( + privateKey = privateKey.hexToByteArray(), + pubKey = publicKey.hexToByteArray(), + ) - assertEquals("577c966f499dddd8e8dcc34e8f352e283cc177e53ae372794947e0b8ede7cfd8", key.toHexKey()) - } + assertEquals("577c966f499dddd8e8dcc34e8f352e283cc177e53ae372794947e0b8ede7cfd8", key.toHexKey()) + } - @Test - fun testSharedSecret() { - val sender = KeyPair() - val receiver = KeyPair() + @Test + fun testSharedSecret() { + val sender = KeyPair() + val receiver = KeyPair() - val sharedSecret1 = CryptoUtils.getSharedSecretNIP44v1(sender.privKey!!, receiver.pubKey) - val sharedSecret2 = CryptoUtils.getSharedSecretNIP44v1(receiver.privKey!!, sender.pubKey) + val sharedSecret1 = CryptoUtils.getSharedSecretNIP44v1(sender.privKey!!, receiver.pubKey) + val sharedSecret2 = CryptoUtils.getSharedSecretNIP44v1(receiver.privKey!!, sender.pubKey) - assertEquals(sharedSecret1.toHexKey(), sharedSecret2.toHexKey()) + assertEquals(sharedSecret1.toHexKey(), sharedSecret2.toHexKey()) - val secretKey1 = KeyPair(privKey = sharedSecret1) - val secretKey2 = KeyPair(privKey = sharedSecret2) + val secretKey1 = KeyPair(privKey = sharedSecret1) + val secretKey2 = KeyPair(privKey = sharedSecret2) - assertEquals(secretKey1.pubKey.toHexKey(), secretKey2.pubKey.toHexKey()) - assertEquals(secretKey1.privKey?.toHexKey(), secretKey2.privKey?.toHexKey()) - } + assertEquals(secretKey1.pubKey.toHexKey(), secretKey2.pubKey.toHexKey()) + assertEquals(secretKey1.privKey?.toHexKey(), secretKey2.privKey?.toHexKey()) + } - @Test - fun encryptDecryptNIP4Test() { - val msg = "Hi" + @Test + fun encryptDecryptNIP4Test() { + val msg = "Hi" - val privateKey = CryptoUtils.privkeyCreate() - val publicKey = CryptoUtils.pubkeyCreate(privateKey) + val privateKey = CryptoUtils.privkeyCreate() + val publicKey = CryptoUtils.pubkeyCreate(privateKey) - val encrypted = CryptoUtils.encryptNIP04(msg, privateKey, publicKey) - val decrypted = CryptoUtils.decryptNIP04(encrypted, privateKey, publicKey) + val encrypted = CryptoUtils.encryptNIP04(msg, privateKey, publicKey) + val decrypted = CryptoUtils.decryptNIP04(encrypted, privateKey, publicKey) - assertEquals(msg, decrypted) - } + assertEquals(msg, decrypted) + } - @Test - fun encryptDecryptNIP44v1Test() { - val msg = "Hi" + @Test + fun encryptDecryptNIP44v1Test() { + val msg = "Hi" - val privateKey = CryptoUtils.privkeyCreate() - val publicKey = CryptoUtils.pubkeyCreate(privateKey) + val privateKey = CryptoUtils.privkeyCreate() + val publicKey = CryptoUtils.pubkeyCreate(privateKey) - val encrypted = CryptoUtils.encryptNIP44v1(msg, privateKey, publicKey) - val decrypted = CryptoUtils.decryptNIP44v1(encrypted, privateKey, publicKey) + val encrypted = CryptoUtils.encryptNIP44v1(msg, privateKey, publicKey) + val decrypted = CryptoUtils.decryptNIP44v1(encrypted, privateKey, publicKey) - assertEquals(msg, decrypted) - } + assertEquals(msg, decrypted) + } - @Test - fun encryptSharedSecretDecryptNIP4Test() { - val msg = "Hi" + @Test + fun encryptSharedSecretDecryptNIP4Test() { + val msg = "Hi" - val privateKey = CryptoUtils.privkeyCreate() - val publicKey = CryptoUtils.pubkeyCreate(privateKey) + val privateKey = CryptoUtils.privkeyCreate() + val publicKey = CryptoUtils.pubkeyCreate(privateKey) - val encrypted = CryptoUtils.encryptNIP04(msg, privateKey, publicKey) - val decrypted = CryptoUtils.decryptNIP04(encrypted, privateKey, publicKey) + val encrypted = CryptoUtils.encryptNIP04(msg, privateKey, publicKey) + val decrypted = CryptoUtils.decryptNIP04(encrypted, privateKey, publicKey) - assertEquals(msg, decrypted) - } + assertEquals(msg, decrypted) + } - @Test - fun encryptSharedSecretDecryptNIP44v1Test() { - val msg = "Hi" + @Test + fun encryptSharedSecretDecryptNIP44v1Test() { + val msg = "Hi" - val privateKey = CryptoUtils.privkeyCreate() - val publicKey = CryptoUtils.pubkeyCreate(privateKey) - val sharedSecret = CryptoUtils.getSharedSecretNIP44v1(privateKey, publicKey) + val privateKey = CryptoUtils.privkeyCreate() + val publicKey = CryptoUtils.pubkeyCreate(privateKey) + val sharedSecret = CryptoUtils.getSharedSecretNIP44v1(privateKey, publicKey) - val encrypted = CryptoUtils.encryptNIP44v1(msg, sharedSecret) - val decrypted = CryptoUtils.decryptNIP44v1(encrypted, sharedSecret) + val encrypted = CryptoUtils.encryptNIP44v1(msg, sharedSecret) + val decrypted = CryptoUtils.decryptNIP44v1(encrypted, sharedSecret) - assertEquals(msg, decrypted) - } + assertEquals(msg, decrypted) + } } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/EventSigCheck.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/EventSigCheck.kt index 507b0b495..d04714779 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/EventSigCheck.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/EventSigCheck.kt @@ -27,15 +27,15 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class EventSigCheck { - val payload1 = - "[\"EVENT\",\"40b9\",{\"id\":\"48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf\",\"kind\":1,\"pubkey\":\"3d842afecd5e293f28b6627933704a3fb8ce153aa91d790ab11f6a752d44a42d\",\"created_at\":1677940007,\"content\":\"I got asked about follower count again today. Why does my follower count go down when I delete public relays (in our list) and replace them with filter.nostr.wine? \\n\\nIโ€™ll give you one final explanation to rule them all. First, letโ€™s go over how clients calculate your follower count.\\n\\n1. Your client sends a request to all your connected relays asking for accounts who follow you\\n2. Relays answer back with the events requested\\n3. The client aggregates the event total and displays it\\n\\nEach relay has a set limit on how many stored events it will return per request. For some relays itโ€™s 500, others 1000, some as high as 5000. Letโ€™s say for simplicity that all your public relays use 500 as their limit. If you ask 10 relays for your followers the max possible answer you can get is 5000. That wonโ€™t change if you have 20,000 followers or 100,000. You may get back a โ€œdifferentโ€ 5000 each time, but youโ€™ll still cap out at 5000 because that is the most events your client will receive.\u2028\u2028Our limit on filter.nostr.wine is 2000 events. If you replace 10 public relays with only filter.nostr.wine, the MOST followers you will ever get back from our filter relay is 2000. That doesnโ€™t mean you only have 2000 followers or that your reach is reduced in any way.\\n\\nAs long as you are writing to and reading from the same public relays, neither your reach nor any content was lost. That concludes my TED talk. I hope you all have a fantastic day and weekend.\",\"tags\":[],\"sig\":\"dcaf8ab98bb9179017b35bd814092850d1062b26c263dff89fb1ae8c019a324139d1729012d9d05ff0a517f76b1117d869b2cc7d36bea8aa5f4b94c5e2548aa8\"}]" + val payload1 = + "[\"EVENT\",\"40b9\",{\"id\":\"48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf\",\"kind\":1,\"pubkey\":\"3d842afecd5e293f28b6627933704a3fb8ce153aa91d790ab11f6a752d44a42d\",\"created_at\":1677940007,\"content\":\"I got asked about follower count again today. Why does my follower count go down when I delete public relays (in our list) and replace them with filter.nostr.wine? \\n\\nIโ€™ll give you one final explanation to rule them all. First, letโ€™s go over how clients calculate your follower count.\\n\\n1. Your client sends a request to all your connected relays asking for accounts who follow you\\n2. Relays answer back with the events requested\\n3. The client aggregates the event total and displays it\\n\\nEach relay has a set limit on how many stored events it will return per request. For some relays itโ€™s 500, others 1000, some as high as 5000. Letโ€™s say for simplicity that all your public relays use 500 as their limit. If you ask 10 relays for your followers the max possible answer you can get is 5000. That wonโ€™t change if you have 20,000 followers or 100,000. You may get back a โ€œdifferentโ€ 5000 each time, but youโ€™ll still cap out at 5000 because that is the most events your client will receive.\u2028\u2028Our limit on filter.nostr.wine is 2000 events. If you replace 10 public relays with only filter.nostr.wine, the MOST followers you will ever get back from our filter relay is 2000. That doesnโ€™t mean you only have 2000 followers or that your reach is reduced in any way.\\n\\nAs long as you are writing to and reading from the same public relays, neither your reach nor any content was lost. That concludes my TED talk. I hope you all have a fantastic day and weekend.\",\"tags\":[],\"sig\":\"dcaf8ab98bb9179017b35bd814092850d1062b26c263dff89fb1ae8c019a324139d1729012d9d05ff0a517f76b1117d869b2cc7d36bea8aa5f4b94c5e2548aa8\"}]" - @Test - fun testUnicode2028and2029ShouldNotBeEscaped() { - val msg = Event.mapper.readTree(payload1) - val event = Event.fromJson(msg[2]) + @Test + fun testUnicode2028and2029ShouldNotBeEscaped() { + val msg = Event.mapper.readTree(payload1) + val event = Event.fromJson(msg[2]) - // Should pass - event.checkSignature() - } + // Should pass + event.checkSignature() + } } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/GiftWrapEventTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/GiftWrapEventTest.kt index 124be15bd..27712a34c 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/GiftWrapEventTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/GiftWrapEventTest.kt @@ -31,8 +31,6 @@ import com.vitorpamplona.quartz.events.GiftWrapEvent import com.vitorpamplona.quartz.events.NIP24Factory import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.signers.NostrSignerInternal -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull @@ -40,488 +38,490 @@ import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Test import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class GiftWrapEventTest { - @Test() - fun testNip24Utils() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) - val message = "Hola, que tal?" + @Test() + fun testNip24Utils() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) + val message = "Hola, que tal?" - // Requires 3 tests - val countDownLatch = CountDownLatch(3) + // Requires 3 tests + val countDownLatch = CountDownLatch(3) - NIP24Factory().createMsgNIP24( - message, - listOf(receiver.pubKey), - sender, - ) { events -> - countDownLatch.countDown() + NIP24Factory().createMsgNIP24( + message, + listOf(receiver.pubKey), + sender, + ) { events -> + countDownLatch.countDown() - // Simulate Receiver - val eventsReceiverGets = events.wraps.filter { it.isTaggedUser(receiver.pubKey) } - eventsReceiverGets.forEach { - it.cachedGift(receiver) { event -> - if (event is SealedGossipEvent) { - event.cachedGossip(receiver) { innerData -> - countDownLatch.countDown() - assertEquals(message, innerData.content) + // Simulate Receiver + val eventsReceiverGets = events.wraps.filter { it.isTaggedUser(receiver.pubKey) } + eventsReceiverGets.forEach { + it.cachedGift(receiver) { event -> + if (event is SealedGossipEvent) { + event.cachedGossip(receiver) { innerData -> + countDownLatch.countDown() + assertEquals(message, innerData.content) + } + } else { + fail("Wrong Event") + } + } } - } else { - fail("Wrong Event") - } - } - } - // Simulate Sender - val eventsSenderGets = events.wraps.filter { it.isTaggedUser(sender.pubKey) } - eventsSenderGets.forEach { - it.cachedGift(sender) { event -> - if (event is SealedGossipEvent) { - event.cachedGossip(sender) { innerData -> - countDownLatch.countDown() - assertEquals(message, innerData.content) + // Simulate Sender + val eventsSenderGets = events.wraps.filter { it.isTaggedUser(sender.pubKey) } + eventsSenderGets.forEach { + it.cachedGift(sender) { event -> + if (event is SealedGossipEvent) { + event.cachedGossip(sender) { innerData -> + countDownLatch.countDown() + assertEquals(message, innerData.content) + } + } else { + fail("Wrong Event") + } + } } - } else { - fail("Wrong Event") - } } - } + + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - } + @Test() + fun testNip24UtilsForGroups() { + val sender = NostrSignerInternal(KeyPair()) + val receiver1 = NostrSignerInternal(KeyPair()) + val receiver2 = NostrSignerInternal(KeyPair()) + val receiver3 = NostrSignerInternal(KeyPair()) + val receiver4 = NostrSignerInternal(KeyPair()) + val message = "Hola, que tal?" - @Test() - fun testNip24UtilsForGroups() { - val sender = NostrSignerInternal(KeyPair()) - val receiver1 = NostrSignerInternal(KeyPair()) - val receiver2 = NostrSignerInternal(KeyPair()) - val receiver3 = NostrSignerInternal(KeyPair()) - val receiver4 = NostrSignerInternal(KeyPair()) - val message = "Hola, que tal?" + val receivers = + listOf( + receiver1, + receiver2, + receiver3, + receiver4, + ) - val receivers = - listOf( - receiver1, - receiver2, - receiver3, - receiver4, - ) + val countDownLatch = CountDownLatch(receivers.size + 2) - val countDownLatch = CountDownLatch(receivers.size + 2) + NIP24Factory().createMsgNIP24( + message, + receivers.map { it.pubKey }, + sender, + ) { events -> + countDownLatch.countDown() - NIP24Factory().createMsgNIP24( - message, - receivers.map { it.pubKey }, - sender, - ) { events -> - countDownLatch.countDown() - - // Simulate Receiver - receivers.forEach { receiver -> - val eventsReceiverGets = events.wraps.filter { it.isTaggedUser(receiver.pubKey) } - eventsReceiverGets.forEach { - it.cachedGift(receiver) { event -> - if (event is SealedGossipEvent) { - event.cachedGossip(receiver) { innerData -> - countDownLatch.countDown() - assertEquals(message, innerData.content) - } - } else { - fail("Wrong Event") + // Simulate Receiver + receivers.forEach { receiver -> + val eventsReceiverGets = events.wraps.filter { it.isTaggedUser(receiver.pubKey) } + eventsReceiverGets.forEach { + it.cachedGift(receiver) { event -> + if (event is SealedGossipEvent) { + event.cachedGossip(receiver) { innerData -> + countDownLatch.countDown() + assertEquals(message, innerData.content) + } + } else { + fail("Wrong Event") + } + } + } } - } - } - } - // Simulate Sender - val eventsSenderGets = events.wraps.filter { it.isTaggedUser(sender.pubKey) } - eventsSenderGets.forEach { - it.cachedGift(sender) { event -> - if (event is SealedGossipEvent) { - event.cachedGossip(sender) { innerData -> - countDownLatch.countDown() - assertEquals(message, innerData.content) + // Simulate Sender + val eventsSenderGets = events.wraps.filter { it.isTaggedUser(sender.pubKey) } + eventsSenderGets.forEach { + it.cachedGift(sender) { event -> + if (event is SealedGossipEvent) { + event.cachedGossip(sender) { innerData -> + countDownLatch.countDown() + assertEquals(message, innerData.content) + } + } else { + fail("Wrong Event") + } + } } - } else { - fail("Wrong Event") - } } - } + + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - } + @Test() + fun testInternalsSimpleMessage() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) - @Test() - fun testInternalsSimpleMessage() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) + val countDownLatch = CountDownLatch(2) - val countDownLatch = CountDownLatch(2) + var giftWrapEventToSender: GiftWrapEvent? = null + var giftWrapEventToReceiver: GiftWrapEvent? = null - var giftWrapEventToSender: GiftWrapEvent? = null - var giftWrapEventToReceiver: GiftWrapEvent? = null + ChatMessageEvent.create( + msg = "Hi There!", + to = listOf(receiver.pubKey), + signer = sender, + ) { senderMessage -> + // MsgFor the Receiver - ChatMessageEvent.create( - msg = "Hi There!", - to = listOf(receiver.pubKey), - signer = sender, - ) { senderMessage -> - // MsgFor the Receiver + SealedGossipEvent.create( + event = senderMessage, + encryptTo = receiver.pubKey, + signer = sender, + ) { encMsgFromSenderToReceiver -> + // Should expose sender + assertEquals(encMsgFromSenderToReceiver.pubKey, sender.pubKey) + // Should not expose receiver + assertTrue(encMsgFromSenderToReceiver.tags.isEmpty()) - SealedGossipEvent.create( - event = senderMessage, - encryptTo = receiver.pubKey, - signer = sender, - ) { encMsgFromSenderToReceiver -> - // Should expose sender - assertEquals(encMsgFromSenderToReceiver.pubKey, sender.pubKey) - // Should not expose receiver - assertTrue(encMsgFromSenderToReceiver.tags.isEmpty()) + GiftWrapEvent.create( + event = encMsgFromSenderToReceiver, + recipientPubKey = receiver.pubKey, + ) { giftWrapToReceiver -> + // Should not be signed by neither sender nor receiver + assertNotEquals(giftWrapToReceiver.pubKey, sender.pubKey) + assertNotEquals(giftWrapToReceiver.pubKey, receiver.pubKey) - GiftWrapEvent.create( - event = encMsgFromSenderToReceiver, - recipientPubKey = receiver.pubKey, - ) { giftWrapToReceiver -> - // Should not be signed by neither sender nor receiver - assertNotEquals(giftWrapToReceiver.pubKey, sender.pubKey) - assertNotEquals(giftWrapToReceiver.pubKey, receiver.pubKey) + // Should not include sender as recipient + assertNotEquals(giftWrapToReceiver.recipientPubKey(), sender.pubKey) - // Should not include sender as recipient - assertNotEquals(giftWrapToReceiver.recipientPubKey(), sender.pubKey) + // Should be addressed to the receiver + assertEquals(giftWrapToReceiver.recipientPubKey(), receiver.pubKey) - // Should be addressed to the receiver - assertEquals(giftWrapToReceiver.recipientPubKey(), receiver.pubKey) + giftWrapEventToReceiver = giftWrapToReceiver - giftWrapEventToReceiver = giftWrapToReceiver + countDownLatch.countDown() + } + } - countDownLatch.countDown() + // MsgFor the Sender + SealedGossipEvent.create( + event = senderMessage, + encryptTo = sender.pubKey, + signer = sender, + ) { encMsgFromSenderToSender -> + // Should expose sender + assertEquals(encMsgFromSenderToSender.pubKey, sender.pubKey) + // Should not expose receiver + assertTrue(encMsgFromSenderToSender.tags.isEmpty()) + + GiftWrapEvent.create( + event = encMsgFromSenderToSender, + recipientPubKey = sender.pubKey, + ) { giftWrapToSender -> + // Should not be signed by neither the sender, not the receiver + assertNotEquals(giftWrapToSender.pubKey, sender.pubKey) + assertNotEquals(giftWrapToSender.pubKey, receiver.pubKey) + + // Should not be addressed to the receiver + assertNotEquals(giftWrapToSender.recipientPubKey(), receiver.pubKey) + // Should be addressed to the sender + assertEquals(giftWrapToSender.recipientPubKey(), sender.pubKey) + + giftWrapEventToSender = giftWrapToSender + + countDownLatch.countDown() + } + } } - } - // MsgFor the Sender - SealedGossipEvent.create( - event = senderMessage, - encryptTo = sender.pubKey, - signer = sender, - ) { encMsgFromSenderToSender -> - // Should expose sender - assertEquals(encMsgFromSenderToSender.pubKey, sender.pubKey) - // Should not expose receiver - assertTrue(encMsgFromSenderToSender.tags.isEmpty()) + // Done + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - GiftWrapEvent.create( - event = encMsgFromSenderToSender, - recipientPubKey = sender.pubKey, - ) { giftWrapToSender -> - // Should not be signed by neither the sender, not the receiver - assertNotEquals(giftWrapToSender.pubKey, sender.pubKey) - assertNotEquals(giftWrapToSender.pubKey, receiver.pubKey) + // Receiver's side + // Makes sure it can only be decrypted by the target user - // Should not be addressed to the receiver - assertNotEquals(giftWrapToSender.recipientPubKey(), receiver.pubKey) - // Should be addressed to the sender - assertEquals(giftWrapToSender.recipientPubKey(), sender.pubKey) + assertNotNull(giftWrapEventToSender) + assertNotNull(giftWrapEventToReceiver) - giftWrapEventToSender = giftWrapToSender + val countDownDecryptLatch = CountDownLatch(2) - countDownLatch.countDown() + giftWrapEventToSender!!.cachedGift(sender) { unwrappedMsgForSenderBySender -> + assertEquals(SealedGossipEvent.KIND, unwrappedMsgForSenderBySender.kind) + assertTrue(unwrappedMsgForSenderBySender is SealedGossipEvent) + + if (unwrappedMsgForSenderBySender is SealedGossipEvent) { + unwrappedMsgForSenderBySender.cachedGossip(sender) { unwrappedGossipToSenderBySender -> + assertEquals("Hi There!", unwrappedGossipToSenderBySender.content) + countDownDecryptLatch.countDown() + } + + unwrappedMsgForSenderBySender.cachedGossip(receiver) { _ -> + fail( + "Should not be able to decrypt msg for the sender by the sender but decrypted with receiver", + ) + } + } } - } + + giftWrapEventToReceiver!!.cachedGift(sender) { _ -> + fail("Should not be able to decrypt msg for the receiver decrypted by the sender") + } + + giftWrapEventToSender!!.cachedGift(receiver) { _ -> + fail("Should not be able to decrypt msg for the sender decrypted by the receiver") + } + + giftWrapEventToReceiver!!.cachedGift(receiver) { unwrappedMsgForReceiverByReceiver -> + assertEquals(SealedGossipEvent.KIND, unwrappedMsgForReceiverByReceiver.kind) + assertTrue(unwrappedMsgForReceiverByReceiver is SealedGossipEvent) + + if (unwrappedMsgForReceiverByReceiver is SealedGossipEvent) { + unwrappedMsgForReceiverByReceiver.cachedGossip(receiver) { + unwrappedGossipToReceiverByReceiver -> + assertEquals("Hi There!", unwrappedGossipToReceiverByReceiver?.content) + countDownDecryptLatch.countDown() + } + + unwrappedMsgForReceiverByReceiver.cachedGossip(sender) { unwrappedGossipToReceiverBySender, + -> + fail( + "Should not be able to decrypt msg for the receiver by the receiver but decrypted with the sender", + ) + } + } + } + + assertTrue(countDownDecryptLatch.await(1, TimeUnit.SECONDS)) } - // Done - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + @Test() + fun testInternalsGroupMessage() { + val sender = NostrSignerInternal(KeyPair()) + val receiverA = NostrSignerInternal(KeyPair()) + val receiverB = NostrSignerInternal(KeyPair()) - // Receiver's side - // Makes sure it can only be decrypted by the target user + val countDownLatch = CountDownLatch(3) - assertNotNull(giftWrapEventToSender) - assertNotNull(giftWrapEventToReceiver) + var giftWrapEventToSender: GiftWrapEvent? = null + var giftWrapEventToReceiverA: GiftWrapEvent? = null + var giftWrapEventToReceiverB: GiftWrapEvent? = null - val countDownDecryptLatch = CountDownLatch(2) + ChatMessageEvent.create( + msg = "Who is going to the party tonight?", + to = listOf(receiverA.pubKey, receiverB.pubKey), + signer = sender, + ) { senderMessage -> + SealedGossipEvent.create( + event = senderMessage, + encryptTo = receiverA.pubKey, + signer = sender, + ) { msgFromSenderToReceiverA -> + // Should expose sender + assertEquals(msgFromSenderToReceiverA.pubKey, sender.pubKey) + // Should not expose receiver + assertTrue(msgFromSenderToReceiverA.tags.isEmpty()) - giftWrapEventToSender!!.cachedGift(sender) { unwrappedMsgForSenderBySender -> - assertEquals(SealedGossipEvent.KIND, unwrappedMsgForSenderBySender.kind) - assertTrue(unwrappedMsgForSenderBySender is SealedGossipEvent) + GiftWrapEvent.create( + event = msgFromSenderToReceiverA, + recipientPubKey = receiverA.pubKey, + ) { giftWrapForReceiverA -> + // Should not be signed by neither sender nor receiver + assertNotEquals(giftWrapForReceiverA.pubKey, sender.pubKey) + assertNotEquals(giftWrapForReceiverA.pubKey, receiverA.pubKey) + assertNotEquals(giftWrapForReceiverA.pubKey, receiverB.pubKey) - if (unwrappedMsgForSenderBySender is SealedGossipEvent) { - unwrappedMsgForSenderBySender.cachedGossip(sender) { unwrappedGossipToSenderBySender -> - assertEquals("Hi There!", unwrappedGossipToSenderBySender.content) - countDownDecryptLatch.countDown() + // Should not include sender as recipient + assertNotEquals(giftWrapForReceiverA.recipientPubKey(), sender.pubKey) + + // Should be addressed to the receiver + assertEquals(giftWrapForReceiverA.recipientPubKey(), receiverA.pubKey) + + giftWrapEventToReceiverA = giftWrapForReceiverA + + countDownLatch.countDown() + } + } + + SealedGossipEvent.create( + event = senderMessage, + encryptTo = receiverB.pubKey, + signer = sender, + ) { msgFromSenderToReceiverB -> + // Should expose sender + assertEquals(msgFromSenderToReceiverB.pubKey, sender.pubKey) + // Should not expose receiver + assertTrue(msgFromSenderToReceiverB.tags.isEmpty()) + + GiftWrapEvent.create( + event = msgFromSenderToReceiverB, + recipientPubKey = receiverB.pubKey, + ) { giftWrapForReceiverB -> + // Should not be signed by neither sender nor receiver + assertNotEquals(giftWrapForReceiverB.pubKey, sender.pubKey) + assertNotEquals(giftWrapForReceiverB.pubKey, receiverA.pubKey) + assertNotEquals(giftWrapForReceiverB.pubKey, receiverB.pubKey) + + // Should not include sender as recipient + assertNotEquals(giftWrapForReceiverB.recipientPubKey(), sender.pubKey) + + // Should be addressed to the receiver + assertEquals(giftWrapForReceiverB.recipientPubKey(), receiverB.pubKey) + + giftWrapEventToReceiverB = giftWrapForReceiverB + + countDownLatch.countDown() + } + } + + SealedGossipEvent.create( + event = senderMessage, + encryptTo = sender.pubKey, + signer = sender, + ) { msgFromSenderToSender -> + // Should expose sender + assertEquals(msgFromSenderToSender.pubKey, sender.pubKey) + // Should not expose receiver + assertTrue(msgFromSenderToSender.tags.isEmpty()) + + GiftWrapEvent.create( + event = msgFromSenderToSender, + recipientPubKey = sender.pubKey, + ) { giftWrapToSender -> + // Should not be signed by neither the sender, not the receiver + assertNotEquals(giftWrapToSender.pubKey, sender.pubKey) + assertNotEquals(giftWrapToSender.pubKey, receiverA.pubKey) + assertNotEquals(giftWrapToSender.pubKey, receiverB.pubKey) + + // Should not be addressed to the receiver + assertNotEquals(giftWrapToSender.recipientPubKey(), receiverA.pubKey) + assertNotEquals(giftWrapToSender.recipientPubKey(), receiverB.pubKey) + // Should be addressed to the sender + assertEquals(giftWrapToSender.recipientPubKey(), sender.pubKey) + + giftWrapEventToSender = giftWrapToSender + + countDownLatch.countDown() + } + } } - unwrappedMsgForSenderBySender.cachedGossip(receiver) { _ -> - fail( - "Should not be able to decrypt msg for the sender by the sender but decrypted with receiver", - ) + // Done + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + + // Receiver's side + // Makes sure it can only be decrypted by the target user + + assertNotNull(giftWrapEventToSender) + assertNotNull(giftWrapEventToReceiverA) + assertNotNull(giftWrapEventToReceiverB) + + val countDownDecryptLatch = CountDownLatch(3) + + giftWrapEventToSender?.cachedGift(sender) { unwrappedMsgForSenderBySender -> + assertEquals(SealedGossipEvent.KIND, unwrappedMsgForSenderBySender.kind) + + if (unwrappedMsgForSenderBySender is SealedGossipEvent) { + unwrappedMsgForSenderBySender.cachedGossip(receiverA) { unwrappedGossipToSenderByReceiverA, + -> + fail() + } + + unwrappedMsgForSenderBySender.cachedGossip(receiverB) { unwrappedGossipToSenderByReceiverB, + -> + fail() + } + + unwrappedMsgForSenderBySender.cachedGossip(sender) { unwrappedGossipToSenderBySender -> + assertEquals( + "Who is going to the party tonight?", + unwrappedGossipToSenderBySender.content, + ) + } + } + + countDownDecryptLatch.countDown() } - } + + giftWrapEventToReceiverA!!.cachedGift(sender) { unwrappedMsgForReceiverBySenderA -> + fail("Should not be able to decode msg to the receiver A with the sender's key") + } + + giftWrapEventToReceiverB!!.cachedGift(sender) { unwrappedMsgForReceiverBySenderB -> + fail("Should not be able to decode msg to the receiver B with the sender's key") + } + + giftWrapEventToSender!!.cachedGift(receiverA) { + fail("Should not be able to decode msg to sender with the receiver A's key") + } + + giftWrapEventToReceiverA!!.cachedGift(receiverA) { unwrappedMsgForReceiverAByReceiverA -> + assertEquals(SealedGossipEvent.KIND, unwrappedMsgForReceiverAByReceiverA.kind) + + if (unwrappedMsgForReceiverAByReceiverA is SealedGossipEvent) { + unwrappedMsgForReceiverAByReceiverA.cachedGossip(receiverA) { + unwrappedGossipToReceiverAByReceiverA -> + assertEquals( + "Who is going to the party tonight?", + unwrappedGossipToReceiverAByReceiverA.content, + ) + } + + unwrappedMsgForReceiverAByReceiverA.cachedGossip(sender) { + unwrappedGossipToReceiverABySender -> + fail() + } + + unwrappedMsgForReceiverAByReceiverA.cachedGossip(receiverB) { + unwrappedGossipToReceiverAByReceiverB -> + fail() + } + } + + countDownDecryptLatch.countDown() + } + + giftWrapEventToReceiverB!!.cachedGift(receiverA) { + fail("Should not be able to decode msg to sender with the receiver A's key") + } + + giftWrapEventToSender!!.cachedGift(receiverB) { unwrappedMsgForSenderByReceiverB -> + fail("Should not be able to decode msg to sender with the receiver B's key") + } + giftWrapEventToReceiverA!!.cachedGift(receiverB) { unwrappedMsgForReceiverAByReceiverB -> + fail("Should not be able to decode msg to receiver A with the receiver B's key") + } + giftWrapEventToReceiverB!!.cachedGift(receiverB) { unwrappedMsgForReceiverBByReceiverB -> + assertEquals(SealedGossipEvent.KIND, unwrappedMsgForReceiverBByReceiverB.kind) + + if (unwrappedMsgForReceiverBByReceiverB is SealedGossipEvent) { + unwrappedMsgForReceiverBByReceiverB.cachedGossip(receiverA) { + unwrappedGossipToReceiverBByReceiverA -> + fail() + } + + unwrappedMsgForReceiverBByReceiverB.cachedGossip(receiverB) { + unwrappedGossipToReceiverBByReceiverB -> + assertEquals( + "Who is going to the party tonight?", + unwrappedGossipToReceiverBByReceiverB.content, + ) + + countDownDecryptLatch.countDown() + } + + unwrappedMsgForReceiverBByReceiverB.cachedGossip(sender) { + unwrappedGossipToReceiverBBySender -> + fail() + } + } + } + + assertTrue(countDownDecryptLatch.await(1, TimeUnit.SECONDS)) } - giftWrapEventToReceiver!!.cachedGift(sender) { _ -> - fail("Should not be able to decrypt msg for the receiver decrypted by the sender") - } - - giftWrapEventToSender!!.cachedGift(receiver) { _ -> - fail("Should not be able to decrypt msg for the sender decrypted by the receiver") - } - - giftWrapEventToReceiver!!.cachedGift(receiver) { unwrappedMsgForReceiverByReceiver -> - assertEquals(SealedGossipEvent.KIND, unwrappedMsgForReceiverByReceiver.kind) - assertTrue(unwrappedMsgForReceiverByReceiver is SealedGossipEvent) - - if (unwrappedMsgForReceiverByReceiver is SealedGossipEvent) { - unwrappedMsgForReceiverByReceiver.cachedGossip(receiver) { - unwrappedGossipToReceiverByReceiver -> - assertEquals("Hi There!", unwrappedGossipToReceiverByReceiver?.content) - countDownDecryptLatch.countDown() - } - - unwrappedMsgForReceiverByReceiver.cachedGossip(sender) { unwrappedGossipToReceiverBySender, - -> - fail( - "Should not be able to decrypt msg for the receiver by the receiver but decrypted with the sender", - ) - } - } - } - - assertTrue(countDownDecryptLatch.await(1, TimeUnit.SECONDS)) - } - - @Test() - fun testInternalsGroupMessage() { - val sender = NostrSignerInternal(KeyPair()) - val receiverA = NostrSignerInternal(KeyPair()) - val receiverB = NostrSignerInternal(KeyPair()) - - val countDownLatch = CountDownLatch(3) - - var giftWrapEventToSender: GiftWrapEvent? = null - var giftWrapEventToReceiverA: GiftWrapEvent? = null - var giftWrapEventToReceiverB: GiftWrapEvent? = null - - ChatMessageEvent.create( - msg = "Who is going to the party tonight?", - to = listOf(receiverA.pubKey, receiverB.pubKey), - signer = sender, - ) { senderMessage -> - SealedGossipEvent.create( - event = senderMessage, - encryptTo = receiverA.pubKey, - signer = sender, - ) { msgFromSenderToReceiverA -> - // Should expose sender - assertEquals(msgFromSenderToReceiverA.pubKey, sender.pubKey) - // Should not expose receiver - assertTrue(msgFromSenderToReceiverA.tags.isEmpty()) - - GiftWrapEvent.create( - event = msgFromSenderToReceiverA, - recipientPubKey = receiverA.pubKey, - ) { giftWrapForReceiverA -> - // Should not be signed by neither sender nor receiver - assertNotEquals(giftWrapForReceiverA.pubKey, sender.pubKey) - assertNotEquals(giftWrapForReceiverA.pubKey, receiverA.pubKey) - assertNotEquals(giftWrapForReceiverA.pubKey, receiverB.pubKey) - - // Should not include sender as recipient - assertNotEquals(giftWrapForReceiverA.recipientPubKey(), sender.pubKey) - - // Should be addressed to the receiver - assertEquals(giftWrapForReceiverA.recipientPubKey(), receiverA.pubKey) - - giftWrapEventToReceiverA = giftWrapForReceiverA - - countDownLatch.countDown() - } - } - - SealedGossipEvent.create( - event = senderMessage, - encryptTo = receiverB.pubKey, - signer = sender, - ) { msgFromSenderToReceiverB -> - // Should expose sender - assertEquals(msgFromSenderToReceiverB.pubKey, sender.pubKey) - // Should not expose receiver - assertTrue(msgFromSenderToReceiverB.tags.isEmpty()) - - GiftWrapEvent.create( - event = msgFromSenderToReceiverB, - recipientPubKey = receiverB.pubKey, - ) { giftWrapForReceiverB -> - // Should not be signed by neither sender nor receiver - assertNotEquals(giftWrapForReceiverB.pubKey, sender.pubKey) - assertNotEquals(giftWrapForReceiverB.pubKey, receiverA.pubKey) - assertNotEquals(giftWrapForReceiverB.pubKey, receiverB.pubKey) - - // Should not include sender as recipient - assertNotEquals(giftWrapForReceiverB.recipientPubKey(), sender.pubKey) - - // Should be addressed to the receiver - assertEquals(giftWrapForReceiverB.recipientPubKey(), receiverB.pubKey) - - giftWrapEventToReceiverB = giftWrapForReceiverB - - countDownLatch.countDown() - } - } - - SealedGossipEvent.create( - event = senderMessage, - encryptTo = sender.pubKey, - signer = sender, - ) { msgFromSenderToSender -> - // Should expose sender - assertEquals(msgFromSenderToSender.pubKey, sender.pubKey) - // Should not expose receiver - assertTrue(msgFromSenderToSender.tags.isEmpty()) - - GiftWrapEvent.create( - event = msgFromSenderToSender, - recipientPubKey = sender.pubKey, - ) { giftWrapToSender -> - // Should not be signed by neither the sender, not the receiver - assertNotEquals(giftWrapToSender.pubKey, sender.pubKey) - assertNotEquals(giftWrapToSender.pubKey, receiverA.pubKey) - assertNotEquals(giftWrapToSender.pubKey, receiverB.pubKey) - - // Should not be addressed to the receiver - assertNotEquals(giftWrapToSender.recipientPubKey(), receiverA.pubKey) - assertNotEquals(giftWrapToSender.recipientPubKey(), receiverB.pubKey) - // Should be addressed to the sender - assertEquals(giftWrapToSender.recipientPubKey(), sender.pubKey) - - giftWrapEventToSender = giftWrapToSender - - countDownLatch.countDown() - } - } - } - - // Done - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - - // Receiver's side - // Makes sure it can only be decrypted by the target user - - assertNotNull(giftWrapEventToSender) - assertNotNull(giftWrapEventToReceiverA) - assertNotNull(giftWrapEventToReceiverB) - - val countDownDecryptLatch = CountDownLatch(3) - - giftWrapEventToSender?.cachedGift(sender) { unwrappedMsgForSenderBySender -> - assertEquals(SealedGossipEvent.KIND, unwrappedMsgForSenderBySender.kind) - - if (unwrappedMsgForSenderBySender is SealedGossipEvent) { - unwrappedMsgForSenderBySender.cachedGossip(receiverA) { unwrappedGossipToSenderByReceiverA, - -> - fail() - } - - unwrappedMsgForSenderBySender.cachedGossip(receiverB) { unwrappedGossipToSenderByReceiverB, - -> - fail() - } - - unwrappedMsgForSenderBySender.cachedGossip(sender) { unwrappedGossipToSenderBySender -> - assertEquals( - "Who is going to the party tonight?", - unwrappedGossipToSenderBySender.content, - ) - } - } - - countDownDecryptLatch.countDown() - } - - giftWrapEventToReceiverA!!.cachedGift(sender) { unwrappedMsgForReceiverBySenderA -> - fail("Should not be able to decode msg to the receiver A with the sender's key") - } - - giftWrapEventToReceiverB!!.cachedGift(sender) { unwrappedMsgForReceiverBySenderB -> - fail("Should not be able to decode msg to the receiver B with the sender's key") - } - - giftWrapEventToSender!!.cachedGift(receiverA) { - fail("Should not be able to decode msg to sender with the receiver A's key") - } - - giftWrapEventToReceiverA!!.cachedGift(receiverA) { unwrappedMsgForReceiverAByReceiverA -> - assertEquals(SealedGossipEvent.KIND, unwrappedMsgForReceiverAByReceiverA.kind) - - if (unwrappedMsgForReceiverAByReceiverA is SealedGossipEvent) { - unwrappedMsgForReceiverAByReceiverA.cachedGossip(receiverA) { - unwrappedGossipToReceiverAByReceiverA -> - assertEquals( - "Who is going to the party tonight?", - unwrappedGossipToReceiverAByReceiverA.content, - ) - } - - unwrappedMsgForReceiverAByReceiverA.cachedGossip(sender) { - unwrappedGossipToReceiverABySender -> - fail() - } - - unwrappedMsgForReceiverAByReceiverA.cachedGossip(receiverB) { - unwrappedGossipToReceiverAByReceiverB -> - fail() - } - } - - countDownDecryptLatch.countDown() - } - - giftWrapEventToReceiverB!!.cachedGift(receiverA) { - fail("Should not be able to decode msg to sender with the receiver A's key") - } - - giftWrapEventToSender!!.cachedGift(receiverB) { unwrappedMsgForSenderByReceiverB -> - fail("Should not be able to decode msg to sender with the receiver B's key") - } - giftWrapEventToReceiverA!!.cachedGift(receiverB) { unwrappedMsgForReceiverAByReceiverB -> - fail("Should not be able to decode msg to receiver A with the receiver B's key") - } - giftWrapEventToReceiverB!!.cachedGift(receiverB) { unwrappedMsgForReceiverBByReceiverB -> - assertEquals(SealedGossipEvent.KIND, unwrappedMsgForReceiverBByReceiverB.kind) - - if (unwrappedMsgForReceiverBByReceiverB is SealedGossipEvent) { - unwrappedMsgForReceiverBByReceiverB.cachedGossip(receiverA) { - unwrappedGossipToReceiverBByReceiverA -> - fail() - } - - unwrappedMsgForReceiverBByReceiverB.cachedGossip(receiverB) { - unwrappedGossipToReceiverBByReceiverB -> - assertEquals( - "Who is going to the party tonight?", - unwrappedGossipToReceiverBByReceiverB.content, - ) - - countDownDecryptLatch.countDown() - } - - unwrappedMsgForReceiverBByReceiverB.cachedGossip(sender) { - unwrappedGossipToReceiverBBySender -> - fail() - } - } - } - - assertTrue(countDownDecryptLatch.await(1, TimeUnit.SECONDS)) - } - - @Test - fun testCaseFromAmethyst1() { - val json = - """ + @Test + fun testCaseFromAmethyst1() { + val json = + """ { "content":"{\"ciphertext\":\"AaTN5Mt7AOeMosjHeLfai89kmvW/qJ7W2VMttAwuh6hwRGV+ylJhpDbdVRhVmkCotbDjBgS6xioLrSDcdSngFOiVMHS5dTAP0MkQv09aZlBh/NgdmyfHHd24YlHPkDuF5Yb4Vmz7kq/vmjsNZvDrTen3TG2DcEoTV9GKexdMEqyBA4LsB2DLnWfpvOi0olDkGjPGSteTaU1nCdOtN8knoEKumrxwevvbygKphorvKX/j3ojMMb0AceJM6Cr6TRIvSsQnKGEv5V8qbC/uIrQoH3N108Fd/2SY2MWuyLKRnuak9F/w82MV13elq8ngyjcktLYM5yrPg5nrxZlyJsV8D7V/g/bvhoL+UmWe0XoCR5LXzy77SfIkgA1ePKEfGp5sD2CVIzXt9zHdFwGxAKZuyB4qwrRaAFrS2xx+Bw4nnEmF6V9NhfheSCmGzTILuTePx4ubvnYw/j8Hmqd6UvM3DBNnlJ3D6po0blirfWvMe/ea+Em4CMXfq8Iq+7r4gRx8azADygKeJ+C89GTBEvS9EvgrXCVfTMVTcFc44YAZhekOqYY1BOZgfxIV4gUiJfpMMd4B9MQv/tmnewrpTsq1reSQQcEW/mXT2cnMeCZbAIJSPg8usZ30QlrH+np+YSzFKWYDP1kThcV0ElEE2Ne8KaUUFIRE5KmhBQc/qtORefCpne5s7V7J5vLjT5rinsDzzENB1XVlmY1Icx42raP5tGAL1gOK5gRHLvtcgFQR3WcDRYaNqELiYxx41j9w9lz5e00Ttla255rZkb760KSLaBFBss6wYGiYCabVgtBNpkExpCFPPEd5eAZa5rNK2QrnojYsdxEnlicF6A+zSChLy/TbzxYwyQywDfoF9F8kBakPZkAhsciQViCii2KlieRq4OgJFZGndmnS82hyPqsoJIm22vWr1iqMvSBHo/9cLj/r+lfmGVOdgM62JHckPZjOLS0QWIb9gQiT+zXZG22+eZElMYbGXVpR1dyMaQtde8ivEVVLas6kMCVKaDTHEFglaCBXjJ3RNJv73HsG1kb0rMmOj8ltbBakjHpv7M59amavuu6SReYt\",\"nonce\":\"6anNjUdNwW6MNfoKzRZcz1R09N1h8G4L\",\"v\":1}", "created_at":1690660515, @@ -537,26 +537,26 @@ class GiftWrapEventTest { ] } """ - .trimIndent() + .trimIndent() - var gossip: Event? = null + var gossip: Event? = null - wait1SecondForResult { onDone -> - val privateKey = "de6152a85a0dea3b09a08a6f8139a314d498a7b52f7e5c28858b64270abd4c70" - unwrapUnsealGossip(json, privateKey) { - gossip = it - onDone() - } + wait1SecondForResult { onDone -> + val privateKey = "de6152a85a0dea3b09a08a6f8139a314d498a7b52f7e5c28858b64270abd4c70" + unwrapUnsealGossip(json, privateKey) { + gossip = it + onDone() + } + } + + assertNotNull(gossip) + assertEquals("Hola, que tal?", gossip?.content) } - assertNotNull(gossip) - assertEquals("Hola, que tal?", gossip?.content) - } - - @Test - fun testCaseFromAmethyst2() { - val json = - """ + @Test + fun testCaseFromAmethyst2() { + val json = + """ { "content":"{\"ciphertext\":\"Zb0ZNYAcDG5y7BiCWgbxY/i7rN7TxPwr3Oaste6em4VcetuenaMu2SyH6OuCCxxmIa7kFennJD8ZCrev0086azsPNutl9I6OCoOfDQb2GoFaLoJAkE/FuW0uEoEJuN72KsKj05HEjOM6nqL2KiW0pxTCNmlGpweMwpXQdm2ItWkybNpq8+b4NJUDee2czBUd9Kr2ELbPISTYzA17z1IzPXGQw8c73NL+QX9I/QZjM/agqX2x5q11SU52xiRyVd9zHf7TMctZI4QEsqDB6xi54D1bAeZlMhVdcpQRpGDfqRz3KXFlhB3Bwdc8GLgY0aLTn6tJs4qrHP3mQkxFYk0mju0afoc0rloMEUHcBVtM18S9OrTPqfmSqFTQsjaT8g+PkmeiLBo1sXsMCS62w0abSZD9OzQtciMz70ZpcWoLjx5f8panjFClvg4tJ8czMURIHM/IFS1uKAUHBArGN8QpCw8MXQBblpyLDiEkFcSX334Zdps0OIw4z328JSdeejyRh4ks+NHDt9FcjC4iicEqfEh8OTkXuKqEAVkRyfAioNQxWQPnXDzMX0Q+BXvKzBA7NaEBDpbV36H/KnrpBBQwokV9/Byb6Seh3g6GSqRAWD3U6Nk2aBMXkD0xY8vnIqMckBeYHxn8BW7k1FdXFC9lE5xCxWZHkmksJ4f0NVaF37O6d8qOe6RK7bfUeF8/SouJEu+eEX1f4KCMboslwkdk8QA8bThGcRGn8GQBMrPKrpZwHYNyyH8jwt9pywigXJejRLDDnDp3FH/3dbZy5CfuNH6KGydf/O5xx1r316so1UPO1mL5LHJUFZVIaMaMMUsgq12gpI0lLEh5NJPpsi9e3ibkzEZGf7FlAJjJQURbQ8xacN7R+w3GWKbJNHiQbUZ2lXo6fwz33t0DrSqEW970yWPHlqxcpd27EI+qqb5IqfklQZ3RObZZBhzDvImaCPG+U7SmgLhPxnilpGjd5lw/ttiqJhPG9mYFMf1eJXSG+Q9VVkGzN7jxXYtx0q0WGjVq98ZGv5RSnF1d9+QVGCd1fiPS3rsaWdYWly8l0y2quYObJ6Mv3Wh3\",\"nonce\":\"/Q2UTTjVZthm/atcCuDjU1e4reF+ZSgZ\",\"v\":1}", "created_at":1690660515, @@ -572,27 +572,27 @@ class GiftWrapEventTest { ] } """ - .trimIndent() + .trimIndent() - val privateKey = "409ff7654141eaa16cd2161fe5bd127aeaef71f270c67587474b78998a8e3533" + val privateKey = "409ff7654141eaa16cd2161fe5bd127aeaef71f270c67587474b78998a8e3533" - var gossip: Event? = null + var gossip: Event? = null - wait1SecondForResult { onDone -> - unwrapUnsealGossip(json, privateKey) { - gossip = it - onDone() - } + wait1SecondForResult { onDone -> + unwrapUnsealGossip(json, privateKey) { + gossip = it + onDone() + } + } + + assertNotNull(gossip) + assertEquals("Hola, que tal?", gossip?.content) } - assertNotNull(gossip) - assertEquals("Hola, que tal?", gossip?.content) - } - - @Test - fun testDecryptFromCoracle() { - val json = - """ + @Test + fun testDecryptFromCoracle() { + val json = + """ { "content": "{\"ciphertext\":\"fo0/Ywyfu86cXKoOOeFFlMRv+LYM6GJUL+F/J4ARv6EcAufOZP46vimlurPBLPjNgzuGemGjlTFfC3vtq84AqIsqFo3dqKunq8Vp+mmubvxIQUDzOGYvM0WE/XOiW5LEe3U3Vq399Dq07xRpXELcp4EZxGyu4Fowv2Ppb4SKpH8g+9N3z2+bwYcSxBvI6SrL+hgmVMrRlgvsUHN1d53ni9ehRseBqrLj/DqyyEiygsKm6vnEZAPKnl1MrBaVOBZmGsyjAa/G4SBVVmk78sW7xWWvo4cV+C22FluDWUOdj/bYabH4aR4scnBX3GLYqfLuzGnuQlRNsb5unXVX41+39uXzROrmNP6iYVyYxy5tfoyN7PPZ4osoKpLDUGldmXHD6RjMcAFuou4hXt2JlTPmXpj/x8qInXId5mkmU4nTGiasvsCIpJljbCujwCjbjLTcD4QrjuhMdtSsAzjT0CDv5Lmc632eKRYtDu/9B+lkqBBkp7amhzbqp8suNTnybkvbGFQQGEQnsLfNJw/GGopAuthfi8zkTgUZR/LxFR7ZKAX73G+5PQSDSjPuGH/dQEnsFo45zsh1Xro8SfUQBsPphbX2GS31Lwu5vA30O922T4UiWuU+EdNgZR0JankQ5NPgvr1uS56C3v84VwdrNWQUCwC4eYJl4Mb/OdpEy9qwsisisppq6uuzxmxd1qx3JfocnGsvB7h2g2sG+0lyZADDSobOEZEKHaBP3w+dRcJW9D95EmzPym9GO0n+33OfqFQbda7G0rzUWfPDV0gXIuZcKs/HmDqepgIZN8FG7JhRBeAv0bCbKQACre0c8tzVEn5yCYemltScdKop3pC/r6gH50jRhAlFAiIKx8R+XwuMmJRqOcH4WfkpZlfVU85/I0XJOCHWKk6BnJi/NPP9zYiZiJe+5LecqMUVjtO0YAlv138+U/3FIT/anQ4H5bjVWBZmajwf\",\"nonce\":\"Mv70S6jgrs4D1rlqV9b5DddiymGAcVVe\",\"v\":1}", "created_at": 1690528373, @@ -611,26 +611,26 @@ class GiftWrapEventTest { ] } """ - .trimIndent() + .trimIndent() - val privateKey = "09e0051fdf5fdd9dd7a54713583006442cbdbf87bdcdab1a402f26e527d56771" - var gossip: Event? = null + val privateKey = "09e0051fdf5fdd9dd7a54713583006442cbdbf87bdcdab1a402f26e527d56771" + var gossip: Event? = null - wait1SecondForResult { onDone -> - unwrapUnsealGossip(json, privateKey) { - gossip = it - onDone() - } + wait1SecondForResult { onDone -> + unwrapUnsealGossip(json, privateKey) { + gossip = it + onDone() + } + } + + assertNotNull(gossip) + assertEquals("test", gossip?.content) } - assertNotNull(gossip) - assertEquals("test", gossip?.content) - } - - @Test - fun testFromCoracle2() { - val json = - """ + @Test + fun testFromCoracle2() { + val json = + """ { "content": "{\"ciphertext\":\"Hn/dHo/I8Qk6QWWAiKyo/SfKJqQfHdV0O5tMmgqMyfHrsFoDY6IhGQP2EgCJ/6HsNQyO/8EMAmLW8w0PbDKlBKYGKGpaMwCA6B1r0rLjvu+149RJZuggRNm9rd7tNVNkNs38iqt1KYD++bohePm52q+VhAQikbX2gTONV82ROwZylAg9vjvMnYkDt45g6N97s9FRB6V7YMiUEtJnneMixa6klucpUuenQ4569tyt5vnUMD2VNhKYCc2jit2hf7k0DIhvZrVC3OdopUvxIuYYWr3r7XpuEB3HJ6Ji3ajHPzgGeFcItBR7uKZ9s6XU34F3keyZbxrv3yWHFM5NrOctAdZexSGpqWRW93M0KZUAp9HgQh3YzMLl8xt0mcrVywCgjU6Kx8IwkI0bjPU+Am8acY3cItted6hZQ4Vy1xFITdKVfPWDl3Ab59iBg9+IkY5C31wqsKPgPVVycwQE6UpaGW74gy3qZshwyoo01owvEIbVvrSJWXH7EUVvndDPvUbo+f+EVa84IEwVjPmY2oR7VsxVfqRBdmPg23OSw/9rzVybmruqaQHd3xrTTEcnG0qBc/ugCXsiuILTeScOovEnqIlKKK3KB36jMtdScdJB+b4YrzJInY1AvqU7IAgqe0vmo1LdbMtj7kjuxkXJhhQsunAbTvPigTrsOfJ08P9l7r/95kpxudgagEaW7XAjYVfLphseJT3Iy1IuQEyG5sshQ+pl/CYvkGide7ykHwm9pjSBVkD9Mdcn5X6lSnLNJEcwY43pz43r6Kq3L09qneILY3DSKyQ16Zcu1MiAMAM5r6JGvpAHqcMmixi9ORuiryjteTmY4L0vI7b3W/0RSUblXxUrb8IpeysBrFmiKJBgCoU0r/D/8tgR+Eewyp1qxKI4SfKG5GFH40zZ2oVvKyoHAR4x1oVDp/MttcnxkzAsCFL6QuJC9A/vImjsumpmYB/EChcZCOAsfqkuzH4VSjZx\",\"nonce\":\"K537d+7m5tUcXZfkr3Qk2J2G86vdBMmY\",\"v\":1}", "created_at": 1690655012, @@ -646,31 +646,31 @@ class GiftWrapEventTest { ] } """ - .trimIndent() + .trimIndent() - val privateKey = "09e0051fdf5fdd9dd7a54713583006442cbdbf87bdcdab1a402f26e527d56771" + val privateKey = "09e0051fdf5fdd9dd7a54713583006442cbdbf87bdcdab1a402f26e527d56771" - var gossip: Event? = null + var gossip: Event? = null - wait1SecondForResult { onDone -> - unwrapUnsealGossip(json, privateKey) { - gossip = it - onDone() - } + wait1SecondForResult { onDone -> + unwrapUnsealGossip(json, privateKey) { + gossip = it + onDone() + } + } + + assertEquals("asdfasdfasdf", gossip?.content) + assertEquals(1690659269L, gossip?.createdAt) + assertEquals("827ba09d32ab81d62c60f657b350198c8aaba84372dab9ad3f4f6b8b7274b707", gossip?.id) + assertEquals(14, gossip?.kind) + assertEquals("subject", gossip?.tags?.firstOrNull()?.get(0)) + assertEquals("test", gossip?.tags?.firstOrNull()?.get(1)) } - assertEquals("asdfasdfasdf", gossip?.content) - assertEquals(1690659269L, gossip?.createdAt) - assertEquals("827ba09d32ab81d62c60f657b350198c8aaba84372dab9ad3f4f6b8b7274b707", gossip?.id) - assertEquals(14, gossip?.kind) - assertEquals("subject", gossip?.tags?.firstOrNull()?.get(0)) - assertEquals("test", gossip?.tags?.firstOrNull()?.get(1)) - } - - @Test - fun testFromCoracle3() { - val json = - """ + @Test + fun testFromCoracle3() { + val json = + """ { "content": "{\"ciphertext\":\"PGCodiacmCB/sClw0C6DQRpP/XIfNAKCVZdKLQpOqytgbFryhs9Z+cPldq2FajXzxs3jQsF7/EVQyWpuV8pXetvFT9tvzjg4Xcm7ZcooLUnAeAo2xZNcJontN4cGubuDqKuXy5n59yXP1fIxfnJxRTRRdCZ2edhsKeNR5NSByUi+StjV10rnfHt8AhZCpiXiZ/giTOsC4wdaeONPgMzMeljaJWLvl6n11VjmXhkx1mXIQt43CNB1hIqO3p89Mbd9p+nlLrOsR+Xs0TB4DCh4XTPbvgf7B7Z+PgOfl3GZfJy9x6TciLcF4E3Ba1zrPe4f79czCIEiJ1yrIKrzzYvv+it35DZQ8fgveFXpyHnNL29hml8PNjyOsFbCHVYLMGw88evI5PijOcpe1TtdoioX8kX5kVEQSKJXuoSjTorvbRPCgGzaa1m0J0uTpzri5VD22a/Jh2CcAnubg6w4JDdUWCogdSV3NqiJllo7ZF7WnZ3apPdRD23MEfphVBJrcLBUNlmwajnY5IvVTKTkZOP50r9dBapvMWXIo6M6zhy/5vVWJz57863pelYCRG4upaXZuNK9sMBtbiphxmFR83i8RML8KN8Q391Cd/xBN7TxJNo5p2YU25VeGZUAmHY8DYlMQDm8Br0nStAXp3T+DzTRL8FTECa8DJV+KTAPoCxqhv3B28Ehr0XAP75CsHoLU00G48cR7h3vQ0CnfKh6KXU6nnDA5OWfpMYpirACCpsnpSD0OaCQ3gkQp3zZNMS3HcOpnPK/IY7R0esbzgAkvNhkyxaIfPDdf+eRUSOA9+2Ji28MwjjY8Dw3SLdUqCOzIDjQeR/T5oNmaQJm3lZ8G0FxxC6ejD4VJX/NI/x+STeB9jWHWmHZvqKzV6JHNh6qmZb6TKSIPOHpafWFoeJFOmiiigf46sju9vRXmVEAx59HXWnvnvCBNJg877yCMulB6xyQuSdVDuotQU4tQZwCKedTHJ6GqjesM98UlJrDtdWQURwwW1qc7N8tS6PukmUVEf0jmbIWVIBmUlkcVuiSs1g1h1kjt8c4MnGTz3CSgpOd1MqxLrl9WwrTqM+YnE+yeZYUjFoewyKZIQ==\",\"nonce\":\"OdCZczJiUGR4bOGIElQ4UUH4dQmG5U/3\",\"v\":1}", "kind": 1059, @@ -686,62 +686,62 @@ class GiftWrapEventTest { "sig": "1b20416b83f4b5b8eead11e29c185f46b5e76d1960e4505210ddd00f7a6973cc11268f52a8989e3799b774d5f3a55db95bed4d66a1b6e88ab54becec5c771c17" } """ - .trimIndent() + .trimIndent() - val privateKey = "7dd22cafc512c0bc363a259f6dcda515b13ae3351066d7976fd0bb79cbd0d700" + val privateKey = "7dd22cafc512c0bc363a259f6dcda515b13ae3351066d7976fd0bb79cbd0d700" - var gossip: Event? = null + var gossip: Event? = null - wait1SecondForResult { onDone -> - unwrapUnsealGossip(json, privateKey) { - gossip = it - onDone() - } + wait1SecondForResult { onDone -> + unwrapUnsealGossip(json, privateKey) { + gossip = it + onDone() + } + } + + assertEquals("8d1a56008d4e31dae2fb8bef36b3efea519eff75f57033107e2aa16702466ef2", gossip?.id) + assertEquals("Howdy", gossip?.content) + assertEquals(1690833960L, gossip?.createdAt) + assertEquals(14, gossip?.kind) + assertEquals("p", gossip?.tags?.firstOrNull()?.get(0)) + assertEquals( + "b08d8857a92b4d6aa580ff55cc3c18c4edf313c83388c34abc118621f74f1a78", + gossip?.tags?.firstOrNull()?.get(1), + ) + assertEquals("subject", gossip?.tags?.getOrNull(1)?.get(0)) + assertEquals("Stuff", gossip?.tags?.getOrNull(1)?.get(1)) } - assertEquals("8d1a56008d4e31dae2fb8bef36b3efea519eff75f57033107e2aa16702466ef2", gossip?.id) - assertEquals("Howdy", gossip?.content) - assertEquals(1690833960L, gossip?.createdAt) - assertEquals(14, gossip?.kind) - assertEquals("p", gossip?.tags?.firstOrNull()?.get(0)) - assertEquals( - "b08d8857a92b4d6aa580ff55cc3c18c4edf313c83388c34abc118621f74f1a78", - gossip?.tags?.firstOrNull()?.get(1), - ) - assertEquals("subject", gossip?.tags?.getOrNull(1)?.get(0)) - assertEquals("Stuff", gossip?.tags?.getOrNull(1)?.get(1)) - } + fun unwrapUnsealGossip( + json: String, + privateKey: HexKey, + onReady: (Event) -> Unit, + ) { + val pkBytes = NostrSignerInternal(KeyPair(privateKey.hexToByteArray())) - fun unwrapUnsealGossip( - json: String, - privateKey: HexKey, - onReady: (Event) -> Unit, - ) { - val pkBytes = NostrSignerInternal(KeyPair(privateKey.hexToByteArray())) + val wrap = Event.fromJson(json) as GiftWrapEvent + wrap.checkSignature() - val wrap = Event.fromJson(json) as GiftWrapEvent - wrap.checkSignature() + assertEquals(pkBytes.pubKey, wrap.recipientPubKey()) - assertEquals(pkBytes.pubKey, wrap.recipientPubKey()) - - wrap.cachedGift(pkBytes) { event -> - if (event is SealedGossipEvent) { - event.cachedGossip(pkBytes, onReady) - } else { - println(event.toJson()) - fail("Event is not a Sealed Gossip") - } + wrap.cachedGift(pkBytes) { event -> + if (event is SealedGossipEvent) { + event.cachedGossip(pkBytes, onReady) + } else { + println(event.toJson()) + fail("Event is not a Sealed Gossip") + } + } } - } - @Test - fun decryptMsgFromNostrTools() { - val receiversPrivateKey = - NostrSignerInternal( - KeyPair(Hex.decode("df51ec558372612918a83446d279d683039bece79b7a721274b1d3cb612dc6af")), - ) - val msg = - """ + @Test + fun decryptMsgFromNostrTools() { + val receiversPrivateKey = + NostrSignerInternal( + KeyPair(Hex.decode("df51ec558372612918a83446d279d683039bece79b7a721274b1d3cb612dc6af")), + ) + val msg = + """ { "tags": [], "content": "AUC1i3lHsEOYQZaqav8jAw/Dv25r6BpUX4r7ARaj/7JEqvtHkbtaWXEx3LvMlDJstNX1C90RIelgYTzxb4Xnql7zFmXtxGGd/gXOZzW/OCNWECTrhFTruZUcsyn2ssJMgEMBZKY3PgbAKykHlGCuWR3KI9bo+IA5sTqHlrwDGAysxBypRuAxTdtEApw1LSu2A+1UQsdHK/4HcW/fQLPguWGyPv09dftJIJkFWM8VYBQT7b5FeAEMhjlUM+lEmLMnx6qb07Ji/YMESkhzFlgGjHNVl1Q/BT4i6X+Skogl6Si3lWQzlS9oebUim1BQW+RO0IOyQLalZwjzGP+eE7Ry62ukQg7cPiqk62p7NNula17SF2Q8aVFLxr8WjbLXoWhZOWY25uFbTl7OPGGQb5TewRsjHoFeU4h05Ien3Ymf1VPqJVJCMIxU+yFZ1IMZh/vQW4BSx8VotRdNA05fz03ST88GzGxUvqEm4VW/Yp5q4UUkCDQTKmUImaSFmTser39WmvS5+dHY6ne4RwnrZR0ZYrG1bthRHycnPmaJiYsHn9Ox37EzgLR07pmNxr2+86NR3S3TLAVfTDN3XaXRee/7UfW/MXULVyuyweksIHOYBvANC0PxmGSs4UiFoCbwNi45DT2y0SwP6CxzDuM=", @@ -752,28 +752,28 @@ class GiftWrapEventTest { "sig": "2807a7ab5728984144676fd34686267cbe6fe38bc2f65a3640ba9243c13e8a1ae5a9a051e8852aa0c997a3623d7fa066cf2073a233c6d7db46fb1a0d4c01e5a3" } """ - .trimIndent() + .trimIndent() - val wrap = Event.fromJson(msg) as GiftWrapEvent - wrap.checkSignature() + val wrap = Event.fromJson(msg) as GiftWrapEvent + wrap.checkSignature() - var event: Event? = null + var event: Event? = null - wait1SecondForResult { onDone -> - wrap.cachedGift(receiversPrivateKey) { - event = it - onDone() - } + wait1SecondForResult { onDone -> + wrap.cachedGift(receiversPrivateKey) { + event = it + onDone() + } + } + + assertNotNull(event) } - - assertNotNull(event) - } } fun wait1SecondForResult(run: (onDone: () -> Unit) -> Unit) { - val countDownLatch = CountDownLatch(1) + val countDownLatch = CountDownLatch(1) - run { countDownLatch.countDown() } + run { countDownLatch.countDown() } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/HexEncodingTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/HexEncodingTest.kt index 6bc74b6c2..9c20dba68 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/HexEncodingTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/HexEncodingTest.kt @@ -25,41 +25,41 @@ import org.junit.Assert.assertEquals import org.junit.Test class HexEncodingTest { - val testHex = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf" + val testHex = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf" - @Test - fun testHexEncodeDecodeOurs() { - assertEquals( - testHex, - com.vitorpamplona.quartz.encoders.Hex.encode( - com.vitorpamplona.quartz.encoders.Hex.decode(testHex), - ), - ) - } - - @Test - fun testHexEncodeDecodeSecp256k1() { - assertEquals( - testHex, - fr.acinq.secp256k1.Hex.encode( - fr.acinq.secp256k1.Hex.decode(testHex), - ), - ) - } - - @Test - fun testRandoms() { - for (i in 0..1000) { - val bytes = CryptoUtils.privkeyCreate() - val hex = fr.acinq.secp256k1.Hex.encode(bytes) - assertEquals( - fr.acinq.secp256k1.Hex.encode(bytes), - com.vitorpamplona.quartz.encoders.Hex.encode(bytes), - ) - assertEquals( - bytes.toList(), - com.vitorpamplona.quartz.encoders.Hex.decode(hex).toList(), - ) + @Test + fun testHexEncodeDecodeOurs() { + assertEquals( + testHex, + com.vitorpamplona.quartz.encoders.Hex.encode( + com.vitorpamplona.quartz.encoders.Hex.decode(testHex), + ), + ) + } + + @Test + fun testHexEncodeDecodeSecp256k1() { + assertEquals( + testHex, + fr.acinq.secp256k1.Hex.encode( + fr.acinq.secp256k1.Hex.decode(testHex), + ), + ) + } + + @Test + fun testRandoms() { + for (i in 0..1000) { + val bytes = CryptoUtils.privkeyCreate() + val hex = fr.acinq.secp256k1.Hex.encode(bytes) + assertEquals( + fr.acinq.secp256k1.Hex.encode(bytes), + com.vitorpamplona.quartz.encoders.Hex.encode(bytes), + ) + assertEquals( + bytes.toList(), + com.vitorpamplona.quartz.encoders.Hex.decode(hex).toList(), + ) + } } - } } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt index 0c201effb..ad670a54a 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt @@ -24,30 +24,31 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.events.Event -import java.io.InputStreamReader import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertTrue import kotlinx.coroutines.runBlocking import org.junit.Test import org.junit.runner.RunWith +import java.io.InputStreamReader @RunWith(AndroidJUnit4::class) class LargeDBSignatureCheck { - @Test - fun insertDatabaseSample() = runBlocking { - val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_short.json") + @Test + fun insertDatabaseSample() = + runBlocking { + val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_short.json") - val eventArray = - Event.mapper.readValue>( - InputStreamReader(fullDBInputStream), - ) as List + val eventArray = + Event.mapper.readValue>( + InputStreamReader(fullDBInputStream), + ) as List - var counter = 0 - eventArray.forEach { - assertTrue(it.hasValidSignature()) - counter++ - } + var counter = 0 + eventArray.forEach { + assertTrue(it.hasValidSignature()) + counter++ + } - assertEquals(eventArray.size, counter) - } + assertEquals(eventArray.size, counter) + } } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/LnInvoiceUtilTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/LnInvoiceUtilTest.kt index b2c0803de..0c7e0d0ac 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/LnInvoiceUtilTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/LnInvoiceUtilTest.kt @@ -28,19 +28,19 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class LnInvoiceUtilTest { - @Test - fun test100KAmountCalculation() { - val bolt11 = - "lnbc1m1pjt9u0qsp553q90pj5mafzv20w45eqavned9tgwhl4q99n9s5ppcw24nzw3zeqpp5002kd3ktym67du86kj665fgaev7ka8ys7j5yz5fg686lr5e2gfkshp5dkk27nnuax05az3pk2r6ytxtvwn5j4xzsq9ajprhc7crjkmgvr3qxqyjw5qcqpjrzjqtzxvfsuxe4l92pf97tt4rcgpy2xalkmlwexh899wqxf83l8nwv4xzh0gvqq89qqqqqqqqlgqqqqq0gqvs9qxpqysgqx5mz04wd7kqu5zhhel9enr036hjrp4gga0nz084p2asjl36a0zmrk6mhqa249zsgqref2rlvhffm73u7rxgr47gden6rugup4ksvpzsqvds4pz" - // Context of the app under test. - Assert.assertEquals(100000, LnInvoiceUtil.getAmountInSats(bolt11).toLong()) - } + @Test + fun test100KAmountCalculation() { + val bolt11 = + "lnbc1m1pjt9u0qsp553q90pj5mafzv20w45eqavned9tgwhl4q99n9s5ppcw24nzw3zeqpp5002kd3ktym67du86kj665fgaev7ka8ys7j5yz5fg686lr5e2gfkshp5dkk27nnuax05az3pk2r6ytxtvwn5j4xzsq9ajprhc7crjkmgvr3qxqyjw5qcqpjrzjqtzxvfsuxe4l92pf97tt4rcgpy2xalkmlwexh899wqxf83l8nwv4xzh0gvqq89qqqqqqqqlgqqqqq0gqvs9qxpqysgqx5mz04wd7kqu5zhhel9enr036hjrp4gga0nz084p2asjl36a0zmrk6mhqa249zsgqref2rlvhffm73u7rxgr47gden6rugup4ksvpzsqvds4pz" + // Context of the app under test. + Assert.assertEquals(100000, LnInvoiceUtil.getAmountInSats(bolt11).toLong()) + } - @Test - fun test100GAmountCalculation() { - val bolt11 = - "lnbc1000000000000000p1pjtxqf0pp5myqxhcufqy56elfsg9dd4dthnqptusnnpwnul7u86l95xzjgqd8shp5gueg34sgm3u3nxqjqyunvvqdu0pr6jz6mwh4ew4886f2lpf4cmrqxqztgsp5w0cdfd45dfnqwex5gn85x7fru3jcrxhlcx3enx835477m3gdfcuq9qyyssqelrcmm7p9qazgjuxtdg7sd8nq5cscl2tratjlclt5rk5mc7uq2lphq3r2a43j5ua4leakc4emq8yp2yxdnzvzszpw6u2afac0kgl7hspfj67ta" - // Context of the app under test. - Assert.assertEquals(100000000000, LnInvoiceUtil.getAmountInSats(bolt11).toLong()) - } + @Test + fun test100GAmountCalculation() { + val bolt11 = + "lnbc1000000000000000p1pjtxqf0pp5myqxhcufqy56elfsg9dd4dthnqptusnnpwnul7u86l95xzjgqd8shp5gueg34sgm3u3nxqjqyunvvqdu0pr6jz6mwh4ew4886f2lpf4cmrqxqztgsp5w0cdfd45dfnqwex5gn85x7fru3jcrxhlcx3enx835477m3gdfcuq9qyyssqelrcmm7p9qazgjuxtdg7sd8nq5cscl2tratjlclt5rk5mc7uq2lphq3r2a43j5ua4leakc4emq8yp2yxdnzvzszpw6u2afac0kgl7hspfj67ta" + // Context of the app under test. + Assert.assertEquals(100000000000, LnInvoiceUtil.getAmountInSats(bolt11).toLong()) + } } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/NIP44v2Test.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/NIP44v2Test.kt index 6c01cc975..081141154 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/NIP44v2Test.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/NIP44v2Test.kt @@ -28,205 +28,205 @@ import com.vitorpamplona.quartz.crypto.Nip44v2 import com.vitorpamplona.quartz.encoders.hexToByteArray import com.vitorpamplona.quartz.encoders.toHexKey import fr.acinq.secp256k1.Secp256k1 -import java.security.MessageDigest -import java.security.SecureRandom import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.assertNull import junit.framework.TestCase.fail import org.junit.Test import org.junit.runner.RunWith +import java.security.MessageDigest +import java.security.SecureRandom @RunWith(AndroidJUnit4::class) public class NIP44v2Test { - val vectors: VectorFile = - jacksonObjectMapper() - .readValue( - getInstrumentation().context.assets.open("nip44.vectors.json"), - VectorFile::class.java, - ) + val vectors: VectorFile = + jacksonObjectMapper() + .readValue( + getInstrumentation().context.assets.open("nip44.vectors.json"), + VectorFile::class.java, + ) - val random = SecureRandom() - val nip44v2 = Nip44v2(Secp256k1.get(), random) + val random = SecureRandom() + val nip44v2 = Nip44v2(Secp256k1.get(), random) - @Test - fun conversationKeyTest() { - for (v in vectors.v2?.valid?.getConversationKey!!) { - val conversationKey = - nip44v2.getConversationKey(v.sec1!!.hexToByteArray(), v.pub2!!.hexToByteArray()) + @Test + fun conversationKeyTest() { + for (v in vectors.v2?.valid?.getConversationKey!!) { + val conversationKey = + nip44v2.getConversationKey(v.sec1!!.hexToByteArray(), v.pub2!!.hexToByteArray()) - assertEquals(v.conversationKey, conversationKey.toHexKey()) + assertEquals(v.conversationKey, conversationKey.toHexKey()) + } } - } - @Test - fun paddingTest() { - for (v in vectors.v2?.valid?.calcPaddedLen!!) { - val actual = nip44v2.calcPaddedLen(v[0]) - assertEquals(v[1], actual) + @Test + fun paddingTest() { + for (v in vectors.v2?.valid?.calcPaddedLen!!) { + val actual = nip44v2.calcPaddedLen(v[0]) + assertEquals(v[1], actual) + } } - } - @Test - fun encryptDecryptTest() { - for (v in vectors.v2?.valid?.encryptDecrypt!!) { - val pub2 = com.vitorpamplona.quartz.crypto.KeyPair(v.sec2!!.hexToByteArray()) - val conversationKey = nip44v2.getConversationKey(v.sec1!!.hexToByteArray(), pub2.pubKey) - assertEquals(v.conversationKey, conversationKey.toHexKey()) + @Test + fun encryptDecryptTest() { + for (v in vectors.v2?.valid?.encryptDecrypt!!) { + val pub2 = com.vitorpamplona.quartz.crypto.KeyPair(v.sec2!!.hexToByteArray()) + val conversationKey = nip44v2.getConversationKey(v.sec1!!.hexToByteArray(), pub2.pubKey) + assertEquals(v.conversationKey, conversationKey.toHexKey()) - val ciphertext = - nip44v2 - .encryptWithNonce( - v.plaintext!!, - conversationKey, - v.nonce!!.hexToByteArray(), - ) - .encodePayload() + val ciphertext = + nip44v2 + .encryptWithNonce( + v.plaintext!!, + conversationKey, + v.nonce!!.hexToByteArray(), + ) + .encodePayload() - assertEquals(v.payload, ciphertext) + assertEquals(v.payload, ciphertext) - val decrypted = nip44v2.decrypt(v.payload!!, conversationKey) - assertEquals(v.plaintext, decrypted) + val decrypted = nip44v2.decrypt(v.payload!!, conversationKey) + assertEquals(v.plaintext, decrypted) + } } - } - @Test - fun encryptDecryptLongTest() { - for (v in vectors.v2?.valid?.encryptDecryptLongMsg!!) { - val conversationKey = v.conversationKey!!.hexToByteArray() - val plaintext = v.pattern!!.repeat(v.repeat!!) + @Test + fun encryptDecryptLongTest() { + for (v in vectors.v2?.valid?.encryptDecryptLongMsg!!) { + val conversationKey = v.conversationKey!!.hexToByteArray() + val plaintext = v.pattern!!.repeat(v.repeat!!) - assertEquals(v.plaintextSha256, sha256Hex(plaintext.toByteArray(Charsets.UTF_8))) + assertEquals(v.plaintextSha256, sha256Hex(plaintext.toByteArray(Charsets.UTF_8))) - val ciphertext = - nip44v2 - .encryptWithNonce( - plaintext, - conversationKey, - v.nonce!!.hexToByteArray(), - ) - .encodePayload() + val ciphertext = + nip44v2 + .encryptWithNonce( + plaintext, + conversationKey, + v.nonce!!.hexToByteArray(), + ) + .encodePayload() - assertEquals(v.payloadSha256, sha256Hex(ciphertext.toByteArray(Charsets.UTF_8))) + assertEquals(v.payloadSha256, sha256Hex(ciphertext.toByteArray(Charsets.UTF_8))) - val decrypted = nip44v2.decrypt(ciphertext, conversationKey) + val decrypted = nip44v2.decrypt(ciphertext, conversationKey) - assertEquals(plaintext, decrypted) + assertEquals(plaintext, decrypted) + } } - } - @Test - fun invalidMessageLenghts() { - for (v in vectors.v2?.invalid?.encryptMsgLengths!!) { - val key = ByteArray(32) - random.nextBytes(key) - try { - nip44v2.encrypt("a".repeat(v), key) - fail("Should Throw for $v") - } catch (e: Exception) { - assertNotNull(e) - } + @Test + fun invalidMessageLenghts() { + for (v in vectors.v2?.invalid?.encryptMsgLengths!!) { + val key = ByteArray(32) + random.nextBytes(key) + try { + nip44v2.encrypt("a".repeat(v), key) + fail("Should Throw for $v") + } catch (e: Exception) { + assertNotNull(e) + } + } } - } - @Test - fun invalidDecrypt() { - for (v in vectors.v2?.invalid?.decrypt!!) { - try { - val result = nip44v2.decrypt(v.payload!!, v.conversationKey!!.hexToByteArray()) - assertNull(result) - // fail("Should Throw for ${v.note}") - } catch (e: Exception) { - assertNotNull(e) - } + @Test + fun invalidDecrypt() { + for (v in vectors.v2?.invalid?.decrypt!!) { + try { + val result = nip44v2.decrypt(v.payload!!, v.conversationKey!!.hexToByteArray()) + assertNull(result) + // fail("Should Throw for ${v.note}") + } catch (e: Exception) { + assertNotNull(e) + } + } } - } - @Test - fun invalidConversationKey() { - for (v in vectors.v2?.invalid?.getConversationKey!!) { - try { - nip44v2.getConversationKey(v.sec1!!.hexToByteArray(), v.pub2!!.hexToByteArray()) - fail("Should Throw for ${v.note}") - } catch (e: Exception) { - assertNotNull(e) - } + @Test + fun invalidConversationKey() { + for (v in vectors.v2?.invalid?.getConversationKey!!) { + try { + nip44v2.getConversationKey(v.sec1!!.hexToByteArray(), v.pub2!!.hexToByteArray()) + fail("Should Throw for ${v.note}") + } catch (e: Exception) { + assertNotNull(e) + } + } } - } - fun sha256Hex(data: ByteArray): String { - // Creates a new buffer every time - return MessageDigest.getInstance("SHA-256").digest(data).toHexKey() - } + fun sha256Hex(data: ByteArray): String { + // Creates a new buffer every time + return MessageDigest.getInstance("SHA-256").digest(data).toHexKey() + } } data class VectorFile( - val v2: V2? = V2(), + val v2: V2? = V2(), ) data class V2( - val valid: Valid? = Valid(), - val invalid: Invalid? = Invalid(), + val valid: Valid? = Valid(), + val invalid: Invalid? = Invalid(), ) data class Valid( - @JsonProperty("get_conversation_key") - val getConversationKey: ArrayList = arrayListOf(), - @JsonProperty("get_message_keys") val getMessageKeys: GetMessageKeys? = GetMessageKeys(), - @JsonProperty("calc_padded_len") val calcPaddedLen: ArrayList> = arrayListOf(), - @JsonProperty("encrypt_decrypt") val encryptDecrypt: ArrayList = arrayListOf(), - @JsonProperty("encrypt_decrypt_long_msg") - val encryptDecryptLongMsg: ArrayList = arrayListOf(), + @JsonProperty("get_conversation_key") + val getConversationKey: ArrayList = arrayListOf(), + @JsonProperty("get_message_keys") val getMessageKeys: GetMessageKeys? = GetMessageKeys(), + @JsonProperty("calc_padded_len") val calcPaddedLen: ArrayList> = arrayListOf(), + @JsonProperty("encrypt_decrypt") val encryptDecrypt: ArrayList = arrayListOf(), + @JsonProperty("encrypt_decrypt_long_msg") + val encryptDecryptLongMsg: ArrayList = arrayListOf(), ) data class Invalid( - @JsonProperty("encrypt_msg_lengths") val encryptMsgLengths: ArrayList = arrayListOf(), - @JsonProperty("get_conversation_key") - val getConversationKey: ArrayList = arrayListOf(), - @JsonProperty("decrypt") val decrypt: ArrayList = arrayListOf(), + @JsonProperty("encrypt_msg_lengths") val encryptMsgLengths: ArrayList = arrayListOf(), + @JsonProperty("get_conversation_key") + val getConversationKey: ArrayList = arrayListOf(), + @JsonProperty("decrypt") val decrypt: ArrayList = arrayListOf(), ) data class GetConversationKey( - val sec1: String? = null, - val pub2: String? = null, - val note: String? = null, - @JsonProperty("conversation_key") val conversationKey: String? = null, + val sec1: String? = null, + val pub2: String? = null, + val note: String? = null, + @JsonProperty("conversation_key") val conversationKey: String? = null, ) data class GetMessageKeys( - @JsonProperty("conversation_key") val conversationKey: String? = null, - val keys: ArrayList = arrayListOf(), + @JsonProperty("conversation_key") val conversationKey: String? = null, + val keys: ArrayList = arrayListOf(), ) data class Keys( - @JsonProperty("nonce") val nonce: String? = null, - @JsonProperty("chacha_key") val chachaKey: String? = null, - @JsonProperty("chacha_nonce") val chachaNonce: String? = null, - @JsonProperty("hmac_key") val hmacKey: String? = null, + @JsonProperty("nonce") val nonce: String? = null, + @JsonProperty("chacha_key") val chachaKey: String? = null, + @JsonProperty("chacha_nonce") val chachaNonce: String? = null, + @JsonProperty("hmac_key") val hmacKey: String? = null, ) data class EncryptDecrypt( - val sec1: String? = null, - val sec2: String? = null, - @JsonProperty("conversation_key") val conversationKey: String? = null, - val nonce: String? = null, - val plaintext: String? = null, - val payload: String? = null, + val sec1: String? = null, + val sec2: String? = null, + @JsonProperty("conversation_key") val conversationKey: String? = null, + val nonce: String? = null, + val plaintext: String? = null, + val payload: String? = null, ) data class EncryptDecryptLongMsg( - @JsonProperty("conversation_key") val conversationKey: String? = null, - val nonce: String? = null, - val pattern: String? = null, - val repeat: Int? = null, - @JsonProperty("plaintext_sha256") val plaintextSha256: String? = null, - @JsonProperty("payload_sha256") val payloadSha256: String? = null, + @JsonProperty("conversation_key") val conversationKey: String? = null, + val nonce: String? = null, + val pattern: String? = null, + val repeat: Int? = null, + @JsonProperty("plaintext_sha256") val plaintextSha256: String? = null, + @JsonProperty("payload_sha256") val payloadSha256: String? = null, ) data class Decrypt( - @JsonProperty("conversation_key") val conversationKey: String? = null, - val nonce: String? = null, - val plaintext: String? = null, - val payload: String? = null, - val note: String? = null, + @JsonProperty("conversation_key") val conversationKey: String? = null, + val nonce: String? = null, + val plaintext: String? = null, + val payload: String? = null, + val note: String? = null, ) diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/PrivateZapTests.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/PrivateZapTests.kt index 276bfb890..f2f7b7ac3 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/PrivateZapTests.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/PrivateZapTests.kt @@ -36,11 +36,11 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class PrivateZapTests { - @Test - fun testPollZap() { - val poll = - Event.fromJson( - """{ + @Test + fun testPollZap() { + val poll = + Event.fromJson( + """{ "content": "New poll \n\n #zappoll", "created_at": 1682440713, "id": "16291ba452bb0786a4bf5c278d38de73c96b58c056ed75c5ea466b0795197288", @@ -80,56 +80,56 @@ class PrivateZapTests { ] } """, - ) - - val loggedIn = - NostrSignerInternal( - KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), - ) - - var resultPrivateZap: Event? = null - - wait1SecondForResult { onDone -> - LnZapRequestEvent.create( - originalNote = poll, - relays = setOf("wss://relay.damus.io/"), - signer = loggedIn, - pollOption = 0, - message = "", - zapType = LnZapEvent.ZapType.PRIVATE, - toUserPubHex = null, - ) { privateZapRequest -> - val recepientPK = privateZapRequest.zappedAuthor().firstOrNull() - val recepientPost = privateZapRequest.zappedPost().firstOrNull() - - if (recepientPK != null && recepientPost != null) { - val privateKey = - createEncryptionPrivateKey( - loggedIn.keyPair.privKey!!.toHexKey(), - recepientPost, - privateZapRequest.createdAt, ) - val decodedPrivateZap = privateZapRequest.getPrivateZapEvent(privateKey, recepientPK) - println(decodedPrivateZap?.toJson()) + val loggedIn = + NostrSignerInternal( + KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), + ) - resultPrivateZap = decodedPrivateZap + var resultPrivateZap: Event? = null - onDone() - } else { - fail("Should not be null") + wait1SecondForResult { onDone -> + LnZapRequestEvent.create( + originalNote = poll, + relays = setOf("wss://relay.damus.io/"), + signer = loggedIn, + pollOption = 0, + message = "", + zapType = LnZapEvent.ZapType.PRIVATE, + toUserPubHex = null, + ) { privateZapRequest -> + val recepientPK = privateZapRequest.zappedAuthor().firstOrNull() + val recepientPost = privateZapRequest.zappedPost().firstOrNull() + + if (recepientPK != null && recepientPost != null) { + val privateKey = + createEncryptionPrivateKey( + loggedIn.keyPair.privKey!!.toHexKey(), + recepientPost, + privateZapRequest.createdAt, + ) + val decodedPrivateZap = privateZapRequest.getPrivateZapEvent(privateKey, recepientPK) + + println(decodedPrivateZap?.toJson()) + + resultPrivateZap = decodedPrivateZap + + onDone() + } else { + fail("Should not be null") + } + } } - } + + assertNotNull(resultPrivateZap) } - assertNotNull(resultPrivateZap) - } - - @Test - fun testKind1PrivateZap() { - val textNote = - Event.fromJson( - """{ + @Test + fun testKind1PrivateZap() { + val textNote = + Event.fromJson( + """{ "content": "Testing copied author. \n\nnostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", "created_at": 1682369982, "id": "c757e1371d715c711ec9ef9740a3df6475d64b3d0af45ffcbfca08d273baf1c1", @@ -147,48 +147,48 @@ class PrivateZapTests { ] } """, - ) - - val loggedIn = - NostrSignerInternal( - KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), - ) - - var resultPrivateZap: Event? = null - - wait1SecondForResult { onDone -> - LnZapRequestEvent.create( - originalNote = textNote, - relays = setOf("wss://relay.damus.io/", "wss://relay.damus2.io/", "wss://relay.damus3.io/"), - signer = loggedIn, - pollOption = null, - message = "test", - zapType = LnZapEvent.ZapType.PRIVATE, - toUserPubHex = null, - ) { privateZapRequest -> - val recepientPK = privateZapRequest.zappedAuthor().firstOrNull() - val recepientPost = privateZapRequest.zappedPost().firstOrNull() - - if (recepientPK != null && recepientPost != null) { - val privateKey = - createEncryptionPrivateKey( - loggedIn.keyPair.privKey!!.toHexKey(), - recepientPost, - privateZapRequest.createdAt, ) - val decodedPrivateZap = privateZapRequest.getPrivateZapEvent(privateKey, recepientPK) - println(decodedPrivateZap?.toJson()) + val loggedIn = + NostrSignerInternal( + KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), + ) - resultPrivateZap = decodedPrivateZap + var resultPrivateZap: Event? = null - onDone() - } else { - fail("Should not be null") + wait1SecondForResult { onDone -> + LnZapRequestEvent.create( + originalNote = textNote, + relays = setOf("wss://relay.damus.io/", "wss://relay.damus2.io/", "wss://relay.damus3.io/"), + signer = loggedIn, + pollOption = null, + message = "test", + zapType = LnZapEvent.ZapType.PRIVATE, + toUserPubHex = null, + ) { privateZapRequest -> + val recepientPK = privateZapRequest.zappedAuthor().firstOrNull() + val recepientPost = privateZapRequest.zappedPost().firstOrNull() + + if (recepientPK != null && recepientPost != null) { + val privateKey = + createEncryptionPrivateKey( + loggedIn.keyPair.privKey!!.toHexKey(), + recepientPost, + privateZapRequest.createdAt, + ) + val decodedPrivateZap = privateZapRequest.getPrivateZapEvent(privateKey, recepientPK) + + println(decodedPrivateZap?.toJson()) + + resultPrivateZap = decodedPrivateZap + + onDone() + } else { + fail("Should not be null") + } + } } - } - } - assertNotNull(resultPrivateZap) - } + assertNotNull(resultPrivateZap) + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/CryptoUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/CryptoUtils.kt index 1edc0d371..65e093780 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/CryptoUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/CryptoUtils.kt @@ -28,311 +28,310 @@ import java.security.SecureRandom import java.util.Base64 object CryptoUtils { - private val secp256k1 = Secp256k1.get() - private val random = SecureRandom() + private val secp256k1 = Secp256k1.get() + private val random = SecureRandom() - private val nip04 = Nip04(secp256k1, random) - private val nip44v1 = Nip44v1(secp256k1, random) - private val nip44v2 = Nip44v2(secp256k1, random) + private val nip04 = Nip04(secp256k1, random) + private val nip44v1 = Nip44v1(secp256k1, random) + private val nip44v2 = Nip44v2(secp256k1, random) - fun clearCache() { - nip04.clearCache() - nip44v1.clearCache() - nip44v2.clearCache() - } - - fun randomInt(bound: Int): Int { - return random.nextInt(bound) - } - - /** Provides a 32B "private key" aka random number */ - fun privkeyCreate() = random(32) - - fun random(size: Int): ByteArray { - val bytes = ByteArray(size) - random.nextBytes(bytes) - return bytes - } - - fun pubkeyCreate(privKey: ByteArray) = - secp256k1.pubKeyCompress(secp256k1.pubkeyCreate(privKey)).copyOfRange(1, 33) - - fun sign( - data: ByteArray, - privKey: ByteArray, - ): ByteArray = secp256k1.signSchnorr(data, privKey, null) - - fun verifySignature( - signature: ByteArray, - hash: ByteArray, - pubKey: ByteArray, - ): Boolean { - return secp256k1.verifySchnorr(signature, hash, pubKey) - } - - fun sha256(data: ByteArray): ByteArray { - // Creates a new buffer every time - return MessageDigest.getInstance("SHA-256").digest(data) - } - - /** NIP 04 Utils */ - fun encryptNIP04( - msg: String, - privateKey: ByteArray, - pubKey: ByteArray, - ): String { - return nip04.encrypt(msg, privateKey, pubKey) - } - - fun encryptNIP04( - msg: String, - sharedSecret: ByteArray, - ): Nip04.EncryptedInfo { - return nip04.encrypt(msg, sharedSecret) - } - - fun decryptNIP04( - msg: String, - privateKey: ByteArray, - pubKey: ByteArray, - ): String { - return nip04.decrypt(msg, privateKey, pubKey) - } - - fun decryptNIP04( - encryptedInfo: Nip04.EncryptedInfo, - privateKey: ByteArray, - pubKey: ByteArray, - ): String { - return nip04.decrypt(encryptedInfo, privateKey, pubKey) - } - - fun decryptNIP04( - msg: String, - sharedSecret: ByteArray, - ): String { - return nip04.decrypt(msg, sharedSecret) - } - - private fun decryptNIP04( - cipher: String, - nonce: String, - sharedSecret: ByteArray, - ): String { - return nip04.decrypt(cipher, nonce, sharedSecret) - } - - private fun decryptNIP04( - encryptedMsg: ByteArray, - iv: ByteArray, - sharedSecret: ByteArray, - ): String { - return nip04.decrypt(encryptedMsg, iv, sharedSecret) - } - - fun getSharedSecretNIP04( - privateKey: ByteArray, - pubKey: ByteArray, - ): ByteArray { - return nip04.getSharedSecret(privateKey, pubKey) - } - - fun computeSharedSecretNIP04( - privateKey: ByteArray, - pubKey: ByteArray, - ): ByteArray { - return nip04.computeSharedSecret(privateKey, pubKey) - } - - /** NIP 44v1 Utils */ - fun encryptNIP44v1( - msg: String, - privateKey: ByteArray, - pubKey: ByteArray, - ): Nip44v1.EncryptedInfo { - return nip44v1.encrypt(msg, privateKey, pubKey) - } - - fun encryptNIP44v1( - msg: String, - sharedSecret: ByteArray, - ): Nip44v1.EncryptedInfo { - return nip44v1.encrypt(msg, sharedSecret) - } - - fun decryptNIP44v1( - encryptedInfo: Nip44v1.EncryptedInfo, - privateKey: ByteArray, - pubKey: ByteArray, - ): String? { - return nip44v1.decrypt(encryptedInfo, privateKey, pubKey) - } - - fun decryptNIP44v1( - encryptedInfo: String, - privateKey: ByteArray, - pubKey: ByteArray, - ): String? { - return nip44v1.decrypt(encryptedInfo, privateKey, pubKey) - } - - fun decryptNIP44v1( - encryptedInfo: Nip44v1.EncryptedInfo, - sharedSecret: ByteArray, - ): String? { - return nip44v1.decrypt(encryptedInfo, sharedSecret) - } - - fun getSharedSecretNIP44v1( - privateKey: ByteArray, - pubKey: ByteArray, - ): ByteArray { - return nip44v1.getSharedSecret(privateKey, pubKey) - } - - fun computeSharedSecretNIP44v1( - privateKey: ByteArray, - pubKey: ByteArray, - ): ByteArray { - return nip44v1.computeSharedSecret(privateKey, pubKey) - } - - /** NIP 44v2 Utils */ - fun encryptNIP44v2( - msg: String, - privateKey: ByteArray, - pubKey: ByteArray, - ): Nip44v2.EncryptedInfo { - return nip44v2.encrypt(msg, privateKey, pubKey) - } - - fun encryptNIP44v2( - msg: String, - sharedSecret: ByteArray, - ): Nip44v2.EncryptedInfo { - return nip44v2.encrypt(msg, sharedSecret) - } - - fun decryptNIP44v2( - encryptedInfo: Nip44v2.EncryptedInfo, - privateKey: ByteArray, - pubKey: ByteArray, - ): String? { - return nip44v2.decrypt(encryptedInfo, privateKey, pubKey) - } - - fun decryptNIP44v2( - encryptedInfo: String, - privateKey: ByteArray, - pubKey: ByteArray, - ): String? { - return nip44v2.decrypt(encryptedInfo, privateKey, pubKey) - } - - fun decryptNIP44v2( - encryptedInfo: Nip44v2.EncryptedInfo, - sharedSecret: ByteArray, - ): String? { - return nip44v2.decrypt(encryptedInfo, sharedSecret) - } - - fun getSharedSecretNIP44v2( - privateKey: ByteArray, - pubKey: ByteArray, - ): ByteArray { - return nip44v2.getConversationKey(privateKey, pubKey) - } - - fun computeSharedSecretNIP44v2( - privateKey: ByteArray, - pubKey: ByteArray, - ): ByteArray { - return nip44v2.computeConversationKey(privateKey, pubKey) - } - - fun decryptNIP44( - payload: String, - privateKey: ByteArray, - pubKey: ByteArray, - ): String? { - if (payload.isEmpty()) return null - return if (payload[0] == '{') { - decryptNIP44FromJackson(payload, privateKey, pubKey) - } else { - decryptNIP44FromBase64(payload, privateKey, pubKey) + fun clearCache() { + nip04.clearCache() + nip44v1.clearCache() + nip44v2.clearCache() } - } - class EncryptedInfoString( - val ciphertext: String, - val nonce: String, - val v: Int, - val mac: String?, - ) - - fun decryptNIP44FromJackson( - json: String, - privateKey: ByteArray, - pubKey: ByteArray, - ): String? { - return try { - val info = Event.mapper.readValue(json, EncryptedInfoString::class.java) - - when (info.v) { - Nip04.EncryptedInfo.V -> { - val encryptedInfo = - Nip04.EncryptedInfo( - ciphertext = Base64.getDecoder().decode(info.ciphertext), - nonce = Base64.getDecoder().decode(info.nonce), - ) - decryptNIP04(encryptedInfo, privateKey, pubKey) - } - Nip44v1.EncryptedInfo.V -> { - val encryptedInfo = - Nip44v1.EncryptedInfo( - ciphertext = Base64.getDecoder().decode(info.ciphertext), - nonce = Base64.getDecoder().decode(info.nonce), - ) - decryptNIP44v1(encryptedInfo, privateKey, pubKey) - } - Nip44v2.EncryptedInfo.V -> { - val encryptedInfo = - Nip44v2.EncryptedInfo( - ciphertext = Base64.getDecoder().decode(info.ciphertext), - nonce = Base64.getDecoder().decode(info.nonce), - mac = Base64.getDecoder().decode(info.mac), - ) - decryptNIP44v2(encryptedInfo, privateKey, pubKey) - } - else -> null - } - } catch (e: Exception) { - Log.e("CryptoUtils", "Could not identify the version for NIP44 payload $json") - e.printStackTrace() - null + fun randomInt(bound: Int): Int { + return random.nextInt(bound) } - } - fun decryptNIP44FromBase64( - payload: String, - privateKey: ByteArray, - pubKey: ByteArray, - ): String? { - if (payload.isEmpty()) return null + /** Provides a 32B "private key" aka random number */ + fun privkeyCreate() = random(32) - return try { - val byteArray = Base64.getDecoder().decode(payload) - - when (byteArray[0].toInt()) { - Nip04.EncryptedInfo.V -> decryptNIP04(payload, privateKey, pubKey) - Nip44v1.EncryptedInfo.V -> decryptNIP44v1(payload, privateKey, pubKey) - Nip44v2.EncryptedInfo.V -> decryptNIP44v2(payload, privateKey, pubKey) - else -> null - } - } catch (e: Exception) { - Log.e("CryptoUtils", "Could not identify the version for NIP44 payload $payload") - e.printStackTrace() - null + fun random(size: Int): ByteArray { + val bytes = ByteArray(size) + random.nextBytes(bytes) + return bytes + } + + fun pubkeyCreate(privKey: ByteArray) = secp256k1.pubKeyCompress(secp256k1.pubkeyCreate(privKey)).copyOfRange(1, 33) + + fun sign( + data: ByteArray, + privKey: ByteArray, + ): ByteArray = secp256k1.signSchnorr(data, privKey, null) + + fun verifySignature( + signature: ByteArray, + hash: ByteArray, + pubKey: ByteArray, + ): Boolean { + return secp256k1.verifySchnorr(signature, hash, pubKey) + } + + fun sha256(data: ByteArray): ByteArray { + // Creates a new buffer every time + return MessageDigest.getInstance("SHA-256").digest(data) + } + + /** NIP 04 Utils */ + fun encryptNIP04( + msg: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String { + return nip04.encrypt(msg, privateKey, pubKey) + } + + fun encryptNIP04( + msg: String, + sharedSecret: ByteArray, + ): Nip04.EncryptedInfo { + return nip04.encrypt(msg, sharedSecret) + } + + fun decryptNIP04( + msg: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String { + return nip04.decrypt(msg, privateKey, pubKey) + } + + fun decryptNIP04( + encryptedInfo: Nip04.EncryptedInfo, + privateKey: ByteArray, + pubKey: ByteArray, + ): String { + return nip04.decrypt(encryptedInfo, privateKey, pubKey) + } + + fun decryptNIP04( + msg: String, + sharedSecret: ByteArray, + ): String { + return nip04.decrypt(msg, sharedSecret) + } + + private fun decryptNIP04( + cipher: String, + nonce: String, + sharedSecret: ByteArray, + ): String { + return nip04.decrypt(cipher, nonce, sharedSecret) + } + + private fun decryptNIP04( + encryptedMsg: ByteArray, + iv: ByteArray, + sharedSecret: ByteArray, + ): String { + return nip04.decrypt(encryptedMsg, iv, sharedSecret) + } + + fun getSharedSecretNIP04( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray { + return nip04.getSharedSecret(privateKey, pubKey) + } + + fun computeSharedSecretNIP04( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray { + return nip04.computeSharedSecret(privateKey, pubKey) + } + + /** NIP 44v1 Utils */ + fun encryptNIP44v1( + msg: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): Nip44v1.EncryptedInfo { + return nip44v1.encrypt(msg, privateKey, pubKey) + } + + fun encryptNIP44v1( + msg: String, + sharedSecret: ByteArray, + ): Nip44v1.EncryptedInfo { + return nip44v1.encrypt(msg, sharedSecret) + } + + fun decryptNIP44v1( + encryptedInfo: Nip44v1.EncryptedInfo, + privateKey: ByteArray, + pubKey: ByteArray, + ): String? { + return nip44v1.decrypt(encryptedInfo, privateKey, pubKey) + } + + fun decryptNIP44v1( + encryptedInfo: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String? { + return nip44v1.decrypt(encryptedInfo, privateKey, pubKey) + } + + fun decryptNIP44v1( + encryptedInfo: Nip44v1.EncryptedInfo, + sharedSecret: ByteArray, + ): String? { + return nip44v1.decrypt(encryptedInfo, sharedSecret) + } + + fun getSharedSecretNIP44v1( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray { + return nip44v1.getSharedSecret(privateKey, pubKey) + } + + fun computeSharedSecretNIP44v1( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray { + return nip44v1.computeSharedSecret(privateKey, pubKey) + } + + /** NIP 44v2 Utils */ + fun encryptNIP44v2( + msg: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): Nip44v2.EncryptedInfo { + return nip44v2.encrypt(msg, privateKey, pubKey) + } + + fun encryptNIP44v2( + msg: String, + sharedSecret: ByteArray, + ): Nip44v2.EncryptedInfo { + return nip44v2.encrypt(msg, sharedSecret) + } + + fun decryptNIP44v2( + encryptedInfo: Nip44v2.EncryptedInfo, + privateKey: ByteArray, + pubKey: ByteArray, + ): String? { + return nip44v2.decrypt(encryptedInfo, privateKey, pubKey) + } + + fun decryptNIP44v2( + encryptedInfo: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String? { + return nip44v2.decrypt(encryptedInfo, privateKey, pubKey) + } + + fun decryptNIP44v2( + encryptedInfo: Nip44v2.EncryptedInfo, + sharedSecret: ByteArray, + ): String? { + return nip44v2.decrypt(encryptedInfo, sharedSecret) + } + + fun getSharedSecretNIP44v2( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray { + return nip44v2.getConversationKey(privateKey, pubKey) + } + + fun computeSharedSecretNIP44v2( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray { + return nip44v2.computeConversationKey(privateKey, pubKey) + } + + fun decryptNIP44( + payload: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String? { + if (payload.isEmpty()) return null + return if (payload[0] == '{') { + decryptNIP44FromJackson(payload, privateKey, pubKey) + } else { + decryptNIP44FromBase64(payload, privateKey, pubKey) + } + } + + class EncryptedInfoString( + val ciphertext: String, + val nonce: String, + val v: Int, + val mac: String?, + ) + + fun decryptNIP44FromJackson( + json: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String? { + return try { + val info = Event.mapper.readValue(json, EncryptedInfoString::class.java) + + when (info.v) { + Nip04.EncryptedInfo.V -> { + val encryptedInfo = + Nip04.EncryptedInfo( + ciphertext = Base64.getDecoder().decode(info.ciphertext), + nonce = Base64.getDecoder().decode(info.nonce), + ) + decryptNIP04(encryptedInfo, privateKey, pubKey) + } + Nip44v1.EncryptedInfo.V -> { + val encryptedInfo = + Nip44v1.EncryptedInfo( + ciphertext = Base64.getDecoder().decode(info.ciphertext), + nonce = Base64.getDecoder().decode(info.nonce), + ) + decryptNIP44v1(encryptedInfo, privateKey, pubKey) + } + Nip44v2.EncryptedInfo.V -> { + val encryptedInfo = + Nip44v2.EncryptedInfo( + ciphertext = Base64.getDecoder().decode(info.ciphertext), + nonce = Base64.getDecoder().decode(info.nonce), + mac = Base64.getDecoder().decode(info.mac), + ) + decryptNIP44v2(encryptedInfo, privateKey, pubKey) + } + else -> null + } + } catch (e: Exception) { + Log.e("CryptoUtils", "Could not identify the version for NIP44 payload $json") + e.printStackTrace() + null + } + } + + fun decryptNIP44FromBase64( + payload: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String? { + if (payload.isEmpty()) return null + + return try { + val byteArray = Base64.getDecoder().decode(payload) + + when (byteArray[0].toInt()) { + Nip04.EncryptedInfo.V -> decryptNIP04(payload, privateKey, pubKey) + Nip44v1.EncryptedInfo.V -> decryptNIP44v1(payload, privateKey, pubKey) + Nip44v2.EncryptedInfo.V -> decryptNIP44v2(payload, privateKey, pubKey) + else -> null + } + } catch (e: Exception) { + Log.e("CryptoUtils", "Could not identify the version for NIP44 payload $payload") + e.printStackTrace() + null + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Hkdf.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Hkdf.kt index 0ca592c2a..e36f6d9c3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Hkdf.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Hkdf.kt @@ -25,40 +25,40 @@ import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec class Hkdf(val algorithm: String = "HmacSHA256", val hashLen: Int = 32) { - fun extract( - key: ByteArray, - salt: ByteArray, - ): ByteArray { - val mac = Mac.getInstance(algorithm) - mac.init(SecretKeySpec(salt, algorithm)) - return mac.doFinal(key) - } - - fun expand( - key: ByteArray, - nonce: ByteArray, - outputLength: Int, - ): ByteArray { - check(key.size == hashLen) - check(nonce.size == hashLen) - - val n = if (outputLength % hashLen == 0) outputLength / hashLen else outputLength / hashLen + 1 - var hashRound = ByteArray(0) - val generatedBytes = ByteBuffer.allocate(Math.multiplyExact(n, hashLen)) - val mac = Mac.getInstance(algorithm) - mac.init(SecretKeySpec(key, algorithm)) - for (roundNum in 1..n) { - mac.reset() - val t = ByteBuffer.allocate(hashRound.size + nonce.size + 1) - t.put(hashRound) - t.put(nonce) - t.put(roundNum.toByte()) - hashRound = mac.doFinal(t.array()) - generatedBytes.put(hashRound) + fun extract( + key: ByteArray, + salt: ByteArray, + ): ByteArray { + val mac = Mac.getInstance(algorithm) + mac.init(SecretKeySpec(salt, algorithm)) + return mac.doFinal(key) + } + + fun expand( + key: ByteArray, + nonce: ByteArray, + outputLength: Int, + ): ByteArray { + check(key.size == hashLen) + check(nonce.size == hashLen) + + val n = if (outputLength % hashLen == 0) outputLength / hashLen else outputLength / hashLen + 1 + var hashRound = ByteArray(0) + val generatedBytes = ByteBuffer.allocate(Math.multiplyExact(n, hashLen)) + val mac = Mac.getInstance(algorithm) + mac.init(SecretKeySpec(key, algorithm)) + for (roundNum in 1..n) { + mac.reset() + val t = ByteBuffer.allocate(hashRound.size + nonce.size + 1) + t.put(hashRound) + t.put(nonce) + t.put(roundNum.toByte()) + hashRound = mac.doFinal(t.array()) + generatedBytes.put(hashRound) + } + val result = ByteArray(outputLength) + generatedBytes.rewind() + generatedBytes[result, 0, outputLength] + return result } - val result = ByteArray(outputLength) - generatedBytes.rewind() - generatedBytes[result, 0, outputLength] - return result - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/KeyPair.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/KeyPair.kt index cf89e01c6..27aebaee4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/KeyPair.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/KeyPair.kt @@ -23,32 +23,32 @@ package com.vitorpamplona.quartz.crypto import com.vitorpamplona.quartz.encoders.toHexKey class KeyPair( - privKey: ByteArray? = null, - pubKey: ByteArray? = null, + privKey: ByteArray? = null, + pubKey: ByteArray? = null, ) { - val privKey: ByteArray? - val pubKey: ByteArray + val privKey: ByteArray? + val pubKey: ByteArray - init { - if (privKey == null) { - if (pubKey == null) { - // create new, random keys - this.privKey = CryptoUtils.privkeyCreate() - this.pubKey = CryptoUtils.pubkeyCreate(this.privKey) - } else { - // this is a read-only account - check(pubKey.size == 32) - this.privKey = null - this.pubKey = pubKey - } - } else { - // as private key is provided, ignore the public key and set keys according to private key - this.privKey = privKey - this.pubKey = CryptoUtils.pubkeyCreate(privKey) + init { + if (privKey == null) { + if (pubKey == null) { + // create new, random keys + this.privKey = CryptoUtils.privkeyCreate() + this.pubKey = CryptoUtils.pubkeyCreate(this.privKey) + } else { + // this is a read-only account + check(pubKey.size == 32) + this.privKey = null + this.pubKey = pubKey + } + } else { + // as private key is provided, ignore the public key and set keys according to private key + this.privKey = privKey + this.pubKey = CryptoUtils.pubkeyCreate(privKey) + } } - } - override fun toString(): String { - return "KeyPair(privateKey=${privKey?.toHexKey()}, publicKey=${pubKey.toHexKey()}" - } + override fun toString(): String { + return "KeyPair(privateKey=${privKey?.toHexKey()}, publicKey=${pubKey.toHexKey()}" + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip04.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip04.kt index 8555db6ae..f02858277 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip04.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip04.kt @@ -30,147 +30,147 @@ import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec class Nip04(val secp256k1: Secp256k1, val random: SecureRandom) { - private val sharedKeyCache = SharedKeyCache() - private val h02 = Hex.decode("02") + private val sharedKeyCache = SharedKeyCache() + private val h02 = Hex.decode("02") - fun clearCache() { - sharedKeyCache.clearCache() - } + fun clearCache() { + sharedKeyCache.clearCache() + } - fun encrypt( - msg: String, - privateKey: ByteArray, - pubKey: ByteArray, - ): String { - return encrypt(msg, getSharedSecret(privateKey, pubKey)).encodeToNIP04() - } + fun encrypt( + msg: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String { + return encrypt(msg, getSharedSecret(privateKey, pubKey)).encodeToNIP04() + } - fun encrypt( - msg: String, - sharedSecret: ByteArray, - ): EncryptedInfo { - val iv = ByteArray(16) - random.nextBytes(iv) + fun encrypt( + msg: String, + sharedSecret: ByteArray, + ): EncryptedInfo { + val iv = ByteArray(16) + random.nextBytes(iv) - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv)) - // val ivBase64 = Base64.getEncoder().encodeToString(iv) - val encryptedMsg = cipher.doFinal(msg.toByteArray()) - // val encryptedMsgBase64 = Base64.getEncoder().encodeToString(encryptedMsg) - return EncryptedInfo(encryptedMsg, iv) - } + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv)) + // val ivBase64 = Base64.getEncoder().encodeToString(iv) + val encryptedMsg = cipher.doFinal(msg.toByteArray()) + // val encryptedMsgBase64 = Base64.getEncoder().encodeToString(encryptedMsg) + return EncryptedInfo(encryptedMsg, iv) + } - fun decrypt( - msg: String, - privateKey: ByteArray, - pubKey: ByteArray, - ): String { - val sharedSecret = getSharedSecret(privateKey, pubKey) - return decrypt(msg, sharedSecret) - } + fun decrypt( + msg: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String { + val sharedSecret = getSharedSecret(privateKey, pubKey) + return decrypt(msg, sharedSecret) + } - fun decrypt( - encryptedInfo: EncryptedInfo, - privateKey: ByteArray, - pubKey: ByteArray, - ): String { - val sharedSecret = getSharedSecret(privateKey, pubKey) - return decrypt(encryptedInfo.ciphertext, encryptedInfo.nonce, sharedSecret) - } + fun decrypt( + encryptedInfo: EncryptedInfo, + privateKey: ByteArray, + pubKey: ByteArray, + ): String { + val sharedSecret = getSharedSecret(privateKey, pubKey) + return decrypt(encryptedInfo.ciphertext, encryptedInfo.nonce, sharedSecret) + } - fun decrypt( - msg: String, - sharedSecret: ByteArray, - ): String { - val decoded = EncryptedInfo.decodeFromNIP04(msg) - check(decoded != null) { "Unable to decode msg $msg as NIP04" } - return decrypt(decoded.ciphertext, decoded.nonce, sharedSecret) - } + fun decrypt( + msg: String, + sharedSecret: ByteArray, + ): String { + val decoded = EncryptedInfo.decodeFromNIP04(msg) + check(decoded != null) { "Unable to decode msg $msg as NIP04" } + return decrypt(decoded.ciphertext, decoded.nonce, sharedSecret) + } - fun decrypt( - cipher: String, - nonce: String, - sharedSecret: ByteArray, - ): String { - val iv = Base64.getDecoder().decode(nonce) - val encryptedMsg = Base64.getDecoder().decode(cipher) - return decrypt(encryptedMsg, iv, sharedSecret) - } + fun decrypt( + cipher: String, + nonce: String, + sharedSecret: ByteArray, + ): String { + val iv = Base64.getDecoder().decode(nonce) + val encryptedMsg = Base64.getDecoder().decode(cipher) + return decrypt(encryptedMsg, iv, sharedSecret) + } - fun decrypt( - encryptedMsg: ByteArray, - iv: ByteArray, - sharedSecret: ByteArray, - ): String { - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv)) - return String(cipher.doFinal(encryptedMsg)) - } + fun decrypt( + encryptedMsg: ByteArray, + iv: ByteArray, + sharedSecret: ByteArray, + ): String { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv)) + return String(cipher.doFinal(encryptedMsg)) + } - fun getSharedSecret( - privateKey: ByteArray, - pubKey: ByteArray, - ): ByteArray { - val preComputed = sharedKeyCache.get(privateKey, pubKey) - if (preComputed != null) return preComputed + fun getSharedSecret( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray { + val preComputed = sharedKeyCache.get(privateKey, pubKey) + if (preComputed != null) return preComputed - val computed = computeSharedSecret(privateKey, pubKey) - sharedKeyCache.add(privateKey, pubKey, computed) - return computed - } + val computed = computeSharedSecret(privateKey, pubKey) + sharedKeyCache.add(privateKey, pubKey, computed) + return computed + } - /** @return 32B shared secret */ - fun computeSharedSecret( - privateKey: ByteArray, - pubKey: ByteArray, - ): ByteArray = secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) + /** @return 32B shared secret */ + fun computeSharedSecret( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray = secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) - class EncryptedInfo( - val ciphertext: ByteArray, - val nonce: ByteArray, - ) { - companion object { - const val V: Int = 0 + class EncryptedInfo( + val ciphertext: ByteArray, + val nonce: ByteArray, + ) { + companion object { + const val V: Int = 0 - fun decodePayload(payload: String): EncryptedInfo? { - return try { - val byteArray = Base64.getDecoder().decode(payload) - check(byteArray[0].toInt() == Nip44v1.EncryptedInfo.V) - return EncryptedInfo( - nonce = byteArray.copyOfRange(1, 25), - ciphertext = byteArray.copyOfRange(25, byteArray.size), - ) - } catch (e: Exception) { - Log.w("NIP04", "Unable to Parse encrypted payload: $payload") - null + fun decodePayload(payload: String): EncryptedInfo? { + return try { + val byteArray = Base64.getDecoder().decode(payload) + check(byteArray[0].toInt() == Nip44v1.EncryptedInfo.V) + return EncryptedInfo( + nonce = byteArray.copyOfRange(1, 25), + ciphertext = byteArray.copyOfRange(25, byteArray.size), + ) + } catch (e: Exception) { + Log.w("NIP04", "Unable to Parse encrypted payload: $payload") + null + } + } + + fun decodeFromNIP04(payload: String): EncryptedInfo? { + return try { + val parts = payload.split("?iv=") + EncryptedInfo( + ciphertext = Base64.getDecoder().decode(parts[0]), + nonce = Base64.getDecoder().decode(parts[1]), + ) + } catch (e: Exception) { + Log.w("NIP04", "Unable to Parse encrypted payload: $payload") + null + } + } } - } - fun decodeFromNIP04(payload: String): EncryptedInfo? { - return try { - val parts = payload.split("?iv=") - EncryptedInfo( - ciphertext = Base64.getDecoder().decode(parts[0]), - nonce = Base64.getDecoder().decode(parts[1]), - ) - } catch (e: Exception) { - Log.w("NIP04", "Unable to Parse encrypted payload: $payload") - null + fun encodePayload(): String { + return Base64.getEncoder() + .encodeToString( + byteArrayOf(V.toByte()) + nonce + ciphertext, + ) } - } - } - fun encodePayload(): String { - return Base64.getEncoder() - .encodeToString( - byteArrayOf(V.toByte()) + nonce + ciphertext, - ) + fun encodeToNIP04(): String { + val nonce = Base64.getEncoder().encodeToString(nonce) + val ciphertext = Base64.getEncoder().encodeToString(ciphertext) + return "$ciphertext?iv=$nonce" + } } - - fun encodeToNIP04(): String { - val nonce = Base64.getEncoder().encodeToString(nonce) - val ciphertext = Base64.getEncoder().encodeToString(ciphertext) - return "$ciphertext?iv=$nonce" - } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip44v1.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip44v1.kt index 3509dd6af..d0c730adb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip44v1.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip44v1.kt @@ -30,136 +30,136 @@ import java.security.SecureRandom import java.util.Base64 class Nip44v1(val secp256k1: Secp256k1, val random: SecureRandom) { - private val sharedKeyCache = SharedKeyCache() - private val h02 = Hex.decode("02") - private val libSodium = SodiumAndroid() + private val sharedKeyCache = SharedKeyCache() + private val h02 = Hex.decode("02") + private val libSodium = SodiumAndroid() - fun clearCache() { - sharedKeyCache.clearCache() - } - - fun encrypt( - msg: String, - privateKey: ByteArray, - pubKey: ByteArray, - ): EncryptedInfo { - val sharedSecret = getSharedSecret(privateKey, pubKey) - return encrypt(msg, sharedSecret) - } - - fun encrypt( - msg: String, - sharedSecret: ByteArray, - ): EncryptedInfo { - val nonce = ByteArray(24) - random.nextBytes(nonce) - - val cipher = - cryptoStreamXChaCha20Xor( - libSodium = libSodium, - messageBytes = msg.toByteArray(), - nonce = nonce, - key = Key.fromBytes(sharedSecret), - ) - - return EncryptedInfo( - ciphertext = cipher ?: ByteArray(0), - nonce = nonce, - ) - } - - fun decrypt( - payload: String, - privateKey: ByteArray, - pubKey: ByteArray, - ): String? { - val sharedSecret = getSharedSecret(privateKey, pubKey) - return decrypt(payload, sharedSecret) - } - - fun decrypt( - encryptedInfo: EncryptedInfo, - privateKey: ByteArray, - pubKey: ByteArray, - ): String? { - val sharedSecret = getSharedSecret(privateKey, pubKey) - return decrypt(encryptedInfo, sharedSecret) - } - - fun decrypt( - payload: String, - sharedSecret: ByteArray, - ): String? { - val encryptedInfo = EncryptedInfo.decodePayload(payload) ?: return null - return decrypt(encryptedInfo, sharedSecret) - } - - fun decrypt( - encryptedInfo: EncryptedInfo, - sharedSecret: ByteArray, - ): String? { - return cryptoStreamXChaCha20Xor( - libSodium = libSodium, - messageBytes = encryptedInfo.ciphertext, - nonce = encryptedInfo.nonce, - key = Key.fromBytes(sharedSecret), - ) - ?.decodeToString() - } - - fun getSharedSecret( - privateKey: ByteArray, - pubKey: ByteArray, - ): ByteArray { - val preComputed = sharedKeyCache.get(privateKey, pubKey) - if (preComputed != null) return preComputed - - val computed = computeSharedSecret(privateKey, pubKey) - sharedKeyCache.add(privateKey, pubKey, computed) - return computed - } - - /** @return 32B shared secret */ - fun computeSharedSecret( - privateKey: ByteArray, - pubKey: ByteArray, - ): ByteArray = - sha256( - secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33), - ) - - fun sha256(data: ByteArray): ByteArray { - // Creates a new buffer every time - return MessageDigest.getInstance("SHA-256").digest(data) - } - - class EncryptedInfo( - val ciphertext: ByteArray, - val nonce: ByteArray, - ) { - companion object { - const val V: Int = 1 - - fun decodePayload(payload: String): EncryptedInfo? { - return try { - val byteArray = Base64.getDecoder().decode(payload) - check(byteArray[0].toInt() == V) - return EncryptedInfo( - nonce = byteArray.copyOfRange(1, 25), - ciphertext = byteArray.copyOfRange(25, byteArray.size), - ) - } catch (e: Exception) { - Log.w("NIP44v1", "Unable to Parse encrypted payload: $payload") - null - } - } + fun clearCache() { + sharedKeyCache.clearCache() } - fun encodePayload(): String { - return Base64.getEncoder() - .encodeToString( - byteArrayOf(V.toByte()) + nonce + ciphertext, + fun encrypt( + msg: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): EncryptedInfo { + val sharedSecret = getSharedSecret(privateKey, pubKey) + return encrypt(msg, sharedSecret) + } + + fun encrypt( + msg: String, + sharedSecret: ByteArray, + ): EncryptedInfo { + val nonce = ByteArray(24) + random.nextBytes(nonce) + + val cipher = + cryptoStreamXChaCha20Xor( + libSodium = libSodium, + messageBytes = msg.toByteArray(), + nonce = nonce, + key = Key.fromBytes(sharedSecret), + ) + + return EncryptedInfo( + ciphertext = cipher ?: ByteArray(0), + nonce = nonce, ) } - } + + fun decrypt( + payload: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String? { + val sharedSecret = getSharedSecret(privateKey, pubKey) + return decrypt(payload, sharedSecret) + } + + fun decrypt( + encryptedInfo: EncryptedInfo, + privateKey: ByteArray, + pubKey: ByteArray, + ): String? { + val sharedSecret = getSharedSecret(privateKey, pubKey) + return decrypt(encryptedInfo, sharedSecret) + } + + fun decrypt( + payload: String, + sharedSecret: ByteArray, + ): String? { + val encryptedInfo = EncryptedInfo.decodePayload(payload) ?: return null + return decrypt(encryptedInfo, sharedSecret) + } + + fun decrypt( + encryptedInfo: EncryptedInfo, + sharedSecret: ByteArray, + ): String? { + return cryptoStreamXChaCha20Xor( + libSodium = libSodium, + messageBytes = encryptedInfo.ciphertext, + nonce = encryptedInfo.nonce, + key = Key.fromBytes(sharedSecret), + ) + ?.decodeToString() + } + + fun getSharedSecret( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray { + val preComputed = sharedKeyCache.get(privateKey, pubKey) + if (preComputed != null) return preComputed + + val computed = computeSharedSecret(privateKey, pubKey) + sharedKeyCache.add(privateKey, pubKey, computed) + return computed + } + + /** @return 32B shared secret */ + fun computeSharedSecret( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray = + sha256( + secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33), + ) + + fun sha256(data: ByteArray): ByteArray { + // Creates a new buffer every time + return MessageDigest.getInstance("SHA-256").digest(data) + } + + class EncryptedInfo( + val ciphertext: ByteArray, + val nonce: ByteArray, + ) { + companion object { + const val V: Int = 1 + + fun decodePayload(payload: String): EncryptedInfo? { + return try { + val byteArray = Base64.getDecoder().decode(payload) + check(byteArray[0].toInt() == V) + return EncryptedInfo( + nonce = byteArray.copyOfRange(1, 25), + ciphertext = byteArray.copyOfRange(25, byteArray.size), + ) + } catch (e: Exception) { + Log.w("NIP44v1", "Unable to Parse encrypted payload: $payload") + null + } + } + } + + fun encodePayload(): String { + return Base64.getEncoder() + .encodeToString( + byteArrayOf(V.toByte()) + nonce + ciphertext, + ) + } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip44v2.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip44v2.kt index f5bb4a116..8b7ed939c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip44v2.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip44v2.kt @@ -34,250 +34,250 @@ import kotlin.math.floor import kotlin.math.log2 class Nip44v2(val secp256k1: Secp256k1, val random: SecureRandom) { - private val sharedKeyCache = SharedKeyCache() + private val sharedKeyCache = SharedKeyCache() - private val libSodium = SodiumAndroid() - private val lazySodium = LazySodiumAndroid(libSodium) - private val hkdf = Hkdf() + private val libSodium = SodiumAndroid() + private val lazySodium = LazySodiumAndroid(libSodium) + private val hkdf = Hkdf() - private val h02 = Hex.decode("02") - private val saltPrefix = "nip44-v2".toByteArray(Charsets.UTF_8) - private val hashLength = 32 + private val h02 = Hex.decode("02") + private val saltPrefix = "nip44-v2".toByteArray(Charsets.UTF_8) + private val hashLength = 32 - private val minPlaintextSize: Int = 0x0001 // 1b msg => padded to 32b - private val maxPlaintextSize: Int = 0xffff // 65535 (64kb-1) => padded to 64kb + private val minPlaintextSize: Int = 0x0001 // 1b msg => padded to 32b + private val maxPlaintextSize: Int = 0xffff // 65535 (64kb-1) => padded to 64kb - fun clearCache() { - sharedKeyCache.clearCache() - } - - fun encrypt( - msg: String, - privateKey: ByteArray, - pubKey: ByteArray, - ): EncryptedInfo { - return encrypt(msg, getConversationKey(privateKey, pubKey)) - } - - fun encrypt( - plaintext: String, - conversationKey: ByteArray, - ): EncryptedInfo { - val nonce = ByteArray(hashLength) - random.nextBytes(nonce) - return encryptWithNonce(plaintext, conversationKey, nonce) - } - - fun encryptWithNonce( - plaintext: String, - conversationKey: ByteArray, - nonce: ByteArray, - ): EncryptedInfo { - val messageKeys = getMessageKeys(conversationKey, nonce) - val padded = pad(plaintext) - - val ciphertext = ByteArray(padded.size) - - lazySodium.cryptoStreamChaCha20IetfXor( - ciphertext, - padded, - padded.size.toLong(), - messageKeys.chachaNonce, - messageKeys.chachaKey, - ) - - val mac = hmacAad(messageKeys.hmacKey, ciphertext, nonce) - - return EncryptedInfo( - nonce = nonce, - ciphertext = ciphertext, - mac = mac, - ) - } - - fun decrypt( - payload: String, - privateKey: ByteArray, - pubKey: ByteArray, - ): String? { - return decrypt(payload, getConversationKey(privateKey, pubKey)) - } - - fun decrypt( - decoded: EncryptedInfo, - privateKey: ByteArray, - pubKey: ByteArray, - ): String? { - return decrypt(decoded, getConversationKey(privateKey, pubKey)) - } - - fun decrypt( - payload: String, - conversationKey: ByteArray, - ): String? { - val decoded = EncryptedInfo.decodePayload(payload) ?: return null - return decrypt(decoded, conversationKey) - } - - fun decrypt( - decoded: EncryptedInfo, - conversationKey: ByteArray, - ): String? { - val messageKey = getMessageKeys(conversationKey, decoded.nonce) - val calculatedMac = hmacAad(messageKey.hmacKey, decoded.ciphertext, decoded.nonce) - - check(calculatedMac.contentEquals(decoded.mac)) { - "Invalid Mac: Calculated ${calculatedMac.toHexKey()}, decoded: ${decoded.mac.toHexKey()}" + fun clearCache() { + sharedKeyCache.clearCache() } - val mLen = decoded.ciphertext.size.toLong() - val padded = ByteArray(decoded.ciphertext.size) - - lazySodium.cryptoStreamChaCha20IetfXor( - padded, - decoded.ciphertext, - mLen, - messageKey.chachaNonce, - messageKey.chachaKey, - ) - - return unpad(padded) - } - - fun getConversationKey( - privateKey: ByteArray, - pubKey: ByteArray, - ): ByteArray { - val preComputed = sharedKeyCache.get(privateKey, pubKey) - if (preComputed != null) return preComputed - - val computed = computeConversationKey(privateKey, pubKey) - sharedKeyCache.add(privateKey, pubKey, computed) - return computed - } - - fun calcPaddedLen(len: Int): Int { - check(len > 0) { "expected positive integer" } - if (len <= 32) return 32 - val nextPower = 1 shl (floor(log2(len - 1f)) + 1).toInt() - val chunk = if (nextPower <= 256) 32 else nextPower / 8 - return chunk * (floor((len - 1f) / chunk).toInt() + 1) - } - - fun pad(plaintext: String): ByteArray { - val unpadded = plaintext.toByteArray(Charsets.UTF_8) - val unpaddedLen = unpadded.size - - check(unpaddedLen > 0) { "Message is empty ($unpaddedLen): $plaintext" } - - check(unpaddedLen <= maxPlaintextSize) { "Message is too long ($unpaddedLen): $plaintext" } - - val prefix = - ByteBuffer.allocate(2).order(ByteOrder.BIG_ENDIAN).putShort(unpaddedLen.toShort()).array() - val suffix = ByteArray(calcPaddedLen(unpaddedLen) - unpaddedLen) - return ByteBuffer.wrap(prefix + unpadded + suffix).array() - } - - private fun bytesToInt( - byte1: Byte, - byte2: Byte, - bigEndian: Boolean, - ): Int { - return if (bigEndian) { - (byte1.toInt() and 0xFF shl 8 or (byte2.toInt() and 0xFF)) - } else { - (byte2.toInt() and 0xFF shl 8 or (byte1.toInt() and 0xFF)) - } - } - - fun unpad(padded: ByteArray): String { - val unpaddedLen: Int = bytesToInt(padded[0], padded[1], true) - val unpadded = padded.sliceArray(2 until 2 + unpaddedLen) - - check( - unpaddedLen in minPlaintextSize..maxPlaintextSize && - unpadded.size == unpaddedLen && - padded.size == 2 + calcPaddedLen(unpaddedLen), - ) { - "invalid padding ${unpadded.size} != $unpaddedLen" + fun encrypt( + msg: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): EncryptedInfo { + return encrypt(msg, getConversationKey(privateKey, pubKey)) } - return unpadded.decodeToString() - } - - fun hmacAad( - key: ByteArray, - message: ByteArray, - aad: ByteArray, - ): ByteArray { - check(aad.size == hashLength) { - "AAD associated data must be 32 bytes, but it was ${aad.size} bytes" + fun encrypt( + plaintext: String, + conversationKey: ByteArray, + ): EncryptedInfo { + val nonce = ByteArray(hashLength) + random.nextBytes(nonce) + return encryptWithNonce(plaintext, conversationKey, nonce) } - return hkdf.extract(aad + message, key) - } + fun encryptWithNonce( + plaintext: String, + conversationKey: ByteArray, + nonce: ByteArray, + ): EncryptedInfo { + val messageKeys = getMessageKeys(conversationKey, nonce) + val padded = pad(plaintext) - fun getMessageKeys( - conversationKey: ByteArray, - nonce: ByteArray, - ): MessageKey { - val keys = hkdf.expand(conversationKey, nonce, 76) - return MessageKey( - chachaKey = keys.copyOfRange(0, 32), - chachaNonce = keys.copyOfRange(32, 44), - hmacKey = keys.copyOfRange(44, 76), - ) - } + val ciphertext = ByteArray(padded.size) - class MessageKey( - val chachaKey: ByteArray, - val chachaNonce: ByteArray, - val hmacKey: ByteArray, - ) + lazySodium.cryptoStreamChaCha20IetfXor( + ciphertext, + padded, + padded.size.toLong(), + messageKeys.chachaNonce, + messageKeys.chachaKey, + ) - /** @return 32B shared secret */ - fun computeConversationKey( - privateKey: ByteArray, - pubKey: ByteArray, - ): ByteArray { - val sharedX = secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) - return hkdf.extract(sharedX, saltPrefix) - } + val mac = hmacAad(messageKeys.hmacKey, ciphertext, nonce) - class EncryptedInfo( - val nonce: ByteArray, - val ciphertext: ByteArray, - val mac: ByteArray, - ) { - companion object { - const val V: Int = 2 - - fun decodePayload(payload: String): EncryptedInfo? { - check(payload.length >= 132 || payload.length <= 87472) { - "Invalid payload length ${payload.length} for $payload" - } - check(payload[0] != '#') { "Unknown encryption version ${payload.get(0)}" } - - return try { - val byteArray = Base64.getDecoder().decode(payload) - check(byteArray[0].toInt() == V) - return EncryptedInfo( - nonce = byteArray.copyOfRange(1, 33), - ciphertext = byteArray.copyOfRange(33, byteArray.size - 32), - mac = byteArray.copyOfRange(byteArray.size - 32, byteArray.size), - ) - } catch (e: Exception) { - Log.w("NIP44v2", "Unable to Parse encrypted payload: $payload") - null - } - } - } - - fun encodePayload(): String { - return Base64.getEncoder() - .encodeToString( - byteArrayOf(V.toByte()) + nonce + ciphertext + mac, + return EncryptedInfo( + nonce = nonce, + ciphertext = ciphertext, + mac = mac, ) } - } + + fun decrypt( + payload: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String? { + return decrypt(payload, getConversationKey(privateKey, pubKey)) + } + + fun decrypt( + decoded: EncryptedInfo, + privateKey: ByteArray, + pubKey: ByteArray, + ): String? { + return decrypt(decoded, getConversationKey(privateKey, pubKey)) + } + + fun decrypt( + payload: String, + conversationKey: ByteArray, + ): String? { + val decoded = EncryptedInfo.decodePayload(payload) ?: return null + return decrypt(decoded, conversationKey) + } + + fun decrypt( + decoded: EncryptedInfo, + conversationKey: ByteArray, + ): String? { + val messageKey = getMessageKeys(conversationKey, decoded.nonce) + val calculatedMac = hmacAad(messageKey.hmacKey, decoded.ciphertext, decoded.nonce) + + check(calculatedMac.contentEquals(decoded.mac)) { + "Invalid Mac: Calculated ${calculatedMac.toHexKey()}, decoded: ${decoded.mac.toHexKey()}" + } + + val mLen = decoded.ciphertext.size.toLong() + val padded = ByteArray(decoded.ciphertext.size) + + lazySodium.cryptoStreamChaCha20IetfXor( + padded, + decoded.ciphertext, + mLen, + messageKey.chachaNonce, + messageKey.chachaKey, + ) + + return unpad(padded) + } + + fun getConversationKey( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray { + val preComputed = sharedKeyCache.get(privateKey, pubKey) + if (preComputed != null) return preComputed + + val computed = computeConversationKey(privateKey, pubKey) + sharedKeyCache.add(privateKey, pubKey, computed) + return computed + } + + fun calcPaddedLen(len: Int): Int { + check(len > 0) { "expected positive integer" } + if (len <= 32) return 32 + val nextPower = 1 shl (floor(log2(len - 1f)) + 1).toInt() + val chunk = if (nextPower <= 256) 32 else nextPower / 8 + return chunk * (floor((len - 1f) / chunk).toInt() + 1) + } + + fun pad(plaintext: String): ByteArray { + val unpadded = plaintext.toByteArray(Charsets.UTF_8) + val unpaddedLen = unpadded.size + + check(unpaddedLen > 0) { "Message is empty ($unpaddedLen): $plaintext" } + + check(unpaddedLen <= maxPlaintextSize) { "Message is too long ($unpaddedLen): $plaintext" } + + val prefix = + ByteBuffer.allocate(2).order(ByteOrder.BIG_ENDIAN).putShort(unpaddedLen.toShort()).array() + val suffix = ByteArray(calcPaddedLen(unpaddedLen) - unpaddedLen) + return ByteBuffer.wrap(prefix + unpadded + suffix).array() + } + + private fun bytesToInt( + byte1: Byte, + byte2: Byte, + bigEndian: Boolean, + ): Int { + return if (bigEndian) { + (byte1.toInt() and 0xFF shl 8 or (byte2.toInt() and 0xFF)) + } else { + (byte2.toInt() and 0xFF shl 8 or (byte1.toInt() and 0xFF)) + } + } + + fun unpad(padded: ByteArray): String { + val unpaddedLen: Int = bytesToInt(padded[0], padded[1], true) + val unpadded = padded.sliceArray(2 until 2 + unpaddedLen) + + check( + unpaddedLen in minPlaintextSize..maxPlaintextSize && + unpadded.size == unpaddedLen && + padded.size == 2 + calcPaddedLen(unpaddedLen), + ) { + "invalid padding ${unpadded.size} != $unpaddedLen" + } + + return unpadded.decodeToString() + } + + fun hmacAad( + key: ByteArray, + message: ByteArray, + aad: ByteArray, + ): ByteArray { + check(aad.size == hashLength) { + "AAD associated data must be 32 bytes, but it was ${aad.size} bytes" + } + + return hkdf.extract(aad + message, key) + } + + fun getMessageKeys( + conversationKey: ByteArray, + nonce: ByteArray, + ): MessageKey { + val keys = hkdf.expand(conversationKey, nonce, 76) + return MessageKey( + chachaKey = keys.copyOfRange(0, 32), + chachaNonce = keys.copyOfRange(32, 44), + hmacKey = keys.copyOfRange(44, 76), + ) + } + + class MessageKey( + val chachaKey: ByteArray, + val chachaNonce: ByteArray, + val hmacKey: ByteArray, + ) + + /** @return 32B shared secret */ + fun computeConversationKey( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray { + val sharedX = secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) + return hkdf.extract(sharedX, saltPrefix) + } + + class EncryptedInfo( + val nonce: ByteArray, + val ciphertext: ByteArray, + val mac: ByteArray, + ) { + companion object { + const val V: Int = 2 + + fun decodePayload(payload: String): EncryptedInfo? { + check(payload.length >= 132 || payload.length <= 87472) { + "Invalid payload length ${payload.length} for $payload" + } + check(payload[0] != '#') { "Unknown encryption version ${payload.get(0)}" } + + return try { + val byteArray = Base64.getDecoder().decode(payload) + check(byteArray[0].toInt() == V) + return EncryptedInfo( + nonce = byteArray.copyOfRange(1, 33), + ciphertext = byteArray.copyOfRange(33, byteArray.size - 32), + mac = byteArray.copyOfRange(byteArray.size - 32, byteArray.size), + ) + } catch (e: Exception) { + Log.w("NIP44v2", "Unable to Parse encrypted payload: $payload") + null + } + } + } + + fun encodePayload(): String { + return Base64.getEncoder() + .encodeToString( + byteArrayOf(V.toByte()) + nonce + ciphertext + mac, + ) + } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SharedKeyCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SharedKeyCache.kt index b96f4829a..dd39dec2a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SharedKeyCache.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SharedKeyCache.kt @@ -23,34 +23,34 @@ package com.vitorpamplona.quartz.crypto import android.util.LruCache class SharedKeyCache { - private val sharedKeyCache = LruCache(200) + private val sharedKeyCache = LruCache(200) - fun clearCache() { - sharedKeyCache.evictAll() - } + fun clearCache() { + sharedKeyCache.evictAll() + } - fun combinedHashCode( - a: ByteArray, - b: ByteArray, - ): Int { - var result = 1 - for (element in a) result = 31 * result + element - for (element in b) result = 31 * result + element - return result - } + fun combinedHashCode( + a: ByteArray, + b: ByteArray, + ): Int { + var result = 1 + for (element in a) result = 31 * result + element + for (element in b) result = 31 * result + element + return result + } - fun get( - privateKey: ByteArray, - pubKey: ByteArray, - ): ByteArray? { - return sharedKeyCache[combinedHashCode(privateKey, pubKey)] - } + fun get( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray? { + return sharedKeyCache[combinedHashCode(privateKey, pubKey)] + } - fun add( - privateKey: ByteArray, - pubKey: ByteArray, - secret: ByteArray, - ) { - sharedKeyCache.put(combinedHashCode(privateKey, pubKey), secret) - } + fun add( + privateKey: ByteArray, + pubKey: ByteArray, + secret: ByteArray, + ) { + sharedKeyCache.put(combinedHashCode(privateKey, pubKey), secret) + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SodiumUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SodiumUtils.kt index 4a603d055..3025b4aea 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SodiumUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SodiumUtils.kt @@ -28,78 +28,78 @@ import com.goterl.lazysodium.utils.Key * it. There is some native method overriding bug when using Kotlin extensions */ fun cryptoStreamXchacha20XorIc( - libSodium: SodiumAndroid, - cipher: ByteArray, - message: ByteArray, - messageLen: Long, - nonce: ByteArray, - ic: Long, - key: ByteArray, + libSodium: SodiumAndroid, + cipher: ByteArray, + message: ByteArray, + messageLen: Long, + nonce: ByteArray, + ic: Long, + key: ByteArray, ): Int { - /** - * C++ Code: - * - * unsigned char k2[crypto_core_hchacha20_OUTPUTBYTES]; crypto_core_hchacha20(k2, n, k, NULL); - * return crypto_stream_chacha20_xor_ic( c, m, mlen, n + crypto_core_hchacha20_INPUTBYTES, ic, - * k2); - */ - val k2 = ByteArray(32) + /** + * C++ Code: + * + * unsigned char k2[crypto_core_hchacha20_OUTPUTBYTES]; crypto_core_hchacha20(k2, n, k, NULL); + * return crypto_stream_chacha20_xor_ic( c, m, mlen, n + crypto_core_hchacha20_INPUTBYTES, ic, + * k2); + */ + val k2 = ByteArray(32) - val nonceChaCha = nonce.drop(16).toByteArray() - assert(nonceChaCha.size == 8) + val nonceChaCha = nonce.drop(16).toByteArray() + assert(nonceChaCha.size == 8) - libSodium.crypto_core_hchacha20(k2, nonce, key, null) - return libSodium.crypto_stream_chacha20_xor_ic( - cipher, - message, - messageLen, - nonceChaCha, - ic, - k2, - ) + libSodium.crypto_core_hchacha20(k2, nonce, key, null) + return libSodium.crypto_stream_chacha20_xor_ic( + cipher, + message, + messageLen, + nonceChaCha, + ic, + k2, + ) } fun cryptoStreamXchacha20Xor( - libSodium: SodiumAndroid, - cipher: ByteArray, - message: ByteArray, - messageLen: Long, - nonce: ByteArray, - key: ByteArray, + libSodium: SodiumAndroid, + cipher: ByteArray, + message: ByteArray, + messageLen: Long, + nonce: ByteArray, + key: ByteArray, ): Int { - return cryptoStreamXchacha20XorIc(libSodium, cipher, message, messageLen, nonce, 0, key) + return cryptoStreamXchacha20XorIc(libSodium, cipher, message, messageLen, nonce, 0, key) } fun cryptoStreamXChaCha20Xor( - libSodium: SodiumAndroid, - cipher: ByteArray, - message: ByteArray, - messageLen: Long, - nonce: ByteArray, - key: ByteArray, + libSodium: SodiumAndroid, + cipher: ByteArray, + message: ByteArray, + messageLen: Long, + nonce: ByteArray, + key: ByteArray, ): Boolean { - require(!(messageLen < 0 || messageLen > message.size)) { - "messageLen out of bounds: $messageLen" - } - return cryptoStreamXchacha20Xor( - libSodium, - cipher, - message, - messageLen, - nonce, - key, - ) == 0 + require(!(messageLen < 0 || messageLen > message.size)) { + "messageLen out of bounds: $messageLen" + } + return cryptoStreamXchacha20Xor( + libSodium, + cipher, + message, + messageLen, + nonce, + key, + ) == 0 } fun cryptoStreamXChaCha20Xor( - libSodium: SodiumAndroid, - messageBytes: ByteArray, - nonce: ByteArray, - key: Key, + libSodium: SodiumAndroid, + messageBytes: ByteArray, + nonce: ByteArray, + key: Key, ): ByteArray? { - val mLen = messageBytes.size - val cipher = ByteArray(mLen) - val successful = - cryptoStreamXChaCha20Xor(libSodium, cipher, messageBytes, mLen.toLong(), nonce, key.asBytes) - return if (successful) cipher else null + val mLen = messageBytes.size + val cipher = ByteArray(mLen) + val successful = + cryptoStreamXChaCha20Xor(libSodium, cipher, messageBytes, mLen.toLong(), nonce, key.asBytes) + return if (successful) cipher else null } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ATag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ATag.kt index 25f9165dd..9b2cec4fa 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ATag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ATag.kt @@ -25,72 +25,72 @@ import androidx.compose.runtime.Immutable @Immutable data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String, val relay: String?) { - fun toTag() = "$kind:$pubKeyHex:$dTag" + fun toTag() = "$kind:$pubKeyHex:$dTag" - fun toNAddr(): String { - return TlvBuilder() - .apply { - addString(Nip19.TlvTypes.SPECIAL, dTag) - addStringIfNotNull(Nip19.TlvTypes.RELAY, relay) - addHex(Nip19.TlvTypes.AUTHOR, pubKeyHex) - addInt(Nip19.TlvTypes.KIND, kind) - } - .build() - .toNAddress() - } - - companion object { - fun isATag(key: String): Boolean { - return key.startsWith("naddr1") || key.contains(":") + fun toNAddr(): String { + return TlvBuilder() + .apply { + addString(Nip19.TlvTypes.SPECIAL, dTag) + addStringIfNotNull(Nip19.TlvTypes.RELAY, relay) + addHex(Nip19.TlvTypes.AUTHOR, pubKeyHex) + addInt(Nip19.TlvTypes.KIND, kind) + } + .build() + .toNAddress() } - fun parse( - address: String, - relay: String?, - ): ATag? { - return if (address.startsWith("naddr") || address.startsWith("nostr:naddr")) { - parseNAddr(address) - } else { - parseAtag(address, relay) - } - } - - fun parseAtag( - atag: String, - relay: String?, - ): ATag? { - return try { - val parts = atag.split(":") - Hex.decode(parts[1]) - ATag(parts[0].toInt(), parts[1], parts[2], relay) - } catch (t: Throwable) { - Log.w("ATag", "Error parsing A Tag: $atag: ${t.message}") - null - } - } - - fun parseNAddr(naddr: String): ATag? { - try { - val key = naddr.removePrefix("nostr:") - - if (key.startsWith("naddr")) { - val tlv = Tlv.parse(key.bechToBytes()) - - val d = tlv.firstAsString(Nip19.TlvTypes.SPECIAL) ?: "" - val relay = tlv.firstAsString(Nip19.TlvTypes.RELAY) - val author = tlv.firstAsHex(Nip19.TlvTypes.AUTHOR) - val kind = tlv.firstAsInt(Nip19.TlvTypes.KIND) - - if (kind != null && author != null) { - return ATag(kind, author, d, relay) - } + companion object { + fun isATag(key: String): Boolean { + return key.startsWith("naddr1") || key.contains(":") } - } catch (e: Throwable) { - Log.w("ATag", "Issue trying to Decode NIP19 $this: ${e.message}") - // e.printStackTrace() - } - return null + fun parse( + address: String, + relay: String?, + ): ATag? { + return if (address.startsWith("naddr") || address.startsWith("nostr:naddr")) { + parseNAddr(address) + } else { + parseAtag(address, relay) + } + } + + fun parseAtag( + atag: String, + relay: String?, + ): ATag? { + return try { + val parts = atag.split(":") + Hex.decode(parts[1]) + ATag(parts[0].toInt(), parts[1], parts[2], relay) + } catch (t: Throwable) { + Log.w("ATag", "Error parsing A Tag: $atag: ${t.message}") + null + } + } + + fun parseNAddr(naddr: String): ATag? { + try { + val key = naddr.removePrefix("nostr:") + + if (key.startsWith("naddr")) { + val tlv = Tlv.parse(key.bechToBytes()) + + val d = tlv.firstAsString(Nip19.TlvTypes.SPECIAL) ?: "" + val relay = tlv.firstAsString(Nip19.TlvTypes.RELAY) + val author = tlv.firstAsHex(Nip19.TlvTypes.AUTHOR) + val kind = tlv.firstAsInt(Nip19.TlvTypes.KIND) + + if (kind != null && author != null) { + return ATag(kind, author, d, relay) + } + } + } catch (e: Throwable) { + Log.w("ATag", "Issue trying to Decode NIP19 $this: ${e.message}") + // e.printStackTrace() + } + + return null + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Bech32Util.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Bech32Util.kt index a4dcda64f..ff15a9e8e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Bech32Util.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Bech32Util.kt @@ -50,227 +50,227 @@ private typealias Int5 = Byte * https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki. */ object Bech32 { - const val ALPHABET: String = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" - const val ALPHABET_UPPERCASE: String = "QPZRY9X8GF2TVDW0S3JN54KHCE6MUA7L" + const val ALPHABET: String = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + const val ALPHABET_UPPERCASE: String = "QPZRY9X8GF2TVDW0S3JN54KHCE6MUA7L" - enum class Encoding(public val constant: Int) { - Bech32(1), - Bech32m(0x2bc830a3), - Beck32WithoutChecksum(0), - } - - // char -> 5 bits value - private val map = Array(255) { -1 } - - init { - for (i in 0..ALPHABET.lastIndex) { - map[ALPHABET[i].code] = i.toByte() - } - for (i in 0..ALPHABET_UPPERCASE.lastIndex) { - map[ALPHABET_UPPERCASE[i].code] = i.toByte() - } - } - - fun expand(hrp: String): Array { - val half = hrp.length + 1 - val size = half + hrp.length - return Array(size) { - when (it) { - in hrp.indices -> hrp[it].code.shr(5).toByte() - in half until size -> (hrp[it - half].code and 31).toByte() - else -> 0 - } - } - } - - private val GEN = arrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3) - - fun polymod( - values: Array, - values1: Array, - ): Int { - var chk = 1 - values.forEach { v -> - val b = chk shr 25 - chk = ((chk and 0x1ffffff) shl 5) xor v.toInt() - for (i in 0..4) { - if (((b shr i) and 1) != 0) chk = chk xor GEN[i] - } - } - values1.forEach { v -> - val b = chk shr 25 - chk = ((chk and 0x1ffffff) shl 5) xor v.toInt() - for (i in 0..4) { - if (((b shr i) and 1) != 0) chk = chk xor GEN[i] - } - } - return chk - } - - /** - * @param hrp human readable prefix - * @param int5s 5-bit data - * @param encoding encoding to use (bech32 or bech32m) - * @return hrp + data encoded as a Bech32 string - */ - @JvmStatic - public fun encode( - hrp: String, - int5s: ArrayList, - encoding: Encoding, - ): String { - require(hrp.lowercase() == hrp || hrp.uppercase() == hrp) { - "mixed case strings are not valid bech32 prefixes" - } - val dataWithChecksum = - when (encoding) { - Encoding.Beck32WithoutChecksum -> int5s - else -> addChecksum(hrp, int5s, encoding) - } - - val charArray = - CharArray(dataWithChecksum.size) { ALPHABET[dataWithChecksum[it].toInt()] }.concatToString() - - return hrp + "1" + charArray - } - - /** - * @param hrp human readable prefix - * @param data data to encode - * @param encoding encoding to use (bech32 or bech32m) - * @return hrp + data encoded as a Bech32 string - */ - @JvmStatic - public fun encodeBytes( - hrp: String, - data: ByteArray, - encoding: Encoding, - ): String = encode(hrp, eight2five(data), encoding) - - /** - * decodes a bech32 string - * - * @param bech32 bech32 string - * @param noChecksum if true, the bech32 string doesn't have a checksum - * @return a (hrp, data, encoding) tuple - */ - @JvmStatic - public fun decode( - bech32: String, - noChecksum: Boolean = false, - ): Triple, Encoding> { - var pos = 0 - bech32.forEachIndexed { index, char -> - require(char.code in 33..126) { "invalid character $char" } - if (char == '1') { - pos = index - } + enum class Encoding(public val constant: Int) { + Bech32(1), + Bech32m(0x2bc830a3), + Beck32WithoutChecksum(0), } - val hrp = bech32.take(pos).lowercase() // strings must be lower case - require(hrp.length in 1..83) { "hrp must contain 1 to 83 characters" } + // char -> 5 bits value + private val map = Array(255) { -1 } - val data = Array(bech32.length - pos - 1) { map[bech32[pos + 1 + it].code] } - - return if (noChecksum) { - Triple(hrp, data, Encoding.Beck32WithoutChecksum) - } else { - val encoding = - when (polymod(expand(hrp), data)) { - Encoding.Bech32.constant -> Encoding.Bech32 - Encoding.Bech32m.constant -> Encoding.Bech32m - else -> throw IllegalArgumentException("invalid checksum for $bech32") + init { + for (i in 0..ALPHABET.lastIndex) { + map[ALPHABET[i].code] = i.toByte() + } + for (i in 0..ALPHABET_UPPERCASE.lastIndex) { + map[ALPHABET_UPPERCASE[i].code] = i.toByte() } - Triple(hrp, data.copyOfRange(0, data.size - 6), encoding) - } - } - - /** - * decodes a bech32 string - * - * @param bech32 bech32 string - * @param noChecksum if true, the bech32 string doesn't have a checksum - * @return a (hrp, data, encoding) tuple - */ - @JvmStatic - public fun decodeBytes( - bech32: String, - noChecksum: Boolean = false, - ): Triple { - val (hrp, int5s, encoding) = decode(bech32, noChecksum) - return Triple(hrp, five2eight(int5s, 0), encoding) - } - - val ZEROS = arrayOf(0.toByte(), 0.toByte(), 0.toByte(), 0.toByte(), 0.toByte(), 0.toByte()) - - /** - * @param hrp Human Readable Part - * @param data data (a sequence of 5 bits integers) - * @param encoding encoding to use (bech32 or bech32m) - * @return a checksum computed over hrp and data - */ - private fun addChecksum( - hrp: String, - data: ArrayList, - encoding: Encoding, - ): ArrayList { - val values = expand(hrp) + data - val poly = polymod(values, ZEROS) xor encoding.constant - - for (i in 0 until 6) { - data.add((poly.shr(5 * (5 - i)) and 31).toByte()) } - return data - } - - /** - * @param input a sequence of 8 bits integers - * @return a sequence of 5 bits integers - */ - @JvmStatic - public fun eight2five(input: ByteArray): ArrayList { - var buffer = 0L - val output = - ArrayList(input.size * 2) // larger array on purpose. Checksum is added later. - var count = 0 - input.forEach { b -> - buffer = (buffer shl 8) or (b.toLong() and 0xff) - count += 8 - while (count >= 5) { - output.add(((buffer shr (count - 5)) and 31).toByte()) - count -= 5 - } + fun expand(hrp: String): Array { + val half = hrp.length + 1 + val size = half + hrp.length + return Array(size) { + when (it) { + in hrp.indices -> hrp[it].code.shr(5).toByte() + in half until size -> (hrp[it - half].code and 31).toByte() + else -> 0 + } + } } - if (count > 0) output.add(((buffer shl (5 - count)) and 31).toByte()) - return output - } - /** - * @param input a sequence of 5 bits integers - * @return a sequence of 8 bits integers - */ - @JvmStatic - public fun five2eight( - input: Array, - offset: Int, - ): ByteArray { - var buffer = 0L - val output = ArrayList(input.size) - var count = 0 - for (i in offset..input.lastIndex) { - val b = input[i] - buffer = (buffer shl 5) or (b.toLong() and 31) - count += 5 - while (count >= 8) { - output.add(((buffer shr (count - 8)) and 0xff).toByte()) - count -= 8 - } + private val GEN = arrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3) + + fun polymod( + values: Array, + values1: Array, + ): Int { + var chk = 1 + values.forEach { v -> + val b = chk shr 25 + chk = ((chk and 0x1ffffff) shl 5) xor v.toInt() + for (i in 0..4) { + if (((b shr i) and 1) != 0) chk = chk xor GEN[i] + } + } + values1.forEach { v -> + val b = chk shr 25 + chk = ((chk and 0x1ffffff) shl 5) xor v.toInt() + for (i in 0..4) { + if (((b shr i) and 1) != 0) chk = chk xor GEN[i] + } + } + return chk + } + + /** + * @param hrp human readable prefix + * @param int5s 5-bit data + * @param encoding encoding to use (bech32 or bech32m) + * @return hrp + data encoded as a Bech32 string + */ + @JvmStatic + public fun encode( + hrp: String, + int5s: ArrayList, + encoding: Encoding, + ): String { + require(hrp.lowercase() == hrp || hrp.uppercase() == hrp) { + "mixed case strings are not valid bech32 prefixes" + } + val dataWithChecksum = + when (encoding) { + Encoding.Beck32WithoutChecksum -> int5s + else -> addChecksum(hrp, int5s, encoding) + } + + val charArray = + CharArray(dataWithChecksum.size) { ALPHABET[dataWithChecksum[it].toInt()] }.concatToString() + + return hrp + "1" + charArray + } + + /** + * @param hrp human readable prefix + * @param data data to encode + * @param encoding encoding to use (bech32 or bech32m) + * @return hrp + data encoded as a Bech32 string + */ + @JvmStatic + public fun encodeBytes( + hrp: String, + data: ByteArray, + encoding: Encoding, + ): String = encode(hrp, eight2five(data), encoding) + + /** + * decodes a bech32 string + * + * @param bech32 bech32 string + * @param noChecksum if true, the bech32 string doesn't have a checksum + * @return a (hrp, data, encoding) tuple + */ + @JvmStatic + public fun decode( + bech32: String, + noChecksum: Boolean = false, + ): Triple, Encoding> { + var pos = 0 + bech32.forEachIndexed { index, char -> + require(char.code in 33..126) { "invalid character $char" } + if (char == '1') { + pos = index + } + } + + val hrp = bech32.take(pos).lowercase() // strings must be lower case + require(hrp.length in 1..83) { "hrp must contain 1 to 83 characters" } + + val data = Array(bech32.length - pos - 1) { map[bech32[pos + 1 + it].code] } + + return if (noChecksum) { + Triple(hrp, data, Encoding.Beck32WithoutChecksum) + } else { + val encoding = + when (polymod(expand(hrp), data)) { + Encoding.Bech32.constant -> Encoding.Bech32 + Encoding.Bech32m.constant -> Encoding.Bech32m + else -> throw IllegalArgumentException("invalid checksum for $bech32") + } + Triple(hrp, data.copyOfRange(0, data.size - 6), encoding) + } + } + + /** + * decodes a bech32 string + * + * @param bech32 bech32 string + * @param noChecksum if true, the bech32 string doesn't have a checksum + * @return a (hrp, data, encoding) tuple + */ + @JvmStatic + public fun decodeBytes( + bech32: String, + noChecksum: Boolean = false, + ): Triple { + val (hrp, int5s, encoding) = decode(bech32, noChecksum) + return Triple(hrp, five2eight(int5s, 0), encoding) + } + + val ZEROS = arrayOf(0.toByte(), 0.toByte(), 0.toByte(), 0.toByte(), 0.toByte(), 0.toByte()) + + /** + * @param hrp Human Readable Part + * @param data data (a sequence of 5 bits integers) + * @param encoding encoding to use (bech32 or bech32m) + * @return a checksum computed over hrp and data + */ + private fun addChecksum( + hrp: String, + data: ArrayList, + encoding: Encoding, + ): ArrayList { + val values = expand(hrp) + data + val poly = polymod(values, ZEROS) xor encoding.constant + + for (i in 0 until 6) { + data.add((poly.shr(5 * (5 - i)) and 31).toByte()) + } + + return data + } + + /** + * @param input a sequence of 8 bits integers + * @return a sequence of 5 bits integers + */ + @JvmStatic + public fun eight2five(input: ByteArray): ArrayList { + var buffer = 0L + val output = + ArrayList(input.size * 2) // larger array on purpose. Checksum is added later. + var count = 0 + input.forEach { b -> + buffer = (buffer shl 8) or (b.toLong() and 0xff) + count += 8 + while (count >= 5) { + output.add(((buffer shr (count - 5)) and 31).toByte()) + count -= 5 + } + } + if (count > 0) output.add(((buffer shl (5 - count)) and 31).toByte()) + return output + } + + /** + * @param input a sequence of 5 bits integers + * @return a sequence of 8 bits integers + */ + @JvmStatic + public fun five2eight( + input: Array, + offset: Int, + ): ByteArray { + var buffer = 0L + val output = ArrayList(input.size) + var count = 0 + for (i in offset..input.lastIndex) { + val b = input[i] + buffer = (buffer shl 5) or (b.toLong() and 31) + count += 5 + while (count >= 8) { + output.add(((buffer shr (count - 8)) and 0xff).toByte()) + count -= 8 + } + } + require(count <= 4) { "Zero-padding of more than 4 bits" } + require((buffer and ((1L shl count) - 1L)) == 0L) { "Non-zero padding in 8-to-5 conversion" } + return output.toByteArray() } - require(count <= 4) { "Zero-padding of more than 4 bits" } - require((buffer and ((1L shl count) - 1L)) == 0L) { "Non-zero padding in 8-to-5 conversion" } - return output.toByteArray() - } } fun ByteArray.toNsec() = Bech32.encodeBytes(hrp = "nsec", this, Bech32.Encoding.Bech32) @@ -286,11 +286,11 @@ fun ByteArray.toNAddress() = Bech32.encodeBytes(hrp = "naddr", this, Bech32.Enco fun ByteArray.toLnUrl() = Bech32.encodeBytes(hrp = "lnurl", this, Bech32.Encoding.Bech32) fun String.bechToBytes(hrp: String? = null): ByteArray { - val decodedForm = Bech32.decodeBytes(this) - hrp?.also { - if (it != decodedForm.first) { - throw IllegalArgumentException("Expected $it but obtained ${decodedForm.first}") + val decodedForm = Bech32.decodeBytes(this) + hrp?.also { + if (it != decodedForm.first) { + throw IllegalArgumentException("Expected $it but obtained ${decodedForm.first}") + } } - } - return decodedForm.second + return decodedForm.second } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/HexUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/HexUtils.kt index 6d546970d..a410453a8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/HexUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/HexUtils.kt @@ -24,92 +24,93 @@ package com.vitorpamplona.quartz.encoders typealias HexKey = String fun ByteArray.toHexKey(): HexKey { - return Hex.encode(this) + return Hex.encode(this) } fun HexKey.hexToByteArray(): ByteArray { - return Hex.decode(this) + return Hex.decode(this) } object HexValidator { - private fun isHexChar(c: Char): Boolean { - return when (c) { - '0', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - 'a', - 'b', - 'c', - 'd', - 'e', - 'f', - 'A', - 'B', - 'C', - 'D', - 'E', - 'F', -> true - else -> false + private fun isHexChar(c: Char): Boolean { + return when (c) { + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + -> true + else -> false + } } - } - fun isHex(hex: String?): Boolean { - if (hex == null) return false - if (hex.length % 2 != 0) return false // must be even - var isHex = true + fun isHex(hex: String?): Boolean { + if (hex == null) return false + if (hex.length % 2 != 0) return false // must be even + var isHex = true - for (c in hex) { - if (!isHexChar(c)) { - isHex = false - break - } + for (c in hex) { + if (!isHexChar(c)) { + isHex = false + break + } + } + return isHex } - return isHex - } } object Hex { - val hexCode = - arrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f') + val hexCode = + arrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f') - // Faster if no calculations are needed. - private fun hexToBin(ch: Char): Int = - when (ch) { - in '0'..'9' -> ch - '0' - in 'a'..'f' -> ch - 'a' + 10 - in 'A'..'F' -> ch - 'A' + 10 - else -> throw IllegalArgumentException("illegal hex character: $ch") + // Faster if no calculations are needed. + private fun hexToBin(ch: Char): Int = + when (ch) { + in '0'..'9' -> ch - '0' + in 'a'..'f' -> ch - 'a' + 10 + in 'A'..'F' -> ch - 'A' + 10 + else -> throw IllegalArgumentException("illegal hex character: $ch") + } + + @JvmStatic + fun decode(hex: String): ByteArray { + // faster version of hex decoder + require(hex.length % 2 == 0) + val outSize = hex.length / 2 + val out = ByteArray(outSize) + + for (i in 0 until outSize) { + out[i] = (hexToBin(hex[2 * i]) * 16 + hexToBin(hex[2 * i + 1])).toByte() + } + + return out } - @JvmStatic - fun decode(hex: String): ByteArray { - // faster version of hex decoder - require(hex.length % 2 == 0) - val outSize = hex.length / 2 - val out = ByteArray(outSize) - - for (i in 0 until outSize) { - out[i] = (hexToBin(hex[2 * i]) * 16 + hexToBin(hex[2 * i + 1])).toByte() + @JvmStatic + fun encode(input: ByteArray): String { + val len = input.size + val out = CharArray(len * 2) + for (i in 0 until len) { + out[i * 2] = hexCode[(input[i].toInt() shr 4) and 0xF] + out[i * 2 + 1] = hexCode[input[i].toInt() and 0xF] + } + return String(out) } - - return out - } - - @JvmStatic - fun encode(input: ByteArray): String { - val len = input.size - val out = CharArray(len * 2) - for (i in 0 until len) { - out[i * 2] = hexCode[(input[i].toInt() shr 4) and 0xF] - out[i * 2 + 1] = hexCode[input[i].toInt() and 0xF] - } - return String(out) - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnInvoiceUtil.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnInvoiceUtil.kt index 0f5ffb3c4..986020854 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnInvoiceUtil.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnInvoiceUtil.kt @@ -26,305 +26,305 @@ import java.util.regex.Pattern /** based on litecoinj */ object LnInvoiceUtil { - private val invoicePattern = - Pattern.compile("lnbc((\\d+)([munp])?)?1[^1\\s]+", Pattern.CASE_INSENSITIVE) + private val invoicePattern = + Pattern.compile("lnbc((\\d+)([munp])?)?1[^1\\s]+", Pattern.CASE_INSENSITIVE) - /** The Bech32 character set for encoding. */ - private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + /** The Bech32 character set for encoding. */ + private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" - /** The Bech32 character set for decoding. */ - private val CHARSET_REV = - byteArrayOf( - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - 15, - -1, - 10, - 17, - 21, - 20, - 26, - 30, - 7, - 5, - -1, - -1, - -1, - -1, - -1, - -1, - -1, - 29, - -1, - 24, - 13, - 25, - 9, - 8, - 23, - -1, - 18, - 22, - 31, - 27, - 19, - -1, - 1, - 0, - 3, - 16, - 11, - 28, - 12, - 14, - 6, - 4, - 2, - -1, - -1, - -1, - -1, - -1, - -1, - 29, - -1, - 24, - 13, - 25, - 9, - 8, - 23, - -1, - 18, - 22, - 31, - 27, - 19, - -1, - 1, - 0, - 3, - 16, - 11, - 28, - 12, - 14, - 6, - 4, - 2, - -1, - -1, - -1, - -1, - -1, - ) + /** The Bech32 character set for decoding. */ + private val CHARSET_REV = + byteArrayOf( + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 15, + -1, + 10, + 17, + 21, + 20, + 26, + 30, + 7, + 5, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 29, + -1, + 24, + 13, + 25, + 9, + 8, + 23, + -1, + 18, + 22, + 31, + 27, + 19, + -1, + 1, + 0, + 3, + 16, + 11, + 28, + 12, + 14, + 6, + 4, + 2, + -1, + -1, + -1, + -1, + -1, + -1, + 29, + -1, + 24, + 13, + 25, + 9, + 8, + 23, + -1, + 18, + 22, + 31, + 27, + 19, + -1, + 1, + 0, + 3, + 16, + 11, + 28, + 12, + 14, + 6, + 4, + 2, + -1, + -1, + -1, + -1, + -1, + ) - /** Find the polynomial with value coefficients mod the generator as 30-bit. */ - private fun polymod(values: ByteArray): Int { - var c = 1 - for (v_i in values) { - val c0 = c ushr 25 and 0xff - c = c and 0x1ffffff shl 5 xor (v_i.toInt() and 0xff) - if (c0 and 1 != 0) c = c xor 0x3b6a57b2 - if (c0 and 2 != 0) c = c xor 0x26508e6d - if (c0 and 4 != 0) c = c xor 0x1ea119fa - if (c0 and 8 != 0) c = c xor 0x3d4233dd - if (c0 and 16 != 0) c = c xor 0x2a1462b3 - } - return c - } - - /** Expand a HRP for use in checksum computation. */ - private fun expandHrp(hrp: String): ByteArray { - val hrpLength = hrp.length - val ret = ByteArray(hrpLength * 2 + 1) - for (i in 0 until hrpLength) { - val c = hrp[i].code and 0x7f // Limit to standard 7-bit ASCII - ret[i] = (c ushr 5 and 0x07).toByte() - ret[i + hrpLength + 1] = (c and 0x1f).toByte() - } - ret[hrpLength] = 0 - return ret - } - - /** Verify a checksum. */ - private fun verifyChecksum( - hrp: String, - values: ByteArray, - ): Boolean { - val hrpExpanded: ByteArray = expandHrp(hrp) - val combined = ByteArray(hrpExpanded.size + values.size) - System.arraycopy(hrpExpanded, 0, combined, 0, hrpExpanded.size) - System.arraycopy(values, 0, combined, hrpExpanded.size, values.size) - return polymod(combined) == 1 - } - - class AddressFormatException(message: String) : Exception(message) - - fun decodeUnlimitedLength(invoice: String): Boolean { - var lower = false - var upper = false - for (i in 0 until invoice.length) { - val c = invoice[i] - if (c.code < 33 || c.code > 126) { - throw AddressFormatException("Invalid character: $c, pos: $i") - } - if (c in 'a'..'z') { - if (upper) throw AddressFormatException("Invalid character: $c, pos: $i") - lower = true - } - if (c in 'A'..'Z') { - if (lower) throw AddressFormatException("Invalid character: $c, pos: $i") - upper = true - } - } - val pos = invoice.lastIndexOf('1') - if (pos < 1) throw AddressFormatException("Missing human-readable part") - val dataPartLength = invoice.length - 1 - pos - if (dataPartLength < 6) throw AddressFormatException("Data part too short: $dataPartLength") - val values = ByteArray(dataPartLength) - for (i in 0 until dataPartLength) { - val c = invoice[i + pos + 1] - if (CHARSET_REV.get(c.code).toInt() == -1) { - throw AddressFormatException("Invalid character: " + c + ", pos: " + (i + pos + 1)) - } - values[i] = CHARSET_REV.get(c.code) - } - val hrp = invoice.substring(0, pos).lowercase(Locale.ROOT) - if (!verifyChecksum(hrp, values)) throw AddressFormatException("Invalid Checksum") - return true - } - - /** - * Parses invoice amount according to - * https://github.com/lightningnetwork/lightning-rfc/blob/master/11-payment-encoding.md#human-readable-part - * - * @return invoice amount in bitcoins, zero if the invoice has no amount - * @throws RuntimeException if invoice format is incorrect - */ - private fun getAmount(invoice: String): BigDecimal { - try { - decodeUnlimitedLength(invoice) // checksum must match - } catch (e: AddressFormatException) { - throw IllegalArgumentException("Cannot decode invoice: $invoice", e) + /** Find the polynomial with value coefficients mod the generator as 30-bit. */ + private fun polymod(values: ByteArray): Int { + var c = 1 + for (v_i in values) { + val c0 = c ushr 25 and 0xff + c = c and 0x1ffffff shl 5 xor (v_i.toInt() and 0xff) + if (c0 and 1 != 0) c = c xor 0x3b6a57b2 + if (c0 and 2 != 0) c = c xor 0x26508e6d + if (c0 and 4 != 0) c = c xor 0x1ea119fa + if (c0 and 8 != 0) c = c xor 0x3d4233dd + if (c0 and 16 != 0) c = c xor 0x2a1462b3 + } + return c } - val matcher = invoicePattern.matcher(invoice) - require(matcher.matches()) { "Failed to match HRP pattern" } - val amountGroup = matcher.group(2) - val multiplierGroup = matcher.group(3) - if (amountGroup == null) { - return BigDecimal.ZERO + /** Expand a HRP for use in checksum computation. */ + private fun expandHrp(hrp: String): ByteArray { + val hrpLength = hrp.length + val ret = ByteArray(hrpLength * 2 + 1) + for (i in 0 until hrpLength) { + val c = hrp[i].code and 0x7f // Limit to standard 7-bit ASCII + ret[i] = (c ushr 5 and 0x07).toByte() + ret[i + hrpLength + 1] = (c and 0x1f).toByte() + } + ret[hrpLength] = 0 + return ret } - val amount = BigDecimal(amountGroup) - if (multiplierGroup == null) { - return amount - } - require(!(multiplierGroup == "p" && amountGroup[amountGroup.length - 1] != '0')) { - "sub-millisatoshi amount" - } - return amount.multiply(multiplier(multiplierGroup)) - } - val OneHundredK = BigDecimal(100000000) - val OneMili = BigDecimal("0.001") - val OneMicro = BigDecimal("0.000001") - val OneNano = BigDecimal("0.000000001") - val OnePico = BigDecimal("0.000000000001") + /** Verify a checksum. */ + private fun verifyChecksum( + hrp: String, + values: ByteArray, + ): Boolean { + val hrpExpanded: ByteArray = expandHrp(hrp) + val combined = ByteArray(hrpExpanded.size + values.size) + System.arraycopy(hrpExpanded, 0, combined, 0, hrpExpanded.size) + System.arraycopy(values, 0, combined, hrpExpanded.size, values.size) + return polymod(combined) == 1 + } - fun getAmountInSats(invoice: String): BigDecimal { - return getAmount(invoice).multiply(OneHundredK) - } + class AddressFormatException(message: String) : Exception(message) - private fun multiplier(multiplier: String): BigDecimal { - return when (multiplier.lowercase()) { - "m" -> OneMili - "u" -> OneMicro - "n" -> OneNano - "p" -> OnePico - else -> throw IllegalArgumentException("Invalid multiplier: $multiplier") + fun decodeUnlimitedLength(invoice: String): Boolean { + var lower = false + var upper = false + for (i in 0 until invoice.length) { + val c = invoice[i] + if (c.code < 33 || c.code > 126) { + throw AddressFormatException("Invalid character: $c, pos: $i") + } + if (c in 'a'..'z') { + if (upper) throw AddressFormatException("Invalid character: $c, pos: $i") + lower = true + } + if (c in 'A'..'Z') { + if (lower) throw AddressFormatException("Invalid character: $c, pos: $i") + upper = true + } + } + val pos = invoice.lastIndexOf('1') + if (pos < 1) throw AddressFormatException("Missing human-readable part") + val dataPartLength = invoice.length - 1 - pos + if (dataPartLength < 6) throw AddressFormatException("Data part too short: $dataPartLength") + val values = ByteArray(dataPartLength) + for (i in 0 until dataPartLength) { + val c = invoice[i + pos + 1] + if (CHARSET_REV.get(c.code).toInt() == -1) { + throw AddressFormatException("Invalid character: " + c + ", pos: " + (i + pos + 1)) + } + values[i] = CHARSET_REV.get(c.code) + } + val hrp = invoice.substring(0, pos).lowercase(Locale.ROOT) + if (!verifyChecksum(hrp, values)) throw AddressFormatException("Invalid Checksum") + return true } - } - /** - * Finds LN invoice in the provided input string and returns it. For example for input = "aaa bbb - * lnbc1xxx ccc" it will return "lnbc1xxx" It will only return the first invoice found in the - * input. - * - * @return the invoice if it was found. null for null input or if no invoice is found - */ - fun findInvoice(input: String?): String? { - if (input == null) { - return null - } - val matcher = invoicePattern.matcher(input) - return if (matcher.find()) { - matcher.group() - } else { - null - } - } + /** + * Parses invoice amount according to + * https://github.com/lightningnetwork/lightning-rfc/blob/master/11-payment-encoding.md#human-readable-part + * + * @return invoice amount in bitcoins, zero if the invoice has no amount + * @throws RuntimeException if invoice format is incorrect + */ + private fun getAmount(invoice: String): BigDecimal { + try { + decodeUnlimitedLength(invoice) // checksum must match + } catch (e: AddressFormatException) { + throw IllegalArgumentException("Cannot decode invoice: $invoice", e) + } - /** - * If the string contains an LN invoice, returns a Pair of the start and end positions of the - * invoice in the string. Otherwise, returns (0, 0). This is used to ensure we don't accidentally - * cut an invoice in the middle when taking only a portion of the available text. - */ - fun locateInvoice(input: String?): Pair { - if (input == null) { - return Pair(0, 0) + val matcher = invoicePattern.matcher(invoice) + require(matcher.matches()) { "Failed to match HRP pattern" } + val amountGroup = matcher.group(2) + val multiplierGroup = matcher.group(3) + if (amountGroup == null) { + return BigDecimal.ZERO + } + val amount = BigDecimal(amountGroup) + if (multiplierGroup == null) { + return amount + } + require(!(multiplierGroup == "p" && amountGroup[amountGroup.length - 1] != '0')) { + "sub-millisatoshi amount" + } + return amount.multiply(multiplier(multiplierGroup)) } - val matcher = invoicePattern.matcher(input) - return if (matcher.find()) { - Pair(matcher.start(), matcher.end()) - } else { - Pair(0, 0) + + val OneHundredK = BigDecimal(100000000) + val OneMili = BigDecimal("0.001") + val OneMicro = BigDecimal("0.000001") + val OneNano = BigDecimal("0.000000001") + val OnePico = BigDecimal("0.000000000001") + + fun getAmountInSats(invoice: String): BigDecimal { + return getAmount(invoice).multiply(OneHundredK) + } + + private fun multiplier(multiplier: String): BigDecimal { + return when (multiplier.lowercase()) { + "m" -> OneMili + "u" -> OneMicro + "n" -> OneNano + "p" -> OnePico + else -> throw IllegalArgumentException("Invalid multiplier: $multiplier") + } + } + + /** + * Finds LN invoice in the provided input string and returns it. For example for input = "aaa bbb + * lnbc1xxx ccc" it will return "lnbc1xxx" It will only return the first invoice found in the + * input. + * + * @return the invoice if it was found. null for null input or if no invoice is found + */ + fun findInvoice(input: String?): String? { + if (input == null) { + return null + } + val matcher = invoicePattern.matcher(input) + return if (matcher.find()) { + matcher.group() + } else { + null + } + } + + /** + * If the string contains an LN invoice, returns a Pair of the start and end positions of the + * invoice in the string. Otherwise, returns (0, 0). This is used to ensure we don't accidentally + * cut an invoice in the middle when taking only a portion of the available text. + */ + fun locateInvoice(input: String?): Pair { + if (input == null) { + return Pair(0, 0) + } + val matcher = invoicePattern.matcher(input) + return if (matcher.find()) { + Pair(matcher.start(), matcher.end()) + } else { + Pair(0, 0) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnWithdrawalUtil.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnWithdrawalUtil.kt index ead017ac0..0d88d2d41 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnWithdrawalUtil.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnWithdrawalUtil.kt @@ -23,28 +23,28 @@ package com.vitorpamplona.quartz.encoders import java.util.regex.Pattern object LnWithdrawalUtil { - private val withdrawalPattern = - Pattern.compile( - "lnurl1[02-9ac-hj-np-z]+", - Pattern.CASE_INSENSITIVE, - ) + private val withdrawalPattern = + Pattern.compile( + "lnurl1[02-9ac-hj-np-z]+", + Pattern.CASE_INSENSITIVE, + ) - /** - * Finds LN withdrawal in the provided input string and returns it. For example for input = "aaa - * bbb lnbc1xxx ccc" it will return "lnbc1xxx" It will only return the first withdrawal found in - * the input. - * - * @return the invoice if it was found. null for null input or if no invoice is found - */ - fun findWithdrawal(input: String?): String? { - if (input == null) { - return null + /** + * Finds LN withdrawal in the provided input string and returns it. For example for input = "aaa + * bbb lnbc1xxx ccc" it will return "lnbc1xxx" It will only return the first withdrawal found in + * the input. + * + * @return the invoice if it was found. null for null input or if no invoice is found + */ + fun findWithdrawal(input: String?): String? { + if (input == null) { + return null + } + val matcher = withdrawalPattern.matcher(input) + return if (matcher.find()) { + matcher.group() + } else { + null + } } - val matcher = withdrawalPattern.matcher(input) - return if (matcher.find()) { - matcher.group() - } else { - null - } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Lud06.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Lud06.kt index 978c8648c..f19556812 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Lud06.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Lud06.kt @@ -26,30 +26,30 @@ import java.util.regex.Pattern val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)") class Lud06 { - fun toLud16(str: String): String? { - return try { - val url = toLnUrlp(str) + fun toLud16(str: String): String? { + return try { + val url = toLnUrlp(str) - val matcher = lnurlpPattern.matcher(url) - matcher.find() - val domain = matcher.group(2) - val username = matcher.group(3) + val matcher = lnurlpPattern.matcher(url) + matcher.find() + val domain = matcher.group(2) + val username = matcher.group(3) - "$username@$domain" - } catch (t: Throwable) { - t.printStackTrace() - Log.w("Lud06ToLud16", "Fail to convert LUD06 to LUD16", t) - null + "$username@$domain" + } catch (t: Throwable) { + t.printStackTrace() + Log.w("Lud06ToLud16", "Fail to convert LUD06 to LUD16", t) + null + } } - } - fun toLnUrlp(str: String): String? { - return try { - String(Bech32.decodeBytes(str, false).second) - } catch (t: Throwable) { - t.printStackTrace() - Log.w("Lud06ToLud16", "Fail to convert LUD06 to LUD16", t) - null + fun toLnUrlp(str: String): String? { + return try { + String(Bech32.decodeBytes(str, false).second) + } catch (t: Throwable) { + t.printStackTrace() + Log.w("Lud06ToLud16", "Fail to convert LUD06 to LUD16", t) + null + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19.kt index 682e024e3..7e1d2418e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19.kt @@ -26,205 +26,205 @@ import com.vitorpamplona.quartz.crypto.KeyPair import java.util.regex.Pattern object Nip19 { - enum class Type { - USER, - NOTE, - EVENT, - RELAY, - ADDRESS, - } + enum class Type { + USER, + NOTE, + EVENT, + RELAY, + ADDRESS, + } - enum class TlvTypes(val id: Byte) { - SPECIAL(0), - RELAY(1), - AUTHOR(2), - KIND(3), - } + enum class TlvTypes(val id: Byte) { + SPECIAL(0), + RELAY(1), + AUTHOR(2), + KIND(3), + } - val nip19regex = - Pattern.compile( - "(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", - Pattern.CASE_INSENSITIVE, + val nip19regex = + Pattern.compile( + "(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", + Pattern.CASE_INSENSITIVE, + ) + + @Immutable + data class Return( + val type: Type, + val hex: String, + val relay: String? = null, + val author: String? = null, + val kind: Int? = null, + val additionalChars: String = "", ) - @Immutable - data class Return( - val type: Type, - val hex: String, - val relay: String? = null, - val author: String? = null, - val kind: Int? = null, - val additionalChars: String = "", - ) + fun uriToRoute(uri: String?): Return? { + if (uri == null) return null - fun uriToRoute(uri: String?): Return? { - if (uri == null) return null + try { + val matcher = nip19regex.matcher(uri) + if (!matcher.find()) { + return null + } - try { - val matcher = nip19regex.matcher(uri) - if (!matcher.find()) { - return null - } + val uriScheme = matcher.group(1) // nostr: + val type = matcher.group(2) // npub1 + val key = matcher.group(3) // bech32 + val additionalChars = matcher.group(4) // additional chars - val uriScheme = matcher.group(1) // nostr: - val type = matcher.group(2) // npub1 - val key = matcher.group(3) // bech32 - val additionalChars = matcher.group(4) // additional chars - - return parseComponents(uriScheme, type, key, additionalChars) - } catch (e: Throwable) { - Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e) - } - - return null - } - - fun parseComponents( - uriScheme: String?, - type: String, - key: String?, - additionalChars: String?, - ): Return? { - return try { - val bytes = (type + key).bechToBytes() - val parsed = - when (type.lowercase()) { - "npub1" -> npub(bytes) - "note1" -> note(bytes) - "nprofile1" -> nprofile(bytes) - "nevent1" -> nevent(bytes) - "nrelay1" -> nrelay(bytes) - "naddr1" -> naddr(bytes) - else -> null + return parseComponents(uriScheme, type, key, additionalChars) + } catch (e: Throwable) { + Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e) } - parsed?.copy(additionalChars = additionalChars ?: "") - } catch (e: Throwable) { - Log.w("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e) - null + + return null } - } - private fun npub(bytes: ByteArray): Return { - return Return(Type.USER, bytes.toHexKey()) - } + fun parseComponents( + uriScheme: String?, + type: String, + key: String?, + additionalChars: String?, + ): Return? { + return try { + val bytes = (type + key).bechToBytes() + val parsed = + when (type.lowercase()) { + "npub1" -> npub(bytes) + "note1" -> note(bytes) + "nprofile1" -> nprofile(bytes) + "nevent1" -> nevent(bytes) + "nrelay1" -> nrelay(bytes) + "naddr1" -> naddr(bytes) + else -> null + } + parsed?.copy(additionalChars = additionalChars ?: "") + } catch (e: Throwable) { + Log.w("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e) + null + } + } - private fun note(bytes: ByteArray): Return { - return Return(Type.NOTE, bytes.toHexKey()) - } + private fun npub(bytes: ByteArray): Return { + return Return(Type.USER, bytes.toHexKey()) + } - private fun nprofile(bytes: ByteArray): Return? { - val tlv = Tlv.parse(bytes) + private fun note(bytes: ByteArray): Return { + return Return(Type.NOTE, bytes.toHexKey()) + } - val hex = tlv.firstAsHex(TlvTypes.SPECIAL) ?: return null - val relay = tlv.firstAsString(TlvTypes.RELAY) + private fun nprofile(bytes: ByteArray): Return? { + val tlv = Tlv.parse(bytes) - return Return(Type.USER, hex, relay) - } + val hex = tlv.firstAsHex(TlvTypes.SPECIAL) ?: return null + val relay = tlv.firstAsString(TlvTypes.RELAY) - private fun nevent(bytes: ByteArray): Return? { - val tlv = Tlv.parse(bytes) + return Return(Type.USER, hex, relay) + } - val hex = tlv.firstAsHex(TlvTypes.SPECIAL) ?: return null - val relay = tlv.firstAsString(TlvTypes.RELAY) - val author = tlv.firstAsHex(TlvTypes.AUTHOR) - val kind = tlv.firstAsInt(TlvTypes.KIND.id) + private fun nevent(bytes: ByteArray): Return? { + val tlv = Tlv.parse(bytes) - return Return(Type.EVENT, hex, relay, author, kind) - } + val hex = tlv.firstAsHex(TlvTypes.SPECIAL) ?: return null + val relay = tlv.firstAsString(TlvTypes.RELAY) + val author = tlv.firstAsHex(TlvTypes.AUTHOR) + val kind = tlv.firstAsInt(TlvTypes.KIND.id) - private fun nrelay(bytes: ByteArray): Return? { - val relayUrl = Tlv.parse(bytes).firstAsString(TlvTypes.SPECIAL.id) ?: return null + return Return(Type.EVENT, hex, relay, author, kind) + } - return Return(Type.RELAY, relayUrl) - } + private fun nrelay(bytes: ByteArray): Return? { + val relayUrl = Tlv.parse(bytes).firstAsString(TlvTypes.SPECIAL.id) ?: return null - private fun naddr(bytes: ByteArray): Return? { - val tlv = Tlv.parse(bytes) + return Return(Type.RELAY, relayUrl) + } - val d = tlv.firstAsString(TlvTypes.SPECIAL.id) ?: "" - val relay = tlv.firstAsString(TlvTypes.RELAY.id) - val author = tlv.firstAsHex(TlvTypes.AUTHOR.id) ?: return null - val kind = tlv.firstAsInt(TlvTypes.KIND.id) ?: return null + private fun naddr(bytes: ByteArray): Return? { + val tlv = Tlv.parse(bytes) - return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind) - } + val d = tlv.firstAsString(TlvTypes.SPECIAL.id) ?: "" + val relay = tlv.firstAsString(TlvTypes.RELAY.id) + val author = tlv.firstAsHex(TlvTypes.AUTHOR.id) ?: return null + val kind = tlv.firstAsInt(TlvTypes.KIND.id) ?: return null - public fun createNEvent( - idHex: String, - author: String?, - kind: Int?, - relay: String?, - ): String { - return TlvBuilder() - .apply { - addHex(TlvTypes.SPECIAL, idHex) - addStringIfNotNull(TlvTypes.RELAY, relay) - addHexIfNotNull(TlvTypes.AUTHOR, author) - addIntIfNotNull(TlvTypes.KIND, kind) - } - .build() - .toNEvent() - } + return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind) + } + + public fun createNEvent( + idHex: String, + author: String?, + kind: Int?, + relay: String?, + ): String { + return TlvBuilder() + .apply { + addHex(TlvTypes.SPECIAL, idHex) + addStringIfNotNull(TlvTypes.RELAY, relay) + addHexIfNotNull(TlvTypes.AUTHOR, author) + addIntIfNotNull(TlvTypes.KIND, kind) + } + .build() + .toNEvent() + } } fun decodePublicKey(key: String): ByteArray { - val parsed = Nip19.uriToRoute(key) - val pubKeyParsed = parsed?.hex?.hexToByteArray() + val parsed = Nip19.uriToRoute(key) + val pubKeyParsed = parsed?.hex?.hexToByteArray() - return if (key.startsWith("nsec")) { - KeyPair(privKey = key.bechToBytes()).pubKey - } else if (pubKeyParsed != null) { - pubKeyParsed - } else { - Hex.decode(key) - } + return if (key.startsWith("nsec")) { + KeyPair(privKey = key.bechToBytes()).pubKey + } else if (pubKeyParsed != null) { + pubKeyParsed + } else { + Hex.decode(key) + } } fun decodePublicKeyAsHexOrNull(key: String): HexKey? { - return try { - val parsed = Nip19.uriToRoute(key) - val pubKeyParsed = parsed?.hex + return try { + val parsed = Nip19.uriToRoute(key) + val pubKeyParsed = parsed?.hex - if (key.startsWith("nsec")) { - KeyPair(privKey = key.bechToBytes()).pubKey.toHexKey() - } else if (pubKeyParsed != null) { - pubKeyParsed - } else { - Hex.decode(key).toHexKey() + if (key.startsWith("nsec")) { + KeyPair(privKey = key.bechToBytes()).pubKey.toHexKey() + } else if (pubKeyParsed != null) { + pubKeyParsed + } else { + Hex.decode(key).toHexKey() + } + } catch (e: Exception) { + null } - } catch (e: Exception) { - null - } } fun TlvBuilder.addString( - type: Nip19.TlvTypes, - string: String, + type: Nip19.TlvTypes, + string: String, ) = addString(type.id, string) fun TlvBuilder.addHex( - type: Nip19.TlvTypes, - key: HexKey, + type: Nip19.TlvTypes, + key: HexKey, ) = addHex(type.id, key) fun TlvBuilder.addInt( - type: Nip19.TlvTypes, - data: Int, + type: Nip19.TlvTypes, + data: Int, ) = addInt(type.id, data) fun TlvBuilder.addStringIfNotNull( - type: Nip19.TlvTypes, - data: String?, + type: Nip19.TlvTypes, + data: String?, ) = addStringIfNotNull(type.id, data) fun TlvBuilder.addHexIfNotNull( - type: Nip19.TlvTypes, - data: HexKey?, + type: Nip19.TlvTypes, + data: HexKey?, ) = addHexIfNotNull(type.id, data) fun TlvBuilder.addIntIfNotNull( - type: Nip19.TlvTypes, - data: Int?, + type: Nip19.TlvTypes, + data: Int?, ) = addIntIfNotNull(type.id, data) fun Tlv.firstAsInt(type: Nip19.TlvTypes) = firstAsInt(type.id) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Tlv.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Tlv.kt index 28047e53a..e99a673bb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Tlv.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Tlv.kt @@ -25,92 +25,92 @@ import java.nio.ByteBuffer import java.nio.ByteOrder class TlvBuilder() { - val outputStream = ByteArrayOutputStream() + val outputStream = ByteArrayOutputStream() - private fun add( - type: Byte, - byteArray: ByteArray, - ) { - outputStream.write(byteArrayOf(type, byteArray.size.toByte())) - outputStream.write(byteArray) - } + private fun add( + type: Byte, + byteArray: ByteArray, + ) { + outputStream.write(byteArrayOf(type, byteArray.size.toByte())) + outputStream.write(byteArray) + } - fun addString( - type: Byte, - string: String, - ) = add(type, string.toByteArray(Charsets.UTF_8)) + fun addString( + type: Byte, + string: String, + ) = add(type, string.toByteArray(Charsets.UTF_8)) - fun addHex( - type: Byte, - key: HexKey, - ) = add(type, key.hexToByteArray()) + fun addHex( + type: Byte, + key: HexKey, + ) = add(type, key.hexToByteArray()) - fun addInt( - type: Byte, - data: Int, - ) = add(type, data.to32BitByteArray()) + fun addInt( + type: Byte, + data: Int, + ) = add(type, data.to32BitByteArray()) - fun addStringIfNotNull( - type: Byte, - data: String?, - ) = data?.let { addString(type, it) } + fun addStringIfNotNull( + type: Byte, + data: String?, + ) = data?.let { addString(type, it) } - fun addHexIfNotNull( - type: Byte, - data: HexKey?, - ) = data?.let { addHex(type, it) } + fun addHexIfNotNull( + type: Byte, + data: HexKey?, + ) = data?.let { addHex(type, it) } - fun addIntIfNotNull( - type: Byte, - data: Int?, - ) = data?.let { addInt(type, it) } + fun addIntIfNotNull( + type: Byte, + data: Int?, + ) = data?.let { addInt(type, it) } - fun build(): ByteArray { - return outputStream.toByteArray() - } + fun build(): ByteArray { + return outputStream.toByteArray() + } } fun Int.to32BitByteArray(): ByteArray { - val bytes = ByteArray(4) - (0..3).forEach { bytes[3 - it] = ((this ushr (8 * it)) and 0xFFFF).toByte() } - return bytes + val bytes = ByteArray(4) + (0..3).forEach { bytes[3 - it] = ((this ushr (8 * it)) and 0xFFFF).toByte() } + return bytes } fun ByteArray.toInt32(): Int? { - if (size != 4) return null - return ByteBuffer.wrap(this, 0, 4).order(ByteOrder.BIG_ENDIAN).int + if (size != 4) return null + return ByteBuffer.wrap(this, 0, 4).order(ByteOrder.BIG_ENDIAN).int } class Tlv(val data: Map>) { - fun asInt(type: Byte) = data[type]?.mapNotNull { it.toInt32() } + fun asInt(type: Byte) = data[type]?.mapNotNull { it.toInt32() } - fun asHex(type: Byte) = data[type]?.map { it.toHexKey().intern() } + fun asHex(type: Byte) = data[type]?.map { it.toHexKey().intern() } - fun asString(type: Byte) = data[type]?.map { it.toString(Charsets.UTF_8) } + fun asString(type: Byte) = data[type]?.map { it.toString(Charsets.UTF_8) } - fun firstAsInt(type: Byte) = data[type]?.firstOrNull()?.toInt32() + fun firstAsInt(type: Byte) = data[type]?.firstOrNull()?.toInt32() - fun firstAsHex(type: Byte) = data[type]?.firstOrNull()?.toHexKey()?.intern() + fun firstAsHex(type: Byte) = data[type]?.firstOrNull()?.toHexKey()?.intern() - fun firstAsString(type: Byte) = data[type]?.firstOrNull()?.toString(Charsets.UTF_8) + fun firstAsString(type: Byte) = data[type]?.firstOrNull()?.toString(Charsets.UTF_8) - companion object { - fun parse(data: ByteArray): Tlv { - val result = mutableMapOf>() - var rest = data - while (rest.isNotEmpty()) { - val t = rest[0] - val l = rest[1].toUByte().toInt() - val v = rest.sliceArray(IntRange(2, (2 + l) - 1)) - rest = rest.sliceArray(IntRange(2 + l, rest.size - 1)) - if (v.size < l) continue + companion object { + fun parse(data: ByteArray): Tlv { + val result = mutableMapOf>() + var rest = data + while (rest.isNotEmpty()) { + val t = rest[0] + val l = rest[1].toUByte().toInt() + val v = rest.sliceArray(IntRange(2, (2 + l) - 1)) + rest = rest.sliceArray(IntRange(2 + l, rest.size - 1)) + if (v.size < l) continue - if (!result.containsKey(t)) { - result[t] = mutableListOf() + if (!result.containsKey(t)) { + result[t] = mutableListOf() + } + result[t]?.add(v) + } + return Tlv(result) } - result[t]?.add(v) - } - return Tlv(result) } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/AdvertisedRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/AdvertisedRelayListEvent.kt index 8f77dc9ca..4510c390f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/AdvertisedRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/AdvertisedRelayListEvent.kt @@ -27,65 +27,65 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class AdvertisedRelayListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - override fun dTag() = FIXED_D_TAG + override fun dTag() = FIXED_D_TAG - fun relays(): List { - return tags.mapNotNull { - if (it.size > 1 && it[0] == "r") { - val type = - when (it.getOrNull(2)) { - "read" -> AdvertisedRelayType.READ - "write" -> AdvertisedRelayType.WRITE - else -> AdvertisedRelayType.BOTH - } + fun relays(): List { + return tags.mapNotNull { + if (it.size > 1 && it[0] == "r") { + val type = + when (it.getOrNull(2)) { + "read" -> AdvertisedRelayType.READ + "write" -> AdvertisedRelayType.WRITE + else -> AdvertisedRelayType.BOTH + } - AdvertisedRelayInfo(it[1], type) - } else { - null - } - } - } - - companion object { - const val KIND = 10002 - const val FIXED_D_TAG = "" - - fun create( - list: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (AdvertisedRelayListEvent) -> Unit, - ) { - val tags = - list - .map { - if (it.type == AdvertisedRelayType.BOTH) { - arrayOf(it.relayUrl) + AdvertisedRelayInfo(it[1], type) } else { - arrayOf(it.relayUrl, it.type.code) + null } - } - .plusElement(arrayOf("alt", "Relay list event with ${list.size} relays")) - .toTypedArray() - val msg = "" - - signer.sign(createdAt, KIND, tags, msg, onReady) + } } - } - @Immutable data class AdvertisedRelayInfo(val relayUrl: String, val type: AdvertisedRelayType) + companion object { + const val KIND = 10002 + const val FIXED_D_TAG = "" - @Immutable - enum class AdvertisedRelayType(val code: String) { - BOTH(""), - READ("read"), - WRITE("write"), - } + fun create( + list: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (AdvertisedRelayListEvent) -> Unit, + ) { + val tags = + list + .map { + if (it.type == AdvertisedRelayType.BOTH) { + arrayOf(it.relayUrl) + } else { + arrayOf(it.relayUrl, it.type.code) + } + } + .plusElement(arrayOf("alt", "Relay list event with ${list.size} relays")) + .toTypedArray() + val msg = "" + + signer.sign(createdAt, KIND, tags, msg, onReady) + } + } + + @Immutable data class AdvertisedRelayInfo(val relayUrl: String, val type: AdvertisedRelayType) + + @Immutable + enum class AdvertisedRelayType(val code: String) { + BOTH(""), + READ("read"), + WRITE("write"), + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/AppDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/AppDefinitionEvent.kt index 03b06becf..1843ea7ce 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/AppDefinitionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/AppDefinitionEvent.kt @@ -29,57 +29,57 @@ import java.io.ByteArrayInputStream @Immutable class AppDefinitionEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient private var cachedMetadata: UserMetadata? = null + @Transient private var cachedMetadata: UserMetadata? = null - fun appMetaData() = - if (cachedMetadata != null) { - cachedMetadata - } else { - try { - val newMetadata = - mapper.readValue( - ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)), - UserMetadata::class.java, - ) + fun appMetaData() = + if (cachedMetadata != null) { + cachedMetadata + } else { + try { + val newMetadata = + mapper.readValue( + ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)), + UserMetadata::class.java, + ) - cachedMetadata = newMetadata + cachedMetadata = newMetadata - newMetadata - } catch (e: Exception) { - e.printStackTrace() - Log.w("MT", "Content Parse Error ${e.localizedMessage} $content") - null - } + newMetadata + } catch (e: Exception) { + e.printStackTrace() + Log.w("MT", "Content Parse Error ${e.localizedMessage} $content") + null + } + } + + fun supportedKinds() = + tags + .filter { it.size > 1 && it[0] == "k" } + .mapNotNull { runCatching { it[1].toInt() }.getOrNull() } + + fun publishedAt() = tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1) + + companion object { + const val KIND = 31990 + + fun create( + details: UserMetadata, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (AppDefinitionEvent) -> Unit, + ) { + val tags = + arrayOf( + arrayOf("alt", "App definition event for ${details.name}"), + ) + signer.sign(createdAt, KIND, tags, "", onReady) + } } - - fun supportedKinds() = - tags - .filter { it.size > 1 && it[0] == "k" } - .mapNotNull { runCatching { it[1].toInt() }.getOrNull() } - - fun publishedAt() = tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1) - - companion object { - const val KIND = 31990 - - fun create( - details: UserMetadata, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (AppDefinitionEvent) -> Unit, - ) { - val tags = - arrayOf( - arrayOf("alt", "App definition event for ${details.name}"), - ) - signer.sign(createdAt, KIND, tags, "", onReady) - } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/AppRecommendationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/AppRecommendationEvent.kt index 5fe75fa37..265a30717 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/AppRecommendationEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/AppRecommendationEvent.kt @@ -28,30 +28,29 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class AppRecommendationEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun recommendations() = - tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull { ATag.parse(it[1], it.getOrNull(2)) } + fun recommendations() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull { ATag.parse(it[1], it.getOrNull(2)) } - companion object { - const val KIND = 31989 - const val ALT = "App recommendations by the author" + companion object { + const val KIND = 31989 + const val ALT = "App recommendations by the author" - fun create( - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (AppRecommendationEvent) -> Unit, - ) { - val tags = - arrayOf( - arrayOf("alt", ALT), - ) - signer.sign(createdAt, KIND, tags, "", onReady) + fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (AppRecommendationEvent) -> Unit, + ) { + val tags = + arrayOf( + arrayOf("alt", ALT), + ) + signer.sign(createdAt, KIND, tags, "", onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioHeaderEvent.kt index 3f12e25ef..27c4b0785 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioHeaderEvent.kt @@ -28,58 +28,58 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class AudioHeaderEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun download() = tags.firstOrNull { it.size > 1 && it[0] == DOWNLOAD_URL }?.get(1) + fun download() = tags.firstOrNull { it.size > 1 && it[0] == DOWNLOAD_URL }?.get(1) - fun stream() = tags.firstOrNull { it.size > 1 && it[0] == STREAM_URL }?.get(1) + fun stream() = tags.firstOrNull { it.size > 1 && it[0] == STREAM_URL }?.get(1) - fun wavefrom() = - tags - .firstOrNull { it.size > 1 && it[0] == WAVEFORM } - ?.get(1) - ?.let { mapper.readValue>(it) } + fun wavefrom() = + tags + .firstOrNull { it.size > 1 && it[0] == WAVEFORM } + ?.get(1) + ?.let { mapper.readValue>(it) } - companion object { - const val KIND = 1808 - const val ALT = "Audio header" + companion object { + const val KIND = 1808 + const val ALT = "Audio header" - private const val DOWNLOAD_URL = "download_url" - private const val STREAM_URL = "stream_url" - private const val WAVEFORM = "waveform" + private const val DOWNLOAD_URL = "download_url" + private const val STREAM_URL = "stream_url" + private const val WAVEFORM = "waveform" - fun create( - description: String, - downloadUrl: String, - streamUrl: String? = null, - wavefront: String? = null, - sensitiveContent: Boolean? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (AudioHeaderEvent) -> Unit, - ) { - val tags = - listOfNotNull( - downloadUrl.let { arrayOf(DOWNLOAD_URL, it) }, - streamUrl?.let { arrayOf(STREAM_URL, it) }, - wavefront?.let { arrayOf(WAVEFORM, it) }, - sensitiveContent?.let { - if (it) { - arrayOf("content-warning", "") - } else { - null - } - }, - arrayOf("alt", ALT), - ) - .toTypedArray() + fun create( + description: String, + downloadUrl: String, + streamUrl: String? = null, + wavefront: String? = null, + sensitiveContent: Boolean? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (AudioHeaderEvent) -> Unit, + ) { + val tags = + listOfNotNull( + downloadUrl.let { arrayOf(DOWNLOAD_URL, it) }, + streamUrl?.let { arrayOf(STREAM_URL, it) }, + wavefront?.let { arrayOf(WAVEFORM, it) }, + sensitiveContent?.let { + if (it) { + arrayOf("content-warning", "") + } else { + null + } + }, + arrayOf("alt", ALT), + ) + .toTypedArray() - signer.sign(createdAt, KIND, tags, description, onReady) + signer.sign(createdAt, KIND, tags, description, onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioTrackEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioTrackEvent.kt index bef52308a..68b087692 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioTrackEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioTrackEvent.kt @@ -27,59 +27,58 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class AudioTrackEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun participants() = - tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(2)) } + fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(2)) } - fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1) + fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1) - fun price() = tags.firstOrNull { it.size > 1 && it[0] == PRICE }?.get(1) + fun price() = tags.firstOrNull { it.size > 1 && it[0] == PRICE }?.get(1) - fun cover() = tags.firstOrNull { it.size > 1 && it[0] == COVER }?.get(1) + fun cover() = tags.firstOrNull { it.size > 1 && it[0] == COVER }?.get(1) - // fun subject() = tags.firstOrNull { it.size > 1 && it[0] == SUBJECT }?.get(1) - fun media() = tags.firstOrNull { it.size > 1 && it[0] == MEDIA }?.get(1) + // fun subject() = tags.firstOrNull { it.size > 1 && it[0] == SUBJECT }?.get(1) + fun media() = tags.firstOrNull { it.size > 1 && it[0] == MEDIA }?.get(1) - companion object { - const val KIND = 31337 - const val ALT = "Audio track" + companion object { + const val KIND = 31337 + const val ALT = "Audio track" - private const val TYPE = "c" - private const val PRICE = "price" - private const val COVER = "cover" - private const val SUBJECT = "subject" - private const val MEDIA = "media" + private const val TYPE = "c" + private const val PRICE = "price" + private const val COVER = "cover" + private const val SUBJECT = "subject" + private const val MEDIA = "media" - fun create( - type: String, - media: String, - price: String? = null, - cover: String? = null, - subject: String? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (AudioTrackEvent) -> Unit, - ) { - val tags = - listOfNotNull( - arrayOf(MEDIA, media), - arrayOf(TYPE, type), - price?.let { arrayOf(PRICE, it) }, - cover?.let { arrayOf(COVER, it) }, - subject?.let { arrayOf(SUBJECT, it) }, - arrayOf("alt", ALT), - ) - .toTypedArray() + fun create( + type: String, + media: String, + price: String? = null, + cover: String? = null, + subject: String? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (AudioTrackEvent) -> Unit, + ) { + val tags = + listOfNotNull( + arrayOf(MEDIA, media), + arrayOf(TYPE, type), + price?.let { arrayOf(PRICE, it) }, + cover?.let { arrayOf(COVER, it) }, + subject?.let { arrayOf(SUBJECT, it) }, + arrayOf("alt", ALT), + ) + .toTypedArray() - signer.sign(createdAt, KIND, tags, "", onReady) + signer.sign(createdAt, KIND, tags, "", onReady) + } } - } } @Immutable data class Participant(val key: String, val role: String?) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeAwardEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeAwardEvent.kt index 995fdee39..545acc363 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeAwardEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeAwardEvent.kt @@ -25,19 +25,19 @@ import com.vitorpamplona.quartz.encoders.HexKey @Immutable class BadgeAwardEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun awardees() = taggedUsers() + fun awardees() = taggedUsers() - fun awardDefinition() = taggedAddresses() + fun awardDefinition() = taggedAddresses() - companion object { - const val KIND = 8 - const val ALT = "Badge award" - } + companion object { + const val KIND = 8 + const val ALT = "Badge award" + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeDefinitionEvent.kt index 7888a7264..1ed4596ce 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeDefinitionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeDefinitionEvent.kt @@ -25,23 +25,23 @@ import com.vitorpamplona.quartz.encoders.HexKey @Immutable class BadgeDefinitionEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1) + fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1) - fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == "thumb" }?.get(1) + fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == "thumb" }?.get(1) - fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) + fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) - fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) + fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) - companion object { - const val KIND = 30009 - const val ALT = "Badge definition" - } + companion object { + const val KIND = 30009 + const val ALT = "Badge definition" + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeProfilesEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeProfilesEvent.kt index e3d6076b8..10cc2e147 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeProfilesEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeProfilesEvent.kt @@ -26,28 +26,28 @@ import com.vitorpamplona.quartz.encoders.HexKey @Immutable class BadgeProfilesEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun badgeAwardEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } + fun badgeAwardEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - fun badgeAwardDefinitions() = - tags - .filter { it.firstOrNull() == "a" } - .mapNotNull { - val aTagValue = it.getOrNull(1) - val relay = it.getOrNull(2) + fun badgeAwardDefinitions() = + tags + .filter { it.firstOrNull() == "a" } + .mapNotNull { + val aTagValue = it.getOrNull(1) + val relay = it.getOrNull(2) - if (aTagValue != null) ATag.parse(aTagValue, relay) else null - } + if (aTagValue != null) ATag.parse(aTagValue, relay) else null + } - companion object { - const val KIND = 30008 - const val STANDARD_D_TAG = "profile_badges" - const val ALT = "List of accepted badges by the author" - } + companion object { + const val KIND = 30008 + const val STANDARD_D_TAG = "profile_badges" + const val ALT = "List of accepted badges by the author" + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt index 7d69db19c..5afa7657d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt @@ -32,144 +32,148 @@ val hashtagSearch = Pattern.compile("(?:\\s|\\A)#([^\\s!@#\$%^&*()=+./,\\[{\\]}; @Immutable open class BaseTextNoteEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun mentions() = taggedUsers() + fun mentions() = taggedUsers() - open fun replyTos() = taggedEvents() + open fun replyTos() = taggedEvents() - fun replyingTo(): HexKey? { - val oldStylePositional = tags.lastOrNull { it.size > 1 && it[0] == "e" }?.get(1) - val newStyle = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "reply" }?.get(1) + fun replyingTo(): HexKey? { + val oldStylePositional = tags.lastOrNull { it.size > 1 && it[0] == "e" }?.get(1) + val newStyle = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "reply" }?.get(1) - return newStyle ?: oldStylePositional - } - - @Transient private var citedUsersCache: Set? = null - - @Transient private var citedNotesCache: Set? = null - - fun citedUsers(): Set { - citedUsersCache?.let { - return it + return newStyle ?: oldStylePositional } - val matcher = tagSearch.matcher(content) - val returningList = mutableSetOf() - while (matcher.find()) { - try { - val tag = matcher.group(1)?.let { tags[it.toInt()] } - if (tag != null && tag.size > 1 && tag[0] == "p") { - returningList.add(tag[1]) + @Transient private var citedUsersCache: Set? = null + + @Transient private var citedNotesCache: Set? = null + + fun citedUsers(): Set { + citedUsersCache?.let { + return it } - } catch (e: Exception) {} - } - val matcher2 = nip19regex.matcher(content) - while (matcher2.find()) { - val uriScheme = matcher2.group(1) // nostr: - val type = matcher2.group(2) // npub1 - val key = matcher2.group(3) // bech32 - val additionalChars = matcher2.group(4) // additional chars - - try { - val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars) - - if (parsed != null) { - val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex } - - if (tag != null && tag[0] == "p") { - returningList.add(tag[1]) - } + val matcher = tagSearch.matcher(content) + val returningList = mutableSetOf() + while (matcher.find()) { + try { + val tag = matcher.group(1)?.let { tags[it.toInt()] } + if (tag != null && tag.size > 1 && tag[0] == "p") { + returningList.add(tag[1]) + } + } catch (e: Exception) { + } } - } catch (e: Exception) { - Log.w("Unable to parse cited users that matched a NIP19 regex", e) - } - } - citedUsersCache = returningList - return returningList - } + val matcher2 = nip19regex.matcher(content) + while (matcher2.find()) { + val uriScheme = matcher2.group(1) // nostr: + val type = matcher2.group(2) // npub1 + val key = matcher2.group(3) // bech32 + val additionalChars = matcher2.group(4) // additional chars - fun findCitations(): Set { - citedNotesCache?.let { - return it - } + try { + val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars) - val citations = mutableSetOf() - // Removes citations from replies: - val matcher = tagSearch.matcher(content) - while (matcher.find()) { - try { - val tag = matcher.group(1)?.let { tags[it.toInt()] } - if (tag != null && tag.size > 1 && tag[0] == "e") { - citations.add(tag[1]) + if (parsed != null) { + val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex } + + if (tag != null && tag[0] == "p") { + returningList.add(tag[1]) + } + } + } catch (e: Exception) { + Log.w("Unable to parse cited users that matched a NIP19 regex", e) + } } - if (tag != null && tag.size > 1 && tag[0] == "a") { - citations.add(tag[1]) + + citedUsersCache = returningList + return returningList + } + + fun findCitations(): Set { + citedNotesCache?.let { + return it } - } catch (e: Exception) {} + + val citations = mutableSetOf() + // Removes citations from replies: + val matcher = tagSearch.matcher(content) + while (matcher.find()) { + try { + val tag = matcher.group(1)?.let { tags[it.toInt()] } + if (tag != null && tag.size > 1 && tag[0] == "e") { + citations.add(tag[1]) + } + if (tag != null && tag.size > 1 && tag[0] == "a") { + citations.add(tag[1]) + } + } catch (e: Exception) { + } + } + + val matcher2 = nip19regex.matcher(content) + while (matcher2.find()) { + val uriScheme = matcher2.group(1) // nostr: + val type = matcher2.group(2) // npub1 + val key = matcher2.group(3) // bech32 + val additionalChars = matcher2.group(4) // additional chars + + val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars) + + if (parsed != null) { + try { + val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex } + + if (tag != null && tag[0] == "e") { + citations.add(tag[1]) + } + if (tag != null && tag[0] == "a") { + citations.add(tag[1]) + } + } catch (e: Exception) { + } + } + } + + citedNotesCache = citations + return citations } - val matcher2 = nip19regex.matcher(content) - while (matcher2.find()) { - val uriScheme = matcher2.group(1) // nostr: - val type = matcher2.group(2) // npub1 - val key = matcher2.group(3) // bech32 - val additionalChars = matcher2.group(4) // additional chars + fun tagsWithoutCitations(): List { + val repliesTo = replyTos() + val tagAddresses = + taggedAddresses().filter { it.kind != CommunityDefinitionEvent.KIND }.map { it.toTag() } + if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList() - val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars) + val citations = findCitations() - if (parsed != null) { - try { - val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex } - - if (tag != null && tag[0] == "e") { - citations.add(tag[1]) - } - if (tag != null && tag[0] == "a") { - citations.add(tag[1]) - } - } catch (e: Exception) {} - } + return if (citations.isEmpty()) { + repliesTo + tagAddresses + } else { + repliesTo.filter { it !in citations } + } } - - citedNotesCache = citations - return citations - } - - fun tagsWithoutCitations(): List { - val repliesTo = replyTos() - val tagAddresses = - taggedAddresses().filter { it.kind != CommunityDefinitionEvent.KIND }.map { it.toTag() } - if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList() - - val citations = findCitations() - - return if (citations.isEmpty()) { - repliesTo + tagAddresses - } else { - repliesTo.filter { it !in citations } - } - } } fun findHashtags(content: String): List { - val matcher = hashtagSearch.matcher(content) - val returningList = mutableSetOf() - while (matcher.find()) { - try { - val tag = matcher.group(1) - if (tag != null && tag.isNotBlank()) { - returningList.add(tag) - } - } catch (e: Exception) {} - } - return returningList.toList() + val matcher = hashtagSearch.matcher(content) + val returningList = mutableSetOf() + while (matcher.find()) { + try { + val tag = matcher.group(1) + if (tag != null && tag.isNotBlank()) { + returningList.add(tag) + } + } catch (e: Exception) { + } + } + return returningList.toList() } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BookmarkListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BookmarkListEvent.kt index 2224d2282..8d65a2030 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/BookmarkListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BookmarkListEvent.kt @@ -28,205 +28,205 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class BookmarkListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 30001 - const val ALT = "List of bookmarks" + companion object { + const val KIND = 30001 + const val ALT = "List of bookmarks" - fun addEvent( - earlierVersion: BookmarkListEvent?, - eventId: HexKey, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) = addTag(earlierVersion, "e", eventId, isPrivate, signer, createdAt, onReady) + fun addEvent( + earlierVersion: BookmarkListEvent?, + eventId: HexKey, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BookmarkListEvent) -> Unit, + ) = addTag(earlierVersion, "e", eventId, isPrivate, signer, createdAt, onReady) - fun addReplaceable( - earlierVersion: BookmarkListEvent?, - aTag: ATag, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) = addTag(earlierVersion, "a", aTag.toTag(), isPrivate, signer, createdAt, onReady) + fun addReplaceable( + earlierVersion: BookmarkListEvent?, + aTag: ATag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BookmarkListEvent) -> Unit, + ) = addTag(earlierVersion, "a", aTag.toTag(), isPrivate, signer, createdAt, onReady) - fun addTag( - earlierVersion: BookmarkListEvent?, - tagName: String, - tagValue: HexKey, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) { - add( - earlierVersion, - arrayOf(arrayOf(tagName, tagValue)), - isPrivate, - signer, - createdAt, - onReady, - ) - } + fun addTag( + earlierVersion: BookmarkListEvent?, + tagName: String, + tagValue: HexKey, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BookmarkListEvent) -> Unit, + ) { + add( + earlierVersion, + arrayOf(arrayOf(tagName, tagValue)), + isPrivate, + signer, + createdAt, + onReady, + ) + } - fun add( - earlierVersion: BookmarkListEvent?, - listNewTags: Array>, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) { - if (isPrivate) { - if (earlierVersion != null) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = privateTags.plus(listNewTags), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) + fun add( + earlierVersion: BookmarkListEvent?, + listNewTags: Array>, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BookmarkListEvent) -> Unit, + ) { + if (isPrivate) { + if (earlierVersion != null) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = privateTags.plus(listNewTags), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = earlierVersion.tags, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + encryptTags( + privateTags = listNewTags, + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = emptyArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion?.content ?: "", + tags = (earlierVersion?.tags ?: emptyArray()).plus(listNewTags), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) } - } - } else { - encryptTags( - privateTags = listNewTags, - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = emptyArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion?.content ?: "", - tags = (earlierVersion?.tags ?: emptyArray()).plus(listNewTags), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun removeEvent( - earlierVersion: BookmarkListEvent, - eventId: HexKey, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) = removeTag(earlierVersion, "e", eventId, isPrivate, signer, createdAt, onReady) - - fun removeReplaceable( - earlierVersion: BookmarkListEvent, - aTag: ATag, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) = removeTag(earlierVersion, "a", aTag.toTag(), isPrivate, signer, createdAt, onReady) - - private fun removeTag( - earlierVersion: BookmarkListEvent, - tagName: String, - tagValue: HexKey, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags - .filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) } - .toTypedArray(), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = - earlierVersion.tags - .filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = - earlierVersion.tags - .filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) { - val newTags = - if (tags.any { it.size > 1 && it[0] == "alt" }) { - tags - } else { - tags + arrayOf("alt", ALT) } - signer.sign(createdAt, KIND, newTags, content, onReady) + fun removeEvent( + earlierVersion: BookmarkListEvent, + eventId: HexKey, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BookmarkListEvent) -> Unit, + ) = removeTag(earlierVersion, "e", eventId, isPrivate, signer, createdAt, onReady) + + fun removeReplaceable( + earlierVersion: BookmarkListEvent, + aTag: ATag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BookmarkListEvent) -> Unit, + ) = removeTag(earlierVersion, "a", aTag.toTag(), isPrivate, signer, createdAt, onReady) + + private fun removeTag( + earlierVersion: BookmarkListEvent, + tagName: String, + tagValue: HexKey, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BookmarkListEvent) -> Unit, + ) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = + privateTags + .filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) } + .toTypedArray(), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = + earlierVersion.tags + .filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) } + .toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = + earlierVersion.tags + .filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) } + .toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + + fun create( + content: String, + tags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BookmarkListEvent) -> Unit, + ) { + val newTags = + if (tags.any { it.size > 1 && it[0] == "alt" }) { + tags + } else { + tags + arrayOf("alt", ALT) + } + + signer.sign(createdAt, KIND, newTags, content, onReady) + } + + fun create( + name: String = "", + events: List? = null, + users: List? = null, + addresses: List? = null, + privEvents: List? = null, + privUsers: List? = null, + privAddresses: List? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BookmarkListEvent) -> Unit, + ) { + val tags = mutableListOf>() + tags.add(arrayOf("d", name)) + + events?.forEach { tags.add(arrayOf("e", it)) } + users?.forEach { tags.add(arrayOf("p", it)) } + addresses?.forEach { tags.add(arrayOf("a", it.toTag())) } + tags.add(arrayOf("alt", ALT)) + + createPrivateTags(privEvents, privUsers, privAddresses, signer) { content -> + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } + } } - - fun create( - name: String = "", - events: List? = null, - users: List? = null, - addresses: List? = null, - privEvents: List? = null, - privUsers: List? = null, - privAddresses: List? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit, - ) { - val tags = mutableListOf>() - tags.add(arrayOf("d", name)) - - events?.forEach { tags.add(arrayOf("e", it)) } - users?.forEach { tags.add(arrayOf("p", it)) } - addresses?.forEach { tags.add(arrayOf("a", it.toTag())) } - tags.add(arrayOf("alt", ALT)) - - createPrivateTags(privEvents, privUsers, privAddresses, signer) { content -> - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) - } - } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarDateSlotEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarDateSlotEvent.kt index bbd7fcecb..5df244d29 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarDateSlotEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarDateSlotEvent.kt @@ -27,33 +27,33 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class CalendarDateSlotEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun location() = tags.firstOrNull { it.size > 1 && it[0] == "location" }?.get(1) + fun location() = tags.firstOrNull { it.size > 1 && it[0] == "location" }?.get(1) - fun start() = tags.firstOrNull { it.size > 1 && it[0] == "start" }?.get(1) + fun start() = tags.firstOrNull { it.size > 1 && it[0] == "start" }?.get(1) - fun end() = tags.firstOrNull { it.size > 1 && it[0] == "end" }?.get(1) + fun end() = tags.firstOrNull { it.size > 1 && it[0] == "end" }?.get(1) - // ["start", ""], - // ["end", ""], + // ["start", ""], + // ["end", ""], - companion object { - const val KIND = 31922 - const val ALT = "Full-day calendar event" + companion object { + const val KIND = 31922 + const val ALT = "Full-day calendar event" - fun create( - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CalendarDateSlotEvent) -> Unit, - ) { - val tags = arrayOf(arrayOf("alt", ALT)) - signer.sign(createdAt, KIND, tags, "", onReady) + fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CalendarDateSlotEvent) -> Unit, + ) { + val tags = arrayOf(arrayOf("alt", ALT)) + signer.sign(createdAt, KIND, tags, "", onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarEvent.kt index dd6ff430a..40eb82a87 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarEvent.kt @@ -27,24 +27,24 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class CalendarEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 31924 - const val ALT = "Calendar" + companion object { + const val KIND = 31924 + const val ALT = "Calendar" - fun create( - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CalendarEvent) -> Unit, - ) { - val tags = arrayOf(arrayOf("alt", ALT)) - signer.sign(createdAt, KIND, tags, "", onReady) + fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CalendarEvent) -> Unit, + ) { + val tags = arrayOf(arrayOf("alt", ALT)) + signer.sign(createdAt, KIND, tags, "", onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarRSVPEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarRSVPEvent.kt index 179ed09bf..3c1fc2264 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarRSVPEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarRSVPEvent.kt @@ -27,35 +27,35 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class CalendarRSVPEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun status() = tags.firstOrNull { it.size > 1 && it[0] == "location" }?.get(1) + fun status() = tags.firstOrNull { it.size > 1 && it[0] == "location" }?.get(1) - fun start() = tags.firstOrNull { it.size > 1 && it[0] == "start" }?.get(1) + fun start() = tags.firstOrNull { it.size > 1 && it[0] == "start" }?.get(1) - fun end() = tags.firstOrNull { it.size > 1 && it[0] == "end" }?.get(1) + fun end() = tags.firstOrNull { it.size > 1 && it[0] == "end" }?.get(1) - // ["L", "status"], - // ["l", "", "status"], - // ["L", "freebusy"], - // ["l", "", "freebusy"] + // ["L", "status"], + // ["l", "", "status"], + // ["L", "freebusy"], + // ["l", "", "freebusy"] - companion object { - const val KIND = 31925 - const val ALT = "Calendar event's invitation response" + companion object { + const val KIND = 31925 + const val ALT = "Calendar event's invitation response" - fun create( - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CalendarRSVPEvent) -> Unit, - ) { - val tags = arrayOf(arrayOf("alt", ALT)) - signer.sign(createdAt, KIND, tags, "", onReady) + fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CalendarRSVPEvent) -> Unit, + ) { + val tags = arrayOf(arrayOf("alt", ALT)) + signer.sign(createdAt, KIND, tags, "", onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarTimeSlotEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarTimeSlotEvent.kt index 31ef819b8..047fb8b6b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarTimeSlotEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarTimeSlotEvent.kt @@ -27,39 +27,39 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class CalendarTimeSlotEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun location() = tags.firstOrNull { it.size > 1 && it[0] == "location" }?.get(1) + fun location() = tags.firstOrNull { it.size > 1 && it[0] == "location" }?.get(1) - fun start() = tags.firstOrNull { it.size > 1 && it[0] == "start" }?.get(1)?.toLongOrNull() + fun start() = tags.firstOrNull { it.size > 1 && it[0] == "start" }?.get(1)?.toLongOrNull() - fun end() = tags.firstOrNull { it.size > 1 && it[0] == "end" }?.get(1)?.toLongOrNull() + fun end() = tags.firstOrNull { it.size > 1 && it[0] == "end" }?.get(1)?.toLongOrNull() - fun startTmz() = tags.firstOrNull { it.size > 1 && it[0] == "start_tzid" }?.get(1)?.toLongOrNull() + fun startTmz() = tags.firstOrNull { it.size > 1 && it[0] == "start_tzid" }?.get(1)?.toLongOrNull() - fun endTmz() = tags.firstOrNull { it.size > 1 && it[0] == "end_tzid" }?.get(1)?.toLongOrNull() + fun endTmz() = tags.firstOrNull { it.size > 1 && it[0] == "end_tzid" }?.get(1)?.toLongOrNull() - // ["start", ""], - // ["end", ""], - // ["start_tzid", ""], - // ["end_tzid", ""], + // ["start", ""], + // ["end", ""], + // ["start_tzid", ""], + // ["end_tzid", ""], - companion object { - const val KIND = 31923 - const val ALT = "Calendar time-slot event" + companion object { + const val KIND = 31923 + const val ALT = "Calendar time-slot event" - fun create( - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CalendarTimeSlotEvent) -> Unit, - ) { - val tags = arrayOf(arrayOf("alt", ALT)) - signer.sign(createdAt, KIND, tags, "", onReady) + fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CalendarTimeSlotEvent) -> Unit, + ) { + val tags = arrayOf(arrayOf("alt", ALT)) + signer.sign(createdAt, KIND, tags, "", onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelCreateEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelCreateEvent.kt index 4cfd91143..78b1797f1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelCreateEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelCreateEvent.kt @@ -29,70 +29,70 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class ChannelCreateEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun channelInfo(): ChannelData = - try { - mapper.readValue(content) - } catch (e: Exception) { - Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) - ChannelData(null, null, null) - } - - companion object { - const val KIND = 40 - - fun create( - name: String?, - about: String?, - picture: String?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelCreateEvent) -> Unit, - ) { - return create( - ChannelData( - name, - about, - picture, - ), - signer, - createdAt, - onReady, - ) - } - - fun create( - channelInfo: ChannelData?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelCreateEvent) -> Unit, - ) { - val content = + fun channelInfo(): ChannelData = try { - if (channelInfo != null) { - mapper.writeValueAsString(channelInfo) - } else { - "" - } - } catch (t: Throwable) { - Log.e("ChannelCreateEvent", "Couldn't parse channel information", t) - "" + mapper.readValue(content) + } catch (e: Exception) { + Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) + ChannelData(null, null, null) } - val tags = - arrayOf( - arrayOf("alt", "Public chat creation event ${channelInfo?.name?.let { "about $it" }}"), - ) + companion object { + const val KIND = 40 - signer.sign(createdAt, KIND, tags, content, onReady) + fun create( + name: String?, + about: String?, + picture: String?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelCreateEvent) -> Unit, + ) { + return create( + ChannelData( + name, + about, + picture, + ), + signer, + createdAt, + onReady, + ) + } + + fun create( + channelInfo: ChannelData?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelCreateEvent) -> Unit, + ) { + val content = + try { + if (channelInfo != null) { + mapper.writeValueAsString(channelInfo) + } else { + "" + } + } catch (t: Throwable) { + Log.e("ChannelCreateEvent", "Couldn't parse channel information", t) + "" + } + + val tags = + arrayOf( + arrayOf("alt", "Public chat creation event ${channelInfo?.name?.let { "about $it" }}"), + ) + + signer.sign(createdAt, KIND, tags, content, onReady) + } } - } - @Immutable data class ChannelData(val name: String?, val about: String?, val picture: String?) + @Immutable data class ChannelData(val name: String?, val about: String?, val picture: String?) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelHideMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelHideMessageEvent.kt index 2c0444814..ee2f47d07 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelHideMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelHideMessageEvent.kt @@ -27,35 +27,37 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class ChannelHideMessageEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), IsInPublicChatChannel { - override fun channel() = - tags.firstOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) - ?: tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + override fun channel() = + tags.firstOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) + ?: tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) - fun eventsToHide() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } + fun eventsToHide() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - companion object { - const val KIND = 43 - const val ALT = "Hide message instruction for public chats" + companion object { + const val KIND = 43 + const val ALT = "Hide message instruction for public chats" - fun create( - reason: String, - messagesToHide: List?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelHideMessageEvent) -> Unit, - ) { - val tags = - (messagesToHide?.map { arrayOf("e", it) }?.toTypedArray() - ?: emptyArray()) + arrayOf(arrayOf("alt", ALT)) + fun create( + reason: String, + messagesToHide: List?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelHideMessageEvent) -> Unit, + ) { + val tags = + ( + messagesToHide?.map { arrayOf("e", it) }?.toTypedArray() + ?: emptyArray() + ) + arrayOf(arrayOf("alt", ALT)) - signer.sign(createdAt, KIND, tags, reason, onReady) + signer.sign(createdAt, KIND, tags, reason, onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt index d17f00438..265709839 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt @@ -27,68 +27,68 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class ChannelMessageEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig), IsInPublicChatChannel { - override fun channel() = - tags.firstOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) - ?: tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + override fun channel() = + tags.firstOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) + ?: tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) - override fun replyTos() = - tags - .filter { it.firstOrNull() == "e" && it.getOrNull(1) != channel() } - .mapNotNull { it.getOrNull(1) } + override fun replyTos() = + tags + .filter { it.firstOrNull() == "e" && it.getOrNull(1) != channel() } + .mapNotNull { it.getOrNull(1) } - companion object { - const val KIND = 42 - const val ALT = "Public chat message" + companion object { + const val KIND = 42 + const val ALT = "Public chat message" - fun create( - message: String, - channel: String, - replyTos: List? = null, - mentions: List? = null, - zapReceiver: List? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - markAsSensitive: Boolean, - zapRaiserAmount: Long?, - geohash: String? = null, - nip94attachments: List? = null, - onReady: (ChannelMessageEvent) -> Unit, - ) { - val tags = - mutableListOf( - arrayOf("e", channel, "", "root"), - ) - replyTos?.forEach { tags.add(arrayOf("e", it)) } - mentions?.forEach { tags.add(arrayOf("p", it)) } - zapReceiver?.forEach { - tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) - } - if (markAsSensitive) { - tags.add(arrayOf("content-warning", "")) - } - zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } - geohash?.let { tags.addAll(geohashMipMap(it)) } - nip94attachments?.let { - it.forEach { - // tags.add(arrayOf("nip94", it.toJson())) + fun create( + message: String, + channel: String, + replyTos: List? = null, + mentions: List? = null, + zapReceiver: List? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + markAsSensitive: Boolean, + zapRaiserAmount: Long?, + geohash: String? = null, + nip94attachments: List? = null, + onReady: (ChannelMessageEvent) -> Unit, + ) { + val tags = + mutableListOf( + arrayOf("e", channel, "", "root"), + ) + replyTos?.forEach { tags.add(arrayOf("e", it)) } + mentions?.forEach { tags.add(arrayOf("p", it)) } + zapReceiver?.forEach { + tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) + } + if (markAsSensitive) { + tags.add(arrayOf("content-warning", "")) + } + zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } + geohash?.let { tags.addAll(geohashMipMap(it)) } + nip94attachments?.let { + it.forEach { + // tags.add(arrayOf("nip94", it.toJson())) + } + } + tags.add( + arrayOf("alt", ALT), + ) + + signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady) } - } - tags.add( - arrayOf("alt", ALT), - ) - - signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady) } - } } interface IsInPublicChatChannel { - fun channel(): String? + fun channel(): String? } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMetadataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMetadataEvent.kt index 20f53cc41..9868b91dc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMetadataEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMetadataEvent.kt @@ -28,69 +28,69 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class ChannelMetadataEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), IsInPublicChatChannel { - override fun channel() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + override fun channel() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) - fun channelInfo() = - try { - mapper.readValue(content, ChannelCreateEvent.ChannelData::class.java) - } catch (e: Exception) { - Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) - ChannelCreateEvent.ChannelData(null, null, null) - } - - companion object { - const val KIND = 41 - const val ALT = "This is a public chat definition update" - - fun create( - name: String?, - about: String?, - picture: String?, - originalChannelIdHex: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelMetadataEvent) -> Unit, - ) { - create( - ChannelCreateEvent.ChannelData( - name, - about, - picture, - ), - originalChannelIdHex, - signer, - createdAt, - onReady, - ) - } - - fun create( - newChannelInfo: ChannelCreateEvent.ChannelData?, - originalChannelIdHex: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelMetadataEvent) -> Unit, - ) { - val content = - if (newChannelInfo != null) { - mapper.writeValueAsString(newChannelInfo) - } else { - "" + fun channelInfo() = + try { + mapper.readValue(content, ChannelCreateEvent.ChannelData::class.java) + } catch (e: Exception) { + Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) + ChannelCreateEvent.ChannelData(null, null, null) } - val tags = - listOf( - arrayOf("e", originalChannelIdHex, "", "root"), - arrayOf("alt", "Public chat update to ${newChannelInfo?.name}"), - ) - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + companion object { + const val KIND = 41 + const val ALT = "This is a public chat definition update" + + fun create( + name: String?, + about: String?, + picture: String?, + originalChannelIdHex: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelMetadataEvent) -> Unit, + ) { + create( + ChannelCreateEvent.ChannelData( + name, + about, + picture, + ), + originalChannelIdHex, + signer, + createdAt, + onReady, + ) + } + + fun create( + newChannelInfo: ChannelCreateEvent.ChannelData?, + originalChannelIdHex: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelMetadataEvent) -> Unit, + ) { + val content = + if (newChannelInfo != null) { + mapper.writeValueAsString(newChannelInfo) + } else { + "" + } + + val tags = + listOf( + arrayOf("e", originalChannelIdHex, "", "root"), + arrayOf("alt", "Public chat update to ${newChannelInfo?.name}"), + ) + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMuteUserEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMuteUserEvent.kt index 63e6f995d..a96a5a898 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMuteUserEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMuteUserEvent.kt @@ -27,36 +27,38 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class ChannelMuteUserEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), IsInPublicChatChannel { - override fun channel() = - tags.firstOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) - ?: tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + override fun channel() = + tags.firstOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) + ?: tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) - fun usersToMute() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + fun usersToMute() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - companion object { - const val KIND = 44 - const val ALT = "Mute user instruction for public chats" + companion object { + const val KIND = 44 + const val ALT = "Mute user instruction for public chats" - fun create( - reason: String, - usersToMute: List?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelMuteUserEvent) -> Unit, - ) { - val content = reason - val tags = - (usersToMute?.map { arrayOf("p", it) }?.toTypedArray() - ?: emptyArray()) + arrayOf(arrayOf("alt", ALT)) + fun create( + reason: String, + usersToMute: List?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelMuteUserEvent) -> Unit, + ) { + val content = reason + val tags = + ( + usersToMute?.map { arrayOf("p", it) }?.toTypedArray() + ?: emptyArray() + ) + arrayOf(arrayOf("alt", ALT)) - signer.sign(createdAt, KIND, tags, content, onReady) + signer.sign(createdAt, KIND, tags, content, onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt index 0600d65eb..77a62b9b6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt @@ -30,83 +30,83 @@ import kotlinx.collections.immutable.toImmutableSet @Immutable class ChatMessageEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig), ChatroomKeyable { - /** Recipients intended to receive this conversation */ - fun recipientsPubKey() = tags.mapNotNull { if (it.size > 1 && it[0] == "p") it[1] else null } + /** Recipients intended to receive this conversation */ + fun recipientsPubKey() = tags.mapNotNull { if (it.size > 1 && it[0] == "p") it[1] else null } - fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) - fun talkingWith(oneSideHex: String): Set { - val listedPubKeys = recipientsPubKey() + fun talkingWith(oneSideHex: String): Set { + val listedPubKeys = recipientsPubKey() - val result = - if (pubKey == oneSideHex) { - listedPubKeys.toSet().minus(oneSideHex) - } else { - listedPubKeys.plus(pubKey).toSet().minus(oneSideHex) - } + val result = + if (pubKey == oneSideHex) { + listedPubKeys.toSet().minus(oneSideHex) + } else { + listedPubKeys.plus(pubKey).toSet().minus(oneSideHex) + } - if (result.isEmpty()) { - // talking to myself - return setOf(pubKey) + if (result.isEmpty()) { + // talking to myself + return setOf(pubKey) + } + + return result } - return result - } - - override fun chatroomKey(toRemove: String): ChatroomKey { - return ChatroomKey(talkingWith(toRemove).toImmutableSet()) - } - - companion object { - const val KIND = 14 - const val ALT = "Direct message" - - fun create( - msg: String, - to: List? = null, - subject: String? = null, - replyTos: List? = null, - mentions: List? = null, - zapReceiver: List? = null, - markAsSensitive: Boolean = false, - zapRaiserAmount: Long? = null, - geohash: String? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChatMessageEvent) -> Unit, - ) { - val tags = mutableListOf>() - to?.forEach { tags.add(arrayOf("p", it)) } - replyTos?.forEach { tags.add(arrayOf("e", it)) } - mentions?.forEach { tags.add(arrayOf("p", it, "", "mention")) } - zapReceiver?.forEach { - tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) - } - if (markAsSensitive) { - tags.add(arrayOf("content-warning", "")) - } - zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } - geohash?.let { tags.addAll(geohashMipMap(it)) } - subject?.let { tags.add(arrayOf("subject", it)) } - // tags.add(arrayOf("alt", alt)) - - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + override fun chatroomKey(toRemove: String): ChatroomKey { + return ChatroomKey(talkingWith(toRemove).toImmutableSet()) + } + + companion object { + const val KIND = 14 + const val ALT = "Direct message" + + fun create( + msg: String, + to: List? = null, + subject: String? = null, + replyTos: List? = null, + mentions: List? = null, + zapReceiver: List? = null, + markAsSensitive: Boolean = false, + zapRaiserAmount: Long? = null, + geohash: String? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChatMessageEvent) -> Unit, + ) { + val tags = mutableListOf>() + to?.forEach { tags.add(arrayOf("p", it)) } + replyTos?.forEach { tags.add(arrayOf("e", it)) } + mentions?.forEach { tags.add(arrayOf("p", it, "", "mention")) } + zapReceiver?.forEach { + tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) + } + if (markAsSensitive) { + tags.add(arrayOf("content-warning", "")) + } + zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } + geohash?.let { tags.addAll(geohashMipMap(it)) } + subject?.let { tags.add(arrayOf("subject", it)) } + // tags.add(arrayOf("alt", alt)) + + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + } } - } } interface ChatroomKeyable { - fun chatroomKey(toRemove: HexKey): ChatroomKey + fun chatroomKey(toRemove: HexKey): ChatroomKey } @Stable data class ChatroomKey( - val users: ImmutableSet, + val users: ImmutableSet, ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt index 51f6e812c..0d477f589 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt @@ -28,153 +28,153 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class ClassifiedsEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1) + fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1) - fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) + fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) - fun condition() = tags.firstOrNull { it.size > 1 && it[0] == "condition" }?.get(1) + fun condition() = tags.firstOrNull { it.size > 1 && it[0] == "condition" }?.get(1) - fun images() = tags.filter { it.size > 1 && it[0] == "image" }.map { it[1] } + fun images() = tags.filter { it.size > 1 && it[0] == "image" }.map { it[1] } - fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1) + fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1) - fun price() = - tags - .firstOrNull { it.size > 1 && it[0] == "price" } - ?.let { Price(it[1], it.getOrNull(2), it.getOrNull(3)) } + fun price() = + tags + .firstOrNull { it.size > 1 && it[0] == "price" } + ?.let { Price(it[1], it.getOrNull(2), it.getOrNull(3)) } - fun location() = tags.firstOrNull { it.size > 1 && it[0] == "location" }?.get(1) + fun location() = tags.firstOrNull { it.size > 1 && it[0] == "location" }?.get(1) - fun publishedAt() = - try { - tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull() - } catch (_: Exception) { - null + fun publishedAt() = + try { + tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull() + } catch (_: Exception) { + null + } + + enum class CONDITION(val value: String) { + NEW("new"), + USED_LIKE_NEW("like new"), + USED_GOOD("good"), + USED_FAIR("fair"), } - enum class CONDITION(val value: String) { - NEW("new"), - USED_LIKE_NEW("like new"), - USED_GOOD("good"), - USED_FAIR("fair"), - } + companion object { + const val KIND = 30402 + private val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg") + const val ALT = "Classifieds listing" - companion object { - const val KIND = 30402 - private val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg") - const val ALT = "Classifieds listing" + fun create( + dTag: String, + title: String?, + image: String?, + summary: String?, + message: String, + price: Price?, + location: String?, + category: String?, + condition: ClassifiedsEvent.CONDITION?, + publishedAt: Long? = TimeUtils.now(), + replyTos: List?, + addresses: List?, + mentions: List?, + directMentions: Set, + zapReceiver: List? = null, + markAsSensitive: Boolean, + zapRaiserAmount: Long?, + geohash: String? = null, + nip94attachments: List? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ClassifiedsEvent) -> Unit, + ) { + val tags = mutableListOf>() - fun create( - dTag: String, - title: String?, - image: String?, - summary: String?, - message: String, - price: Price?, - location: String?, - category: String?, - condition: ClassifiedsEvent.CONDITION?, - publishedAt: Long? = TimeUtils.now(), - replyTos: List?, - addresses: List?, - mentions: List?, - directMentions: Set, - zapReceiver: List? = null, - markAsSensitive: Boolean, - zapRaiserAmount: Long?, - geohash: String? = null, - nip94attachments: List? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ClassifiedsEvent) -> Unit, - ) { - val tags = mutableListOf>() + replyTos?.forEach { + if (it in directMentions) { + tags.add(arrayOf("e", it, "", "mention")) + } else { + tags.add(arrayOf("e", it)) + } + } + mentions?.forEach { + if (it in directMentions) { + tags.add(arrayOf("p", it, "", "mention")) + } else { + tags.add(arrayOf("p", it)) + } + } + addresses?.forEach { + val aTag = it.toTag() + if (aTag in directMentions) { + tags.add(arrayOf("a", aTag, "", "mention")) + } else { + tags.add(arrayOf("a", aTag)) + } + } - replyTos?.forEach { - if (it in directMentions) { - tags.add(arrayOf("e", it, "", "mention")) - } else { - tags.add(arrayOf("e", it)) + tags.add(arrayOf("d", dTag)) + title?.let { tags.add(arrayOf("title", it)) } + image?.let { tags.add(arrayOf("image", it)) } + summary?.let { tags.add(arrayOf("summary", it)) } + price?.let { + if (it.frequency != null && it.currency != null) { + tags.add(arrayOf("price", it.amount, it.currency, it.frequency)) + } else if (it.currency != null) { + tags.add(arrayOf("price", it.amount, it.currency)) + } else { + tags.add(arrayOf("price", it.amount)) + } + } + category?.let { tags.add(arrayOf("t", it)) } + location?.let { tags.add(arrayOf("location", it)) } + publishedAt?.let { tags.add(arrayOf("publishedAt", it.toString())) } + condition?.let { tags.add(arrayOf("condition", it.value)) } + + findHashtags(message).forEach { + tags.add(arrayOf("t", it)) + tags.add(arrayOf("t", it.lowercase())) + } + zapReceiver?.forEach { + tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) + } + findURLs(message).forEach { + val removedParamsFromUrl = + if (it.contains("?")) { + it.split("?")[0].lowercase() + } else if (it.contains("#")) { + it.split("#")[0].lowercase() + } else { + it + } + + if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { + tags.add(arrayOf("image", it)) + } + tags.add(arrayOf("r", it)) + } + if (markAsSensitive) { + tags.add(arrayOf("content-warning", "")) + } + zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } + geohash?.let { tags.addAll(geohashMipMap(it)) } + nip94attachments?.let { + it.forEach { + // tags.add(arrayOf("nip94", it.toJson())) + } + } + tags.add(arrayOf("alt", ALT)) + + signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady) } - } - mentions?.forEach { - if (it in directMentions) { - tags.add(arrayOf("p", it, "", "mention")) - } else { - tags.add(arrayOf("p", it)) - } - } - addresses?.forEach { - val aTag = it.toTag() - if (aTag in directMentions) { - tags.add(arrayOf("a", aTag, "", "mention")) - } else { - tags.add(arrayOf("a", aTag)) - } - } - - tags.add(arrayOf("d", dTag)) - title?.let { tags.add(arrayOf("title", it)) } - image?.let { tags.add(arrayOf("image", it)) } - summary?.let { tags.add(arrayOf("summary", it)) } - price?.let { - if (it.frequency != null && it.currency != null) { - tags.add(arrayOf("price", it.amount, it.currency, it.frequency)) - } else if (it.currency != null) { - tags.add(arrayOf("price", it.amount, it.currency)) - } else { - tags.add(arrayOf("price", it.amount)) - } - } - category?.let { tags.add(arrayOf("t", it)) } - location?.let { tags.add(arrayOf("location", it)) } - publishedAt?.let { tags.add(arrayOf("publishedAt", it.toString())) } - condition?.let { tags.add(arrayOf("condition", it.value)) } - - findHashtags(message).forEach { - tags.add(arrayOf("t", it)) - tags.add(arrayOf("t", it.lowercase())) - } - zapReceiver?.forEach { - tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) - } - findURLs(message).forEach { - val removedParamsFromUrl = - if (it.contains("?")) { - it.split("?")[0].lowercase() - } else if (it.contains("#")) { - it.split("#")[0].lowercase() - } else { - it - } - - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - tags.add(arrayOf("image", it)) - } - tags.add(arrayOf("r", it)) - } - if (markAsSensitive) { - tags.add(arrayOf("content-warning", "")) - } - zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } - geohash?.let { tags.addAll(geohashMipMap(it)) } - nip94attachments?.let { - it.forEach { - // tags.add(arrayOf("nip94", it.toJson())) - } - } - tags.add(arrayOf("alt", ALT)) - - signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady) } - } } data class Price(val amount: String, val currency: String?, val frequency: String?) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityDefinitionEvent.kt index 4b7436ebe..2978b34cf 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityDefinitionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityDefinitionEvent.kt @@ -27,34 +27,33 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class CommunityDefinitionEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) + fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) - fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) + fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) - fun rules() = tags.firstOrNull { it.size > 1 && it[0] == "rules" }?.get(1) + fun rules() = tags.firstOrNull { it.size > 1 && it[0] == "rules" }?.get(1) - fun moderators() = - tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(3)) } + fun moderators() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(3)) } - companion object { - const val KIND = 34550 - const val ALT = "Community definition" + companion object { + const val KIND = 34550 + const val ALT = "Community definition" - fun create( - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityDefinitionEvent) -> Unit, - ) { - val tags = mutableListOf>() - tags.add(arrayOf("alt", ALT)) - signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityDefinitionEvent) -> Unit, + ) { + val tags = mutableListOf>() + tags.add(arrayOf("alt", ALT)) + signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityPostApprovalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityPostApprovalEvent.kt index 81a888dbf..36ff66816 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityPostApprovalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityPostApprovalEvent.kt @@ -29,69 +29,71 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class CommunityPostApprovalEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun containedPost(): Event? = - try { - content.ifBlank { null }?.let { fromJson(it) } - } catch (e: Exception) { - Log.w( - "CommunityPostEvent", - "Failed to Parse Community Approval Contained Post of $id with $content", - ) - null - } - - fun communities() = - tags - .filter { it.size > 1 && it[0] == "a" } - .mapNotNull { - val aTag = ATag.parse(it[1], it.getOrNull(2)) - - if (aTag?.kind == CommunityDefinitionEvent.KIND) { - aTag - } else { - null + fun containedPost(): Event? = + try { + content.ifBlank { null }?.let { fromJson(it) } + } catch (e: Exception) { + Log.w( + "CommunityPostEvent", + "Failed to Parse Community Approval Contained Post of $id with $content", + ) + null } - } - fun approvedEvents() = - tags - .filter { - it.size > 1 && - (it[0] == "e" || - (it[0] == "a" && ATag.parse(it[1], null)?.kind != CommunityDefinitionEvent.KIND)) - } - .map { it[1] } + fun communities() = + tags + .filter { it.size > 1 && it[0] == "a" } + .mapNotNull { + val aTag = ATag.parse(it[1], it.getOrNull(2)) - companion object { - const val KIND = 4550 - const val ALT = "Community post approval" + if (aTag?.kind == CommunityDefinitionEvent.KIND) { + aTag + } else { + null + } + } - fun create( - approvedPost: Event, - community: CommunityDefinitionEvent, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityPostApprovalEvent) -> Unit, - ) { - val content = approvedPost.toJson() + fun approvedEvents() = + tags + .filter { + it.size > 1 && + ( + it[0] == "e" || + (it[0] == "a" && ATag.parse(it[1], null)?.kind != CommunityDefinitionEvent.KIND) + ) + } + .map { it[1] } - val communities = arrayOf("a", community.address().toTag()) - val replyToPost = arrayOf("e", approvedPost.id()) - val replyToAuthor = arrayOf("p", approvedPost.pubKey()) - val innerKind = arrayOf("k", "${approvedPost.kind()}") - val alt = arrayOf("alt", ALT) + companion object { + const val KIND = 4550 + const val ALT = "Community post approval" - val tags: Array> = - arrayOf(communities, replyToPost, replyToAuthor, innerKind, alt) + fun create( + approvedPost: Event, + community: CommunityDefinitionEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityPostApprovalEvent) -> Unit, + ) { + val content = approvedPost.toJson() - signer.sign(createdAt, KIND, tags, content, onReady) + val communities = arrayOf("a", community.address().toTag()) + val replyToPost = arrayOf("e", approvedPost.id()) + val replyToAuthor = arrayOf("p", approvedPost.pubKey()) + val innerKind = arrayOf("k", "${approvedPost.kind()}") + val alt = arrayOf("alt", ALT) + + val tags: Array> = + arrayOf(communities, replyToPost, replyToAuthor, innerKind, alt) + + signer.sign(createdAt, KIND, tags, content, onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt index 607256333..ece905876 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt @@ -36,414 +36,415 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Stable class ContactListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - // This function is only used by the user logged in - // But it is used all the time. + // This function is only used by the user logged in + // But it is used all the time. - @delegate:Transient - val verifiedFollowKeySet: Set by lazy { - tags - .filter { it.size > 1 && it[0] == "p" } - .mapNotNull { - try { - decodePublicKey(it[1]).toHexKey() - } catch (e: Exception) { - Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e) - null - } - } - .toSet() - } - - @delegate:Transient - val verifiedFollowTagSet: Set by lazy { - unverifiedFollowTagSet().map { it.lowercase() }.toSet() - } - - @delegate:Transient - val verifiedFollowGeohashSet: Set by lazy { - unverifiedFollowGeohashSet().map { it.lowercase() }.toSet() - } - - @delegate:Transient - val verifiedFollowCommunitySet: Set by lazy { unverifiedFollowAddressSet().toSet() } - - @delegate:Transient - val verifiedFollowKeySetAndMe: Set by lazy { verifiedFollowKeySet + pubKey } - - fun unverifiedFollowKeySet() = tags.filter { it[0] == "p" }.mapNotNull { it.getOrNull(1) } - - fun unverifiedFollowTagSet() = tags.filter { it[0] == "t" }.mapNotNull { it.getOrNull(1) } - - fun unverifiedFollowGeohashSet() = tags.filter { it[0] == "g" }.mapNotNull { it.getOrNull(1) } - - fun unverifiedFollowAddressSet() = tags.filter { it[0] == "a" }.mapNotNull { it.getOrNull(1) } - - fun follows() = - tags - .filter { it[0] == "p" } - .mapNotNull { - try { - Contact(decodePublicKey(it[1]).toHexKey(), it.getOrNull(2)) - } catch (e: Exception) { - Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e) - null - } - } - - fun followsTags() = tags.filter { it[0] == "t" }.mapNotNull { it.getOrNull(2) } - - fun relays(): Map? = - try { - if (content.isNotEmpty()) { - mapper.readValue>(content) - } else { - null - } - } catch (e: Exception) { - Log.w("ContactListEvent", "Can't parse content as relay lists: $content", e) - null - } - - companion object { - const val KIND = 3 - const val ALT = "Follow List" - - fun createFromScratch( - followUsers: List, - followTags: List, - followGeohashes: List, - followCommunities: List, - followEvents: List, - relayUse: Map?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - val content = - if (relayUse != null) { - mapper.writeValueAsString(relayUse) - } else { - "" - } - - val tags = - followUsers.map { - if (it.relayUri != null) { - arrayOf("p", it.pubKeyHex, it.relayUri) - } else { - arrayOf("p", it.pubKeyHex) - } - } + - followTags.map { arrayOf("t", it) } + - followEvents.map { arrayOf("e", it) } + - followCommunities.map { - if (it.relay != null) { - arrayOf("a", it.toTag(), it.relay) - } else { - arrayOf("a", it.toTag()) + @delegate:Transient + val verifiedFollowKeySet: Set by lazy { + tags + .filter { it.size > 1 && it[0] == "p" } + .mapNotNull { + try { + decodePublicKey(it[1]).toHexKey() + } catch (e: Exception) { + Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e) + null + } } - } + - followGeohashes.map { arrayOf("g", it) } - - return create( - content = content, - tags = tags.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) + .toSet() } - fun followUser( - earlierVersion: ContactListEvent, - pubKeyHex: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (earlierVersion.isTaggedUser(pubKeyHex)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf("p", pubKeyHex)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) + @delegate:Transient + val verifiedFollowTagSet: Set by lazy { + unverifiedFollowTagSet().map { it.lowercase() }.toSet() } - fun unfollowUser( - earlierVersion: ContactListEvent, - pubKeyHex: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (!earlierVersion.isTaggedUser(pubKeyHex)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.filter { it.size > 1 && it[1] != pubKeyHex }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) + @delegate:Transient + val verifiedFollowGeohashSet: Set by lazy { + unverifiedFollowGeohashSet().map { it.lowercase() }.toSet() } - fun followHashtag( - earlierVersion: ContactListEvent, - hashtag: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (earlierVersion.isTaggedHash(hashtag)) return + @delegate:Transient + val verifiedFollowCommunitySet: Set by lazy { unverifiedFollowAddressSet().toSet() } - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf("t", hashtag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } + @delegate:Transient + val verifiedFollowKeySetAndMe: Set by lazy { verifiedFollowKeySet + pubKey } - fun unfollowHashtag( - earlierVersion: ContactListEvent, - hashtag: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (!earlierVersion.isTaggedHash(hashtag)) return + fun unverifiedFollowKeySet() = tags.filter { it[0] == "p" }.mapNotNull { it.getOrNull(1) } - return create( - content = earlierVersion.content, - tags = - earlierVersion.tags.filter { it.size > 1 && !it[1].equals(hashtag, true) }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } + fun unverifiedFollowTagSet() = tags.filter { it[0] == "t" }.mapNotNull { it.getOrNull(1) } - fun followGeohash( - earlierVersion: ContactListEvent, - hashtag: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (earlierVersion.isTaggedGeoHash(hashtag)) return + fun unverifiedFollowGeohashSet() = tags.filter { it[0] == "g" }.mapNotNull { it.getOrNull(1) } - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf("g", hashtag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } + fun unverifiedFollowAddressSet() = tags.filter { it[0] == "a" }.mapNotNull { it.getOrNull(1) } - fun unfollowGeohash( - earlierVersion: ContactListEvent, - hashtag: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (!earlierVersion.isTaggedGeoHash(hashtag)) return + fun follows() = + tags + .filter { it[0] == "p" } + .mapNotNull { + try { + Contact(decodePublicKey(it[1]).toHexKey(), it.getOrNull(2)) + } catch (e: Exception) { + Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e) + null + } + } - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.filter { it.size > 1 && it[1] != hashtag }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } + fun followsTags() = tags.filter { it[0] == "t" }.mapNotNull { it.getOrNull(2) } - fun followEvent( - earlierVersion: ContactListEvent, - idHex: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (earlierVersion.isTaggedEvent(idHex)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf("e", idHex)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun unfollowEvent( - earlierVersion: ContactListEvent, - idHex: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (!earlierVersion.isTaggedEvent(idHex)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.filter { it.size > 1 && it[1] != idHex }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun followAddressableEvent( - earlierVersion: ContactListEvent, - aTag: ATag, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (earlierVersion.isTaggedAddressableNote(aTag.toTag())) return - - return create( - content = earlierVersion.content, - tags = - earlierVersion.tags.plus( - element = listOfNotNull("a", aTag.toTag(), aTag.relay).toTypedArray(), - ), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun unfollowAddressableEvent( - earlierVersion: ContactListEvent, - aTag: ATag, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (!earlierVersion.isTaggedAddressableNote(aTag.toTag())) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.filter { it.size > 1 && it[1] != aTag.toTag() }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun updateRelayList( - earlierVersion: ContactListEvent, - relayUse: Map?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - val content = - if (relayUse != null) { - mapper.writeValueAsString(relayUse) - } else { - "" + fun relays(): Map? = + try { + if (content.isNotEmpty()) { + mapper.readValue>(content) + } else { + null + } + } catch (e: Exception) { + Log.w("ContactListEvent", "Can't parse content as relay lists: $content", e) + null } - return create( - content = content, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } + companion object { + const val KIND = 3 + const val ALT = "Follow List" - fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - val newTags = - if (tags.any { it.size > 1 && it[0] == "alt" }) { - tags - } else { - tags + arrayOf("alt", ALT) + fun createFromScratch( + followUsers: List, + followTags: List, + followGeohashes: List, + followCommunities: List, + followEvents: List, + relayUse: Map?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + val content = + if (relayUse != null) { + mapper.writeValueAsString(relayUse) + } else { + "" + } + + val tags = + followUsers.map { + if (it.relayUri != null) { + arrayOf("p", it.pubKeyHex, it.relayUri) + } else { + arrayOf("p", it.pubKeyHex) + } + } + + followTags.map { arrayOf("t", it) } + + followEvents.map { arrayOf("e", it) } + + followCommunities.map { + if (it.relay != null) { + arrayOf("a", it.toTag(), it.relay) + } else { + arrayOf("a", it.toTag()) + } + } + + followGeohashes.map { arrayOf("g", it) } + + return create( + content = content, + tags = tags.toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) } - signer.sign(createdAt, KIND, newTags, content, onReady) - } - } + fun followUser( + earlierVersion: ContactListEvent, + pubKeyHex: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + if (earlierVersion.isTaggedUser(pubKeyHex)) return - data class ReadWrite(val read: Boolean, val write: Boolean) + return create( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(element = arrayOf("p", pubKeyHex)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun unfollowUser( + earlierVersion: ContactListEvent, + pubKeyHex: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + if (!earlierVersion.isTaggedUser(pubKeyHex)) return + + return create( + content = earlierVersion.content, + tags = earlierVersion.tags.filter { it.size > 1 && it[1] != pubKeyHex }.toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun followHashtag( + earlierVersion: ContactListEvent, + hashtag: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + if (earlierVersion.isTaggedHash(hashtag)) return + + return create( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(element = arrayOf("t", hashtag)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun unfollowHashtag( + earlierVersion: ContactListEvent, + hashtag: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + if (!earlierVersion.isTaggedHash(hashtag)) return + + return create( + content = earlierVersion.content, + tags = + earlierVersion.tags.filter { it.size > 1 && !it[1].equals(hashtag, true) }.toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun followGeohash( + earlierVersion: ContactListEvent, + hashtag: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + if (earlierVersion.isTaggedGeoHash(hashtag)) return + + return create( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(element = arrayOf("g", hashtag)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun unfollowGeohash( + earlierVersion: ContactListEvent, + hashtag: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + if (!earlierVersion.isTaggedGeoHash(hashtag)) return + + return create( + content = earlierVersion.content, + tags = earlierVersion.tags.filter { it.size > 1 && it[1] != hashtag }.toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun followEvent( + earlierVersion: ContactListEvent, + idHex: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + if (earlierVersion.isTaggedEvent(idHex)) return + + return create( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(element = arrayOf("e", idHex)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun unfollowEvent( + earlierVersion: ContactListEvent, + idHex: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + if (!earlierVersion.isTaggedEvent(idHex)) return + + return create( + content = earlierVersion.content, + tags = earlierVersion.tags.filter { it.size > 1 && it[1] != idHex }.toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun followAddressableEvent( + earlierVersion: ContactListEvent, + aTag: ATag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + if (earlierVersion.isTaggedAddressableNote(aTag.toTag())) return + + return create( + content = earlierVersion.content, + tags = + earlierVersion.tags.plus( + element = listOfNotNull("a", aTag.toTag(), aTag.relay).toTypedArray(), + ), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun unfollowAddressableEvent( + earlierVersion: ContactListEvent, + aTag: ATag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + if (!earlierVersion.isTaggedAddressableNote(aTag.toTag())) return + + return create( + content = earlierVersion.content, + tags = earlierVersion.tags.filter { it.size > 1 && it[1] != aTag.toTag() }.toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun updateRelayList( + earlierVersion: ContactListEvent, + relayUse: Map?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + val content = + if (relayUse != null) { + mapper.writeValueAsString(relayUse) + } else { + "" + } + + return create( + content = content, + tags = earlierVersion.tags, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun create( + content: String, + tags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + val newTags = + if (tags.any { it.size > 1 && it[0] == "alt" }) { + tags + } else { + tags + arrayOf("alt", ALT) + } + + signer.sign(createdAt, KIND, newTags, content, onReady) + } + } + + data class ReadWrite(val read: Boolean, val write: Boolean) } @Stable class UserMetadata { - var name: String? = null - var username: String? = null + var name: String? = null + var username: String? = null - @JsonProperty("display_name") var displayName: String? = null - var picture: String? = null - var banner: String? = null - var website: String? = null - var about: String? = null + @JsonProperty("display_name") + var displayName: String? = null + var picture: String? = null + var banner: String? = null + var website: String? = null + var about: String? = null - var nip05: String? = null - var nip05Verified: Boolean = false - var nip05LastVerificationTime: Long? = 0 + var nip05: String? = null + var nip05Verified: Boolean = false + var nip05LastVerificationTime: Long? = 0 - var domain: String? = null - var lud06: String? = null - var lud16: String? = null + var domain: String? = null + var lud06: String? = null + var lud16: String? = null - var twitter: String? = null + var twitter: String? = null - var updatedMetadataAt: Long = 0 - var latestMetadata: MetadataEvent? = null - var tags: ImmutableListOfLists? = null + var updatedMetadataAt: Long = 0 + var latestMetadata: MetadataEvent? = null + var tags: ImmutableListOfLists? = null - fun anyName(): String? { - return displayName ?: name ?: username - } - - fun anyNameStartsWith(prefix: String): Boolean { - return listOfNotNull(name, username, displayName, nip05, lud06, lud16).any { - it.contains(prefix, true) + fun anyName(): String? { + return displayName ?: name ?: username } - } - fun lnAddress(): String? { - return (lud16?.trim() ?: lud06?.trim())?.ifBlank { null } - } + fun anyNameStartsWith(prefix: String): Boolean { + return listOfNotNull(name, username, displayName, nip05, lud06, lud16).any { + it.contains(prefix, true) + } + } - fun bestUsername(): String? { - return name?.ifBlank { null } ?: username?.ifBlank { null } - } + fun lnAddress(): String? { + return (lud16?.trim() ?: lud06?.trim())?.ifBlank { null } + } - fun bestDisplayName(): String? { - return displayName?.ifBlank { null } - } + fun bestUsername(): String? { + return name?.ifBlank { null } ?: username?.ifBlank { null } + } - fun nip05(): String? { - return nip05?.ifBlank { null } - } + fun bestDisplayName(): String? { + return displayName?.ifBlank { null } + } - fun profilePicture(): String? { - if (picture.isNullOrBlank()) picture = null - return picture - } + fun nip05(): String? { + return nip05?.ifBlank { null } + } + + fun profilePicture(): String? { + if (picture.isNullOrBlank()) picture = null + return picture + } } @Stable class ImmutableListOfLists(val lists: Array>) @@ -451,5 +452,5 @@ class UserMetadata { val EmptyTagList = ImmutableListOfLists(emptyArray()) fun Array>.toImmutableListOfLists(): ImmutableListOfLists { - return ImmutableListOfLists(this) + return ImmutableListOfLists(this) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/DeletionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/DeletionEvent.kt index 3b8f42b10..cb9bd04c5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/DeletionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/DeletionEvent.kt @@ -27,29 +27,29 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class DeletionEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun deleteEvents() = tags.map { it[1] } + fun deleteEvents() = tags.map { it[1] } - companion object { - const val KIND = 5 - const val ALT = "Deletion event" + companion object { + const val KIND = 5 + const val ALT = "Deletion event" - fun create( - deleteEvents: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (DeletionEvent) -> Unit, - ) { - val content = "" - val tags = - deleteEvents.map { arrayOf("e", it) }.plusElement(arrayOf("alt", ALT)).toTypedArray() - signer.sign(createdAt, KIND, tags, content, onReady) + fun create( + deleteEvents: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (DeletionEvent) -> Unit, + ) { + val content = "" + val tags = + deleteEvents.map { arrayOf("e", it) }.plusElement(arrayOf("alt", ALT)).toTypedArray() + signer.sign(createdAt, KIND, tags, content, onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackEvent.kt index af732fa38..33d92aae4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackEvent.kt @@ -27,48 +27,48 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class EmojiPackEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 30030 - const val ALT = "Emoji pack" + companion object { + const val KIND = 30030 + const val ALT = "Emoji pack" - fun create( - name: String = "", - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (EmojiPackEvent) -> Unit, - ) { - val content = "" + fun create( + name: String = "", + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (EmojiPackEvent) -> Unit, + ) { + val content = "" - val tags = mutableListOf>() - tags.add(arrayOf("d", name)) - tags.add(arrayOf("alt", ALT)) + val tags = mutableListOf>() + tags.add(arrayOf("d", name)) + tags.add(arrayOf("alt", ALT)) - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } } - } } @Immutable data class EmojiUrl(val code: String, val url: String) { - fun encode(): String { - return ":$code:$url" - } - - companion object { - fun decode(encodedEmojiSetup: String): EmojiUrl? { - val emojiParts = encodedEmojiSetup.split(":", limit = 3) - return if (emojiParts.size > 2) { - EmojiUrl(emojiParts[1], emojiParts[2]) - } else { - null - } + fun encode(): String { + return ":$code:$url" + } + + companion object { + fun decode(encodedEmojiSetup: String): EmojiUrl? { + val emojiParts = encodedEmojiSetup.split(":", limit = 3) + return if (emojiParts.size > 2) { + EmojiUrl(emojiParts[1], emojiParts[2]) + } else { + null + } + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackSelectionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackSelectionEvent.kt index 1ff4cc91c..b294d6cad 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackSelectionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackSelectionEvent.kt @@ -28,34 +28,34 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class EmojiPackSelectionEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - override fun dTag() = FIXED_D_TAG + override fun dTag() = FIXED_D_TAG - companion object { - const val KIND = 10030 - const val FIXED_D_TAG = "" - const val ALT = "Emoji selection" + companion object { + const val KIND = 10030 + const val FIXED_D_TAG = "" + const val ALT = "Emoji selection" - fun create( - listOfEmojiPacks: List?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (EmojiPackSelectionEvent) -> Unit, - ) { - val msg = "" - val tags = mutableListOf>() + fun create( + listOfEmojiPacks: List?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (EmojiPackSelectionEvent) -> Unit, + ) { + val msg = "" + val tags = mutableListOf>() - listOfEmojiPacks?.forEach { tags.add(arrayOf("a", it.toTag())) } + listOfEmojiPacks?.forEach { tags.add(arrayOf("a", it.toTag())) } - tags.add(arrayOf("alt", ALT)) + tags.add(arrayOf("alt", ALT)) - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt index 4e3f564af..219283c45 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt @@ -45,499 +45,491 @@ import java.math.BigDecimal @Immutable open class Event( - val id: HexKey, - @JsonProperty("pubkey") val pubKey: HexKey, - @JsonProperty("created_at") val createdAt: Long, - val kind: Int, - val tags: Array>, - val content: String, - val sig: HexKey, + val id: HexKey, + @JsonProperty("pubkey") val pubKey: HexKey, + @JsonProperty("created_at") val createdAt: Long, + val kind: Int, + val tags: Array>, + val content: String, + val sig: HexKey, ) : EventInterface { - override fun countMemory(): Long { - return 12L + - id.bytesUsedInMemory() + - pubKey.bytesUsedInMemory() + - tags.sumOf { it.sumOf { it.bytesUsedInMemory() } } + - content.bytesUsedInMemory() + - sig.bytesUsedInMemory() - } - - override fun id(): HexKey = id - - override fun pubKey(): HexKey = pubKey - - override fun createdAt(): Long = createdAt - - override fun kind(): Int = kind - - override fun tags(): Array> = tags - - override fun content(): String = content - - override fun sig(): HexKey = sig - - override fun toJson(): String = mapper.writeValueAsString(toJsonObject()) - - override fun hasAnyTaggedUser() = hasTagWithContent("p") - - override fun hasTagWithContent(tagName: String) = tags.any { it.size > 1 && it[0] == tagName } - - override fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } - - override fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } - - override fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] } - - override fun firstTaggedUser() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.let { it[1] } - - override fun firstTaggedEvent() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.let { it[1] } - - override fun firstTaggedUrl() = tags.firstOrNull { it.size > 1 && it[0] == "r" }?.let { it[1] } - - override fun firstTaggedAddress() = - tags - .firstOrNull { it.size > 1 && it[0] == "a" } - ?.let { - val aTagValue = it[1] - val relay = it.getOrNull(2) - - ATag.parse(aTagValue, relay) - } - - override fun taggedEmojis() = - tags.filter { it.size > 2 && it[0] == "emoji" }.map { EmojiUrl(it[1], it[2]) } - - override fun isSensitive() = - tags.any { - (it.size > 0 && it[0].equals("content-warning", true)) || - (it.size > 1 && it[0] == "t" && it[1].equals("nsfw", true)) || - (it.size > 1 && it[0] == "t" && it[1].equals("nude", true)) + override fun countMemory(): Long { + return 12L + + id.bytesUsedInMemory() + + pubKey.bytesUsedInMemory() + + tags.sumOf { it.sumOf { it.bytesUsedInMemory() } } + + content.bytesUsedInMemory() + + sig.bytesUsedInMemory() } - override fun subject() = tags.firstOrNull { it.size > 1 && it[0] == "subject" }?.get(1) + override fun id(): HexKey = id - override fun zapraiserAmount() = - tags.firstOrNull { (it.size > 1 && it[0] == "zapraiser") }?.get(1)?.toLongOrNull() + override fun pubKey(): HexKey = pubKey - override fun hasZapSplitSetup() = tags.any { it.size > 1 && it[0] == "zap" } + override fun createdAt(): Long = createdAt - override fun zapSplitSetup(): List { - return tags - .filter { it.size > 1 && it[0] == "zap" } - .mapNotNull { - val isLnAddress = it[0].contains("@") || it[0].startsWith("LNURL", true) - val weight = if (isLnAddress) 1.0 else (it.getOrNull(3)?.toDoubleOrNull() ?: 0.0) + override fun kind(): Int = kind - if (weight > 0) { - ZapSplitSetup( - it[1], - it.getOrNull(2), - weight, - isLnAddress, - ) - } else { - null + override fun tags(): Array> = tags + + override fun content(): String = content + + override fun sig(): HexKey = sig + + override fun toJson(): String = mapper.writeValueAsString(toJsonObject()) + + override fun hasAnyTaggedUser() = hasTagWithContent("p") + + override fun hasTagWithContent(tagName: String) = tags.any { it.size > 1 && it[0] == tagName } + + override fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } + + override fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + + override fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] } + + override fun firstTaggedUser() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.let { it[1] } + + override fun firstTaggedEvent() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.let { it[1] } + + override fun firstTaggedUrl() = tags.firstOrNull { it.size > 1 && it[0] == "r" }?.let { it[1] } + + override fun firstTaggedAddress() = + tags + .firstOrNull { it.size > 1 && it[0] == "a" } + ?.let { + val aTagValue = it[1] + val relay = it.getOrNull(2) + + ATag.parse(aTagValue, relay) + } + + override fun taggedEmojis() = tags.filter { it.size > 2 && it[0] == "emoji" }.map { EmojiUrl(it[1], it[2]) } + + override fun isSensitive() = + tags.any { + (it.size > 0 && it[0].equals("content-warning", true)) || + (it.size > 1 && it[0] == "t" && it[1].equals("nsfw", true)) || + (it.size > 1 && it[0] == "t" && it[1].equals("nude", true)) } - } - } - override fun taggedAddresses() = - tags - .filter { it.size > 1 && it[0] == "a" } - .mapNotNull { - val aTagValue = it[1] - val relay = it.getOrNull(2) + override fun subject() = tags.firstOrNull { it.size > 1 && it[0] == "subject" }?.get(1) - ATag.parse(aTagValue, relay) - } + override fun zapraiserAmount() = tags.firstOrNull { (it.size > 1 && it[0] == "zapraiser") }?.get(1)?.toLongOrNull() - override fun hasHashtags() = tags.any { it.size > 1 && it[0] == "t" } + override fun hasZapSplitSetup() = tags.any { it.size > 1 && it[0] == "zap" } - override fun hasGeohashes() = tags.any { it.size > 1 && it[0] == "g" } + override fun zapSplitSetup(): List { + return tags + .filter { it.size > 1 && it[0] == "zap" } + .mapNotNull { + val isLnAddress = it[0].contains("@") || it[0].startsWith("LNURL", true) + val weight = if (isLnAddress) 1.0 else (it.getOrNull(3)?.toDoubleOrNull() ?: 0.0) - override fun hashtags() = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] } - - override fun geohashes() = tags.filter { it.size > 1 && it[0] == "g" }.map { it[1] } - - override fun matchTag1With(text: String) = tags.any { it.size > 1 && it[1].contains(text, true) } - - override fun isTagged( - key: String, - tag: String, - ) = tags.any { it.size > 1 && it[0] == key && it[1] == tag } - - override fun isAnyTagged( - key: String, - tags: Set, - ) = this.tags.any { it.size > 1 && it[0] == key && it[1] in tags } - - override fun isTaggedWord(word: String) = isTagged("word", word) - - override fun isTaggedUser(idHex: String) = isTagged("p", idHex) - - override fun isTaggedUsers(idHexes: Set) = isAnyTagged("p", idHexes) - - override fun isTaggedEvent(idHex: String) = isTagged("e", idHex) - - override fun isTaggedAddressableNote(idHex: String) = isTagged("a", idHex) - - override fun isTaggedAddressableNotes(idHexes: Set) = isAnyTagged("a", idHexes) - - override fun isTaggedHash(hashtag: String) = - tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) } - - override fun isTaggedGeoHash(hashtag: String) = - tags.any { it.size > 1 && it[0] == "g" && it[1].startsWith(hashtag, true) } - - override fun isTaggedHashes(hashtags: Set) = - tags.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags } - - override fun isTaggedGeoHashes(hashtags: Set) = - tags.any { it.size > 1 && it[0] == "g" && it[1].lowercase() in hashtags } - - override fun firstIsTaggedHashes(hashtags: Set) = - tags.firstOrNull { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }?.getOrNull(1) - - override fun firstIsTaggedAddressableNote(addressableNotes: Set) = - tags.firstOrNull { it.size > 1 && it[0] == "a" && it[1] in addressableNotes }?.getOrNull(1) - - override fun isTaggedAddressableKind(kind: Int): Boolean { - val kindStr = kind.toString() - return tags.any { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) } - } - - override fun expiration() = - try { - tags.firstOrNull { it.size > 1 && it[0] == "expiration" }?.get(1)?.toLongOrNull() - } catch (_: Exception) { - null + if (weight > 0) { + ZapSplitSetup( + it[1], + it.getOrNull(2), + weight, + isLnAddress, + ) + } else { + null + } + } } - override fun isExpired() = (expiration() ?: Long.MAX_VALUE) < TimeUtils.now() + override fun taggedAddresses() = + tags + .filter { it.size > 1 && it[0] == "a" } + .mapNotNull { + val aTagValue = it[1] + val relay = it.getOrNull(2) - override fun getTagOfAddressableKind(kind: Int): ATag? { - val kindStr = kind.toString() - val aTag = - tags.firstOrNull { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) }?.getOrNull(1) - ?: return null + ATag.parse(aTagValue, relay) + } - return ATag.parse(aTag, null) - } + override fun hasHashtags() = tags.any { it.size > 1 && it[0] == "t" } - override fun getPoWRank(): Int { - var rank = 0 - for (i in 0..id.length) { - if (id[i] == '0') { - rank += 4 - } else if (id[i] in '4'..'7') { - rank += 1 - break - } else if (id[i] in '2'..'3') { - rank += 2 - break - } else if (id[i] == '1') { - rank += 3 - break - } else { - break - } + override fun hasGeohashes() = tags.any { it.size > 1 && it[0] == "g" } + + override fun hashtags() = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] } + + override fun geohashes() = tags.filter { it.size > 1 && it[0] == "g" }.map { it[1] } + + override fun matchTag1With(text: String) = tags.any { it.size > 1 && it[1].contains(text, true) } + + override fun isTagged( + key: String, + tag: String, + ) = tags.any { it.size > 1 && it[0] == key && it[1] == tag } + + override fun isAnyTagged( + key: String, + tags: Set, + ) = this.tags.any { it.size > 1 && it[0] == key && it[1] in tags } + + override fun isTaggedWord(word: String) = isTagged("word", word) + + override fun isTaggedUser(idHex: String) = isTagged("p", idHex) + + override fun isTaggedUsers(idHexes: Set) = isAnyTagged("p", idHexes) + + override fun isTaggedEvent(idHex: String) = isTagged("e", idHex) + + override fun isTaggedAddressableNote(idHex: String) = isTagged("a", idHex) + + override fun isTaggedAddressableNotes(idHexes: Set) = isAnyTagged("a", idHexes) + + override fun isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) } + + override fun isTaggedGeoHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "g" && it[1].startsWith(hashtag, true) } + + override fun isTaggedHashes(hashtags: Set) = tags.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags } + + override fun isTaggedGeoHashes(hashtags: Set) = tags.any { it.size > 1 && it[0] == "g" && it[1].lowercase() in hashtags } + + override fun firstIsTaggedHashes(hashtags: Set) = tags.firstOrNull { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }?.getOrNull(1) + + override fun firstIsTaggedAddressableNote(addressableNotes: Set) = tags.firstOrNull { it.size > 1 && it[0] == "a" && it[1] in addressableNotes }?.getOrNull(1) + + override fun isTaggedAddressableKind(kind: Int): Boolean { + val kindStr = kind.toString() + return tags.any { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) } } - return rank - } - override fun getGeoHash(): String? { - return tags.firstOrNull { it.size > 1 && it[0] == "g" }?.get(1)?.ifBlank { null } - } + override fun expiration() = + try { + tags.firstOrNull { it.size > 1 && it[0] == "expiration" }?.get(1)?.toLongOrNull() + } catch (_: Exception) { + null + } - override fun getReward(): BigDecimal? { - return try { - tags.firstOrNull { it.size > 1 && it[0] == "reward" }?.get(1)?.let { BigDecimal(it) } - } catch (e: Exception) { - null + override fun isExpired() = (expiration() ?: Long.MAX_VALUE) < TimeUtils.now() + + override fun getTagOfAddressableKind(kind: Int): ATag? { + val kindStr = kind.toString() + val aTag = + tags.firstOrNull { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) }?.getOrNull(1) + ?: return null + + return ATag.parse(aTag, null) } - } - open fun toNIP19(): String { - return if (this is AddressableEvent) { - ATag(kind, pubKey, dTag(), null).toNAddr() - } else { - Nip19.createNEvent(id, pubKey, kind, null) + override fun getPoWRank(): Int { + var rank = 0 + for (i in 0..id.length) { + if (id[i] == '0') { + rank += 4 + } else if (id[i] in '4'..'7') { + rank += 1 + break + } else if (id[i] in '2'..'3') { + rank += 2 + break + } else if (id[i] == '1') { + rank += 3 + break + } else { + break + } + } + return rank } - } - fun toNostrUri(): String { - return "nostr:${toNIP19()}" - } + override fun getGeoHash(): String? { + return tags.firstOrNull { it.size > 1 && it[0] == "g" }?.get(1)?.ifBlank { null } + } - fun hasCorrectIDHash(): Boolean { - if (id.isEmpty()) return false - return id.equals(generateId()) - } + override fun getReward(): BigDecimal? { + return try { + tags.firstOrNull { it.size > 1 && it[0] == "reward" }?.get(1)?.let { BigDecimal(it) } + } catch (e: Exception) { + null + } + } - fun hasVerifiedSignature(): Boolean { - if (id.isEmpty() || sig.isEmpty()) return false - return CryptoUtils.verifySignature(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey)) - } + open fun toNIP19(): String { + return if (this is AddressableEvent) { + ATag(kind, pubKey, dTag(), null).toNAddr() + } else { + Nip19.createNEvent(id, pubKey, kind, null) + } + } - /** Checks if the ID is correct and then if the pubKey's secret key signed the event. */ - override fun checkSignature() { - if (!hasCorrectIDHash()) { - throw Exception( - """ + fun toNostrUri(): String { + return "nostr:${toNIP19()}" + } + + fun hasCorrectIDHash(): Boolean { + if (id.isEmpty()) return false + return id.equals(generateId()) + } + + fun hasVerifiedSignature(): Boolean { + if (id.isEmpty() || sig.isEmpty()) return false + return CryptoUtils.verifySignature(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey)) + } + + /** Checks if the ID is correct and then if the pubKey's secret key signed the event. */ + override fun checkSignature() { + if (!hasCorrectIDHash()) { + throw Exception( + """ |Unexpected ID. | Event: ${toJson()} | Actual ID: $id | Generated: ${generateId()} """ - .trimIndent(), - ) - } - if (!hasVerifiedSignature()) { - throw Exception("""Bad signature!""") - } - } - - override fun hasValidSignature(): Boolean { - return try { - hasCorrectIDHash() && hasVerifiedSignature() - } catch (e: Exception) { - Log.w("Event", "Event $id does not have a valid signature: ${toJson()}", e) - false - } - } - - fun makeJsonForId(): String { - return makeJsonForId(pubKey, createdAt, kind, tags, content) - } - - private fun generateId(): String { - return CryptoUtils.sha256(makeJsonForId().toByteArray()).toHexKey() - } - - private class EventDeserializer : StdDeserializer(Event::class.java) { - override fun deserialize( - jp: JsonParser, - ctxt: DeserializationContext, - ): Event { - return fromJson(jp.codec.readTree(jp)) - } - } - - private class GossipDeserializer : StdDeserializer(Gossip::class.java) { - override fun deserialize( - jp: JsonParser, - ctxt: DeserializationContext, - ): Gossip { - val jsonObject: JsonNode = jp.codec.readTree(jp) - return Gossip( - id = jsonObject.get("id")?.asText()?.intern(), - pubKey = jsonObject.get("pubkey")?.asText()?.intern(), - createdAt = jsonObject.get("created_at")?.asLong(), - kind = jsonObject.get("kind")?.asInt(), - tags = - jsonObject.get("tags").toTypedArray { - it.toTypedArray { s -> if (s.isNull) "" else s.asText().intern() } - }, - content = jsonObject.get("content")?.asText(), - ) - } - } - - private class EventSerializer : StdSerializer(Event::class.java) { - override fun serialize( - event: Event, - gen: JsonGenerator, - provider: SerializerProvider, - ) { - gen.writeStartObject() - gen.writeStringField("id", event.id) - gen.writeStringField("pubkey", event.pubKey) - gen.writeNumberField("created_at", event.createdAt) - gen.writeNumberField("kind", event.kind) - gen.writeArrayFieldStart("tags") - event.tags.forEach { tag -> gen.writeArray(tag, 0, tag.size) } - gen.writeEndArray() - gen.writeStringField("content", event.content) - gen.writeStringField("sig", event.sig) - gen.writeEndObject() - } - } - - private class GossipSerializer : StdSerializer(Gossip::class.java) { - override fun serialize( - event: Gossip, - gen: JsonGenerator, - provider: SerializerProvider, - ) { - gen.writeStartObject() - event.id?.let { gen.writeStringField("id", it) } - event.pubKey?.let { gen.writeStringField("pubkey", it) } - event.createdAt?.let { gen.writeNumberField("created_at", it) } - event.kind?.let { gen.writeNumberField("kind", it) } - event.tags?.let { - gen.writeArrayFieldStart("tags") - event.tags.forEach { tag -> gen.writeArray(tag, 0, tag.size) } - gen.writeEndArray() - } - event.content?.let { gen.writeStringField("content", it) } - gen.writeEndObject() - } - } - - fun toJsonObject(): JsonNode { - val factory = mapper.nodeFactory - - return factory.objectNode().apply { - put("id", id) - put("pubkey", pubKey) - put("created_at", createdAt) - put("kind", kind) - put( - "tags", - factory.arrayNode(tags.size).apply { - tags.forEach { tag -> - add( - factory.arrayNode(tag.size).apply { tag.forEach { add(it) } }, + .trimIndent(), ) - } - }, - ) - put("content", content) - put("sig", sig) - } - } - - companion object { - val mapper = - jacksonObjectMapper() - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - .registerModule( - SimpleModule() - .addSerializer(Event::class.java, EventSerializer()) - .addDeserializer(Event::class.java, EventDeserializer()) - .addSerializer(Gossip::class.java, GossipSerializer()) - .addDeserializer(Gossip::class.java, GossipDeserializer()) - .addDeserializer(Response::class.java, ResponseDeserializer()) - .addDeserializer(Request::class.java, RequestDeserializer()), - ) - - fun fromJson(jsonObject: JsonNode): Event { - return EventFactory.create( - id = jsonObject.get("id").asText().intern(), - pubKey = jsonObject.get("pubkey").asText().intern(), - createdAt = jsonObject.get("created_at").asLong(), - kind = jsonObject.get("kind").asInt(), - tags = - jsonObject.get("tags").toTypedArray { - it.toTypedArray { s -> if (s.isNull) "" else s.asText().intern() } - }, - content = jsonObject.get("content").asText(), - sig = jsonObject.get("sig").asText(), - ) + } + if (!hasVerifiedSignature()) { + throw Exception("""Bad signature!""") + } } - private inline fun JsonNode.toTypedArray(transform: (JsonNode) -> R): Array { - return Array(size()) { transform(get(it)) } + override fun hasValidSignature(): Boolean { + return try { + hasCorrectIDHash() && hasVerifiedSignature() + } catch (e: Exception) { + Log.w("Event", "Event $id does not have a valid signature: ${toJson()}", e) + false + } } - fun fromJson(json: String): Event = mapper.readValue(json, Event::class.java) + fun makeJsonForId(): String { + return makeJsonForId(pubKey, createdAt, kind, tags, content) + } - fun toJson(event: Event): String = mapper.writeValueAsString(event) + private fun generateId(): String { + return CryptoUtils.sha256(makeJsonForId().toByteArray()).toHexKey() + } - fun makeJsonForId( - pubKey: HexKey, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - ): String { - val factory = mapper.nodeFactory - val rawEvent = - factory.arrayNode(6).apply { - add(0) - add(pubKey) - add(createdAt) - add(kind) - add( - factory.arrayNode(tags.size).apply { - tags.forEach { tag -> - add( - factory.arrayNode(tag.size).apply { tag.forEach { add(it) } }, + private class EventDeserializer : StdDeserializer(Event::class.java) { + override fun deserialize( + jp: JsonParser, + ctxt: DeserializationContext, + ): Event { + return fromJson(jp.codec.readTree(jp)) + } + } + + private class GossipDeserializer : StdDeserializer(Gossip::class.java) { + override fun deserialize( + jp: JsonParser, + ctxt: DeserializationContext, + ): Gossip { + val jsonObject: JsonNode = jp.codec.readTree(jp) + return Gossip( + id = jsonObject.get("id")?.asText()?.intern(), + pubKey = jsonObject.get("pubkey")?.asText()?.intern(), + createdAt = jsonObject.get("created_at")?.asLong(), + kind = jsonObject.get("kind")?.asInt(), + tags = + jsonObject.get("tags").toTypedArray { + it.toTypedArray { s -> if (s.isNull) "" else s.asText().intern() } + }, + content = jsonObject.get("content")?.asText(), + ) + } + } + + private class EventSerializer : StdSerializer(Event::class.java) { + override fun serialize( + event: Event, + gen: JsonGenerator, + provider: SerializerProvider, + ) { + gen.writeStartObject() + gen.writeStringField("id", event.id) + gen.writeStringField("pubkey", event.pubKey) + gen.writeNumberField("created_at", event.createdAt) + gen.writeNumberField("kind", event.kind) + gen.writeArrayFieldStart("tags") + event.tags.forEach { tag -> gen.writeArray(tag, 0, tag.size) } + gen.writeEndArray() + gen.writeStringField("content", event.content) + gen.writeStringField("sig", event.sig) + gen.writeEndObject() + } + } + + private class GossipSerializer : StdSerializer(Gossip::class.java) { + override fun serialize( + event: Gossip, + gen: JsonGenerator, + provider: SerializerProvider, + ) { + gen.writeStartObject() + event.id?.let { gen.writeStringField("id", it) } + event.pubKey?.let { gen.writeStringField("pubkey", it) } + event.createdAt?.let { gen.writeNumberField("created_at", it) } + event.kind?.let { gen.writeNumberField("kind", it) } + event.tags?.let { + gen.writeArrayFieldStart("tags") + event.tags.forEach { tag -> gen.writeArray(tag, 0, tag.size) } + gen.writeEndArray() + } + event.content?.let { gen.writeStringField("content", it) } + gen.writeEndObject() + } + } + + fun toJsonObject(): JsonNode { + val factory = mapper.nodeFactory + + return factory.objectNode().apply { + put("id", id) + put("pubkey", pubKey) + put("created_at", createdAt) + put("kind", kind) + put( + "tags", + factory.arrayNode(tags.size).apply { + tags.forEach { tag -> + add( + factory.arrayNode(tag.size).apply { tag.forEach { add(it) } }, + ) + } + }, + ) + put("content", content) + put("sig", sig) + } + } + + companion object { + val mapper = + jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .registerModule( + SimpleModule() + .addSerializer(Event::class.java, EventSerializer()) + .addDeserializer(Event::class.java, EventDeserializer()) + .addSerializer(Gossip::class.java, GossipSerializer()) + .addDeserializer(Gossip::class.java, GossipDeserializer()) + .addDeserializer(Response::class.java, ResponseDeserializer()) + .addDeserializer(Request::class.java, RequestDeserializer()), ) - } - }, - ) - add(content) + + fun fromJson(jsonObject: JsonNode): Event { + return EventFactory.create( + id = jsonObject.get("id").asText().intern(), + pubKey = jsonObject.get("pubkey").asText().intern(), + createdAt = jsonObject.get("created_at").asLong(), + kind = jsonObject.get("kind").asInt(), + tags = + jsonObject.get("tags").toTypedArray { + it.toTypedArray { s -> if (s.isNull) "" else s.asText().intern() } + }, + content = jsonObject.get("content").asText(), + sig = jsonObject.get("sig").asText(), + ) } - return mapper.writeValueAsString(rawEvent) - } + private inline fun JsonNode.toTypedArray(transform: (JsonNode) -> R): Array { + return Array(size()) { transform(get(it)) } + } - fun generateId( - pubKey: HexKey, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - ): ByteArray { - return CryptoUtils.sha256(makeJsonForId(pubKey, createdAt, kind, tags, content).toByteArray()) - } + fun fromJson(json: String): Event = mapper.readValue(json, Event::class.java) - fun create( - signer: NostrSigner, - kind: Int, - tags: Array> = emptyArray(), - content: String = "", - createdAt: Long = TimeUtils.now(), - onReady: (Event) -> Unit, - ) { - return signer.sign(createdAt, kind, tags, content, onReady) + fun toJson(event: Event): String = mapper.writeValueAsString(event) + + fun makeJsonForId( + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): String { + val factory = mapper.nodeFactory + val rawEvent = + factory.arrayNode(6).apply { + add(0) + add(pubKey) + add(createdAt) + add(kind) + add( + factory.arrayNode(tags.size).apply { + tags.forEach { tag -> + add( + factory.arrayNode(tag.size).apply { tag.forEach { add(it) } }, + ) + } + }, + ) + add(content) + } + + return mapper.writeValueAsString(rawEvent) + } + + fun generateId( + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): ByteArray { + return CryptoUtils.sha256(makeJsonForId(pubKey, createdAt, kind, tags, content).toByteArray()) + } + + fun create( + signer: NostrSigner, + kind: Int, + tags: Array> = emptyArray(), + content: String = "", + createdAt: Long = TimeUtils.now(), + onReady: (Event) -> Unit, + ) { + return signer.sign(createdAt, kind, tags, content, onReady) + } } - } } @Immutable open class WrappedEvent( - id: HexKey, - @JsonProperty("pubkey") pubKey: HexKey, - @JsonProperty("created_at") createdAt: Long, - kind: Int, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + @JsonProperty("pubkey") pubKey: HexKey, + @JsonProperty("created_at") createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient var host: Event? = null // host event to broadcast when needed + @Transient var host: Event? = null // host event to broadcast when needed } @Immutable interface AddressableEvent { - fun dTag(): String + fun dTag(): String - fun address(): ATag + fun address(): ATag } @Immutable open class BaseAddressableEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent { - override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" + override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" - override fun address() = ATag(kind, pubKey, dTag(), null) + override fun address() = ATag(kind, pubKey, dTag(), null) } fun String.bytesUsedInMemory(): Int { - return (8 * ((((this.length) * 2) + 45) / 8)) + return (8 * ((((this.length) * 2) + 45) / 8)) } data class ZapSplitSetup( - val lnAddressOrPubKeyHex: String, - val relay: String?, - val weight: Double, - val isLnAddress: Boolean, + val lnAddressOrPubKeyHex: String, + val relay: String?, + val weight: Double, + val isLnAddress: Boolean, ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt index 78ed0b92f..6c29e297b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt @@ -23,107 +23,106 @@ package com.vitorpamplona.quartz.events import com.vitorpamplona.quartz.encoders.toHexKey class EventFactory { - companion object { - fun create( - id: String, - pubKey: String, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - sig: String, - ) = - when (kind) { - AdvertisedRelayListEvent.KIND -> - AdvertisedRelayListEvent(id, pubKey, createdAt, tags, content, sig) - AppDefinitionEvent.KIND -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig) - AppRecommendationEvent.KIND -> - AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig) - AudioHeaderEvent.KIND -> AudioHeaderEvent(id, pubKey, createdAt, tags, content, sig) - AudioTrackEvent.KIND -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig) - BadgeAwardEvent.KIND -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig) - BadgeDefinitionEvent.KIND -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig) - BadgeProfilesEvent.KIND -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig) - BookmarkListEvent.KIND -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig) - CalendarDateSlotEvent.KIND -> - CalendarDateSlotEvent(id, pubKey, createdAt, tags, content, sig) - CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig) - CalendarTimeSlotEvent.KIND -> - CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig) - CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig) - ChannelCreateEvent.KIND -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig) - ChannelHideMessageEvent.KIND -> - ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig) - ChannelMessageEvent.KIND -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig) - ChannelMetadataEvent.KIND -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig) - ChannelMuteUserEvent.KIND -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig) - ChatMessageEvent.KIND -> { - if (id.isBlank()) { - val newId = Event.generateId(pubKey, createdAt, kind, tags, content).toHexKey() - ChatMessageEvent( - newId, - pubKey, - createdAt, - tags, - content, - sig, - ) - } else { - ChatMessageEvent(id, pubKey, createdAt, tags, content, sig) - } + companion object { + fun create( + id: String, + pubKey: String, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: String, + ) = when (kind) { + AdvertisedRelayListEvent.KIND -> + AdvertisedRelayListEvent(id, pubKey, createdAt, tags, content, sig) + AppDefinitionEvent.KIND -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig) + AppRecommendationEvent.KIND -> + AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig) + AudioHeaderEvent.KIND -> AudioHeaderEvent(id, pubKey, createdAt, tags, content, sig) + AudioTrackEvent.KIND -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig) + BadgeAwardEvent.KIND -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig) + BadgeDefinitionEvent.KIND -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig) + BadgeProfilesEvent.KIND -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig) + BookmarkListEvent.KIND -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig) + CalendarDateSlotEvent.KIND -> + CalendarDateSlotEvent(id, pubKey, createdAt, tags, content, sig) + CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig) + CalendarTimeSlotEvent.KIND -> + CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig) + CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig) + ChannelCreateEvent.KIND -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig) + ChannelHideMessageEvent.KIND -> + ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig) + ChannelMessageEvent.KIND -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig) + ChannelMetadataEvent.KIND -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig) + ChannelMuteUserEvent.KIND -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig) + ChatMessageEvent.KIND -> { + if (id.isBlank()) { + val newId = Event.generateId(pubKey, createdAt, kind, tags, content).toHexKey() + ChatMessageEvent( + newId, + pubKey, + createdAt, + tags, + content, + sig, + ) + } else { + ChatMessageEvent(id, pubKey, createdAt, tags, content, sig) + } + } + ClassifiedsEvent.KIND -> ClassifiedsEvent(id, pubKey, createdAt, tags, content, sig) + CommunityDefinitionEvent.KIND -> + CommunityDefinitionEvent(id, pubKey, createdAt, tags, content, sig) + CommunityPostApprovalEvent.KIND -> + CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig) + ContactListEvent.KIND -> ContactListEvent(id, pubKey, createdAt, tags, content, sig) + DeletionEvent.KIND -> DeletionEvent(id, pubKey, createdAt, tags, content, sig) + EmojiPackEvent.KIND -> EmojiPackEvent(id, pubKey, createdAt, tags, content, sig) + EmojiPackSelectionEvent.KIND -> + EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig) + FileHeaderEvent.KIND -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig) + FileServersEvent.KIND -> FileServersEvent(id, pubKey, createdAt, tags, content, sig) + FileStorageEvent.KIND -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig) + FileStorageHeaderEvent.KIND -> + FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig) + GenericRepostEvent.KIND -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig) + GiftWrapEvent.KIND -> GiftWrapEvent(id, pubKey, createdAt, tags, content, sig) + GoalEvent.KIND -> GoalEvent(id, pubKey, createdAt, tags, content, sig) + HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) + HTTPAuthorizationEvent.KIND -> + HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) + LiveActivitiesChatMessageEvent.KIND -> + LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig) + LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig) + LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) + LnZapPaymentRequestEvent.KIND -> + LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) + LnZapPaymentResponseEvent.KIND -> + LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) + LnZapPrivateEvent.KIND -> LnZapPrivateEvent(id, pubKey, createdAt, tags, content, sig) + LnZapRequestEvent.KIND -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig) + LongTextNoteEvent.KIND -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig) + MetadataEvent.KIND -> MetadataEvent(id, pubKey, createdAt, tags, content, sig) + MuteListEvent.KIND -> MuteListEvent(id, pubKey, createdAt, tags, content, sig) + NNSEvent.KIND -> NNSEvent(id, pubKey, createdAt, tags, content, sig) + PeopleListEvent.KIND -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig) + PinListEvent.KIND -> PinListEvent(id, pubKey, createdAt, tags, content, sig) + PollNoteEvent.KIND -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig) + PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) + ReactionEvent.KIND -> ReactionEvent(id, pubKey, createdAt, tags, content, sig) + RecommendRelayEvent.KIND -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig) + RelayAuthEvent.KIND -> RelayAuthEvent(id, pubKey, createdAt, tags, content, sig) + RelaySetEvent.KIND -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig) + ReportEvent.KIND -> ReportEvent(id, pubKey, createdAt, tags, content, sig) + RepostEvent.KIND -> RepostEvent(id, pubKey, createdAt, tags, content, sig) + SealedGossipEvent.KIND -> SealedGossipEvent(id, pubKey, createdAt, tags, content, sig) + StatusEvent.KIND -> StatusEvent(id, pubKey, createdAt, tags, content, sig) + TextNoteEvent.KIND -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig) + VideoHorizontalEvent.KIND -> VideoHorizontalEvent(id, pubKey, createdAt, tags, content, sig) + VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig) + VideoViewEvent.KIND -> VideoViewEvent(id, pubKey, createdAt, tags, content, sig) + else -> Event(id, pubKey, createdAt, kind, tags, content, sig) } - ClassifiedsEvent.KIND -> ClassifiedsEvent(id, pubKey, createdAt, tags, content, sig) - CommunityDefinitionEvent.KIND -> - CommunityDefinitionEvent(id, pubKey, createdAt, tags, content, sig) - CommunityPostApprovalEvent.KIND -> - CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig) - ContactListEvent.KIND -> ContactListEvent(id, pubKey, createdAt, tags, content, sig) - DeletionEvent.KIND -> DeletionEvent(id, pubKey, createdAt, tags, content, sig) - EmojiPackEvent.KIND -> EmojiPackEvent(id, pubKey, createdAt, tags, content, sig) - EmojiPackSelectionEvent.KIND -> - EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig) - FileHeaderEvent.KIND -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig) - FileServersEvent.KIND -> FileServersEvent(id, pubKey, createdAt, tags, content, sig) - FileStorageEvent.KIND -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig) - FileStorageHeaderEvent.KIND -> - FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig) - GenericRepostEvent.KIND -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig) - GiftWrapEvent.KIND -> GiftWrapEvent(id, pubKey, createdAt, tags, content, sig) - GoalEvent.KIND -> GoalEvent(id, pubKey, createdAt, tags, content, sig) - HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) - HTTPAuthorizationEvent.KIND -> - HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) - LiveActivitiesChatMessageEvent.KIND -> - LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig) - LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig) - LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) - LnZapPaymentRequestEvent.KIND -> - LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) - LnZapPaymentResponseEvent.KIND -> - LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) - LnZapPrivateEvent.KIND -> LnZapPrivateEvent(id, pubKey, createdAt, tags, content, sig) - LnZapRequestEvent.KIND -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig) - LongTextNoteEvent.KIND -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig) - MetadataEvent.KIND -> MetadataEvent(id, pubKey, createdAt, tags, content, sig) - MuteListEvent.KIND -> MuteListEvent(id, pubKey, createdAt, tags, content, sig) - NNSEvent.KIND -> NNSEvent(id, pubKey, createdAt, tags, content, sig) - PeopleListEvent.KIND -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig) - PinListEvent.KIND -> PinListEvent(id, pubKey, createdAt, tags, content, sig) - PollNoteEvent.KIND -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig) - PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) - ReactionEvent.KIND -> ReactionEvent(id, pubKey, createdAt, tags, content, sig) - RecommendRelayEvent.KIND -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig) - RelayAuthEvent.KIND -> RelayAuthEvent(id, pubKey, createdAt, tags, content, sig) - RelaySetEvent.KIND -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig) - ReportEvent.KIND -> ReportEvent(id, pubKey, createdAt, tags, content, sig) - RepostEvent.KIND -> RepostEvent(id, pubKey, createdAt, tags, content, sig) - SealedGossipEvent.KIND -> SealedGossipEvent(id, pubKey, createdAt, tags, content, sig) - StatusEvent.KIND -> StatusEvent(id, pubKey, createdAt, tags, content, sig) - TextNoteEvent.KIND -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig) - VideoHorizontalEvent.KIND -> VideoHorizontalEvent(id, pubKey, createdAt, tags, content, sig) - VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig) - VideoViewEvent.KIND -> VideoViewEvent(id, pubKey, createdAt, tags, content, sig) - else -> Event(id, pubKey, createdAt, kind, tags, content, sig) - } - } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt index eb50526b1..d8c977288 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt @@ -27,115 +27,115 @@ import java.math.BigDecimal @Immutable interface EventInterface { - fun countMemory(): Long + fun countMemory(): Long - fun id(): HexKey + fun id(): HexKey - fun pubKey(): HexKey + fun pubKey(): HexKey - fun createdAt(): Long + fun createdAt(): Long - fun kind(): Int + fun kind(): Int - fun tags(): Array> + fun tags(): Array> - fun content(): String + fun content(): String - fun sig(): HexKey + fun sig(): HexKey - fun toJson(): String + fun toJson(): String - fun checkSignature() + fun checkSignature() - fun hasValidSignature(): Boolean + fun hasValidSignature(): Boolean - fun isTagged( - key: String, - tag: String, - ): Boolean + fun isTagged( + key: String, + tag: String, + ): Boolean - fun isAnyTagged( - key: String, - tags: Set, - ): Boolean + fun isAnyTagged( + key: String, + tags: Set, + ): Boolean - fun isTaggedWord(word: String): Boolean + fun isTaggedWord(word: String): Boolean - fun isTaggedUser(idHex: String): Boolean + fun isTaggedUser(idHex: String): Boolean - fun isTaggedUsers(idHex: Set): Boolean + fun isTaggedUsers(idHex: Set): Boolean - fun isTaggedEvent(idHex: String): Boolean + fun isTaggedEvent(idHex: String): Boolean - fun isTaggedAddressableNote(idHex: String): Boolean + fun isTaggedAddressableNote(idHex: String): Boolean - fun isTaggedAddressableNotes(idHexes: Set): Boolean + fun isTaggedAddressableNotes(idHexes: Set): Boolean - fun isTaggedHash(hashtag: String): Boolean + fun isTaggedHash(hashtag: String): Boolean - fun isTaggedGeoHash(hashtag: String): Boolean + fun isTaggedGeoHash(hashtag: String): Boolean - fun isTaggedHashes(hashtags: Set): Boolean + fun isTaggedHashes(hashtags: Set): Boolean - fun isTaggedGeoHashes(hashtags: Set): Boolean + fun isTaggedGeoHashes(hashtags: Set): Boolean - fun firstIsTaggedHashes(hashtags: Set): String? + fun firstIsTaggedHashes(hashtags: Set): String? - fun firstIsTaggedAddressableNote(addressableNotes: Set): String? + fun firstIsTaggedAddressableNote(addressableNotes: Set): String? - fun isTaggedAddressableKind(kind: Int): Boolean + fun isTaggedAddressableKind(kind: Int): Boolean - fun getTagOfAddressableKind(kind: Int): ATag? + fun getTagOfAddressableKind(kind: Int): ATag? - fun expiration(): Long? + fun expiration(): Long? - fun hasHashtags(): Boolean + fun hasHashtags(): Boolean - fun hasGeohashes(): Boolean + fun hasGeohashes(): Boolean - fun hashtags(): List + fun hashtags(): List - fun geohashes(): List + fun geohashes(): List - fun getReward(): BigDecimal? + fun getReward(): BigDecimal? - fun getPoWRank(): Int + fun getPoWRank(): Int - fun getGeoHash(): String? + fun getGeoHash(): String? - fun zapSplitSetup(): List + fun zapSplitSetup(): List - fun isSensitive(): Boolean + fun isSensitive(): Boolean - fun subject(): String? + fun subject(): String? - fun zapraiserAmount(): Long? + fun zapraiserAmount(): Long? - fun hasAnyTaggedUser(): Boolean + fun hasAnyTaggedUser(): Boolean - fun hasTagWithContent(tagName: String): Boolean + fun hasTagWithContent(tagName: String): Boolean - fun taggedAddresses(): List + fun taggedAddresses(): List - fun taggedUsers(): List + fun taggedUsers(): List - fun taggedEvents(): List + fun taggedEvents(): List - fun taggedUrls(): List + fun taggedUrls(): List - fun firstTaggedAddress(): ATag? + fun firstTaggedAddress(): ATag? - fun firstTaggedUser(): HexKey? + fun firstTaggedUser(): HexKey? - fun firstTaggedEvent(): HexKey? + fun firstTaggedEvent(): HexKey? - fun firstTaggedUrl(): String? + fun firstTaggedUrl(): String? - fun taggedEmojis(): List + fun taggedEmojis(): List - fun matchTag1With(text: String): Boolean + fun matchTag1With(text: String): Boolean - fun isExpired(): Boolean + fun isExpired(): Boolean - fun hasZapSplitSetup(): Boolean + fun hasZapSplitSetup(): Boolean } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt index 93a4d972a..0c7a8fa0b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt @@ -27,99 +27,98 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class FileHeaderEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1) + fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1) - fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] } + fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] } - fun encryptionKey() = - tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) } + fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) } - fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) + fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) - fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) + fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) - fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) + fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) - fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) + fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) - fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1) + fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1) - fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) + fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) - fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1) + fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1) - fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) + fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) - fun hasUrl() = tags.any { it.size > 1 && it[0] == URL } + fun hasUrl() = tags.any { it.size > 1 && it[0] == URL } - companion object { - const val KIND = 1063 - const val ALT_DESCRIPTION = "Verifiable file url" + companion object { + const val KIND = 1063 + const val ALT_DESCRIPTION = "Verifiable file url" - private const val URL = "url" - private const val ENCRYPTION_KEY = "aes-256-gcm" - private const val MIME_TYPE = "m" - private const val FILE_SIZE = "size" - private const val DIMENSION = "dim" - private const val HASH = "x" - private const val MAGNET_URI = "magnet" - private const val TORRENT_INFOHASH = "i" - private const val BLUR_HASH = "blurhash" - private const val ORIGINAL_HASH = "ox" - private const val ALT = "alt" + private const val URL = "url" + private const val ENCRYPTION_KEY = "aes-256-gcm" + private const val MIME_TYPE = "m" + private const val FILE_SIZE = "size" + private const val DIMENSION = "dim" + private const val HASH = "x" + private const val MAGNET_URI = "magnet" + private const val TORRENT_INFOHASH = "i" + private const val BLUR_HASH = "blurhash" + private const val ORIGINAL_HASH = "ox" + private const val ALT = "alt" - fun create( - url: String, - magnetUri: String? = null, - mimeType: String? = null, - alt: String? = null, - hash: String? = null, - size: String? = null, - dimensions: String? = null, - blurhash: String? = null, - originalHash: String? = null, - magnetURI: String? = null, - torrentInfoHash: String? = null, - encryptionKey: AESGCM? = null, - sensitiveContent: Boolean? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (FileHeaderEvent) -> Unit, - ) { - val tags = - listOfNotNull( - arrayOf(URL, url), - magnetUri?.let { arrayOf(MAGNET_URI, it) }, - mimeType?.let { arrayOf(MIME_TYPE, it) }, - alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf("alt", ALT_DESCRIPTION), - hash?.let { arrayOf(HASH, it) }, - size?.let { arrayOf(FILE_SIZE, it) }, - dimensions?.let { arrayOf(DIMENSION, it) }, - blurhash?.let { arrayOf(BLUR_HASH, it) }, - originalHash?.let { arrayOf(ORIGINAL_HASH, it) }, - magnetURI?.let { arrayOf(MAGNET_URI, it) }, - torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) }, - encryptionKey?.let { arrayOf(ENCRYPTION_KEY, it.key, it.nonce) }, - sensitiveContent?.let { - if (it) { - arrayOf("content-warning", "") - } else { - null - } - }, - ) + fun create( + url: String, + magnetUri: String? = null, + mimeType: String? = null, + alt: String? = null, + hash: String? = null, + size: String? = null, + dimensions: String? = null, + blurhash: String? = null, + originalHash: String? = null, + magnetURI: String? = null, + torrentInfoHash: String? = null, + encryptionKey: AESGCM? = null, + sensitiveContent: Boolean? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (FileHeaderEvent) -> Unit, + ) { + val tags = + listOfNotNull( + arrayOf(URL, url), + magnetUri?.let { arrayOf(MAGNET_URI, it) }, + mimeType?.let { arrayOf(MIME_TYPE, it) }, + alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf("alt", ALT_DESCRIPTION), + hash?.let { arrayOf(HASH, it) }, + size?.let { arrayOf(FILE_SIZE, it) }, + dimensions?.let { arrayOf(DIMENSION, it) }, + blurhash?.let { arrayOf(BLUR_HASH, it) }, + originalHash?.let { arrayOf(ORIGINAL_HASH, it) }, + magnetURI?.let { arrayOf(MAGNET_URI, it) }, + torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) }, + encryptionKey?.let { arrayOf(ENCRYPTION_KEY, it.key, it.nonce) }, + sensitiveContent?.let { + if (it) { + arrayOf("content-warning", "") + } else { + null + } + }, + ) - val content = alt ?: "" - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + val content = alt ?: "" + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } } - } } data class AESGCM(val key: String, val nonce: String) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileServersEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileServersEvent.kt index 7a76c3189..25aef0b06 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileServersEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileServersEvent.kt @@ -27,33 +27,33 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class FileServersEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - override fun dTag() = FIXED_D_TAG + override fun dTag() = FIXED_D_TAG - companion object { - const val KIND = 10096 - const val FIXED_D_TAG = "" - const val ALT = "File servers used by the author" + companion object { + const val KIND = 10096 + const val FIXED_D_TAG = "" + const val ALT = "File servers used by the author" - fun create( - listOfServers: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (FileServersEvent) -> Unit, - ) { - val msg = "" - val tags = mutableListOf>() + fun create( + listOfServers: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (FileServersEvent) -> Unit, + ) { + val msg = "" + val tags = mutableListOf>() - listOfServers.forEach { tags.add(arrayOf("server", it)) } - tags.add(arrayOf("alt", ALT)) + listOfServers.forEach { tags.add(arrayOf("server", it)) } + tags.add(arrayOf("alt", ALT)) - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageEvent.kt index 8a7d515cc..e0a5aaddc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageEvent.kt @@ -29,53 +29,52 @@ import java.util.Base64 @Immutable class FileStorageEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1) + fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1) - fun decryptKey() = - tags.firstOrNull { it.size > 2 && it[0] == DECRYPT }?.let { AESGCM(it[1], it[2]) } + fun decryptKey() = tags.firstOrNull { it.size > 2 && it[0] == DECRYPT }?.let { AESGCM(it[1], it[2]) } - fun decode(): ByteArray? { - return try { - Base64.getDecoder().decode(content) - } catch (e: Exception) { - Log.e("FileStorageEvent", "Unable to decode base 64 ${e.message} $content") - null - } - } - - companion object { - const val KIND = 1064 - const val ALT = "Binary data" - - private const val TYPE = "type" - private const val DECRYPT = "decrypt" - - fun encode(bytes: ByteArray): String { - return Base64.getEncoder().encodeToString(bytes) + fun decode(): ByteArray? { + return try { + Base64.getDecoder().decode(content) + } catch (e: Exception) { + Log.e("FileStorageEvent", "Unable to decode base 64 ${e.message} $content") + null + } } - fun create( - mimeType: String, - data: ByteArray, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (FileStorageEvent) -> Unit, - ) { - val tags = - listOfNotNull( - arrayOf(TYPE, mimeType), - arrayOf("alt", ALT), - ) + companion object { + const val KIND = 1064 + const val ALT = "Binary data" - val content = encode(data) - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + private const val TYPE = "type" + private const val DECRYPT = "decrypt" + + fun encode(bytes: ByteArray): String { + return Base64.getEncoder().encodeToString(bytes) + } + + fun create( + mimeType: String, + data: ByteArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (FileStorageEvent) -> Unit, + ) { + val tags = + listOfNotNull( + arrayOf(TYPE, mimeType), + arrayOf("alt", ALT), + ) + + val content = encode(data) + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt index 13057cce0..5b8ca0ef4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt @@ -27,87 +27,86 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class FileStorageHeaderEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun dataEventId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + fun dataEventId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) - fun encryptionKey() = - tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) } + fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) } - fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) + fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) - fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) + fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) - fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) + fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) - fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) + fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) - fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1) + fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1) - fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) + fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) - fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1) + fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1) - fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) + fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) - companion object { - const val KIND = 1065 - const val ALT_DESCRIPTION = "Descriptors for a binary file" + companion object { + const val KIND = 1065 + const val ALT_DESCRIPTION = "Descriptors for a binary file" - private const val ENCRYPTION_KEY = "aes-256-gcm" - private const val MIME_TYPE = "m" - private const val FILE_SIZE = "size" - private const val DIMENSION = "dim" - private const val HASH = "x" - private const val MAGNET_URI = "magnet" - private const val TORRENT_INFOHASH = "i" - private const val BLUR_HASH = "blurhash" - private const val ALT = "alt" + private const val ENCRYPTION_KEY = "aes-256-gcm" + private const val MIME_TYPE = "m" + private const val FILE_SIZE = "size" + private const val DIMENSION = "dim" + private const val HASH = "x" + private const val MAGNET_URI = "magnet" + private const val TORRENT_INFOHASH = "i" + private const val BLUR_HASH = "blurhash" + private const val ALT = "alt" - fun create( - storageEvent: FileStorageEvent, - mimeType: String? = null, - alt: String? = null, - hash: String? = null, - size: String? = null, - dimensions: String? = null, - blurhash: String? = null, - magnetURI: String? = null, - torrentInfoHash: String? = null, - encryptionKey: AESGCM? = null, - sensitiveContent: Boolean? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (FileStorageHeaderEvent) -> Unit, - ) { - val tags = - listOfNotNull( - arrayOf("e", storageEvent.id), - mimeType?.let { arrayOf(MIME_TYPE, mimeType) }, - hash?.let { arrayOf(HASH, it) }, - alt?.let { arrayOf(ALT, it) } ?: arrayOf("alt", ALT_DESCRIPTION), - size?.let { arrayOf(FILE_SIZE, it) }, - dimensions?.let { arrayOf(DIMENSION, it) }, - blurhash?.let { arrayOf(BLUR_HASH, it) }, - magnetURI?.let { arrayOf(MAGNET_URI, it) }, - torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) }, - encryptionKey?.let { arrayOf(ENCRYPTION_KEY, it.key, it.nonce) }, - sensitiveContent?.let { - if (it) { - arrayOf("content-warning", "") - } else { - null - } - }, - ) + fun create( + storageEvent: FileStorageEvent, + mimeType: String? = null, + alt: String? = null, + hash: String? = null, + size: String? = null, + dimensions: String? = null, + blurhash: String? = null, + magnetURI: String? = null, + torrentInfoHash: String? = null, + encryptionKey: AESGCM? = null, + sensitiveContent: Boolean? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (FileStorageHeaderEvent) -> Unit, + ) { + val tags = + listOfNotNull( + arrayOf("e", storageEvent.id), + mimeType?.let { arrayOf(MIME_TYPE, mimeType) }, + hash?.let { arrayOf(HASH, it) }, + alt?.let { arrayOf(ALT, it) } ?: arrayOf("alt", ALT_DESCRIPTION), + size?.let { arrayOf(FILE_SIZE, it) }, + dimensions?.let { arrayOf(DIMENSION, it) }, + blurhash?.let { arrayOf(BLUR_HASH, it) }, + magnetURI?.let { arrayOf(MAGNET_URI, it) }, + torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) }, + encryptionKey?.let { arrayOf(ENCRYPTION_KEY, it.key, it.nonce) }, + sensitiveContent?.let { + if (it) { + arrayOf("content-warning", "") + } else { + null + } + }, + ) - val content = alt ?: "" - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + val content = alt ?: "" + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GeneralListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GeneralListEvent.kt index eff2fc154..2563ad821 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GeneralListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GeneralListEvent.kt @@ -31,173 +31,173 @@ import kotlinx.collections.immutable.toImmutableSet @Immutable abstract class GeneralListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient private var privateTagsCache: Array>? = null + @Transient private var privateTagsCache: Array>? = null - fun category() = dTag() + fun category() = dTag() - fun bookmarkedPosts() = taggedEvents() + fun bookmarkedPosts() = taggedEvents() - fun bookmarkedPeople() = taggedUsers() + fun bookmarkedPeople() = taggedUsers() - fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1) + fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1) - fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1) + fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1) - fun nameOrTitle() = name() ?: title() + fun nameOrTitle() = name() ?: title() - fun cachedPrivateTags(): Array>? { - return privateTagsCache - } - - fun filterTagList( - key: String, - privateTags: Array>?, - ): ImmutableSet { - val privateUserList = - privateTags?.let { it.filter { it.size > 1 && it[0] == key }.map { it[1] }.toSet() } - ?: emptySet() - val publicUserList = tags.filter { it.size > 1 && it[0] == key }.map { it[1] }.toSet() - - return (privateUserList + publicUserList).toImmutableSet() - } - - fun isTagged( - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - onReady: (Boolean) -> Unit, - ) { - return if (isPrivate) { - privateTagsOrEmpty(signer = signer) { - onReady( - it.any { it.size > 1 && it[0] == key && it[1] == tag }, - ) - } - } else { - onReady(isTagged(key, tag)) - } - } - - fun privateTags( - signer: NostrSigner, - onReady: (Array>) -> Unit, - ) { - if (content.isEmpty()) { - onReady(emptyArray()) - return + fun cachedPrivateTags(): Array>? { + return privateTagsCache } - privateTagsCache?.let { - onReady(it) - return + fun filterTagList( + key: String, + privateTags: Array>?, + ): ImmutableSet { + val privateUserList = + privateTags?.let { it.filter { it.size > 1 && it[0] == key }.map { it[1] }.toSet() } + ?: emptySet() + val publicUserList = tags.filter { it.size > 1 && it[0] == key }.map { it[1] }.toSet() + + return (privateUserList + publicUserList).toImmutableSet() } - try { - signer.nip04Decrypt(content, pubKey) { - privateTagsCache = mapper.readValue>>(it) - privateTagsCache?.let { onReady(it) } - } - } catch (e: Throwable) { - Log.w("GeneralList", "Error parsing the JSON ${e.message}") - } - } - - fun privateTagsOrEmpty( - signer: NostrSigner, - onReady: (Array>) -> Unit, - ) { - privateTags(signer, onReady) - } - - fun privateTaggedUsers( - signer: NostrSigner, - onReady: (List) -> Unit, - ) = privateTags(signer) { onReady(filterUsers(it)) } - - fun privateHashtags( - signer: NostrSigner, - onReady: (List) -> Unit, - ) = privateTags(signer) { onReady(filterHashtags(it)) } - - fun privateGeohashes( - signer: NostrSigner, - onReady: (List) -> Unit, - ) = privateTags(signer) { onReady(filterGeohashes(it)) } - - fun privateTaggedEvents( - signer: NostrSigner, - onReady: (List) -> Unit, - ) = privateTags(signer) { onReady(filterEvents(it)) } - - fun privateTaggedAddresses( - signer: NostrSigner, - onReady: (List) -> Unit, - ) = privateTags(signer) { onReady(filterAddresses(it)) } - - fun filterUsers(tags: Array>): List { - return tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } - } - - fun filterHashtags(tags: Array>): List { - return tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] } - } - - fun filterGeohashes(tags: Array>): List { - return tags.filter { it.size > 1 && it[0] == "g" }.map { it[1] } - } - - fun filterEvents(tags: Array>): List { - return tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } - } - - fun filterAddresses(tags: Array>): List { - return tags - .filter { it.firstOrNull() == "a" } - .mapNotNull { - val aTagValue = it.getOrNull(1) - val relay = it.getOrNull(2) - - if (aTagValue != null) ATag.parse(aTagValue, relay) else null - } - } - - companion object { - fun createPrivateTags( - privEvents: List? = null, - privUsers: List? = null, - privAddresses: List? = null, - signer: NostrSigner, - onReady: (String) -> Unit, + fun isTagged( + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + onReady: (Boolean) -> Unit, ) { - val privTags = mutableListOf>() - privEvents?.forEach { privTags.add(arrayOf("e", it)) } - privUsers?.forEach { privTags.add(arrayOf("p", it)) } - privAddresses?.forEach { privTags.add(arrayOf("a", it.toTag())) } - - return encryptTags(privTags.toTypedArray(), signer, onReady) + return if (isPrivate) { + privateTagsOrEmpty(signer = signer) { + onReady( + it.any { it.size > 1 && it[0] == key && it[1] == tag }, + ) + } + } else { + onReady(isTagged(key, tag)) + } } - fun encryptTags( - privateTags: Array>? = null, - signer: NostrSigner, - onReady: (String) -> Unit, + fun privateTags( + signer: NostrSigner, + onReady: (Array>) -> Unit, ) { - val msg = mapper.writeValueAsString(privateTags) + if (content.isEmpty()) { + onReady(emptyArray()) + return + } - signer.nip04Encrypt( - msg, - signer.pubKey, - onReady, - ) + privateTagsCache?.let { + onReady(it) + return + } + + try { + signer.nip04Decrypt(content, pubKey) { + privateTagsCache = mapper.readValue>>(it) + privateTagsCache?.let { onReady(it) } + } + } catch (e: Throwable) { + Log.w("GeneralList", "Error parsing the JSON ${e.message}") + } + } + + fun privateTagsOrEmpty( + signer: NostrSigner, + onReady: (Array>) -> Unit, + ) { + privateTags(signer, onReady) + } + + fun privateTaggedUsers( + signer: NostrSigner, + onReady: (List) -> Unit, + ) = privateTags(signer) { onReady(filterUsers(it)) } + + fun privateHashtags( + signer: NostrSigner, + onReady: (List) -> Unit, + ) = privateTags(signer) { onReady(filterHashtags(it)) } + + fun privateGeohashes( + signer: NostrSigner, + onReady: (List) -> Unit, + ) = privateTags(signer) { onReady(filterGeohashes(it)) } + + fun privateTaggedEvents( + signer: NostrSigner, + onReady: (List) -> Unit, + ) = privateTags(signer) { onReady(filterEvents(it)) } + + fun privateTaggedAddresses( + signer: NostrSigner, + onReady: (List) -> Unit, + ) = privateTags(signer) { onReady(filterAddresses(it)) } + + fun filterUsers(tags: Array>): List { + return tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } + } + + fun filterHashtags(tags: Array>): List { + return tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] } + } + + fun filterGeohashes(tags: Array>): List { + return tags.filter { it.size > 1 && it[0] == "g" }.map { it[1] } + } + + fun filterEvents(tags: Array>): List { + return tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + } + + fun filterAddresses(tags: Array>): List { + return tags + .filter { it.firstOrNull() == "a" } + .mapNotNull { + val aTagValue = it.getOrNull(1) + val relay = it.getOrNull(2) + + if (aTagValue != null) ATag.parse(aTagValue, relay) else null + } + } + + companion object { + fun createPrivateTags( + privEvents: List? = null, + privUsers: List? = null, + privAddresses: List? = null, + signer: NostrSigner, + onReady: (String) -> Unit, + ) { + val privTags = mutableListOf>() + privEvents?.forEach { privTags.add(arrayOf("e", it)) } + privUsers?.forEach { privTags.add(arrayOf("p", it)) } + privAddresses?.forEach { privTags.add(arrayOf("a", it.toTag())) } + + return encryptTags(privTags.toTypedArray(), signer, onReady) + } + + fun encryptTags( + privateTags: Array>? = null, + signer: NostrSigner, + onReady: (String) -> Unit, + ) { + val msg = mapper.writeValueAsString(privateTags) + + signer.nip04Encrypt( + msg, + signer.pubKey, + onReady, + ) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GenericRepostEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GenericRepostEvent.kt index 06baf8870..afca0716d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GenericRepostEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GenericRepostEvent.kt @@ -27,50 +27,50 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class GenericRepostEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } + fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - fun containedPost() = - try { - fromJson(content) - } catch (e: Exception) { - null + fun containedPost() = + try { + fromJson(content) + } catch (e: Exception) { + null + } + + companion object { + const val KIND = 16 + const val ALT = "Generic repost" + + fun create( + boostedPost: EventInterface, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (GenericRepostEvent) -> Unit, + ) { + val content = boostedPost.toJson() + + val tags = + mutableListOf( + arrayOf("e", boostedPost.id()), + arrayOf("p", boostedPost.pubKey()), + ) + + if (boostedPost is AddressableEvent) { + tags.add(arrayOf("a", boostedPost.address().toTag())) + } + + tags.add(arrayOf("k", "${boostedPost.kind()}")) + tags.add(arrayOf("alt", ALT)) + + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } } - - companion object { - const val KIND = 16 - const val ALT = "Generic repost" - - fun create( - boostedPost: EventInterface, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (GenericRepostEvent) -> Unit, - ) { - val content = boostedPost.toJson() - - val tags = - mutableListOf( - arrayOf("e", boostedPost.id()), - arrayOf("p", boostedPost.pubKey()), - ) - - if (boostedPost is AddressableEvent) { - tags.add(arrayOf("a", boostedPost.address().toTag())) - } - - tags.add(arrayOf("k", "${boostedPost.kind()}")) - tags.add(arrayOf("alt", ALT)) - - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) - } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt index 1e8875719..dc576da31 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt @@ -29,72 +29,72 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class GiftWrapEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient private var cachedInnerEvent: Map = mapOf() + @Transient private var cachedInnerEvent: Map = mapOf() - fun cachedGift( - signer: NostrSigner, - onReady: (Event) -> Unit, - ) { - cachedInnerEvent[signer.pubKey]?.let { - onReady(it) - return - } - unwrap(signer) { gift -> - if (gift is WrappedEvent) { - gift.host = this - } - cachedInnerEvent = cachedInnerEvent + Pair(signer.pubKey, gift) - - onReady(gift) - } - } - - private fun unwrap( - signer: NostrSigner, - onReady: (Event) -> Unit, - ) { - try { - plainContent(signer) { onReady(fromJson(it)) } - } catch (e: Exception) { - // Log.e("UnwrapError", "Couldn't Decrypt the content", e) - } - } - - private fun plainContent( - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - if (content.isEmpty()) return - - signer.nip44Decrypt(content, pubKey, onReady) - } - - fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) - - companion object { - const val KIND = 1059 - const val ALT = "Encrypted event" - - fun create( - event: Event, - recipientPubKey: HexKey, - createdAt: Long = TimeUtils.randomWithinAWeek(), - onReady: (GiftWrapEvent) -> Unit, + fun cachedGift( + signer: NostrSigner, + onReady: (Event) -> Unit, ) { - val signer = NostrSignerInternal(KeyPair()) // GiftWrap is always a random key - val serializedContent = toJson(event) - val tags = arrayOf(arrayOf("p", recipientPubKey)) + cachedInnerEvent[signer.pubKey]?.let { + onReady(it) + return + } + unwrap(signer) { gift -> + if (gift is WrappedEvent) { + gift.host = this + } + cachedInnerEvent = cachedInnerEvent + Pair(signer.pubKey, gift) - signer.nip44Encrypt(serializedContent, recipientPubKey) { - signer.sign(createdAt, KIND, tags, it, onReady) - } + onReady(gift) + } + } + + private fun unwrap( + signer: NostrSigner, + onReady: (Event) -> Unit, + ) { + try { + plainContent(signer) { onReady(fromJson(it)) } + } catch (e: Exception) { + // Log.e("UnwrapError", "Couldn't Decrypt the content", e) + } + } + + private fun plainContent( + signer: NostrSigner, + onReady: (String) -> Unit, + ) { + if (content.isEmpty()) return + + signer.nip44Decrypt(content, pubKey, onReady) + } + + fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) + + companion object { + const val KIND = 1059 + const val ALT = "Encrypted event" + + fun create( + event: Event, + recipientPubKey: HexKey, + createdAt: Long = TimeUtils.randomWithinAWeek(), + onReady: (GiftWrapEvent) -> Unit, + ) { + val signer = NostrSignerInternal(KeyPair()) // GiftWrap is always a random key + val serializedContent = toJson(event) + val tags = arrayOf(arrayOf("p", recipientPubKey)) + + signer.nip44Encrypt(serializedContent, recipientPubKey) { + signer.sign(createdAt, KIND, tags, it, onReady) + } + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GoalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GoalEvent.kt index b81bbd09d..dc7fbf7cf 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GoalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GoalEvent.kt @@ -27,54 +27,54 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class GoalEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 9041 - const val ALT = "Zap Goal" + companion object { + const val KIND = 9041 + const val ALT = "Zap Goal" - private const val SUMMARY = "summary" - private const val CLOSED_AT = "closed_at" - private const val IMAGE = "image" - private const val AMOUNT = "amount" + private const val SUMMARY = "summary" + private const val CLOSED_AT = "closed_at" + private const val IMAGE = "image" + private const val AMOUNT = "amount" - fun create( - description: String, - amount: Long, - relays: Set, - closedAt: Long? = null, - image: String? = null, - summary: String? = null, - websiteUrl: String? = null, - linkedEvent: Event? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (GoalEvent) -> Unit, - ) { - val tags = - mutableListOf( - arrayOf(AMOUNT, amount.toString()), - arrayOf("relays") + relays, - arrayOf("alt", ALT), - ) + fun create( + description: String, + amount: Long, + relays: Set, + closedAt: Long? = null, + image: String? = null, + summary: String? = null, + websiteUrl: String? = null, + linkedEvent: Event? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (GoalEvent) -> Unit, + ) { + val tags = + mutableListOf( + arrayOf(AMOUNT, amount.toString()), + arrayOf("relays") + relays, + arrayOf("alt", ALT), + ) - if (linkedEvent is AddressableEvent) { - tags.add(arrayOf("a", linkedEvent.address().toTag())) - } else if (linkedEvent is Event) { - tags.add(arrayOf("e", linkedEvent.id)) - } + if (linkedEvent is AddressableEvent) { + tags.add(arrayOf("a", linkedEvent.address().toTag())) + } else if (linkedEvent is Event) { + tags.add(arrayOf("e", linkedEvent.id)) + } - closedAt?.let { tags.add(arrayOf(CLOSED_AT, it.toString())) } - summary?.let { tags.add(arrayOf(SUMMARY, it)) } - image?.let { tags.add(arrayOf(IMAGE, it)) } - websiteUrl?.let { tags.add(arrayOf("r", it)) } + closedAt?.let { tags.add(arrayOf(CLOSED_AT, it.toString())) } + summary?.let { tags.add(arrayOf(SUMMARY, it)) } + image?.let { tags.add(arrayOf(IMAGE, it)) } + websiteUrl?.let { tags.add(arrayOf("r", it)) } - signer.sign(createdAt, KIND, tags.toTypedArray(), description, onReady) + signer.sign(createdAt, KIND, tags.toTypedArray(), description, onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/HTTPAuthorizationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/HTTPAuthorizationEvent.kt index c976a287f..d4c380406 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/HTTPAuthorizationEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/HTTPAuthorizationEvent.kt @@ -29,35 +29,35 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class HTTPAuthorizationEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 27235 + companion object { + const val KIND = 27235 - fun create( - url: String, - method: String, - file: ByteArray? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (HTTPAuthorizationEvent) -> Unit, - ) { - var hash = "" - file?.let { hash = CryptoUtils.sha256(file).toHexKey() } + fun create( + url: String, + method: String, + file: ByteArray? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (HTTPAuthorizationEvent) -> Unit, + ) { + var hash = "" + file?.let { hash = CryptoUtils.sha256(file).toHexKey() } - val tags = - listOfNotNull( - arrayOf("u", url), - arrayOf("method", method), - arrayOf("payload", hash), - ) + val tags = + listOfNotNull( + arrayOf("u", url), + arrayOf("method", method), + arrayOf("payload", hash), + ) - signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/HighlightEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/HighlightEvent.kt index bed441749..179c1ccb7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/HighlightEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/HighlightEvent.kt @@ -27,32 +27,32 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class HighlightEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun inUrl() = taggedUrls().firstOrNull() + fun inUrl() = taggedUrls().firstOrNull() - fun author() = taggedUsers().firstOrNull() + fun author() = taggedUsers().firstOrNull() - fun quote() = content + fun quote() = content - fun inPost() = taggedAddresses().firstOrNull() + fun inPost() = taggedAddresses().firstOrNull() - companion object { - const val KIND = 9802 - const val ALT = "Highlight/quote event" + companion object { + const val KIND = 9802 + const val ALT = "Highlight/quote event" - fun create( - msg: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (HighlightEvent) -> Unit, - ) { - signer.sign(createdAt, KIND, arrayOf(arrayOf("alt", ALT)), msg, onReady) + fun create( + msg: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (HighlightEvent) -> Unit, + ) { + signer.sign(createdAt, KIND, arrayOf(arrayOf("alt", ALT)), msg, onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt index 8b5ade013..d2349f15a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt @@ -28,74 +28,74 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class LiveActivitiesChatMessageEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - private fun innerActivity() = - tags.firstOrNull { it.size > 3 && it[0] == "a" && it[3] == "root" } - ?: tags.firstOrNull { it.size > 1 && it[0] == "a" } + private fun innerActivity() = + tags.firstOrNull { it.size > 3 && it[0] == "a" && it[3] == "root" } + ?: tags.firstOrNull { it.size > 1 && it[0] == "a" } - private fun activityHex() = innerActivity()?.let { it.getOrNull(1) } + private fun activityHex() = innerActivity()?.let { it.getOrNull(1) } - fun activity() = - innerActivity()?.let { - if (it.size > 1) { - val aTagValue = it[1] - val relay = it.getOrNull(2) + fun activity() = + innerActivity()?.let { + if (it.size > 1) { + val aTagValue = it[1] + val relay = it.getOrNull(2) - ATag.parse(aTagValue, relay) - } else { - null - } - } - - override fun replyTos() = taggedEvents().minus(activityHex() ?: "") - - companion object { - const val KIND = 1311 - const val ALT = "Live activity chat message" - - fun create( - message: String, - activity: ATag, - replyTos: List? = null, - mentions: List? = null, - zapReceiver: List? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - markAsSensitive: Boolean, - zapRaiserAmount: Long?, - geohash: String? = null, - nip94attachments: List? = null, - onReady: (LiveActivitiesChatMessageEvent) -> Unit, - ) { - val content = message - val tags = - mutableListOf( - arrayOf("a", activity.toTag(), "", "root"), - ) - replyTos?.forEach { tags.add(arrayOf("e", it)) } - mentions?.forEach { tags.add(arrayOf("p", it)) } - zapReceiver?.forEach { - tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) - } - if (markAsSensitive) { - tags.add(arrayOf("content-warning", "")) - } - zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } - geohash?.let { tags.addAll(geohashMipMap(it)) } - nip94attachments?.let { - it.forEach { - // tags.add(arrayOf("nip94", it.toJson())) + ATag.parse(aTagValue, relay) + } else { + null + } } - } - tags.add(arrayOf("alt", ALT)) - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + override fun replyTos() = taggedEvents().minus(activityHex() ?: "") + + companion object { + const val KIND = 1311 + const val ALT = "Live activity chat message" + + fun create( + message: String, + activity: ATag, + replyTos: List? = null, + mentions: List? = null, + zapReceiver: List? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + markAsSensitive: Boolean, + zapRaiserAmount: Long?, + geohash: String? = null, + nip94attachments: List? = null, + onReady: (LiveActivitiesChatMessageEvent) -> Unit, + ) { + val content = message + val tags = + mutableListOf( + arrayOf("a", activity.toTag(), "", "root"), + ) + replyTos?.forEach { tags.add(arrayOf("e", it)) } + mentions?.forEach { tags.add(arrayOf("p", it)) } + zapReceiver?.forEach { + tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) + } + if (markAsSensitive) { + tags.add(arrayOf("content-warning", "")) + } + zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } + geohash?.let { tags.addAll(geohashMipMap(it)) } + nip94attachments?.let { + it.forEach { + // tags.add(arrayOf("nip94", it.toJson())) + } + } + tags.add(arrayOf("alt", ALT)) + + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesEvent.kt index d0d0035de..bd1f0f5a6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesEvent.kt @@ -27,71 +27,66 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class LiveActivitiesEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1) + fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1) - fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1) + fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1) - fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) + fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) - fun streaming() = tags.firstOrNull { it.size > 1 && it[0] == "streaming" }?.get(1) + fun streaming() = tags.firstOrNull { it.size > 1 && it[0] == "streaming" }?.get(1) - fun starts() = tags.firstOrNull { it.size > 1 && it[0] == "starts" }?.get(1)?.toLongOrNull() + fun starts() = tags.firstOrNull { it.size > 1 && it[0] == "starts" }?.get(1)?.toLongOrNull() - fun ends() = tags.firstOrNull { it.size > 1 && it[0] == "ends" }?.get(1) + fun ends() = tags.firstOrNull { it.size > 1 && it[0] == "ends" }?.get(1) - fun status() = checkStatus(tags.firstOrNull { it.size > 1 && it[0] == "status" }?.get(1)) + fun status() = checkStatus(tags.firstOrNull { it.size > 1 && it[0] == "status" }?.get(1)) - fun currentParticipants() = - tags.firstOrNull { it.size > 1 && it[0] == "current_participants" }?.get(1) + fun currentParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "current_participants" }?.get(1) - fun totalParticipants() = - tags.firstOrNull { it.size > 1 && it[0] == "total_participants" }?.get(1) + fun totalParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "total_participants" }?.get(1) - fun participants() = - tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(3)) } + fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(3)) } - fun hasHost() = tags.any { it.size > 3 && it[0] == "p" && it[3].equals("Host", true) } + fun hasHost() = tags.any { it.size > 3 && it[0] == "p" && it[3].equals("Host", true) } - fun host() = - tags.firstOrNull { it.size > 3 && it[0] == "p" && it[3].equals("Host", true) }?.get(1) + fun host() = tags.firstOrNull { it.size > 3 && it[0] == "p" && it[3].equals("Host", true) }?.get(1) - fun hosts() = - tags.filter { it.size > 3 && it[0] == "p" && it[3].equals("Host", true) }.map { it[1] } + fun hosts() = tags.filter { it.size > 3 && it[0] == "p" && it[3].equals("Host", true) }.map { it[1] } - fun checkStatus(eventStatus: String?): String? { - return if (eventStatus == STATUS_LIVE && createdAt < TimeUtils.eightHoursAgo()) { - STATUS_ENDED - } else { - eventStatus + fun checkStatus(eventStatus: String?): String? { + return if (eventStatus == STATUS_LIVE && createdAt < TimeUtils.eightHoursAgo()) { + STATUS_ENDED + } else { + eventStatus + } } - } - fun participantsIntersect(keySet: Set): Boolean { - return tags.any { it.size > 1 && it[0] == "p" && it[1] in keySet } - } - - companion object { - const val KIND = 30311 - const val ALT = "Live activity event" - - const val STATUS_LIVE = "live" - const val STATUS_PLANNED = "planned" - const val STATUS_ENDED = "ended" - - fun create( - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (LiveActivitiesEvent) -> Unit, - ) { - val tags = arrayOf(arrayOf("alt", ALT)) - signer.sign(createdAt, KIND, tags, "", onReady) + fun participantsIntersect(keySet: Set): Boolean { + return tags.any { it.size > 1 && it[0] == "p" && it[1] in keySet } + } + + companion object { + const val KIND = 30311 + const val ALT = "Live activity event" + + const val STATUS_LIVE = "live" + const val STATUS_PLANNED = "planned" + const val STATUS_ENDED = "ended" + + fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (LiveActivitiesEvent) -> Unit, + ) { + val tags = arrayOf(arrayOf("alt", ALT)) + signer.sign(createdAt, KIND, tags, "", onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEvent.kt index 8d57ed0e7..6bff48110 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEvent.kt @@ -27,71 +27,71 @@ import com.vitorpamplona.quartz.encoders.LnInvoiceUtil @Immutable class LnZapEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : LnZapEventInterface, Event(id, pubKey, createdAt, KIND, tags, content, sig) { - // This event is also kept in LocalCache (same object) - @Transient val zapRequest: LnZapRequestEvent? + // This event is also kept in LocalCache (same object) + @Transient val zapRequest: LnZapRequestEvent? - override fun containedPost(): LnZapRequestEvent? = - try { - description()?.ifBlank { null }?.let { fromJson(it) } as? LnZapRequestEvent - } catch (e: Exception) { - Log.w("LnZapEvent", "Failed to Parse Contained Post ${description()} in event $id", e) - null + override fun containedPost(): LnZapRequestEvent? = + try { + description()?.ifBlank { null }?.let { fromJson(it) } as? LnZapRequestEvent + } catch (e: Exception) { + Log.w("LnZapEvent", "Failed to Parse Contained Post ${description()} in event $id", e) + null + } + + init { + zapRequest = containedPost() } - init { - zapRequest = containedPost() - } + override fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } - override fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + override fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } - override fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } + override fun zappedPollOption(): Int? = + try { + zapRequest?.tags?.firstOrNull { it.size > 1 && it[0] == POLL_OPTION }?.get(1)?.toInt() + } catch (e: Exception) { + Log.e("LnZapEvent", "ZappedPollOption failed to parse", e) + null + } - override fun zappedPollOption(): Int? = - try { - zapRequest?.tags?.firstOrNull { it.size > 1 && it[0] == POLL_OPTION }?.get(1)?.toInt() - } catch (e: Exception) { - Log.e("LnZapEvent", "ZappedPollOption failed to parse", e) - null + override fun zappedRequestAuthor(): String? = zapRequest?.pubKey() + + override fun amount() = amount + + // Keeps this as a field because it's a heavier function used everywhere. + val amount by lazy { + try { + lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) } + } catch (e: Exception) { + Log.e("LnZapEvent", "Failed to Parse LnInvoice ${lnInvoice()}", e) + null + } } - override fun zappedRequestAuthor(): String? = zapRequest?.pubKey() - - override fun amount() = amount - - // Keeps this as a field because it's a heavier function used everywhere. - val amount by lazy { - try { - lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) } - } catch (e: Exception) { - Log.e("LnZapEvent", "Failed to Parse LnInvoice ${lnInvoice()}", e) - null + override fun content(): String { + return content } - } - override fun content(): String { - return content - } + fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1) - fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1) + private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) - private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) + companion object { + const val KIND = 9735 + const val ALT = "Zap event" + } - companion object { - const val KIND = 9735 - const val ALT = "Zap event" - } - - enum class ZapType() { - PUBLIC, - PRIVATE, - ANONYMOUS, - NONZAP, - } + enum class ZapType() { + PUBLIC, + PRIVATE, + ANONYMOUS, + NONZAP, + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEventInterface.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEventInterface.kt index 92e4ab78e..b0df739a7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEventInterface.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEventInterface.kt @@ -25,15 +25,15 @@ import java.math.BigDecimal @Immutable interface LnZapEventInterface : EventInterface { - fun zappedPost(): List + fun zappedPost(): List - fun zappedPollOption(): Int? + fun zappedPollOption(): Int? - fun zappedAuthor(): List + fun zappedAuthor(): List - fun zappedRequestAuthor(): String? + fun zappedRequestAuthor(): String? - fun amount(): BigDecimal? + fun amount(): BigDecimal? - fun containedPost(): Event? + fun containedPost(): Event? } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentRequestEvent.kt index 914fa7b35..63259472a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentRequestEvent.kt @@ -32,67 +32,67 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class LnZapPaymentRequestEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - // Once one of an app user decrypts the payment, all users else can see it. - @Transient private var lnInvoice: String? = null + // Once one of an app user decrypts the payment, all users else can see it. + @Transient private var lnInvoice: String? = null - fun walletServicePubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) + fun walletServicePubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) - fun talkingWith(oneSideHex: String): HexKey { - return if (pubKey == oneSideHex) walletServicePubKey() ?: pubKey else pubKey - } - - fun lnInvoice( - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - lnInvoice?.let { - onReady(it) - return + fun talkingWith(oneSideHex: String): HexKey { + return if (pubKey == oneSideHex) walletServicePubKey() ?: pubKey else pubKey } - try { - signer.nip04Decrypt(content, talkingWith(signer.pubKey)) { jsonText -> - val payInvoiceMethod = mapper.readValue(jsonText, Request::class.java) - - lnInvoice = (payInvoiceMethod as? PayInvoiceMethod)?.params?.invoice - - lnInvoice?.let { onReady(it) } - } - } catch (e: Exception) { - Log.w("BookmarkList", "Error decrypting the message ${e.message}") - } - } - - companion object { - const val KIND = 23194 - const val ALT = "Zap payment request" - - fun create( - lnInvoice: String, - walletServicePubkey: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (LnZapPaymentRequestEvent) -> Unit, + fun lnInvoice( + signer: NostrSigner, + onReady: (String) -> Unit, ) { - val serializedRequest = mapper.writeValueAsString(PayInvoiceMethod.create(lnInvoice)) + lnInvoice?.let { + onReady(it) + return + } - val tags = arrayOf(arrayOf("p", walletServicePubkey), arrayOf("alt", ALT)) + try { + signer.nip04Decrypt(content, talkingWith(signer.pubKey)) { jsonText -> + val payInvoiceMethod = mapper.readValue(jsonText, Request::class.java) - signer.nip04Encrypt( - serializedRequest, - walletServicePubkey, - ) { content -> - signer.sign(createdAt, KIND, tags, content, onReady) - } + lnInvoice = (payInvoiceMethod as? PayInvoiceMethod)?.params?.invoice + + lnInvoice?.let { onReady(it) } + } + } catch (e: Exception) { + Log.w("BookmarkList", "Error decrypting the message ${e.message}") + } + } + + companion object { + const val KIND = 23194 + const val ALT = "Zap payment request" + + fun create( + lnInvoice: String, + walletServicePubkey: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (LnZapPaymentRequestEvent) -> Unit, + ) { + val serializedRequest = mapper.writeValueAsString(PayInvoiceMethod.create(lnInvoice)) + + val tags = arrayOf(arrayOf("p", walletServicePubkey), arrayOf("alt", ALT)) + + signer.nip04Encrypt( + serializedRequest, + walletServicePubkey, + ) { content -> + signer.sign(createdAt, KIND, tags, content, onReady) + } + } } - } } // REQUEST OBJECTS @@ -103,24 +103,24 @@ abstract class Request(var method: String? = null) class PayInvoiceParams(var invoice: String? = null) class PayInvoiceMethod(var params: PayInvoiceParams? = null) : Request("pay_invoice") { - companion object { - fun create(bolt11: String): PayInvoiceMethod { - return PayInvoiceMethod(PayInvoiceParams(bolt11)) + companion object { + fun create(bolt11: String): PayInvoiceMethod { + return PayInvoiceMethod(PayInvoiceParams(bolt11)) + } } - } } class RequestDeserializer : StdDeserializer(Request::class.java) { - override fun deserialize( - jp: JsonParser, - ctxt: DeserializationContext, - ): Request? { - val jsonObject: JsonNode = jp.codec.readTree(jp) - val method = jsonObject.get("method")?.asText() + override fun deserialize( + jp: JsonParser, + ctxt: DeserializationContext, + ): Request? { + val jsonObject: JsonNode = jp.codec.readTree(jp) + val method = jsonObject.get("method")?.asText() - if (method == "pay_invoice") { - return jp.codec.treeToValue(jsonObject, PayInvoiceMethod::class.java) + if (method == "pay_invoice") { + return jp.codec.treeToValue(jsonObject, PayInvoiceMethod::class.java) + } + return null } - return null - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentResponseEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentResponseEvent.kt index c22ee2054..5d260776b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentResponseEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentResponseEvent.kt @@ -32,131 +32,139 @@ import com.vitorpamplona.quartz.signers.NostrSigner @Immutable class LnZapPaymentResponseEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - // Once one of an app user decrypts the payment, all users else can see it. - @Transient private var response: Response? = null + // Once one of an app user decrypts the payment, all users else can see it. + @Transient private var response: Response? = null - fun requestAuthor() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) + fun requestAuthor() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) - fun requestId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + fun requestId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) - fun talkingWith(oneSideHex: String): HexKey { - return if (pubKey == oneSideHex) requestAuthor() ?: pubKey else pubKey - } - - private fun plainContent( - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - try { - signer.nip04Decrypt(content, talkingWith(signer.pubKey)) { content -> onReady(content) } - } catch (e: Exception) { - Log.w("PrivateDM", "Error decrypting the message ${e.message}") - } - } - - fun response( - signer: NostrSigner, - onReady: (Response) -> Unit, - ) { - response?.let { - onReady(it) - return + fun talkingWith(oneSideHex: String): HexKey { + return if (pubKey == oneSideHex) requestAuthor() ?: pubKey else pubKey } - try { - if (content.isNotEmpty()) { - plainContent(signer) { - mapper.readValue(it, Response::class.java)?.let { - response = it - onReady(it) - } + private fun plainContent( + signer: NostrSigner, + onReady: (String) -> Unit, + ) { + try { + signer.nip04Decrypt(content, talkingWith(signer.pubKey)) { content -> onReady(content) } + } catch (e: Exception) { + Log.w("PrivateDM", "Error decrypting the message ${e.message}") } - } - } catch (e: Exception) { - Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e) } - } - companion object { - const val KIND = 23195 - const val ALT = "Zap payment response" - } + fun response( + signer: NostrSigner, + onReady: (Response) -> Unit, + ) { + response?.let { + onReady(it) + return + } + + try { + if (content.isNotEmpty()) { + plainContent(signer) { + mapper.readValue(it, Response::class.java)?.let { + response = it + onReady(it) + } + } + } + } catch (e: Exception) { + Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e) + } + } + + companion object { + const val KIND = 23195 + const val ALT = "Zap payment response" + } } // RESPONSE OBJECTS abstract class Response( - @JsonProperty("result_type") val resultType: String, + @JsonProperty("result_type") val resultType: String, ) // PayInvoice Call class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams? = null) : - Response("pay_invoice") { - class PayInvoiceResultParams(val preimage: String) + Response("pay_invoice") { + class PayInvoiceResultParams(val preimage: String) } class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null) : Response("pay_invoice") { - class PayInvoiceErrorParams(val code: ErrorType?, val message: String?) + class PayInvoiceErrorParams(val code: ErrorType?, val message: String?) - enum class ErrorType { - @JsonProperty(value = "RATE_LIMITED") RATE_LIMITED, + enum class ErrorType { + @JsonProperty(value = "RATE_LIMITED") + RATE_LIMITED, - // The client is sending commands too fast. It should retry in a few seconds. - @JsonProperty(value = "NOT_IMPLEMENTED") NOT_IMPLEMENTED, + // The client is sending commands too fast. It should retry in a few seconds. + @JsonProperty(value = "NOT_IMPLEMENTED") + NOT_IMPLEMENTED, - // The command is not known or is intentionally not implemented. - @JsonProperty(value = "INSUFFICIENT_BALANCE") INSUFFICIENT_BALANCE, + // The command is not known or is intentionally not implemented. + @JsonProperty(value = "INSUFFICIENT_BALANCE") + INSUFFICIENT_BALANCE, - // The wallet does not have enough funds to cover a fee reserve or the payment amount. - @JsonProperty(value = "QUOTA_EXCEEDED") QUOTA_EXCEEDED, + // The wallet does not have enough funds to cover a fee reserve or the payment amount. + @JsonProperty(value = "QUOTA_EXCEEDED") + QUOTA_EXCEEDED, - // The wallet has exceeded its spending quota. - @JsonProperty(value = "RESTRICTED") RESTRICTED, + // The wallet has exceeded its spending quota. + @JsonProperty(value = "RESTRICTED") + RESTRICTED, - // This public key is not allowed to do this operation. - @JsonProperty(value = "UNAUTHORIZED") UNAUTHORIZED, + // This public key is not allowed to do this operation. + @JsonProperty(value = "UNAUTHORIZED") + UNAUTHORIZED, - // This public key has no wallet connected. - @JsonProperty(value = "INTERNAL") INTERNAL, + // This public key has no wallet connected. + @JsonProperty(value = "INTERNAL") + INTERNAL, - // An internal error. - @JsonProperty(value = "OTHER") OTHER, // Other error. - } + // An internal error. + @JsonProperty(value = "OTHER") + OTHER, // Other error. + } } class ResponseDeserializer : StdDeserializer(Response::class.java) { - override fun deserialize( - jp: JsonParser, - ctxt: DeserializationContext, - ): Response? { - val jsonObject: JsonNode = jp.codec.readTree(jp) - val resultType = jsonObject.get("result_type")?.asText() + override fun deserialize( + jp: JsonParser, + ctxt: DeserializationContext, + ): Response? { + val jsonObject: JsonNode = jp.codec.readTree(jp) + val resultType = jsonObject.get("result_type")?.asText() - if (resultType == "pay_invoice") { - val result = jsonObject.get("result") - val error = jsonObject.get("error") - if (result != null) { - return jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java) - } - if (error != null) { - return jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java) - } - } else { - // tries to guess - if (jsonObject.get("result")?.get("preimage") != null) { - return jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java) - } - if (jsonObject.get("error")?.get("code") != null) { - return jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java) - } + if (resultType == "pay_invoice") { + val result = jsonObject.get("result") + val error = jsonObject.get("error") + if (result != null) { + return jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java) + } + if (error != null) { + return jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java) + } + } else { + // tries to guess + if (jsonObject.get("result")?.get("preimage") != null) { + return jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java) + } + if (jsonObject.get("error")?.get("code") != null) { + return jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java) + } + } + return null } - return null - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPrivateEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPrivateEvent.kt index e0a9c81b8..20b7268ba 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPrivateEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPrivateEvent.kt @@ -27,25 +27,25 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class LnZapPrivateEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 9733 - const val ALT = "Private zap" + companion object { + const val KIND = 9733 + const val ALT = "Private zap" - fun create( - signer: NostrSigner, - tags: Array> = emptyArray(), - content: String = "", - createdAt: Long = TimeUtils.now(), - onReady: (LnZapPrivateEvent) -> Unit, - ) { - signer.sign(createdAt, KIND, tags, content, onReady) + fun create( + signer: NostrSigner, + tags: Array> = emptyArray(), + content: String = "", + createdAt: Long = TimeUtils.now(), + onReady: (LnZapPrivateEvent) -> Unit, + ) { + signer.sign(createdAt, KIND, tags, content, onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapRequestEvent.kt index 10f00c04d..009ed26e9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapRequestEvent.kt @@ -38,198 +38,198 @@ import javax.crypto.spec.SecretKeySpec @Immutable class LnZapRequestEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient private var privateZapEvent: LnZapPrivateEvent? = null - - fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } - - fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } - - fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() } - - fun getPrivateZapEvent( - loggedInUserPrivKey: ByteArray, + id: HexKey, pubKey: HexKey, - ): LnZapPrivateEvent? { - val anonTag = tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" } - if (anonTag != null) { - val encnote = anonTag[1] - if (encnote.isNotBlank()) { - try { - val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.hexToByteArray()) - val decryptedEvent = fromJson(note) - if (decryptedEvent.kind == 9733) { - return decryptedEvent as LnZapPrivateEvent - } - } catch (e: Exception) { - e.printStackTrace() + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + @Transient private var privateZapEvent: LnZapPrivateEvent? = null + + fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + + fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } + + fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() } + + fun getPrivateZapEvent( + loggedInUserPrivKey: ByteArray, + pubKey: HexKey, + ): LnZapPrivateEvent? { + val anonTag = tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" } + if (anonTag != null) { + val encnote = anonTag[1] + if (encnote.isNotBlank()) { + try { + val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.hexToByteArray()) + val decryptedEvent = fromJson(note) + if (decryptedEvent.kind == 9733) { + return decryptedEvent as LnZapPrivateEvent + } + } catch (e: Exception) { + e.printStackTrace() + } + } } - } - } - return null - } - - fun cachedPrivateZap(): LnZapPrivateEvent? { - return privateZapEvent - } - - fun decryptPrivateZap( - signer: NostrSigner, - onReady: (Event) -> Unit, - ) { - privateZapEvent?.let { - onReady(it) - return + return null } - signer.decryptZapEvent(this) { - // caches it - privateZapEvent = it - onReady(it) + fun cachedPrivateZap(): LnZapPrivateEvent? { + return privateZapEvent } - } - companion object { - const val KIND = 9734 - const val ALT = "Zap request" - - fun create( - originalNote: EventInterface, - relays: Set, - signer: NostrSigner, - pollOption: Int?, - message: String, - zapType: LnZapEvent.ZapType, - toUserPubHex: String?, - createdAt: Long = TimeUtils.now(), - onReady: (LnZapRequestEvent) -> Unit, + fun decryptPrivateZap( + signer: NostrSigner, + onReady: (Event) -> Unit, ) { - if (zapType == LnZapEvent.ZapType.NONZAP) return + privateZapEvent?.let { + onReady(it) + return + } - var tags = - listOf( - arrayOf("e", originalNote.id()), - arrayOf("p", toUserPubHex ?: originalNote.pubKey()), - arrayOf("relays") + relays, - arrayOf("alt", ALT), - ) - if (originalNote is AddressableEvent) { - tags = tags + listOf(arrayOf("a", originalNote.address().toTag())) - } - if (pollOption != null && pollOption >= 0) { - tags = tags + listOf(arrayOf(POLL_OPTION, pollOption.toString())) - } - - if (zapType == LnZapEvent.ZapType.ANONYMOUS) { - tags = tags + listOf(arrayOf("anon")) - NostrSignerInternal(KeyPair()).sign(createdAt, KIND, tags.toTypedArray(), message, onReady) - } else if (zapType == LnZapEvent.ZapType.PRIVATE) { - tags = tags + listOf(arrayOf("anon", "")) - signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady) - } else { - signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady) - } + signer.decryptZapEvent(this) { + // caches it + privateZapEvent = it + onReady(it) + } } - fun create( - userHex: String, - relays: Set, - signer: NostrSigner, - message: String, - zapType: LnZapEvent.ZapType, - createdAt: Long = TimeUtils.now(), - onReady: (LnZapRequestEvent) -> Unit, - ) { - if (zapType == LnZapEvent.ZapType.NONZAP) return + companion object { + const val KIND = 9734 + const val ALT = "Zap request" - var tags = - arrayOf( - arrayOf("p", userHex), - arrayOf("relays") + relays, - ) + fun create( + originalNote: EventInterface, + relays: Set, + signer: NostrSigner, + pollOption: Int?, + message: String, + zapType: LnZapEvent.ZapType, + toUserPubHex: String?, + createdAt: Long = TimeUtils.now(), + onReady: (LnZapRequestEvent) -> Unit, + ) { + if (zapType == LnZapEvent.ZapType.NONZAP) return - if (zapType == LnZapEvent.ZapType.ANONYMOUS) { - tags += arrayOf(arrayOf("anon", "")) - NostrSignerInternal(KeyPair()).sign(createdAt, KIND, tags, message, onReady) - } else if (zapType == LnZapEvent.ZapType.PRIVATE) { - tags += arrayOf(arrayOf("anon", "")) - signer.sign(createdAt, KIND, tags, message, onReady) - } else { - signer.sign(createdAt, KIND, tags, message, onReady) - } + var tags = + listOf( + arrayOf("e", originalNote.id()), + arrayOf("p", toUserPubHex ?: originalNote.pubKey()), + arrayOf("relays") + relays, + arrayOf("alt", ALT), + ) + if (originalNote is AddressableEvent) { + tags = tags + listOf(arrayOf("a", originalNote.address().toTag())) + } + if (pollOption != null && pollOption >= 0) { + tags = tags + listOf(arrayOf(POLL_OPTION, pollOption.toString())) + } + + if (zapType == LnZapEvent.ZapType.ANONYMOUS) { + tags = tags + listOf(arrayOf("anon")) + NostrSignerInternal(KeyPair()).sign(createdAt, KIND, tags.toTypedArray(), message, onReady) + } else if (zapType == LnZapEvent.ZapType.PRIVATE) { + tags = tags + listOf(arrayOf("anon", "")) + signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady) + } else { + signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady) + } + } + + fun create( + userHex: String, + relays: Set, + signer: NostrSigner, + message: String, + zapType: LnZapEvent.ZapType, + createdAt: Long = TimeUtils.now(), + onReady: (LnZapRequestEvent) -> Unit, + ) { + if (zapType == LnZapEvent.ZapType.NONZAP) return + + var tags = + arrayOf( + arrayOf("p", userHex), + arrayOf("relays") + relays, + ) + + if (zapType == LnZapEvent.ZapType.ANONYMOUS) { + tags += arrayOf(arrayOf("anon", "")) + NostrSignerInternal(KeyPair()).sign(createdAt, KIND, tags, message, onReady) + } else if (zapType == LnZapEvent.ZapType.PRIVATE) { + tags += arrayOf(arrayOf("anon", "")) + signer.sign(createdAt, KIND, tags, message, onReady) + } else { + signer.sign(createdAt, KIND, tags, message, onReady) + } + } + + fun createEncryptionPrivateKey( + privkey: String, + id: String, + createdAt: Long, + ): ByteArray { + val str = privkey + id + createdAt.toString() + val strbyte = str.toByteArray(Charset.forName("utf-8")) + return CryptoUtils.sha256(strbyte) + } + + fun encryptPrivateZapMessage( + msg: String, + privkey: ByteArray, + pubkey: ByteArray, + ): String { + val sharedSecret = CryptoUtils.getSharedSecretNIP04(privkey, pubkey) + val iv = ByteArray(16) + SecureRandom().nextBytes(iv) + + val keySpec = SecretKeySpec(sharedSecret, "AES") + val ivSpec = IvParameterSpec(iv) + + val utf8message = msg.toByteArray(Charset.forName("utf-8")) + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec) + val encryptedMsg = cipher.doFinal(utf8message) + + val encryptedMsgBech32 = + Bech32.encode("pzap", Bech32.eight2five(encryptedMsg), Bech32.Encoding.Bech32) + val ivBech32 = Bech32.encode("iv", Bech32.eight2five(iv), Bech32.Encoding.Bech32) + + return encryptedMsgBech32 + "_" + ivBech32 + } + + private fun decryptPrivateZapMessage( + msg: String, + privkey: ByteArray, + pubkey: ByteArray, + ): String { + val sharedSecret = CryptoUtils.getSharedSecretNIP04(privkey, pubkey) + if (sharedSecret.size != 16 && sharedSecret.size != 32) { + throw IllegalArgumentException("Invalid shared secret size") + } + val parts = msg.split("_") + if (parts.size != 2) { + throw IllegalArgumentException("Invalid message format") + } + val iv = parts[1].run { Bech32.decode(this).second } + val encryptedMsg = parts.first().run { Bech32.decode(this).second } + val encryptedBytes = Bech32.five2eight(encryptedMsg, 0) + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init( + Cipher.DECRYPT_MODE, + SecretKeySpec(sharedSecret, "AES"), + IvParameterSpec( + Bech32.five2eight(iv, 0), + ), + ) + + try { + val decryptedMsgBytes = cipher.doFinal(encryptedBytes) + return String(decryptedMsgBytes) + } catch (ex: BadPaddingException) { + throw IllegalArgumentException("Bad padding: ${ex.message}") + } + } } - - fun createEncryptionPrivateKey( - privkey: String, - id: String, - createdAt: Long, - ): ByteArray { - val str = privkey + id + createdAt.toString() - val strbyte = str.toByteArray(Charset.forName("utf-8")) - return CryptoUtils.sha256(strbyte) - } - - fun encryptPrivateZapMessage( - msg: String, - privkey: ByteArray, - pubkey: ByteArray, - ): String { - val sharedSecret = CryptoUtils.getSharedSecretNIP04(privkey, pubkey) - val iv = ByteArray(16) - SecureRandom().nextBytes(iv) - - val keySpec = SecretKeySpec(sharedSecret, "AES") - val ivSpec = IvParameterSpec(iv) - - val utf8message = msg.toByteArray(Charset.forName("utf-8")) - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec) - val encryptedMsg = cipher.doFinal(utf8message) - - val encryptedMsgBech32 = - Bech32.encode("pzap", Bech32.eight2five(encryptedMsg), Bech32.Encoding.Bech32) - val ivBech32 = Bech32.encode("iv", Bech32.eight2five(iv), Bech32.Encoding.Bech32) - - return encryptedMsgBech32 + "_" + ivBech32 - } - - private fun decryptPrivateZapMessage( - msg: String, - privkey: ByteArray, - pubkey: ByteArray, - ): String { - val sharedSecret = CryptoUtils.getSharedSecretNIP04(privkey, pubkey) - if (sharedSecret.size != 16 && sharedSecret.size != 32) { - throw IllegalArgumentException("Invalid shared secret size") - } - val parts = msg.split("_") - if (parts.size != 2) { - throw IllegalArgumentException("Invalid message format") - } - val iv = parts[1].run { Bech32.decode(this).second } - val encryptedMsg = parts.first().run { Bech32.decode(this).second } - val encryptedBytes = Bech32.five2eight(encryptedMsg, 0) - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init( - Cipher.DECRYPT_MODE, - SecretKeySpec(sharedSecret, "AES"), - IvParameterSpec( - Bech32.five2eight(iv, 0), - ), - ) - - try { - val decryptedMsgBytes = cipher.doFinal(encryptedBytes) - return String(decryptedMsgBytes) - } catch (ex: BadPaddingException) { - throw IllegalArgumentException("Bad padding: ${ex.message}") - } - } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LongTextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LongTextNoteEvent.kt index 90b332cdb..97fabab49 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LongTextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LongTextNoteEvent.kt @@ -28,54 +28,50 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class LongTextNoteEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig), AddressableEvent { - override fun dTag() = - tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" + override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" - override fun address() = ATag(kind, pubKey, dTag(), null) + override fun address() = ATag(kind, pubKey, dTag(), null) - fun topics() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) } + fun topics() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) } - fun title() = - tags.filter { it.firstOrNull() == "title" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + fun title() = tags.filter { it.firstOrNull() == "title" }.mapNotNull { it.getOrNull(1) }.firstOrNull() - fun image() = - tags.filter { it.firstOrNull() == "image" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + fun image() = tags.filter { it.firstOrNull() == "image" }.mapNotNull { it.getOrNull(1) }.firstOrNull() - fun summary() = - tags.filter { it.firstOrNull() == "summary" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + fun summary() = tags.filter { it.firstOrNull() == "summary" }.mapNotNull { it.getOrNull(1) }.firstOrNull() - fun publishedAt() = - try { - tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull() - } catch (_: Exception) { - null + fun publishedAt() = + try { + tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull() + } catch (_: Exception) { + null + } + + companion object { + const val KIND = 30023 + + fun create( + msg: String, + title: String?, + replyTos: List?, + mentions: List?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (LongTextNoteEvent) -> Unit, + ) { + val tags = mutableListOf>() + replyTos?.forEach { tags.add(arrayOf("e", it)) } + mentions?.forEach { tags.add(arrayOf("p", it)) } + title?.let { tags.add(arrayOf("title", it)) } + tags.add(arrayOf("alt", "Blog post: $title")) + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + } } - - companion object { - const val KIND = 30023 - - fun create( - msg: String, - title: String?, - replyTos: List?, - mentions: List?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (LongTextNoteEvent) -> Unit, - ) { - val tags = mutableListOf>() - replyTos?.forEach { tags.add(arrayOf("e", it)) } - mentions?.forEach { tags.add(arrayOf("p", it)) } - title?.let { tags.add(arrayOf("title", it)) } - tags.add(arrayOf("alt", "Blog post: $title")) - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) - } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt index cc5a6c9f1..70956a3c5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt @@ -29,170 +29,170 @@ import java.io.ByteArrayInputStream @Stable abstract class IdentityClaim( - val identity: String, - val proof: String, + val identity: String, + val proof: String, ) { - abstract fun toProofUrl(): String + abstract fun toProofUrl(): String - abstract fun platform(): String + abstract fun platform(): String - fun platformIdentity() = "${platform()}:$identity" + fun platformIdentity() = "${platform()}:$identity" - companion object { - fun create( - platformIdentity: String, - proof: String, - ): IdentityClaim? { - val (platform, identity) = platformIdentity.split(':') + companion object { + fun create( + platformIdentity: String, + proof: String, + ): IdentityClaim? { + val (platform, identity) = platformIdentity.split(':') - return when (platform.lowercase()) { - GitHubIdentity.platform -> GitHubIdentity(identity, proof) - TwitterIdentity.platform -> TwitterIdentity(identity, proof) - TelegramIdentity.platform -> TelegramIdentity(identity, proof) - MastodonIdentity.platform -> MastodonIdentity(identity, proof) - else -> throw IllegalArgumentException("Platform $platform not supported") - } + return when (platform.lowercase()) { + GitHubIdentity.platform -> GitHubIdentity(identity, proof) + TwitterIdentity.platform -> TwitterIdentity(identity, proof) + TelegramIdentity.platform -> TelegramIdentity(identity, proof) + MastodonIdentity.platform -> MastodonIdentity(identity, proof) + else -> throw IllegalArgumentException("Platform $platform not supported") + } + } } - } } class GitHubIdentity( - identity: String, - proof: String, + identity: String, + proof: String, ) : IdentityClaim(identity, proof) { - override fun toProofUrl() = "https://gist.github.com/$identity/$proof" + override fun toProofUrl() = "https://gist.github.com/$identity/$proof" - override fun platform() = platform + override fun platform() = platform - companion object { - val platform = "github" + companion object { + val platform = "github" - fun parseProofUrl(proofUrl: String): GitHubIdentity? { - return try { - if (proofUrl.isBlank()) return null - val path = proofUrl.removePrefix("https://gist.github.com/").split("?")[0].split("/") + fun parseProofUrl(proofUrl: String): GitHubIdentity? { + return try { + if (proofUrl.isBlank()) return null + val path = proofUrl.removePrefix("https://gist.github.com/").split("?")[0].split("/") - GitHubIdentity(path[0], path[1]) - } catch (e: Exception) { - null - } + GitHubIdentity(path[0], path[1]) + } catch (e: Exception) { + null + } + } } - } } class TwitterIdentity( - identity: String, - proof: String, + identity: String, + proof: String, ) : IdentityClaim(identity, proof) { - override fun toProofUrl() = "https://twitter.com/$identity/status/$proof" + override fun toProofUrl() = "https://twitter.com/$identity/status/$proof" - override fun platform() = platform + override fun platform() = platform - companion object { - val platform = "twitter" + companion object { + val platform = "twitter" - fun parseProofUrl(proofUrl: String): TwitterIdentity? { - return try { - if (proofUrl.isBlank()) return null - val path = proofUrl.removePrefix("https://twitter.com/").split("?")[0].split("/") + fun parseProofUrl(proofUrl: String): TwitterIdentity? { + return try { + if (proofUrl.isBlank()) return null + val path = proofUrl.removePrefix("https://twitter.com/").split("?")[0].split("/") - TwitterIdentity(path[0], path[2]) - } catch (e: Exception) { - null - } + TwitterIdentity(path[0], path[2]) + } catch (e: Exception) { + null + } + } } - } } class TelegramIdentity( - identity: String, - proof: String, + identity: String, + proof: String, ) : IdentityClaim(identity, proof) { - override fun toProofUrl() = "https://t.me/$proof" + override fun toProofUrl() = "https://t.me/$proof" - override fun platform() = platform + override fun platform() = platform - companion object { - val platform = "telegram" - } + companion object { + val platform = "telegram" + } } class MastodonIdentity( - identity: String, - proof: String, + identity: String, + proof: String, ) : IdentityClaim(identity, proof) { - override fun toProofUrl() = "https://$identity/$proof" + override fun toProofUrl() = "https://$identity/$proof" - override fun platform() = platform + override fun platform() = platform - companion object { - val platform = "mastodon" + companion object { + val platform = "mastodon" - fun parseProofUrl(proofUrl: String): MastodonIdentity? { - return try { - if (proofUrl.isBlank()) return null - val path = proofUrl.removePrefix("https://").split("?")[0].split("/") + fun parseProofUrl(proofUrl: String): MastodonIdentity? { + return try { + if (proofUrl.isBlank()) return null + val path = proofUrl.removePrefix("https://").split("?")[0].split("/") - return MastodonIdentity("${path[0]}/${path[1]}", path[2]) - } catch (e: Exception) { - null - } + return MastodonIdentity("${path[0]}/${path[1]}", path[2]) + } catch (e: Exception) { + null + } + } } - } } class MetadataEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun contactMetaData() = - try { - mapper.readValue( - ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)), - UserMetadata::class.java, - ) - } catch (e: Exception) { - // e.printStackTrace() - Log.w("MT", "Content Parse Error: ${e.localizedMessage} $content") - null - } - - fun identityClaims() = - tags - .filter { it.firstOrNull() == "i" } - .mapNotNull { + fun contactMetaData() = try { - IdentityClaim.create(it.get(1), it.get(2)) + mapper.readValue( + ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)), + UserMetadata::class.java, + ) } catch (e: Exception) { - Log.e("MetadataEvent", "Can't parse identity [${it.joinToString { "," }}]", e) - null + // e.printStackTrace() + Log.w("MT", "Content Parse Error: ${e.localizedMessage} $content") + null } - } - companion object { - const val KIND = 0 + fun identityClaims() = + tags + .filter { it.firstOrNull() == "i" } + .mapNotNull { + try { + IdentityClaim.create(it.get(1), it.get(2)) + } catch (e: Exception) { + Log.e("MetadataEvent", "Can't parse identity [${it.joinToString { "," }}]", e) + null + } + } - fun create( - contactMetaData: String, - newName: String, - identities: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MetadataEvent) -> Unit, - ) { - val tags = mutableListOf>() + companion object { + const val KIND = 0 - tags.add( - arrayOf("alt", "User profile for $newName"), - ) + fun create( + contactMetaData: String, + newName: String, + identities: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MetadataEvent) -> Unit, + ) { + val tags = mutableListOf>() - identities.forEach { tags.add(arrayOf("i", it.platformIdentity(), it.proof)) } + tags.add( + arrayOf("alt", "User profile for $newName"), + ) - signer.sign(createdAt, KIND, tags.toTypedArray(), contactMetaData, onReady) + identities.forEach { tags.add(arrayOf("i", it.platformIdentity(), it.proof)) } + + signer.sign(createdAt, KIND, tags.toTypedArray(), contactMetaData, onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/MuteListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/MuteListEvent.kt index 7ef2db84f..80d4c650e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/MuteListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/MuteListEvent.kt @@ -28,288 +28,288 @@ import kotlinx.collections.immutable.ImmutableSet @Immutable class MuteListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient var publicAndPrivateUserCache: ImmutableSet? = null + @Transient var publicAndPrivateUserCache: ImmutableSet? = null - @Transient var publicAndPrivateWordCache: ImmutableSet? = null + @Transient var publicAndPrivateWordCache: ImmutableSet? = null - override fun dTag() = FIXED_D_TAG + override fun dTag() = FIXED_D_TAG - fun publicAndPrivateUsersAndWords( - signer: NostrSigner, - onReady: (PeopleListEvent.UsersAndWords) -> Unit, - ) { - publicAndPrivateUserCache?.let { userList -> - publicAndPrivateWordCache?.let { wordList -> - onReady(PeopleListEvent.UsersAndWords(userList, wordList)) - return - } - } - - privateTagsOrEmpty(signer) { - publicAndPrivateUserCache = filterTagList("p", it) - publicAndPrivateWordCache = filterTagList("word", it) - - publicAndPrivateUserCache?.let { userList -> - publicAndPrivateWordCache?.let { wordList -> - onReady( - PeopleListEvent.UsersAndWords(userList, wordList), - ) - } - } - } - } - - companion object { - const val KIND = 10000 - const val FIXED_D_TAG = "" - const val ALT = "Mute List" - - fun blockListFor(pubKeyHex: HexKey): String { - return "10000:$pubKeyHex:" - } - - fun createListWithTag( - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, + fun publicAndPrivateUsersAndWords( + signer: NostrSigner, + onReady: (PeopleListEvent.UsersAndWords) -> Unit, ) { - if (isPrivate) { - encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags -> - create( - content = encryptedTags, - tags = emptyArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } else { - create( - content = "", - tags = arrayOf(arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun createListWithUser( - pubKeyHex: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) { - return createListWithTag("p", pubKeyHex, isPrivate, signer, createdAt, onReady) - } - - fun createListWithWord( - word: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) { - return createListWithTag("word", word, isPrivate, signer, createdAt, onReady) - } - - fun addUsers( - earlierVersion: MuteListEvent, - listPubKeyHex: List, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags.plus( - listPubKeyHex.map { arrayOf("p", it) }, - ), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = - earlierVersion.tags.plus( - listPubKeyHex.map { arrayOf("p", it) }, - ), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun addWord( - earlierVersion: MuteListEvent, - word: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) { - return addTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady) - } - - fun addUser( - earlierVersion: MuteListEvent, - pubKeyHex: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) { - return addTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) - } - - fun addTag( - earlierVersion: MuteListEvent, - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) { - earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> - if (!isTagged) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = privateTags.plus(element = arrayOf(key, tag)), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } + publicAndPrivateUserCache?.let { userList -> + publicAndPrivateWordCache?.let { wordList -> + onReady(PeopleListEvent.UsersAndWords(userList, wordList)) + return } - } else { - create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } } - } - } - fun removeWord( - earlierVersion: MuteListEvent, - word: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) { - return removeTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady) - } + privateTagsOrEmpty(signer) { + publicAndPrivateUserCache = filterTagList("p", it) + publicAndPrivateWordCache = filterTagList("word", it) - fun removeUser( - earlierVersion: MuteListEvent, - pubKeyHex: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) { - return removeTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) - } - - fun removeTag( - earlierVersion: MuteListEvent, - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) { - earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> - if (isTagged) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = - earlierVersion.tags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } + publicAndPrivateUserCache?.let { userList -> + publicAndPrivateWordCache?.let { wordList -> + onReady( + PeopleListEvent.UsersAndWords(userList, wordList), + ) + } } - } else { - create( - content = earlierVersion.content, - tags = - earlierVersion.tags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } } - } } - fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit, - ) { - val newTags = - if (tags.any { it.size > 1 && it[0] == "alt" }) { - tags - } else { - tags + arrayOf("alt", ALT) + companion object { + const val KIND = 10000 + const val FIXED_D_TAG = "" + const val ALT = "Mute List" + + fun blockListFor(pubKeyHex: HexKey): String { + return "10000:$pubKeyHex:" } - signer.sign(createdAt, KIND, newTags, content, onReady) + fun createListWithTag( + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MuteListEvent) -> Unit, + ) { + if (isPrivate) { + encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags -> + create( + content = encryptedTags, + tags = emptyArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } else { + create( + content = "", + tags = arrayOf(arrayOf(key, tag)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + + fun createListWithUser( + pubKeyHex: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MuteListEvent) -> Unit, + ) { + return createListWithTag("p", pubKeyHex, isPrivate, signer, createdAt, onReady) + } + + fun createListWithWord( + word: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MuteListEvent) -> Unit, + ) { + return createListWithTag("word", word, isPrivate, signer, createdAt, onReady) + } + + fun addUsers( + earlierVersion: MuteListEvent, + listPubKeyHex: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MuteListEvent) -> Unit, + ) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = + privateTags.plus( + listPubKeyHex.map { arrayOf("p", it) }, + ), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = earlierVersion.tags, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = + earlierVersion.tags.plus( + listPubKeyHex.map { arrayOf("p", it) }, + ), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + + fun addWord( + earlierVersion: MuteListEvent, + word: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MuteListEvent) -> Unit, + ) { + return addTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady) + } + + fun addUser( + earlierVersion: MuteListEvent, + pubKeyHex: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MuteListEvent) -> Unit, + ) { + return addTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) + } + + fun addTag( + earlierVersion: MuteListEvent, + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MuteListEvent) -> Unit, + ) { + earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> + if (!isTagged) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = privateTags.plus(element = arrayOf(key, tag)), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = earlierVersion.tags, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(element = arrayOf(key, tag)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } + } + + fun removeWord( + earlierVersion: MuteListEvent, + word: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MuteListEvent) -> Unit, + ) { + return removeTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady) + } + + fun removeUser( + earlierVersion: MuteListEvent, + pubKeyHex: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MuteListEvent) -> Unit, + ) { + return removeTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) + } + + fun removeTag( + earlierVersion: MuteListEvent, + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MuteListEvent) -> Unit, + ) { + earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> + if (isTagged) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = + privateTags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = + earlierVersion.tags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = + earlierVersion.tags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } + } + + fun create( + content: String, + tags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MuteListEvent) -> Unit, + ) { + val newTags = + if (tags.any { it.size > 1 && it[0] == "alt" }) { + tags + } else { + tags + arrayOf("alt", ALT) + } + + signer.sign(createdAt, KIND, newTags, content, onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt index 8444c9aee..4fddd1925 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt @@ -25,177 +25,177 @@ import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner class NIP24Factory { - data class Result(val msg: Event, val wraps: List) + data class Result(val msg: Event, val wraps: List) - private fun recursiveGiftWrapCreation( - event: Event, - remainingTos: List, - signer: NostrSigner, - output: MutableList, - onReady: (List) -> Unit, - ) { - if (remainingTos.isEmpty()) { - onReady(output) - return + private fun recursiveGiftWrapCreation( + event: Event, + remainingTos: List, + signer: NostrSigner, + output: MutableList, + onReady: (List) -> Unit, + ) { + if (remainingTos.isEmpty()) { + onReady(output) + return + } + + val next = remainingTos.first() + + SealedGossipEvent.create( + event = event, + encryptTo = next, + signer = signer, + ) { seal -> + GiftWrapEvent.create( + event = seal, + recipientPubKey = next, + ) { giftWrap -> + output.add(giftWrap) + recursiveGiftWrapCreation(event, remainingTos.minus(next), signer, output, onReady) + } + } } - val next = remainingTos.first() - - SealedGossipEvent.create( - event = event, - encryptTo = next, - signer = signer, - ) { seal -> - GiftWrapEvent.create( - event = seal, - recipientPubKey = next, - ) { giftWrap -> - output.add(giftWrap) - recursiveGiftWrapCreation(event, remainingTos.minus(next), signer, output, onReady) - } + private fun createWraps( + event: Event, + to: Set, + signer: NostrSigner, + onReady: (List) -> Unit, + ) { + val wraps = mutableListOf() + recursiveGiftWrapCreation(event, to.toList(), signer, wraps, onReady) } - } - private fun createWraps( - event: Event, - to: Set, - signer: NostrSigner, - onReady: (List) -> Unit, - ) { - val wraps = mutableListOf() - recursiveGiftWrapCreation(event, to.toList(), signer, wraps, onReady) - } + fun createMsgNIP24( + msg: String, + to: List, + signer: NostrSigner, + subject: String? = null, + replyTos: List? = null, + mentions: List? = null, + zapReceiver: List? = null, + markAsSensitive: Boolean = false, + zapRaiserAmount: Long? = null, + geohash: String? = null, + onReady: (Result) -> Unit, + ) { + val senderPublicKey = signer.pubKey - fun createMsgNIP24( - msg: String, - to: List, - signer: NostrSigner, - subject: String? = null, - replyTos: List? = null, - mentions: List? = null, - zapReceiver: List? = null, - markAsSensitive: Boolean = false, - zapRaiserAmount: Long? = null, - geohash: String? = null, - onReady: (Result) -> Unit, - ) { - val senderPublicKey = signer.pubKey - - ChatMessageEvent.create( - msg = msg, - to = to, - signer = signer, - subject = subject, - replyTos = replyTos, - mentions = mentions, - zapReceiver = zapReceiver, - markAsSensitive = markAsSensitive, - zapRaiserAmount = zapRaiserAmount, - geohash = geohash, - ) { senderMessage -> - createWraps(senderMessage, to.plus(senderPublicKey).toSet(), signer) { wraps -> - onReady( - Result( - msg = senderMessage, - wraps = wraps, - ), - ) - } + ChatMessageEvent.create( + msg = msg, + to = to, + signer = signer, + subject = subject, + replyTos = replyTos, + mentions = mentions, + zapReceiver = zapReceiver, + markAsSensitive = markAsSensitive, + zapRaiserAmount = zapRaiserAmount, + geohash = geohash, + ) { senderMessage -> + createWraps(senderMessage, to.plus(senderPublicKey).toSet(), signer) { wraps -> + onReady( + Result( + msg = senderMessage, + wraps = wraps, + ), + ) + } + } } - } - fun createReactionWithinGroup( - content: String, - originalNote: EventInterface, - to: List, - signer: NostrSigner, - onReady: (Result) -> Unit, - ) { - val senderPublicKey = signer.pubKey + fun createReactionWithinGroup( + content: String, + originalNote: EventInterface, + to: List, + signer: NostrSigner, + onReady: (Result) -> Unit, + ) { + val senderPublicKey = signer.pubKey - ReactionEvent.create( - content, - originalNote, - signer, - ) { senderReaction -> - createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer) { wraps -> - onReady( - Result( - msg = senderReaction, - wraps = wraps, - ), - ) - } + ReactionEvent.create( + content, + originalNote, + signer, + ) { senderReaction -> + createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer) { wraps -> + onReady( + Result( + msg = senderReaction, + wraps = wraps, + ), + ) + } + } } - } - fun createReactionWithinGroup( - emojiUrl: EmojiUrl, - originalNote: EventInterface, - to: List, - signer: NostrSigner, - onReady: (Result) -> Unit, - ) { - val senderPublicKey = signer.pubKey + fun createReactionWithinGroup( + emojiUrl: EmojiUrl, + originalNote: EventInterface, + to: List, + signer: NostrSigner, + onReady: (Result) -> Unit, + ) { + val senderPublicKey = signer.pubKey - ReactionEvent.create( - emojiUrl, - originalNote, - signer, - ) { senderReaction -> - createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer) { wraps -> - onReady( - Result( - msg = senderReaction, - wraps = wraps, - ), - ) - } + ReactionEvent.create( + emojiUrl, + originalNote, + signer, + ) { senderReaction -> + createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer) { wraps -> + onReady( + Result( + msg = senderReaction, + wraps = wraps, + ), + ) + } + } } - } - fun createTextNoteNIP24( - msg: String, - to: List, - signer: NostrSigner, - replyTos: List? = null, - mentions: List? = null, - addresses: List?, - extraTags: List?, - zapReceiver: List? = null, - markAsSensitive: Boolean = false, - replyingTo: String?, - root: String?, - directMentions: Set, - zapRaiserAmount: Long? = null, - geohash: String? = null, - onReady: (Result) -> Unit, - ) { - val senderPublicKey = signer.pubKey + fun createTextNoteNIP24( + msg: String, + to: List, + signer: NostrSigner, + replyTos: List? = null, + mentions: List? = null, + addresses: List?, + extraTags: List?, + zapReceiver: List? = null, + markAsSensitive: Boolean = false, + replyingTo: String?, + root: String?, + directMentions: Set, + zapRaiserAmount: Long? = null, + geohash: String? = null, + onReady: (Result) -> Unit, + ) { + val senderPublicKey = signer.pubKey - TextNoteEvent.create( - msg = msg, - signer = signer, - replyTos = replyTos, - mentions = mentions, - zapReceiver = zapReceiver, - root = root, - extraTags = extraTags, - addresses = addresses, - directMentions = directMentions, - replyingTo = replyingTo, - markAsSensitive = markAsSensitive, - zapRaiserAmount = zapRaiserAmount, - geohash = geohash, - ) { senderMessage -> - createWraps(senderMessage, to.plus(senderPublicKey).toSet(), signer) { wraps -> - onReady( - Result( - msg = senderMessage, - wraps = wraps, - ), - ) - } + TextNoteEvent.create( + msg = msg, + signer = signer, + replyTos = replyTos, + mentions = mentions, + zapReceiver = zapReceiver, + root = root, + extraTags = extraTags, + addresses = addresses, + directMentions = directMentions, + replyingTo = replyingTo, + markAsSensitive = markAsSensitive, + zapRaiserAmount = zapRaiserAmount, + geohash = geohash, + ) { senderMessage -> + createWraps(senderMessage, to.plus(senderPublicKey).toSet(), signer) { wraps -> + onReady( + Result( + msg = senderMessage, + wraps = wraps, + ), + ) + } + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/NNSEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/NNSEvent.kt index e70e42a9c..17316d851 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/NNSEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/NNSEvent.kt @@ -27,30 +27,30 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class NNSEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun ip4() = tags.firstOrNull { it.size > 1 && it[0] == "ip4" }?.get(1) + fun ip4() = tags.firstOrNull { it.size > 1 && it[0] == "ip4" }?.get(1) - fun ip6() = tags.firstOrNull { it.size > 1 && it[0] == "ip6" }?.get(1) + fun ip6() = tags.firstOrNull { it.size > 1 && it[0] == "ip6" }?.get(1) - fun version() = tags.firstOrNull { it.size > 1 && it[0] == "version" }?.get(1) + fun version() = tags.firstOrNull { it.size > 1 && it[0] == "version" }?.get(1) - companion object { - const val KIND = 30053 - const val ALT = "DNS records" + companion object { + const val KIND = 30053 + const val ALT = "DNS records" - fun create( - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (NNSEvent) -> Unit, - ) { - val tags = arrayOf(arrayOf("alt", ALT)) - signer.sign(createdAt, KIND, tags, "", onReady) + fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (NNSEvent) -> Unit, + ) { + val tags = arrayOf(arrayOf("alt", ALT)) + signer.sign(createdAt, KIND, tags, "", onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PeopleListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PeopleListEvent.kt index a1c8760b9..ec9bae9bd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PeopleListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PeopleListEvent.kt @@ -29,339 +29,339 @@ import kotlinx.collections.immutable.persistentSetOf @Immutable class PeopleListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient var publicAndPrivateUserCache: ImmutableSet? = null + @Transient var publicAndPrivateUserCache: ImmutableSet? = null - @Transient var publicAndPrivateWordCache: ImmutableSet? = null + @Transient var publicAndPrivateWordCache: ImmutableSet? = null - fun publicAndPrivateWords( - signer: NostrSigner, - onReady: (ImmutableSet) -> Unit, - ) { - publicAndPrivateWordCache?.let { - onReady(it) - return - } - - privateTagsOrEmpty(signer) { - publicAndPrivateWordCache = filterTagList("word", it) - publicAndPrivateWordCache?.let { onReady(it) } - } - } - - fun publicAndPrivateUsers( - signer: NostrSigner, - onReady: (ImmutableSet) -> Unit, - ) { - publicAndPrivateUserCache?.let { - onReady(it) - return - } - - privateTagsOrEmpty(signer) { - publicAndPrivateUserCache = filterTagList("p", it) - publicAndPrivateUserCache?.let { onReady(it) } - } - } - - @Immutable - data class UsersAndWords( - val users: ImmutableSet = persistentSetOf(), - val words: ImmutableSet = persistentSetOf(), - ) - - fun publicAndPrivateUsersAndWords( - signer: NostrSigner, - onReady: (UsersAndWords) -> Unit, - ) { - publicAndPrivateUserCache?.let { userList -> - publicAndPrivateWordCache?.let { wordList -> - onReady(UsersAndWords(userList, wordList)) - return - } - } - - privateTagsOrEmpty(signer) { - publicAndPrivateUserCache = filterTagList("p", it) - publicAndPrivateWordCache = filterTagList("word", it) - - publicAndPrivateUserCache?.let { userList -> - publicAndPrivateWordCache?.let { wordList -> - onReady( - UsersAndWords(userList, wordList), - ) + fun publicAndPrivateWords( + signer: NostrSigner, + onReady: (ImmutableSet) -> Unit, + ) { + publicAndPrivateWordCache?.let { + onReady(it) + return } - } - } - } - fun isTaggedWord( - word: String, - isPrivate: Boolean, - signer: NostrSigner, - onReady: (Boolean) -> Unit, - ) = isTagged("word", word, isPrivate, signer, onReady) - - fun isTaggedUser( - idHex: String, - isPrivate: Boolean, - signer: NostrSigner, - onReady: (Boolean) -> Unit, - ) = isTagged("p", idHex, isPrivate, signer, onReady) - - companion object { - const val KIND = 30000 - const val BLOCK_LIST_D_TAG = "mute" - const val ALT = "List of people" - - fun blockListFor(pubKeyHex: HexKey): String { - return "30000:$pubKeyHex:$BLOCK_LIST_D_TAG" - } - - fun createListWithTag( - name: String, - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) { - if (isPrivate) { - encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags -> - create( - content = encryptedTags, - tags = arrayOf(arrayOf("d", name)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) + privateTagsOrEmpty(signer) { + publicAndPrivateWordCache = filterTagList("word", it) + publicAndPrivateWordCache?.let { onReady(it) } } - } else { - create( - content = "", - tags = arrayOf(arrayOf("d", name), arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } } - fun createListWithUser( - name: String, - pubKeyHex: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, + fun publicAndPrivateUsers( + signer: NostrSigner, + onReady: (ImmutableSet) -> Unit, ) { - return createListWithTag(name, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) - } - - fun createListWithWord( - name: String, - word: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) { - return createListWithTag(name, "word", word, isPrivate, signer, createdAt, onReady) - } - - fun addUsers( - earlierVersion: PeopleListEvent, - listPubKeyHex: List, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags.plus( - listPubKeyHex.map { arrayOf("p", it) }, - ), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } + publicAndPrivateUserCache?.let { + onReady(it) + return + } + + privateTagsOrEmpty(signer) { + publicAndPrivateUserCache = filterTagList("p", it) + publicAndPrivateUserCache?.let { onReady(it) } } - } else { - create( - content = earlierVersion.content, - tags = - earlierVersion.tags.plus( - listPubKeyHex.map { arrayOf("p", it) }, - ), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } } - fun addWord( - earlierVersion: PeopleListEvent, - word: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) { - return addTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady) - } + @Immutable + data class UsersAndWords( + val users: ImmutableSet = persistentSetOf(), + val words: ImmutableSet = persistentSetOf(), + ) - fun addUser( - earlierVersion: PeopleListEvent, - pubKeyHex: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, + fun publicAndPrivateUsersAndWords( + signer: NostrSigner, + onReady: (UsersAndWords) -> Unit, ) { - return addTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) - } - - fun addTag( - earlierVersion: PeopleListEvent, - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) { - earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> - if (!isTagged) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = privateTags.plus(element = arrayOf(key, tag)), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } + publicAndPrivateUserCache?.let { userList -> + publicAndPrivateWordCache?.let { wordList -> + onReady(UsersAndWords(userList, wordList)) + return } - } else { - create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } } - } - } - fun removeWord( - earlierVersion: PeopleListEvent, - word: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) { - return removeTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady) - } + privateTagsOrEmpty(signer) { + publicAndPrivateUserCache = filterTagList("p", it) + publicAndPrivateWordCache = filterTagList("word", it) - fun removeUser( - earlierVersion: PeopleListEvent, - pubKeyHex: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) { - return removeTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) - } - - fun removeTag( - earlierVersion: PeopleListEvent, - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) { - earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> - if (isTagged) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = - earlierVersion.tags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } + publicAndPrivateUserCache?.let { userList -> + publicAndPrivateWordCache?.let { wordList -> + onReady( + UsersAndWords(userList, wordList), + ) + } } - } else { - create( - content = earlierVersion.content, - tags = - earlierVersion.tags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } } - } } - fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) { - val newTags = - if (tags.any { it.size > 1 && it[0] == "alt" }) { - tags - } else { - tags + arrayOf("alt", ALT) + fun isTaggedWord( + word: String, + isPrivate: Boolean, + signer: NostrSigner, + onReady: (Boolean) -> Unit, + ) = isTagged("word", word, isPrivate, signer, onReady) + + fun isTaggedUser( + idHex: String, + isPrivate: Boolean, + signer: NostrSigner, + onReady: (Boolean) -> Unit, + ) = isTagged("p", idHex, isPrivate, signer, onReady) + + companion object { + const val KIND = 30000 + const val BLOCK_LIST_D_TAG = "mute" + const val ALT = "List of people" + + fun blockListFor(pubKeyHex: HexKey): String { + return "30000:$pubKeyHex:$BLOCK_LIST_D_TAG" } - signer.sign(createdAt, KIND, newTags, content, onReady) + fun createListWithTag( + name: String, + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + if (isPrivate) { + encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags -> + create( + content = encryptedTags, + tags = arrayOf(arrayOf("d", name)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } else { + create( + content = "", + tags = arrayOf(arrayOf("d", name), arrayOf(key, tag)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + + fun createListWithUser( + name: String, + pubKeyHex: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + return createListWithTag(name, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) + } + + fun createListWithWord( + name: String, + word: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + return createListWithTag(name, "word", word, isPrivate, signer, createdAt, onReady) + } + + fun addUsers( + earlierVersion: PeopleListEvent, + listPubKeyHex: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = + privateTags.plus( + listPubKeyHex.map { arrayOf("p", it) }, + ), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = earlierVersion.tags, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = + earlierVersion.tags.plus( + listPubKeyHex.map { arrayOf("p", it) }, + ), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + + fun addWord( + earlierVersion: PeopleListEvent, + word: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + return addTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady) + } + + fun addUser( + earlierVersion: PeopleListEvent, + pubKeyHex: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + return addTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) + } + + fun addTag( + earlierVersion: PeopleListEvent, + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> + if (!isTagged) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = privateTags.plus(element = arrayOf(key, tag)), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = earlierVersion.tags, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(element = arrayOf(key, tag)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } + } + + fun removeWord( + earlierVersion: PeopleListEvent, + word: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + return removeTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady) + } + + fun removeUser( + earlierVersion: PeopleListEvent, + pubKeyHex: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + return removeTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) + } + + fun removeTag( + earlierVersion: PeopleListEvent, + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> + if (isTagged) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = + privateTags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = + earlierVersion.tags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = + earlierVersion.tags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } + } + + fun create( + content: String, + tags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + val newTags = + if (tags.any { it.size > 1 && it[0] == "alt" }) { + tags + } else { + tags + arrayOf("alt", ALT) + } + + signer.sign(createdAt, KIND, newTags, content, onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PinListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PinListEvent.kt index 93b973bbd..155ef83eb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PinListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PinListEvent.kt @@ -27,30 +27,30 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class PinListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun pins() = tags.filter { it.size > 1 && it[0] == "pin" }.map { it[1] } + fun pins() = tags.filter { it.size > 1 && it[0] == "pin" }.map { it[1] } - companion object { - const val KIND = 33888 - const val ALT = "Pinned Posts" + companion object { + const val KIND = 33888 + const val ALT = "Pinned Posts" - fun create( - pins: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PinListEvent) -> Unit, - ) { - val tags = mutableListOf>() - pins.forEach { tags.add(arrayOf("pin", it)) } - tags.add(arrayOf("alt", ALT)) + fun create( + pins: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PinListEvent) -> Unit, + ) { + val tags = mutableListOf>() + pins.forEach { tags.add(arrayOf("pin", it)) } + tags.add(arrayOf("alt", ALT)) - signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt index 653eae085..3d9a5dc57 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt @@ -34,87 +34,84 @@ const val CLOSED_AT = "closed_at" @Immutable class PollNoteEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - // ots: String?, TODO implement OTS: https://github.com/opentimestamps/java-opentimestamps - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + // ots: String?, TODO implement OTS: https://github.com/opentimestamps/java-opentimestamps + content: String, + sig: HexKey, ) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun pollOptions() = - tags.filter { it.size > 2 && it[0] == POLL_OPTION }.associate { it[1].toInt() to it[2] } + fun pollOptions() = tags.filter { it.size > 2 && it[0] == POLL_OPTION }.associate { it[1].toInt() to it[2] } - fun minimumAmount() = - tags.firstOrNull { it.size > 1 && it[0] == VALUE_MINIMUM }?.getOrNull(1)?.toLongOrNull() + fun minimumAmount() = tags.firstOrNull { it.size > 1 && it[0] == VALUE_MINIMUM }?.getOrNull(1)?.toLongOrNull() - fun maximumAmount() = - tags.firstOrNull { it.size > 1 && it[0] == VALUE_MAXIMUM }?.getOrNull(1)?.toLongOrNull() + fun maximumAmount() = tags.firstOrNull { it.size > 1 && it[0] == VALUE_MAXIMUM }?.getOrNull(1)?.toLongOrNull() - fun getTagLong(property: String): Long? { - val number = tags.firstOrNull { it.size > 1 && it[0] == property }?.get(1) + fun getTagLong(property: String): Long? { + val number = tags.firstOrNull { it.size > 1 && it[0] == property }?.get(1) - return if (number.isNullOrBlank() || number == "null") { - null - } else { - number.toLong() - } - } - - companion object { - const val KIND = 6969 - const val ALT = "Poll event" - - fun create( - msg: String, - replyTos: List?, - mentions: List?, - addresses: List?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - pollOptions: Map, - valueMaximum: Int?, - valueMinimum: Int?, - consensusThreshold: Int?, - closedAt: Int?, - zapReceiver: List? = null, - markAsSensitive: Boolean, - zapRaiserAmount: Long?, - geohash: String? = null, - nip94attachments: List? = null, - onReady: (PollNoteEvent) -> Unit, - ) { - val tags = mutableListOf>() - replyTos?.forEach { tags.add(arrayOf("e", it)) } - mentions?.forEach { tags.add(arrayOf("p", it)) } - addresses?.forEach { tags.add(arrayOf("a", it.toTag())) } - pollOptions.forEach { poll_op -> - tags.add(arrayOf(POLL_OPTION, poll_op.key.toString(), poll_op.value)) - } - valueMaximum?.let { tags.add(arrayOf(VALUE_MAXIMUM, valueMaximum.toString())) } - valueMinimum?.let { tags.add(arrayOf(VALUE_MINIMUM, valueMinimum.toString())) } - consensusThreshold?.let { - tags.add(arrayOf(CONSENSUS_THRESHOLD, consensusThreshold.toString())) - } - closedAt?.let { tags.add(arrayOf(CLOSED_AT, closedAt.toString())) } - zapReceiver?.forEach { - tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) - } - if (markAsSensitive) { - tags.add(arrayOf("content-warning", "")) - } - zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } - geohash?.let { tags.addAll(geohashMipMap(it)) } - nip94attachments?.let { - it.forEach { - // tags.add(arrayOf("nip94", it.toJson())) + return if (number.isNullOrBlank() || number == "null") { + null + } else { + number.toLong() + } + } + + companion object { + const val KIND = 6969 + const val ALT = "Poll event" + + fun create( + msg: String, + replyTos: List?, + mentions: List?, + addresses: List?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + pollOptions: Map, + valueMaximum: Int?, + valueMinimum: Int?, + consensusThreshold: Int?, + closedAt: Int?, + zapReceiver: List? = null, + markAsSensitive: Boolean, + zapRaiserAmount: Long?, + geohash: String? = null, + nip94attachments: List? = null, + onReady: (PollNoteEvent) -> Unit, + ) { + val tags = mutableListOf>() + replyTos?.forEach { tags.add(arrayOf("e", it)) } + mentions?.forEach { tags.add(arrayOf("p", it)) } + addresses?.forEach { tags.add(arrayOf("a", it.toTag())) } + pollOptions.forEach { poll_op -> + tags.add(arrayOf(POLL_OPTION, poll_op.key.toString(), poll_op.value)) + } + valueMaximum?.let { tags.add(arrayOf(VALUE_MAXIMUM, valueMaximum.toString())) } + valueMinimum?.let { tags.add(arrayOf(VALUE_MINIMUM, valueMinimum.toString())) } + consensusThreshold?.let { + tags.add(arrayOf(CONSENSUS_THRESHOLD, consensusThreshold.toString())) + } + closedAt?.let { tags.add(arrayOf(CLOSED_AT, closedAt.toString())) } + zapReceiver?.forEach { + tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) + } + if (markAsSensitive) { + tags.add(arrayOf("content-warning", "")) + } + zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } + geohash?.let { tags.addAll(geohashMipMap(it)) } + nip94attachments?.let { + it.forEach { + // tags.add(arrayOf("nip94", it.toJson())) + } + } + tags.add(arrayOf("alt", ALT)) + + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) } - } - tags.add(arrayOf("alt", ALT)) - - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) } - } } /* diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt index da2f6a251..d5475ca39 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt @@ -30,132 +30,132 @@ import kotlinx.collections.immutable.persistentSetOf @Immutable class PrivateDmEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), ChatroomKeyable { - @Transient private var decryptedContent: Map = mapOf() + @Transient private var decryptedContent: Map = mapOf() - /** - * This may or may not be the actual recipient's pub key. The event is intended to look like a - * nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used for - * initial messages. - */ - private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) + /** + * This may or may not be the actual recipient's pub key. The event is intended to look like a + * nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used for + * initial messages. + */ + private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) - fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this) }?.getOrNull() + fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this) }?.getOrNull() - fun verifiedRecipientPubKey(): HexKey? { - val recipient = recipientPubKey() - return if (HexValidator.isHex(recipient)) { - recipient - } else { - null - } - } - - fun talkingWith(oneSideHex: String): HexKey { - return if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey - } - - override fun chatroomKey(toRemove: String): ChatroomKey { - return ChatroomKey(persistentSetOf(talkingWith(toRemove))) - } - - /** - * To be fully compatible with nip-04, we read e-tags that are in violation to nip-18. - * - * Nip-18 messages should refer to other events by inline references in the content like - * `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506). - */ - fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) - - fun with(pubkeyHex: String): Boolean { - return pubkeyHex == pubKey || tags.any { it.size > 1 && it[0] == "p" && it[1] == pubkeyHex } - } - - fun cachedContentFor(signer: NostrSigner): String? { - return decryptedContent[signer.pubKey] - } - - fun plainContent( - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - decryptedContent[signer.pubKey]?.let { - onReady(it) - return - } - - signer.nip04Decrypt(content, talkingWith(signer.pubKey)) { retVal -> - val content = - if (retVal.startsWith(NIP_18_ADVERTISEMENT)) { - retVal.substring(16) + fun verifiedRecipientPubKey(): HexKey? { + val recipient = recipientPubKey() + return if (HexValidator.isHex(recipient)) { + recipient } else { - retVal + null + } + } + + fun talkingWith(oneSideHex: String): HexKey { + return if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey + } + + override fun chatroomKey(toRemove: String): ChatroomKey { + return ChatroomKey(persistentSetOf(talkingWith(toRemove))) + } + + /** + * To be fully compatible with nip-04, we read e-tags that are in violation to nip-18. + * + * Nip-18 messages should refer to other events by inline references in the content like + * `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506). + */ + fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + + fun with(pubkeyHex: String): Boolean { + return pubkeyHex == pubKey || tags.any { it.size > 1 && it[0] == "p" && it[1] == pubkeyHex } + } + + fun cachedContentFor(signer: NostrSigner): String? { + return decryptedContent[signer.pubKey] + } + + fun plainContent( + signer: NostrSigner, + onReady: (String) -> Unit, + ) { + decryptedContent[signer.pubKey]?.let { + onReady(it) + return } - decryptedContent = decryptedContent + Pair(signer.pubKey, content) + signer.nip04Decrypt(content, talkingWith(signer.pubKey)) { retVal -> + val content = + if (retVal.startsWith(NIP_18_ADVERTISEMENT)) { + retVal.substring(16) + } else { + retVal + } - onReady(content) + decryptedContent = decryptedContent + Pair(signer.pubKey, content) + + onReady(content) + } } - } - companion object { - const val KIND = 4 - const val ALT = "Private Message" - const val NIP_18_ADVERTISEMENT = "[//]: # (nip18)\n" + companion object { + const val KIND = 4 + const val ALT = "Private Message" + const val NIP_18_ADVERTISEMENT = "[//]: # (nip18)\n" - fun create( - recipientPubKey: HexKey, - msg: String, - replyTos: List? = null, - mentions: List? = null, - zapReceiver: List? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - publishedRecipientPubKey: HexKey? = null, - advertiseNip18: Boolean = true, - markAsSensitive: Boolean, - zapRaiserAmount: Long?, - geohash: String? = null, - onReady: (PrivateDmEvent) -> Unit, - ) { - val message = - if (advertiseNip18) { - NIP_18_ADVERTISEMENT - } else { - "" - } + msg - val tags = mutableListOf>() - publishedRecipientPubKey?.let { tags.add(arrayOf("p", publishedRecipientPubKey)) } - replyTos?.forEach { tags.add(arrayOf("e", it)) } - mentions?.forEach { tags.add(arrayOf("p", it)) } - zapReceiver?.forEach { - tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) - } - if (markAsSensitive) { - tags.add(arrayOf("content-warning", "")) - } - zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } - geohash?.let { tags.addAll(geohashMipMap(it)) } - tags.add(arrayOf("alt", ALT)) + fun create( + recipientPubKey: HexKey, + msg: String, + replyTos: List? = null, + mentions: List? = null, + zapReceiver: List? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + publishedRecipientPubKey: HexKey? = null, + advertiseNip18: Boolean = true, + markAsSensitive: Boolean, + zapRaiserAmount: Long?, + geohash: String? = null, + onReady: (PrivateDmEvent) -> Unit, + ) { + val message = + if (advertiseNip18) { + NIP_18_ADVERTISEMENT + } else { + "" + } + msg + val tags = mutableListOf>() + publishedRecipientPubKey?.let { tags.add(arrayOf("p", publishedRecipientPubKey)) } + replyTos?.forEach { tags.add(arrayOf("e", it)) } + mentions?.forEach { tags.add(arrayOf("p", it)) } + zapReceiver?.forEach { + tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) + } + if (markAsSensitive) { + tags.add(arrayOf("content-warning", "")) + } + zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } + geohash?.let { tags.addAll(geohashMipMap(it)) } + tags.add(arrayOf("alt", ALT)) - signer.nip04Encrypt(message, recipientPubKey) { content -> - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) - } + signer.nip04Encrypt(message, recipientPubKey) { content -> + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } + } } - } } fun geohashMipMap(geohash: String): Array> { - return geohash.indices - .asSequence() - .map { arrayOf("g", geohash.substring(0, it + 1)) } - .toList() - .reversed() - .toTypedArray() + return geohash.indices + .asSequence() + .map { arrayOf("g", geohash.substring(0, it + 1)) } + .toList() + .reversed() + .toTypedArray() } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ReactionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ReactionEvent.kt index acfdb6577..da1ca943a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ReactionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ReactionEvent.kt @@ -27,79 +27,79 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class ReactionEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun originalPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + fun originalPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } - fun originalAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } + fun originalAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } - companion object { - const val KIND = 7 + companion object { + const val KIND = 7 - fun createWarning( - originalNote: EventInterface, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ReactionEvent) -> Unit, - ) { - return create("\u26A0\uFE0F", originalNote, signer, createdAt, onReady) + fun createWarning( + originalNote: EventInterface, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ReactionEvent) -> Unit, + ) { + return create("\u26A0\uFE0F", originalNote, signer, createdAt, onReady) + } + + fun createLike( + originalNote: EventInterface, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ReactionEvent) -> Unit, + ) { + return create("+", originalNote, signer, createdAt, onReady) + } + + fun create( + content: String, + originalNote: EventInterface, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ReactionEvent) -> Unit, + ) { + var tags = + listOf( + arrayOf("e", originalNote.id()), + arrayOf("p", originalNote.pubKey()), + arrayOf("k", originalNote.kind().toString()), + ) + if (originalNote is AddressableEvent) { + tags = tags + listOf(arrayOf("a", originalNote.address().toTag())) + } + + return signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } + + fun create( + emojiUrl: EmojiUrl, + originalNote: EventInterface, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ReactionEvent) -> Unit, + ) { + val content = ":${emojiUrl.code}:" + + var tags = + arrayOf( + arrayOf("e", originalNote.id()), + arrayOf("p", originalNote.pubKey()), + arrayOf("emoji", emojiUrl.code, emojiUrl.url), + ) + + if (originalNote is AddressableEvent) { + tags += arrayOf(arrayOf("a", originalNote.address().toTag())) + } + + signer.sign(createdAt, KIND, tags, content, onReady) + } } - - fun createLike( - originalNote: EventInterface, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ReactionEvent) -> Unit, - ) { - return create("+", originalNote, signer, createdAt, onReady) - } - - fun create( - content: String, - originalNote: EventInterface, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ReactionEvent) -> Unit, - ) { - var tags = - listOf( - arrayOf("e", originalNote.id()), - arrayOf("p", originalNote.pubKey()), - arrayOf("k", originalNote.kind().toString()), - ) - if (originalNote is AddressableEvent) { - tags = tags + listOf(arrayOf("a", originalNote.address().toTag())) - } - - return signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) - } - - fun create( - emojiUrl: EmojiUrl, - originalNote: EventInterface, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ReactionEvent) -> Unit, - ) { - val content = ":${emojiUrl.code}:" - - var tags = - arrayOf( - arrayOf("e", originalNote.id()), - arrayOf("p", originalNote.pubKey()), - arrayOf("emoji", emojiUrl.code, emojiUrl.url), - ) - - if (originalNote is AddressableEvent) { - tags += arrayOf(arrayOf("a", originalNote.address().toTag())) - } - - signer.sign(createdAt, KIND, tags, content, onReady) - } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/RecommendRelayEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/RecommendRelayEvent.kt index e53d51719..63b2c39d2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/RecommendRelayEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/RecommendRelayEvent.kt @@ -28,26 +28,26 @@ import java.net.URI @Immutable class RecommendRelayEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun relay() = URI.create(content.trim()) + fun relay() = URI.create(content.trim()) - companion object { - const val KIND = 2 + companion object { + const val KIND = 2 - fun create( - relay: URI, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (RecommendRelayEvent) -> Unit, - ) { - val content = relay.toString() - signer.sign(createdAt, KIND, emptyArray(), content, onReady) + fun create( + relay: URI, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (RecommendRelayEvent) -> Unit, + ) { + val content = relay.toString() + signer.sign(createdAt, KIND, emptyArray(), content, onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/RelayAuthEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/RelayAuthEvent.kt index 82d1efb1a..9aa0060ea 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/RelayAuthEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/RelayAuthEvent.kt @@ -27,34 +27,34 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class RelayAuthEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun relay() = tags.firstOrNull { it.size > 1 && it[0] == "relay" }?.get(1) + fun relay() = tags.firstOrNull { it.size > 1 && it[0] == "relay" }?.get(1) - fun challenge() = tags.firstOrNull { it.size > 1 && it[0] == "challenge" }?.get(1) + fun challenge() = tags.firstOrNull { it.size > 1 && it[0] == "challenge" }?.get(1) - companion object { - const val KIND = 22242 + companion object { + const val KIND = 22242 - fun create( - relay: String, - challenge: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (RelayAuthEvent) -> Unit, - ) { - val content = "" - val tags = - arrayOf( - arrayOf("relay", relay), - arrayOf("challenge", challenge), - ) - signer.sign(createdAt, KIND, tags, content, onReady) + fun create( + relay: String, + challenge: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (RelayAuthEvent) -> Unit, + ) { + val content = "" + val tags = + arrayOf( + arrayOf("relay", relay), + arrayOf("challenge", challenge), + ) + signer.sign(createdAt, KIND, tags, content, onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/RelaySetEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/RelaySetEvent.kt index ecf2e76f6..a8da5f7dd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/RelaySetEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/RelaySetEvent.kt @@ -27,32 +27,32 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class RelaySetEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun relays() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] } + fun relays() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] } - fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) + fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) - companion object { - const val KIND = 30022 - const val ALT = "Relay list" + companion object { + const val KIND = 30022 + const val ALT = "Relay list" - fun create( - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (RelaySetEvent) -> Unit, - ) { - val tags = mutableListOf>() - relays.forEach { tags.add(arrayOf("r", it)) } - tags.add(arrayOf("alt", ALT)) + fun create( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (RelaySetEvent) -> Unit, + ) { + val tags = mutableListOf>() + relays.forEach { tags.add(arrayOf("r", it)) } + tags.add(arrayOf("alt", ALT)) - signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ReportEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ReportEvent.kt index bb3e54fc1..255a1641e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ReportEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ReportEvent.kt @@ -30,101 +30,101 @@ import com.vitorpamplona.quartz.utils.TimeUtils // NIP 56 event. @Immutable class ReportEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - private fun defaultReportType(): ReportType { - // Works with old and new structures for report. - var reportType = - tags - .filter { it.firstOrNull() == "report" } - .mapNotNull { it.getOrNull(1) } - .map { ReportType.valueOf(it.uppercase()) } - .firstOrNull() - if (reportType == null) { - reportType = - tags.mapNotNull { it.getOrNull(2) }.map { ReportType.valueOf(it.uppercase()) }.firstOrNull() - } - if (reportType == null) { - reportType = ReportType.SPAM - } - return reportType - } - - fun reportedPost() = - tags - .filter { it.size > 1 && it[0] == "e" } - .map { - ReportedKey( - it[1], - it.getOrNull(2)?.uppercase()?.let { it1 -> ReportType.valueOf(it1) } - ?: defaultReportType(), - ) - } - - fun reportedAuthor() = - tags - .filter { it.size > 1 && it[0] == "p" } - .map { - ReportedKey( - it[1], - it.getOrNull(2)?.uppercase()?.let { it1 -> ReportType.valueOf(it1) } - ?: defaultReportType(), - ) - } - - companion object { - const val KIND = 1984 - - fun create( - reportedPost: EventInterface, - type: ReportType, - signer: NostrSigner, - content: String = "", - createdAt: Long = TimeUtils.now(), - onReady: (ReportEvent) -> Unit, - ) { - val reportPostTag = arrayOf("e", reportedPost.id(), type.name.lowercase()) - val reportAuthorTag = arrayOf("p", reportedPost.pubKey(), type.name.lowercase()) - - var tags: Array> = arrayOf(reportPostTag, reportAuthorTag) - - if (reportedPost is AddressableEvent) { - tags += listOf(arrayOf("a", reportedPost.address().toTag())) - } - - tags += listOf(arrayOf("alt", "Report for ${type.name}")) - - signer.sign(createdAt, KIND, tags, content, onReady) + private fun defaultReportType(): ReportType { + // Works with old and new structures for report. + var reportType = + tags + .filter { it.firstOrNull() == "report" } + .mapNotNull { it.getOrNull(1) } + .map { ReportType.valueOf(it.uppercase()) } + .firstOrNull() + if (reportType == null) { + reportType = + tags.mapNotNull { it.getOrNull(2) }.map { ReportType.valueOf(it.uppercase()) }.firstOrNull() + } + if (reportType == null) { + reportType = ReportType.SPAM + } + return reportType } - fun create( - reportedUser: String, - type: ReportType, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ReportEvent) -> Unit, - ) { - val content = "" + fun reportedPost() = + tags + .filter { it.size > 1 && it[0] == "e" } + .map { + ReportedKey( + it[1], + it.getOrNull(2)?.uppercase()?.let { it1 -> ReportType.valueOf(it1) } + ?: defaultReportType(), + ) + } - val reportAuthorTag = arrayOf("p", reportedUser, type.name.lowercase()) - val alt = arrayOf("alt", "Report for ${type.name}") + fun reportedAuthor() = + tags + .filter { it.size > 1 && it[0] == "p" } + .map { + ReportedKey( + it[1], + it.getOrNull(2)?.uppercase()?.let { it1 -> ReportType.valueOf(it1) } + ?: defaultReportType(), + ) + } - val tags: Array> = arrayOf(reportAuthorTag, alt) - signer.sign(createdAt, KIND, tags, content, onReady) + companion object { + const val KIND = 1984 + + fun create( + reportedPost: EventInterface, + type: ReportType, + signer: NostrSigner, + content: String = "", + createdAt: Long = TimeUtils.now(), + onReady: (ReportEvent) -> Unit, + ) { + val reportPostTag = arrayOf("e", reportedPost.id(), type.name.lowercase()) + val reportAuthorTag = arrayOf("p", reportedPost.pubKey(), type.name.lowercase()) + + var tags: Array> = arrayOf(reportPostTag, reportAuthorTag) + + if (reportedPost is AddressableEvent) { + tags += listOf(arrayOf("a", reportedPost.address().toTag())) + } + + tags += listOf(arrayOf("alt", "Report for ${type.name}")) + + signer.sign(createdAt, KIND, tags, content, onReady) + } + + fun create( + reportedUser: String, + type: ReportType, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ReportEvent) -> Unit, + ) { + val content = "" + + val reportAuthorTag = arrayOf("p", reportedUser, type.name.lowercase()) + val alt = arrayOf("alt", "Report for ${type.name}") + + val tags: Array> = arrayOf(reportAuthorTag, alt) + signer.sign(createdAt, KIND, tags, content, onReady) + } } - } - enum class ReportType() { - EXPLICIT, // Not used anymore. - ILLEGAL, - SPAM, - IMPERSONATION, - NUDITY, - PROFANITY, - } + enum class ReportType() { + EXPLICIT, // Not used anymore. + ILLEGAL, + SPAM, + IMPERSONATION, + NUDITY, + PROFANITY, + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/RepostEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/RepostEvent.kt index 1e021627e..ec12ce9f8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/RepostEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/RepostEvent.kt @@ -27,48 +27,48 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class RepostEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun boostedPost() = taggedEvents() + fun boostedPost() = taggedEvents() - fun originalAuthor() = taggedUsers() + fun originalAuthor() = taggedUsers() - fun containedPost() = - try { - fromJson(content) - } catch (e: Exception) { - null + fun containedPost() = + try { + fromJson(content) + } catch (e: Exception) { + null + } + + companion object { + const val KIND = 6 + const val ALT = "Repost event" + + fun create( + boostedPost: EventInterface, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (RepostEvent) -> Unit, + ) { + val content = boostedPost.toJson() + + val replyToPost = arrayOf("e", boostedPost.id()) + val replyToAuthor = arrayOf("p", boostedPost.pubKey()) + + var tags: Array> = arrayOf(replyToPost, replyToAuthor) + + if (boostedPost is AddressableEvent) { + tags += listOf(arrayOf("a", boostedPost.address().toTag())) + } + + tags += listOf(arrayOf("alt", ALT)) + + signer.sign(createdAt, KIND, tags, content, onReady) + } } - - companion object { - const val KIND = 6 - const val ALT = "Repost event" - - fun create( - boostedPost: EventInterface, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (RepostEvent) -> Unit, - ) { - val content = boostedPost.toJson() - - val replyToPost = arrayOf("e", boostedPost.id()) - val replyToAuthor = arrayOf("p", boostedPost.pubKey()) - - var tags: Array> = arrayOf(replyToPost, replyToAuthor) - - if (boostedPost is AddressableEvent) { - tags += listOf(arrayOf("a", boostedPost.address().toTag())) - } - - tags += listOf(arrayOf("alt", ALT)) - - signer.sign(createdAt, KIND, tags, content, onReady) - } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/SealedGossipEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/SealedGossipEvent.kt index 19075ad8d..610baabf2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/SealedGossipEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/SealedGossipEvent.kt @@ -30,120 +30,120 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class SealedGossipEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient private var cachedInnerEvent: Map = mapOf() + @Transient private var cachedInnerEvent: Map = mapOf() - fun cachedGossip( - signer: NostrSigner, - onReady: (Event) -> Unit, - ) { - cachedInnerEvent[signer.pubKey]?.let { - onReady(it) - return - } - - unseal(signer) { gossip -> - val event = gossip.mergeWith(this) - if (event is WrappedEvent) { - event.host = host ?: this - } - - cachedInnerEvent = cachedInnerEvent + Pair(signer.pubKey, event) - onReady(event) - } - } - - private fun unseal( - signer: NostrSigner, - onReady: (Gossip) -> Unit, - ) { - try { - plainContent(signer) { - try { - onReady(Gossip.fromJson(it)) - } catch (e: Exception) { - Log.w("GossipEvent", "Fail to decrypt or parse Gossip", e) + fun cachedGossip( + signer: NostrSigner, + onReady: (Event) -> Unit, + ) { + cachedInnerEvent[signer.pubKey]?.let { + onReady(it) + return + } + + unseal(signer) { gossip -> + val event = gossip.mergeWith(this) + if (event is WrappedEvent) { + event.host = host ?: this + } + + cachedInnerEvent = cachedInnerEvent + Pair(signer.pubKey, event) + onReady(event) } - } - } catch (e: Exception) { - Log.w("GossipEvent", "Fail to decrypt or parse Gossip", e) } - } - private fun plainContent( - signer: NostrSigner, - onReady: (String) -> Unit, - ) { - if (content.isEmpty()) return - - signer.nip44Decrypt(content, pubKey, onReady) - } - - companion object { - const val KIND = 13 - - fun create( - event: Event, - encryptTo: HexKey, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (SealedGossipEvent) -> Unit, + private fun unseal( + signer: NostrSigner, + onReady: (Gossip) -> Unit, ) { - val gossip = Gossip.create(event) - create(gossip, encryptTo, signer, createdAt, onReady) + try { + plainContent(signer) { + try { + onReady(Gossip.fromJson(it)) + } catch (e: Exception) { + Log.w("GossipEvent", "Fail to decrypt or parse Gossip", e) + } + } + } catch (e: Exception) { + Log.w("GossipEvent", "Fail to decrypt or parse Gossip", e) + } } - fun create( - gossip: Gossip, - encryptTo: HexKey, - signer: NostrSigner, - createdAt: Long = TimeUtils.randomWithinAWeek(), - onReady: (SealedGossipEvent) -> Unit, + private fun plainContent( + signer: NostrSigner, + onReady: (String) -> Unit, ) { - val msg = Gossip.toJson(gossip) + if (content.isEmpty()) return - signer.nip44Encrypt(msg, encryptTo) { content -> - signer.sign(createdAt, KIND, emptyArray(), content, onReady) - } + signer.nip44Decrypt(content, pubKey, onReady) + } + + companion object { + const val KIND = 13 + + fun create( + event: Event, + encryptTo: HexKey, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (SealedGossipEvent) -> Unit, + ) { + val gossip = Gossip.create(event) + create(gossip, encryptTo, signer, createdAt, onReady) + } + + fun create( + gossip: Gossip, + encryptTo: HexKey, + signer: NostrSigner, + createdAt: Long = TimeUtils.randomWithinAWeek(), + onReady: (SealedGossipEvent) -> Unit, + ) { + val msg = Gossip.toJson(gossip) + + signer.nip44Encrypt(msg, encryptTo) { content -> + signer.sign(createdAt, KIND, emptyArray(), content, onReady) + } + } } - } } class Gossip( - val id: HexKey?, - @JsonProperty("pubkey") val pubKey: HexKey?, - @JsonProperty("created_at") val createdAt: Long?, - val kind: Int?, - val tags: Array>?, - val content: String?, + val id: HexKey?, + @JsonProperty("pubkey") val pubKey: HexKey?, + @JsonProperty("created_at") val createdAt: Long?, + val kind: Int?, + val tags: Array>?, + val content: String?, ) { - fun mergeWith(event: SealedGossipEvent): Event { - val newPubKey = pubKey?.ifBlank { null } ?: event.pubKey - val newCreatedAt = if (createdAt != null && createdAt > 1000) createdAt else event.createdAt - val newKind = kind ?: -1 - val newTags = (tags ?: emptyArray()).plus(event.tags) - val newContent = content ?: "" - val newID = - id?.ifBlank { null } - ?: Event.generateId(newPubKey, newCreatedAt, newKind, newTags, newContent).toHexKey() - val sig = "" + fun mergeWith(event: SealedGossipEvent): Event { + val newPubKey = pubKey?.ifBlank { null } ?: event.pubKey + val newCreatedAt = if (createdAt != null && createdAt > 1000) createdAt else event.createdAt + val newKind = kind ?: -1 + val newTags = (tags ?: emptyArray()).plus(event.tags) + val newContent = content ?: "" + val newID = + id?.ifBlank { null } + ?: Event.generateId(newPubKey, newCreatedAt, newKind, newTags, newContent).toHexKey() + val sig = "" - return EventFactory.create(newID, newPubKey, newCreatedAt, newKind, newTags, newContent, sig) - } - - companion object { - fun fromJson(json: String): Gossip = Event.mapper.readValue(json, Gossip::class.java) - - fun toJson(event: Gossip): String = Event.mapper.writeValueAsString(event) - - fun create(event: Event): Gossip { - return Gossip(event.id, event.pubKey, event.createdAt, event.kind, event.tags, event.content) + return EventFactory.create(newID, newPubKey, newCreatedAt, newKind, newTags, newContent, sig) + } + + companion object { + fun fromJson(json: String): Gossip = Event.mapper.readValue(json, Gossip::class.java) + + fun toJson(event: Gossip): String = Event.mapper.writeValueAsString(event) + + fun create(event: Event): Gossip { + return Gossip(event.id, event.pubKey, event.createdAt, event.kind, event.tags, event.content) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/StatusEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/StatusEvent.kt index c47de3d78..962e8bd91 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/StatusEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/StatusEvent.kt @@ -27,52 +27,52 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class StatusEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 30315 + companion object { + const val KIND = 30315 - fun create( - msg: String, - type: String, - expiration: Long?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (StatusEvent) -> Unit, - ) { - val tags = mutableListOf>() + fun create( + msg: String, + type: String, + expiration: Long?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (StatusEvent) -> Unit, + ) { + val tags = mutableListOf>() - tags.add(arrayOf("d", type)) - expiration?.let { tags.add(arrayOf("expiration", it.toString())) } + tags.add(arrayOf("d", type)) + expiration?.let { tags.add(arrayOf("expiration", it.toString())) } - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + } + + fun update( + event: StatusEvent, + newStatus: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (StatusEvent) -> Unit, + ) { + val tags = event.tags + signer.sign(createdAt, KIND, tags, newStatus, onReady) + } + + fun clear( + event: StatusEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (StatusEvent) -> Unit, + ) { + val msg = "" + val tags = event.tags.filter { it.size > 1 && it[0] == "d" } + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + } } - - fun update( - event: StatusEvent, - newStatus: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (StatusEvent) -> Unit, - ) { - val tags = event.tags - signer.sign(createdAt, KIND, tags, newStatus, onReady) - } - - fun clear( - event: StatusEvent, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (StatusEvent) -> Unit, - ) { - val msg = "" - val tags = event.tags.filter { it.size > 1 && it[0] == "d" } - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) - } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt index 5ded187a2..7abb7b242 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt @@ -30,126 +30,125 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class TextNoteEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun root() = tags.firstOrNull { it.size > 3 && it[3] == "root" }?.get(1) + fun root() = tags.firstOrNull { it.size > 3 && it[3] == "root" }?.get(1) - companion object { - const val KIND = 1 + companion object { + const val KIND = 1 - fun create( - msg: String, - replyTos: List?, - mentions: List?, - addresses: List?, - extraTags: List?, - zapReceiver: List? = null, - markAsSensitive: Boolean, - zapRaiserAmount: Long?, - replyingTo: String?, - root: String?, - directMentions: Set, - geohash: String? = null, - nip94attachments: List? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (TextNoteEvent) -> Unit, - ) { - val tags = mutableListOf>() - replyTos?.let { - tags.addAll( - it.positionalMarkedTags( - tagName = "e", - root = root, - replyingTo = replyingTo, - directMentions = directMentions, - ), - ) - } - mentions?.forEach { - if (it in directMentions) { - tags.add(arrayOf("p", it, "", "mention")) - } else { - tags.add(arrayOf("p", it)) - } - } - addresses - ?.map { it.toTag() } - ?.let { - tags.addAll( - it.positionalMarkedTags( - tagName = "a", - root = root, - replyingTo = replyingTo, - directMentions = directMentions, - ), - ) - } - findHashtags(msg).forEach { - tags.add(arrayOf("t", it)) - tags.add(arrayOf("t", it.lowercase())) - } - extraTags?.forEach { tags.add(arrayOf("t", it)) } - zapReceiver?.forEach { - tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) - } - findURLs(msg).forEach { tags.add(arrayOf("r", it)) } - if (markAsSensitive) { - tags.add(arrayOf("content-warning", "")) - } - zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } - geohash?.let { tags.addAll(geohashMipMap(it)) } - nip94attachments?.let { - it.forEach { - // tags.add(arrayOf("nip94", it.toJson())) - } - } + fun create( + msg: String, + replyTos: List?, + mentions: List?, + addresses: List?, + extraTags: List?, + zapReceiver: List? = null, + markAsSensitive: Boolean, + zapRaiserAmount: Long?, + replyingTo: String?, + root: String?, + directMentions: Set, + geohash: String? = null, + nip94attachments: List? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (TextNoteEvent) -> Unit, + ) { + val tags = mutableListOf>() + replyTos?.let { + tags.addAll( + it.positionalMarkedTags( + tagName = "e", + root = root, + replyingTo = replyingTo, + directMentions = directMentions, + ), + ) + } + mentions?.forEach { + if (it in directMentions) { + tags.add(arrayOf("p", it, "", "mention")) + } else { + tags.add(arrayOf("p", it)) + } + } + addresses + ?.map { it.toTag() } + ?.let { + tags.addAll( + it.positionalMarkedTags( + tagName = "a", + root = root, + replyingTo = replyingTo, + directMentions = directMentions, + ), + ) + } + findHashtags(msg).forEach { + tags.add(arrayOf("t", it)) + tags.add(arrayOf("t", it.lowercase())) + } + extraTags?.forEach { tags.add(arrayOf("t", it)) } + zapReceiver?.forEach { + tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) + } + findURLs(msg).forEach { tags.add(arrayOf("r", it)) } + if (markAsSensitive) { + tags.add(arrayOf("content-warning", "")) + } + zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } + geohash?.let { tags.addAll(geohashMipMap(it)) } + nip94attachments?.let { + it.forEach { + // tags.add(arrayOf("nip94", it.toJson())) + } + } - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + } + + /** + * Returns a list of NIP-10 marked tags that are also ordered at best effort to support the + * deprecated method of positional tags to maximize backwards compatibility with clients that + * support replies but have not been updated to understand tag markers. + * + * https://github.com/nostr-protocol/nips/blob/master/10.md + * + * The tag to the root of the reply chain goes first. The tag to the reply event being responded + * to goes last. The order for any other tag does not matter, so keep the relative order. + */ + private fun List.positionalMarkedTags( + tagName: String, + root: String?, + replyingTo: String?, + directMentions: Set, + ) = sortedWith { o1, o2 -> + when { + o1 == o2 -> 0 + o1 == root -> -1 // root goes first + o2 == root -> 1 // root goes first + o1 == replyingTo -> 1 // reply event being responded to goes last + o2 == replyingTo -> -1 // reply event being responded to goes last + else -> 0 // keep the relative order for any other tag + } + } + .map { + when (it) { + root -> arrayOf(tagName, it, "", "root") + replyingTo -> arrayOf(tagName, it, "", "reply") + in directMentions -> arrayOf(tagName, it, "", "mention") + else -> arrayOf(tagName, it) + } + } } - - /** - * Returns a list of NIP-10 marked tags that are also ordered at best effort to support the - * deprecated method of positional tags to maximize backwards compatibility with clients that - * support replies but have not been updated to understand tag markers. - * - * https://github.com/nostr-protocol/nips/blob/master/10.md - * - * The tag to the root of the reply chain goes first. The tag to the reply event being responded - * to goes last. The order for any other tag does not matter, so keep the relative order. - */ - private fun List.positionalMarkedTags( - tagName: String, - root: String?, - replyingTo: String?, - directMentions: Set, - ) = - sortedWith { o1, o2 -> - when { - o1 == o2 -> 0 - o1 == root -> -1 // root goes first - o2 == root -> 1 // root goes first - o1 == replyingTo -> 1 // reply event being responded to goes last - o2 == replyingTo -> -1 // reply event being responded to goes last - else -> 0 // keep the relative order for any other tag - } - } - .map { - when (it) { - root -> arrayOf(tagName, it, "", "root") - replyingTo -> arrayOf(tagName, it, "", "reply") - in directMentions -> arrayOf(tagName, it, "", "mention") - else -> arrayOf(tagName, it) - } - } - } } fun findURLs(text: String): List { - return UrlDetector(text, UrlDetectorOptions.Default).detect().map { it.originalUrl } + return UrlDetector(text, UrlDetectorOptions.Default).detect().map { it.originalUrl } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt index 797f0c67c..698d17a42 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt @@ -27,108 +27,108 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable abstract class VideoEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1) + fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1) - fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] } + fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] } - fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) + fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) - fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) + fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) - fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) + fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) - fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) + fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) - fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1) + fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1) - fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) + fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) - fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1) + fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1) - fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) + fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) - fun title() = tags.firstOrNull { it.size > 1 && it[0] == TITLE }?.get(1) + fun title() = tags.firstOrNull { it.size > 1 && it[0] == TITLE }?.get(1) - fun summary() = tags.firstOrNull { it.size > 1 && it[0] == SUMMARY }?.get(1) + fun summary() = tags.firstOrNull { it.size > 1 && it[0] == SUMMARY }?.get(1) - fun image() = tags.firstOrNull { it.size > 1 && it[0] == IMAGE }?.get(1) + fun image() = tags.firstOrNull { it.size > 1 && it[0] == IMAGE }?.get(1) - fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == THUMB }?.get(1) + fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == THUMB }?.get(1) - fun hasUrl() = tags.any { it.size > 1 && it[0] == URL } + fun hasUrl() = tags.any { it.size > 1 && it[0] == URL } - companion object { - private const val URL = "url" - private const val ENCRYPTION_KEY = "aes-256-gcm" - private const val MIME_TYPE = "m" - private const val FILE_SIZE = "size" - private const val DIMENSION = "dim" - private const val HASH = "x" - private const val MAGNET_URI = "magnet" - private const val TORRENT_INFOHASH = "i" - private const val BLUR_HASH = "blurhash" - private const val ORIGINAL_HASH = "ox" - private const val ALT = "alt" - private const val TITLE = "title" - private const val PUBLISHED_AT = "published_at" - private const val SUMMARY = "summary" - private const val DURATION = "duration" - private const val IMAGE = "image" - private const val THUMB = "thumb" + companion object { + private const val URL = "url" + private const val ENCRYPTION_KEY = "aes-256-gcm" + private const val MIME_TYPE = "m" + private const val FILE_SIZE = "size" + private const val DIMENSION = "dim" + private const val HASH = "x" + private const val MAGNET_URI = "magnet" + private const val TORRENT_INFOHASH = "i" + private const val BLUR_HASH = "blurhash" + private const val ORIGINAL_HASH = "ox" + private const val ALT = "alt" + private const val TITLE = "title" + private const val PUBLISHED_AT = "published_at" + private const val SUMMARY = "summary" + private const val DURATION = "duration" + private const val IMAGE = "image" + private const val THUMB = "thumb" - fun create( - kind: Int, - url: String, - magnetUri: String? = null, - mimeType: String? = null, - alt: String? = null, - hash: String? = null, - size: String? = null, - dimensions: String? = null, - blurhash: String? = null, - originalHash: String? = null, - magnetURI: String? = null, - torrentInfoHash: String? = null, - encryptionKey: AESGCM? = null, - sensitiveContent: Boolean? = null, - altDescription: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (FileHeaderEvent) -> Unit, - ) { - val tags = - listOfNotNull( - arrayOf(URL, url), - magnetUri?.let { arrayOf(MAGNET_URI, it) }, - mimeType?.let { arrayOf(MIME_TYPE, it) }, - alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf("alt", altDescription), - hash?.let { arrayOf(HASH, it) }, - size?.let { arrayOf(FILE_SIZE, it) }, - dimensions?.let { arrayOf(DIMENSION, it) }, - blurhash?.let { arrayOf(BLUR_HASH, it) }, - originalHash?.let { arrayOf(ORIGINAL_HASH, it) }, - magnetURI?.let { arrayOf(MAGNET_URI, it) }, - torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) }, - encryptionKey?.let { arrayOf(ENCRYPTION_KEY, it.key, it.nonce) }, - sensitiveContent?.let { - if (it) { - arrayOf("content-warning", "") - } else { - null - } - }, - ) + fun create( + kind: Int, + url: String, + magnetUri: String? = null, + mimeType: String? = null, + alt: String? = null, + hash: String? = null, + size: String? = null, + dimensions: String? = null, + blurhash: String? = null, + originalHash: String? = null, + magnetURI: String? = null, + torrentInfoHash: String? = null, + encryptionKey: AESGCM? = null, + sensitiveContent: Boolean? = null, + altDescription: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (FileHeaderEvent) -> Unit, + ) { + val tags = + listOfNotNull( + arrayOf(URL, url), + magnetUri?.let { arrayOf(MAGNET_URI, it) }, + mimeType?.let { arrayOf(MIME_TYPE, it) }, + alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf("alt", altDescription), + hash?.let { arrayOf(HASH, it) }, + size?.let { arrayOf(FILE_SIZE, it) }, + dimensions?.let { arrayOf(DIMENSION, it) }, + blurhash?.let { arrayOf(BLUR_HASH, it) }, + originalHash?.let { arrayOf(ORIGINAL_HASH, it) }, + magnetURI?.let { arrayOf(MAGNET_URI, it) }, + torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) }, + encryptionKey?.let { arrayOf(ENCRYPTION_KEY, it.key, it.nonce) }, + sensitiveContent?.let { + if (it) { + arrayOf("content-warning", "") + } else { + null + } + }, + ) - val content = alt ?: "" - signer.sign(createdAt, kind, tags.toTypedArray(), content, onReady) + val content = alt ?: "" + signer.sign(createdAt, kind, tags.toTypedArray(), content, onReady) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt index f580a188a..ff6a5f87f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt @@ -27,55 +27,55 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class VideoHorizontalEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : VideoEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 34235 - const val ALT_DESCRIPTION = "Horizontal Video" + companion object { + const val KIND = 34235 + const val ALT_DESCRIPTION = "Horizontal Video" - fun create( - url: String, - magnetUri: String? = null, - mimeType: String? = null, - alt: String? = null, - hash: String? = null, - size: String? = null, - dimensions: String? = null, - blurhash: String? = null, - originalHash: String? = null, - magnetURI: String? = null, - torrentInfoHash: String? = null, - encryptionKey: AESGCM? = null, - sensitiveContent: Boolean? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (FileHeaderEvent) -> Unit, - ) { - create( - KIND, - url, - magnetUri, - mimeType, - alt, - hash, - size, - dimensions, - blurhash, - originalHash, - magnetURI, - torrentInfoHash, - encryptionKey, - sensitiveContent, - ALT_DESCRIPTION, - signer, - createdAt, - onReady, - ) + fun create( + url: String, + magnetUri: String? = null, + mimeType: String? = null, + alt: String? = null, + hash: String? = null, + size: String? = null, + dimensions: String? = null, + blurhash: String? = null, + originalHash: String? = null, + magnetURI: String? = null, + torrentInfoHash: String? = null, + encryptionKey: AESGCM? = null, + sensitiveContent: Boolean? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (FileHeaderEvent) -> Unit, + ) { + create( + KIND, + url, + magnetUri, + mimeType, + alt, + hash, + size, + dimensions, + blurhash, + originalHash, + magnetURI, + torrentInfoHash, + encryptionKey, + sensitiveContent, + ALT_DESCRIPTION, + signer, + createdAt, + onReady, + ) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt index 2b7e1b195..412c9e542 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt @@ -27,55 +27,55 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class VideoVerticalEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : VideoEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 34236 - const val ALT_DESCRIPTION = "Vertical Video" + companion object { + const val KIND = 34236 + const val ALT_DESCRIPTION = "Vertical Video" - fun create( - url: String, - magnetUri: String? = null, - mimeType: String? = null, - alt: String? = null, - hash: String? = null, - size: String? = null, - dimensions: String? = null, - blurhash: String? = null, - originalHash: String? = null, - magnetURI: String? = null, - torrentInfoHash: String? = null, - encryptionKey: AESGCM? = null, - sensitiveContent: Boolean? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (FileHeaderEvent) -> Unit, - ) { - create( - KIND, - url, - magnetUri, - mimeType, - alt, - hash, - size, - dimensions, - blurhash, - originalHash, - magnetURI, - torrentInfoHash, - encryptionKey, - sensitiveContent, - ALT_DESCRIPTION, - signer, - createdAt, - onReady, - ) + fun create( + url: String, + magnetUri: String? = null, + mimeType: String? = null, + alt: String? = null, + hash: String? = null, + size: String? = null, + dimensions: String? = null, + blurhash: String? = null, + originalHash: String? = null, + magnetURI: String? = null, + torrentInfoHash: String? = null, + encryptionKey: AESGCM? = null, + sensitiveContent: Boolean? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (FileHeaderEvent) -> Unit, + ) { + create( + KIND, + url, + magnetUri, + mimeType, + alt, + hash, + size, + dimensions, + blurhash, + originalHash, + magnetURI, + torrentInfoHash, + encryptionKey, + sensitiveContent, + ALT_DESCRIPTION, + signer, + createdAt, + onReady, + ) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoViewEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoViewEvent.kt index aeddeadcb..1a7d0ebdb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoViewEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoViewEvent.kt @@ -28,54 +28,54 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class VideoViewEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 34237 + companion object { + const val KIND = 34237 - fun create( - video: ATag, - signer: NostrSigner, - viewStart: Long?, - viewEnd: Long?, - createdAt: Long = TimeUtils.now(), - onReady: (VideoViewEvent) -> Unit, - ) { - val tags = mutableListOf>() + fun create( + video: ATag, + signer: NostrSigner, + viewStart: Long?, + viewEnd: Long?, + createdAt: Long = TimeUtils.now(), + onReady: (VideoViewEvent) -> Unit, + ) { + val tags = mutableListOf>() - val aTag = video.toTag() - tags.add(arrayOf("d", aTag)) - tags.add(arrayOf("a", aTag)) - if (viewEnd != null) { - tags.add(arrayOf("viewed", viewStart?.toString() ?: "0", viewEnd.toString())) - } else { - tags.add(arrayOf("viewed", viewStart?.toString() ?: "0")) - } + val aTag = video.toTag() + tags.add(arrayOf("d", aTag)) + tags.add(arrayOf("a", aTag)) + if (viewEnd != null) { + tags.add(arrayOf("viewed", viewStart?.toString() ?: "0", viewEnd.toString())) + } else { + tags.add(arrayOf("viewed", viewStart?.toString() ?: "0")) + } - signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + } + + fun addViewedTime( + event: VideoViewEvent, + viewStart: Long?, + viewEnd: Long?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (VideoViewEvent) -> Unit, + ) { + val tags = event.tags.toMutableList() + if (viewEnd != null) { + tags.add(arrayOf("viewed", viewStart?.toString() ?: "0", viewEnd.toString())) + } else { + tags.add(arrayOf("viewed", viewStart?.toString() ?: "0")) + } + + signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + } } - - fun addViewedTime( - event: VideoViewEvent, - viewStart: Long?, - viewEnd: Long?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (VideoViewEvent) -> Unit, - ) { - val tags = event.tags.toMutableList() - if (viewEnd != null) { - tags.add(arrayOf("viewed", viewStart?.toString() ?: "0", viewEnd.toString())) - } else { - tags.add(arrayOf("viewed", viewStart?.toString() ?: "0")) - } - - signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) - } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt index 300e348c1..a037817cd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt @@ -39,342 +39,342 @@ import com.vitorpamplona.quartz.events.LnZapRequestEvent import org.json.JSONArray enum class SignerType { - SIGN_EVENT, - NIP04_ENCRYPT, - NIP04_DECRYPT, - NIP44_ENCRYPT, - NIP44_DECRYPT, - GET_PUBLIC_KEY, - DECRYPT_ZAP_EVENT, + SIGN_EVENT, + NIP04_ENCRYPT, + NIP04_DECRYPT, + NIP44_ENCRYPT, + NIP44_DECRYPT, + GET_PUBLIC_KEY, + DECRYPT_ZAP_EVENT, } class Permission( - val type: String, - val kind: Int? = null, + val type: String, + val kind: Int? = null, ) { - fun toJson(): String { - return "{\"type\":\"${type}\",\"kind\":$kind}" - } + fun toJson(): String { + return "{\"type\":\"${type}\",\"kind\":$kind}" + } } class Result( - @JsonProperty("package") val `package`: String?, - @JsonProperty("signature") val signature: String?, - @JsonProperty("id") val id: String?, + @JsonProperty("package") val `package`: String?, + @JsonProperty("signature") val signature: String?, + @JsonProperty("id") val id: String?, ) { - companion object { - val mapper = - jacksonObjectMapper() - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - .registerModule( - SimpleModule().addDeserializer(Result::class.java, ResultDeserializer()), - ) + companion object { + val mapper = + jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .registerModule( + SimpleModule().addDeserializer(Result::class.java, ResultDeserializer()), + ) - private class ResultDeserializer : StdDeserializer(Result::class.java) { - override fun deserialize( - jp: JsonParser, - ctxt: DeserializationContext, - ): Result { - val jsonObject: JsonNode = jp.codec.readTree(jp) - return Result( - jsonObject.get("package").asText().intern(), - jsonObject.get("signature").asText().intern(), - jsonObject.get("id").asText().intern(), - ) - } + private class ResultDeserializer : StdDeserializer(Result::class.java) { + override fun deserialize( + jp: JsonParser, + ctxt: DeserializationContext, + ): Result { + val jsonObject: JsonNode = jp.codec.readTree(jp) + return Result( + jsonObject.get("package").asText().intern(), + jsonObject.get("signature").asText().intern(), + jsonObject.get("id").asText().intern(), + ) + } + } + + fun fromJson(json: String): Result = mapper.readValue(json, Result::class.java) + + fun fromJsonArray(json: String): Array { + val result: MutableList = mutableListOf() + val array = JSONArray(json) + (0 until array.length()).forEach { + val resultJson = array.getJSONObject(it) + val localResult = fromJson(resultJson.toString()) + result.add(localResult) + } + return result.toTypedArray() + } } - - fun fromJson(json: String): Result = mapper.readValue(json, Result::class.java) - - fun fromJsonArray(json: String): Array { - val result: MutableList = mutableListOf() - val array = JSONArray(json) - (0 until array.length()).forEach { - val resultJson = array.getJSONObject(it) - val localResult = fromJson(resultJson.toString()) - result.add(localResult) - } - return result.toTypedArray() - } - } } class ExternalSignerLauncher( - private val npub: String, - val signerPackageName: String = "com.greenart7c3.nostrsigner", + private val npub: String, + val signerPackageName: String = "com.greenart7c3.nostrsigner", ) { - private val contentCache = LruCache Unit>(20) + private val contentCache = LruCache Unit>(20) - private var signerAppLauncher: ((Intent) -> Unit)? = null - private var contentResolver: (() -> ContentResolver)? = null + private var signerAppLauncher: ((Intent) -> Unit)? = null + private var contentResolver: (() -> ContentResolver)? = null - /** Call this function when the launcher becomes available on activity, fragment or compose */ - fun registerLauncher( - launcher: ((Intent) -> Unit), - contentResolver: (() -> ContentResolver), - ) { - this.signerAppLauncher = launcher - this.contentResolver = contentResolver - } - - /** Call this function when the activity is destroyed or is about to be replaced. */ - fun clearLauncher() { - this.signerAppLauncher = null - this.contentResolver = null - } - - fun newResult(data: Intent) { - val results = data.getStringExtra("results") - if (results != null) { - val localResults: Array = Result.fromJsonArray(results) - localResults.forEach { - val signature = it.signature ?: "" - val packageName = it.`package` ?: "" - val id = it.id ?: "" - if (id.isNotBlank()) { - val result = if (packageName.isNotBlank()) "$signature-$packageName" else signature - val contentCache = contentCache.get(id) - contentCache?.invoke(result) - } - } - } else { - val signature = data.getStringExtra("signature") ?: "" - val packageName = data.getStringExtra("package") ?: "" - val id = data.getStringExtra("id") ?: "" - if (id.isNotBlank()) { - val result = if (packageName.isNotBlank()) "$signature-$packageName" else signature - val contentCache = contentCache.get(id) - contentCache?.invoke(result) - } - } - } - - fun openSignerApp( - data: String, - type: SignerType, - pubKey: HexKey, - id: String, - onReady: (String) -> Unit, - ) { - signerAppLauncher?.let { - openSignerApp( - data, - type, - it, - pubKey, - id, - onReady, - ) - } - } - - private fun defaultPermissions(): String { - val permissions = - listOf( - Permission( - "sign_event", - 22242, - ), - Permission( - "nip04_encrypt", - ), - Permission( - "nip04_decrypt", - ), - Permission( - "nip44_encrypt", - ), - Permission( - "nip44_decrypt", - ), - Permission( - "decrypt_zap_event", - ), - ) - val jsonArray = StringBuilder("[") - permissions.forEachIndexed { index, permission -> - jsonArray.append(permission.toJson()) - if (index < permissions.size - 1) { - jsonArray.append(",") - } - } - jsonArray.append("]") - - return jsonArray.toString() - } - - private fun openSignerApp( - data: String, - type: SignerType, - intentLauncher: (Intent) -> Unit, - pubKey: HexKey, - id: String, - onReady: (String) -> Unit, - ) { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:$data")) - val signerType = - when (type) { - SignerType.SIGN_EVENT -> "sign_event" - SignerType.NIP04_ENCRYPT -> "nip04_encrypt" - SignerType.NIP04_DECRYPT -> "nip04_decrypt" - SignerType.NIP44_ENCRYPT -> "nip44_encrypt" - SignerType.NIP44_DECRYPT -> "nip44_decrypt" - SignerType.GET_PUBLIC_KEY -> "get_public_key" - SignerType.DECRYPT_ZAP_EVENT -> "decrypt_zap_event" - } - intent.putExtra("type", signerType) - intent.putExtra("pubKey", pubKey) - intent.putExtra("id", id) - if (type !== SignerType.GET_PUBLIC_KEY) { - intent.putExtra("current_user", npub) - } else { - intent.putExtra("permissions", defaultPermissions()) - } - if (signerPackageName.isNotBlank()) { - intent.`package` = signerPackageName + /** Call this function when the launcher becomes available on activity, fragment or compose */ + fun registerLauncher( + launcher: ((Intent) -> Unit), + contentResolver: (() -> ContentResolver), + ) { + this.signerAppLauncher = launcher + this.contentResolver = contentResolver } - intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) - - contentCache.put(id, onReady) - - intentLauncher(intent) - } - - fun openSigner( - event: EventInterface, - columnName: String = "signature", - onReady: (String) -> Unit, - ) { - val result = - getDataFromResolver( - SignerType.SIGN_EVENT, - arrayOf(event.toJson(), event.pubKey()), - columnName, - ) - if (result == null) { - openSignerApp( - event.toJson(), - SignerType.SIGN_EVENT, - "", - event.id(), - onReady, - ) - } else { - onReady(result) + /** Call this function when the activity is destroyed or is about to be replaced. */ + fun clearLauncher() { + this.signerAppLauncher = null + this.contentResolver = null } - } - fun getDataFromResolver( - signerType: SignerType, - data: Array, - columnName: String = "signature", - ): String? { - return getDataFromResolver(signerType, data, columnName, contentResolver) - } - - fun getDataFromResolver( - signerType: SignerType, - data: Array, - columnName: String = "signature", - contentResolver: (() -> ContentResolver)? = null, - ): String? { - val localData = - if (signerType !== SignerType.GET_PUBLIC_KEY) { - data.toList().plus(npub).toTypedArray() - } else { - data - } - - try { - contentResolver - ?.let { it() } - ?.query( - Uri.parse("content://$signerPackageName.$signerType"), - localData, - null, - null, - null, - ) - .use { - if (it == null) { - return null - } - if (it.moveToFirst()) { - val index = it.getColumnIndex(columnName) - if (index < 0) { - Log.d("getDataFromResolver", "column '$columnName' not found") - return null + fun newResult(data: Intent) { + val results = data.getStringExtra("results") + if (results != null) { + val localResults: Array = Result.fromJsonArray(results) + localResults.forEach { + val signature = it.signature ?: "" + val packageName = it.`package` ?: "" + val id = it.id ?: "" + if (id.isNotBlank()) { + val result = if (packageName.isNotBlank()) "$signature-$packageName" else signature + val contentCache = contentCache.get(id) + contentCache?.invoke(result) + } + } + } else { + val signature = data.getStringExtra("signature") ?: "" + val packageName = data.getStringExtra("package") ?: "" + val id = data.getStringExtra("id") ?: "" + if (id.isNotBlank()) { + val result = if (packageName.isNotBlank()) "$signature-$packageName" else signature + val contentCache = contentCache.get(id) + contentCache?.invoke(result) } - return it.getString(index) - } } - } catch (e: Exception) { - Log.e("ExternalSignerLauncher", "Failed to query the Signer app in the background") - return null } - return null - } - - fun decrypt( - encryptedContent: String, - pubKey: HexKey, - signerType: SignerType = SignerType.NIP04_DECRYPT, - onReady: (String) -> Unit, - ) { - val id = (encryptedContent + pubKey + onReady.toString()).hashCode().toString() - val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey)) - if (result == null) { - openSignerApp( - encryptedContent, - signerType, - pubKey, - id, - onReady, - ) - } else { - onReady(result) + fun openSignerApp( + data: String, + type: SignerType, + pubKey: HexKey, + id: String, + onReady: (String) -> Unit, + ) { + signerAppLauncher?.let { + openSignerApp( + data, + type, + it, + pubKey, + id, + onReady, + ) + } } - } - fun encrypt( - decryptedContent: String, - pubKey: HexKey, - signerType: SignerType = SignerType.NIP04_ENCRYPT, - onReady: (String) -> Unit, - ) { - val id = (decryptedContent + pubKey + onReady.toString()).hashCode().toString() - val result = getDataFromResolver(signerType, arrayOf(decryptedContent, pubKey)) - if (result == null) { - openSignerApp( - decryptedContent, - signerType, - pubKey, - id, - onReady, - ) - } else { - onReady(result) - } - } + private fun defaultPermissions(): String { + val permissions = + listOf( + Permission( + "sign_event", + 22242, + ), + Permission( + "nip04_encrypt", + ), + Permission( + "nip04_decrypt", + ), + Permission( + "nip44_encrypt", + ), + Permission( + "nip44_decrypt", + ), + Permission( + "decrypt_zap_event", + ), + ) + val jsonArray = StringBuilder("[") + permissions.forEachIndexed { index, permission -> + jsonArray.append(permission.toJson()) + if (index < permissions.size - 1) { + jsonArray.append(",") + } + } + jsonArray.append("]") - fun decryptZapEvent( - event: LnZapRequestEvent, - onReady: (String) -> Unit, - ) { - val result = - getDataFromResolver(SignerType.DECRYPT_ZAP_EVENT, arrayOf(event.toJson(), event.pubKey)) - if (result == null) { - openSignerApp( - event.toJson(), - SignerType.DECRYPT_ZAP_EVENT, - event.pubKey, - event.id, - onReady, - ) - } else { - onReady(result) + return jsonArray.toString() + } + + private fun openSignerApp( + data: String, + type: SignerType, + intentLauncher: (Intent) -> Unit, + pubKey: HexKey, + id: String, + onReady: (String) -> Unit, + ) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:$data")) + val signerType = + when (type) { + SignerType.SIGN_EVENT -> "sign_event" + SignerType.NIP04_ENCRYPT -> "nip04_encrypt" + SignerType.NIP04_DECRYPT -> "nip04_decrypt" + SignerType.NIP44_ENCRYPT -> "nip44_encrypt" + SignerType.NIP44_DECRYPT -> "nip44_decrypt" + SignerType.GET_PUBLIC_KEY -> "get_public_key" + SignerType.DECRYPT_ZAP_EVENT -> "decrypt_zap_event" + } + intent.putExtra("type", signerType) + intent.putExtra("pubKey", pubKey) + intent.putExtra("id", id) + if (type !== SignerType.GET_PUBLIC_KEY) { + intent.putExtra("current_user", npub) + } else { + intent.putExtra("permissions", defaultPermissions()) + } + if (signerPackageName.isNotBlank()) { + intent.`package` = signerPackageName + } + + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + + contentCache.put(id, onReady) + + intentLauncher(intent) + } + + fun openSigner( + event: EventInterface, + columnName: String = "signature", + onReady: (String) -> Unit, + ) { + val result = + getDataFromResolver( + SignerType.SIGN_EVENT, + arrayOf(event.toJson(), event.pubKey()), + columnName, + ) + if (result == null) { + openSignerApp( + event.toJson(), + SignerType.SIGN_EVENT, + "", + event.id(), + onReady, + ) + } else { + onReady(result) + } + } + + fun getDataFromResolver( + signerType: SignerType, + data: Array, + columnName: String = "signature", + ): String? { + return getDataFromResolver(signerType, data, columnName, contentResolver) + } + + fun getDataFromResolver( + signerType: SignerType, + data: Array, + columnName: String = "signature", + contentResolver: (() -> ContentResolver)? = null, + ): String? { + val localData = + if (signerType !== SignerType.GET_PUBLIC_KEY) { + data.toList().plus(npub).toTypedArray() + } else { + data + } + + try { + contentResolver + ?.let { it() } + ?.query( + Uri.parse("content://$signerPackageName.$signerType"), + localData, + null, + null, + null, + ) + .use { + if (it == null) { + return null + } + if (it.moveToFirst()) { + val index = it.getColumnIndex(columnName) + if (index < 0) { + Log.d("getDataFromResolver", "column '$columnName' not found") + return null + } + return it.getString(index) + } + } + } catch (e: Exception) { + Log.e("ExternalSignerLauncher", "Failed to query the Signer app in the background") + return null + } + + return null + } + + fun decrypt( + encryptedContent: String, + pubKey: HexKey, + signerType: SignerType = SignerType.NIP04_DECRYPT, + onReady: (String) -> Unit, + ) { + val id = (encryptedContent + pubKey + onReady.toString()).hashCode().toString() + val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey)) + if (result == null) { + openSignerApp( + encryptedContent, + signerType, + pubKey, + id, + onReady, + ) + } else { + onReady(result) + } + } + + fun encrypt( + decryptedContent: String, + pubKey: HexKey, + signerType: SignerType = SignerType.NIP04_ENCRYPT, + onReady: (String) -> Unit, + ) { + val id = (decryptedContent + pubKey + onReady.toString()).hashCode().toString() + val result = getDataFromResolver(signerType, arrayOf(decryptedContent, pubKey)) + if (result == null) { + openSignerApp( + decryptedContent, + signerType, + pubKey, + id, + onReady, + ) + } else { + onReady(result) + } + } + + fun decryptZapEvent( + event: LnZapRequestEvent, + onReady: (String) -> Unit, + ) { + val result = + getDataFromResolver(SignerType.DECRYPT_ZAP_EVENT, arrayOf(event.toJson(), event.pubKey)) + if (result == null) { + openSignerApp( + event.toJson(), + SignerType.DECRYPT_ZAP_EVENT, + event.pubKey, + event.id, + onReady, + ) + } else { + onReady(result) + } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSigner.kt b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSigner.kt index fa1e39249..cca9980e2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSigner.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSigner.kt @@ -26,40 +26,40 @@ import com.vitorpamplona.quartz.events.LnZapPrivateEvent import com.vitorpamplona.quartz.events.LnZapRequestEvent abstract class NostrSigner(val pubKey: HexKey) { - abstract fun sign( - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - onReady: (T) -> Unit, - ) + abstract fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + onReady: (T) -> Unit, + ) - abstract fun nip04Encrypt( - decryptedContent: String, - toPublicKey: HexKey, - onReady: (String) -> Unit, - ) + abstract fun nip04Encrypt( + decryptedContent: String, + toPublicKey: HexKey, + onReady: (String) -> Unit, + ) - abstract fun nip04Decrypt( - encryptedContent: String, - fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) + abstract fun nip04Decrypt( + encryptedContent: String, + fromPublicKey: HexKey, + onReady: (String) -> Unit, + ) - abstract fun nip44Encrypt( - decryptedContent: String, - toPublicKey: HexKey, - onReady: (String) -> Unit, - ) + abstract fun nip44Encrypt( + decryptedContent: String, + toPublicKey: HexKey, + onReady: (String) -> Unit, + ) - abstract fun nip44Decrypt( - encryptedContent: String, - fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) + abstract fun nip44Decrypt( + encryptedContent: String, + fromPublicKey: HexKey, + onReady: (String) -> Unit, + ) - abstract fun decryptZapEvent( - event: LnZapRequestEvent, - onReady: (LnZapPrivateEvent) -> Unit, - ) + abstract fun decryptZapEvent( + event: LnZapRequestEvent, + onReady: (LnZapPrivateEvent) -> Unit, + ) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerExternal.kt b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerExternal.kt index ae63c2774..6952b8b98 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerExternal.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerExternal.kt @@ -31,130 +31,134 @@ import com.vitorpamplona.quartz.events.LnZapPrivateEvent import com.vitorpamplona.quartz.events.LnZapRequestEvent class NostrSignerExternal( - pubKey: HexKey, - val launcher: ExternalSignerLauncher = ExternalSignerLauncher(pubKey.hexToByteArray().toNpub()), + pubKey: HexKey, + val launcher: ExternalSignerLauncher = ExternalSignerLauncher(pubKey.hexToByteArray().toNpub()), ) : NostrSigner(pubKey) { - override fun sign( - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - onReady: (T) -> Unit, - ) { - val id = Event.generateId(pubKey, createdAt, kind, tags, content).toHexKey() + override fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + onReady: (T) -> Unit, + ) { + val id = Event.generateId(pubKey, createdAt, kind, tags, content).toHexKey() - val event = - Event( - id = id, - pubKey = pubKey, - createdAt = createdAt, - kind = kind, - tags = tags, - content = content, - sig = "", - ) + val event = + Event( + id = id, + pubKey = pubKey, + createdAt = createdAt, + kind = kind, + tags = tags, + content = content, + sig = "", + ) - launcher.openSigner(event) { signature -> - if (signature.startsWith("{")) { - val localEvent = Event.fromJson(signature) - (EventFactory.create( - localEvent.id, - localEvent.pubKey, - localEvent.createdAt, - localEvent.kind, - localEvent.tags, - localEvent.content, - localEvent.sig, - ) as? T?) - ?.let { onReady(it) } - } else { - (EventFactory.create( - event.id, - event.pubKey, - event.createdAt, - event.kind, - event.tags, - event.content, - signature, - ) as? T?) - ?.let { onReady(it) } - } - } - } - - override fun nip04Encrypt( - decryptedContent: String, - toPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - Log.d("NostrExternalSigner", "Encrypt NIP04 Event: $decryptedContent") - - return launcher.encrypt( - decryptedContent, - toPublicKey, - SignerType.NIP04_ENCRYPT, - onReady, - ) - } - - override fun nip04Decrypt( - encryptedContent: String, - fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - Log.d("NostrExternalSigner", "Decrypt NIP04 Event: $encryptedContent") - - return launcher.decrypt( - encryptedContent, - fromPublicKey, - SignerType.NIP04_DECRYPT, - onReady, - ) - } - - override fun nip44Encrypt( - decryptedContent: String, - toPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - Log.d("NostrExternalSigner", "Encrypt NIP44 Event: $decryptedContent") - - return launcher.encrypt( - decryptedContent, - toPublicKey, - SignerType.NIP44_ENCRYPT, - onReady, - ) - } - - override fun nip44Decrypt( - encryptedContent: String, - fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - Log.d("NostrExternalSigner", "Decrypt NIP44 Event: $encryptedContent") - - return launcher.decrypt( - encryptedContent, - fromPublicKey, - SignerType.NIP44_DECRYPT, - onReady, - ) - } - - override fun decryptZapEvent( - event: LnZapRequestEvent, - onReady: (LnZapPrivateEvent) -> Unit, - ) { - return launcher.decryptZapEvent(event) { - val event = - try { - Event.fromJson(it) - } catch (e: Exception) { - Log.e("NostrExternalSigner", "Unable to parse returned decrypted Zap: $it") - null + launcher.openSigner(event) { signature -> + if (signature.startsWith("{")) { + val localEvent = Event.fromJson(signature) + ( + EventFactory.create( + localEvent.id, + localEvent.pubKey, + localEvent.createdAt, + localEvent.kind, + localEvent.tags, + localEvent.content, + localEvent.sig, + ) as? T? + ) + ?.let { onReady(it) } + } else { + ( + EventFactory.create( + event.id, + event.pubKey, + event.createdAt, + event.kind, + event.tags, + event.content, + signature, + ) as? T? + ) + ?.let { onReady(it) } + } + } + } + + override fun nip04Encrypt( + decryptedContent: String, + toPublicKey: HexKey, + onReady: (String) -> Unit, + ) { + Log.d("NostrExternalSigner", "Encrypt NIP04 Event: $decryptedContent") + + return launcher.encrypt( + decryptedContent, + toPublicKey, + SignerType.NIP04_ENCRYPT, + onReady, + ) + } + + override fun nip04Decrypt( + encryptedContent: String, + fromPublicKey: HexKey, + onReady: (String) -> Unit, + ) { + Log.d("NostrExternalSigner", "Decrypt NIP04 Event: $encryptedContent") + + return launcher.decrypt( + encryptedContent, + fromPublicKey, + SignerType.NIP04_DECRYPT, + onReady, + ) + } + + override fun nip44Encrypt( + decryptedContent: String, + toPublicKey: HexKey, + onReady: (String) -> Unit, + ) { + Log.d("NostrExternalSigner", "Encrypt NIP44 Event: $decryptedContent") + + return launcher.encrypt( + decryptedContent, + toPublicKey, + SignerType.NIP44_ENCRYPT, + onReady, + ) + } + + override fun nip44Decrypt( + encryptedContent: String, + fromPublicKey: HexKey, + onReady: (String) -> Unit, + ) { + Log.d("NostrExternalSigner", "Decrypt NIP44 Event: $encryptedContent") + + return launcher.decrypt( + encryptedContent, + fromPublicKey, + SignerType.NIP44_DECRYPT, + onReady, + ) + } + + override fun decryptZapEvent( + event: LnZapRequestEvent, + onReady: (LnZapPrivateEvent) -> Unit, + ) { + return launcher.decryptZapEvent(event) { + val event = + try { + Event.fromJson(it) + } catch (e: Exception) { + Log.e("NostrExternalSigner", "Unable to parse returned decrypted Zap: $it") + null + } + (event as? LnZapPrivateEvent)?.let { onReady(event) } } - (event as? LnZapPrivateEvent)?.let { onReady(event) } } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerInternal.kt b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerInternal.kt index a749df1a4..3a04392c9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerInternal.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerInternal.kt @@ -32,224 +32,224 @@ import com.vitorpamplona.quartz.events.LnZapPrivateEvent import com.vitorpamplona.quartz.events.LnZapRequestEvent class NostrSignerInternal(val keyPair: KeyPair) : NostrSigner(keyPair.pubKey.toHexKey()) { - override fun sign( - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - onReady: (T) -> Unit, - ) { - if (keyPair.privKey == null) return + override fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + onReady: (T) -> Unit, + ) { + if (keyPair.privKey == null) return - if (isUnsignedPrivateEvent(kind, tags)) { - // this is a private zap - signPrivateZap(createdAt, kind, tags, content, onReady) - } else { - signNormal(createdAt, kind, tags, content, onReady) + if (isUnsignedPrivateEvent(kind, tags)) { + // this is a private zap + signPrivateZap(createdAt, kind, tags, content, onReady) + } else { + signNormal(createdAt, kind, tags, content, onReady) + } } - } - fun isUnsignedPrivateEvent( - kind: Int, - tags: Array>, - ): Boolean { - return kind == LnZapRequestEvent.KIND && - tags.any { t -> t.size > 1 && t[0] == "anon" && t[1].isBlank() } - } - - fun signNormal( - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - onReady: (T) -> Unit, - ) { - if (keyPair.privKey == null) return - - val id = Event.generateId(pubKey, createdAt, kind, tags, content) - val sig = CryptoUtils.sign(id, keyPair.privKey).toHexKey() - - onReady( - EventFactory.create( - id.toHexKey(), - pubKey, - createdAt, - kind, - tags, - content, - sig, - ) as T, - ) - } - - override fun nip04Encrypt( - decryptedContent: String, - toPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - if (keyPair.privKey == null) return - - onReady( - CryptoUtils.encryptNIP04( - decryptedContent, - keyPair.privKey, - toPublicKey.hexToByteArray(), - ), - ) - } - - override fun nip04Decrypt( - encryptedContent: String, - fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - if (keyPair.privKey == null) return - - try { - val sharedSecret = - CryptoUtils.getSharedSecretNIP04(keyPair.privKey, fromPublicKey.hexToByteArray()) - - onReady(CryptoUtils.decryptNIP04(encryptedContent, sharedSecret)) - } catch (e: Exception) { - Log.w("NIP04Decrypt", "Error decrypting the message ${e.message} on $encryptedContent") + fun isUnsignedPrivateEvent( + kind: Int, + tags: Array>, + ): Boolean { + return kind == LnZapRequestEvent.KIND && + tags.any { t -> t.size > 1 && t[0] == "anon" && t[1].isBlank() } } - } - override fun nip44Encrypt( - decryptedContent: String, - toPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - if (keyPair.privKey == null) return + fun signNormal( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + onReady: (T) -> Unit, + ) { + if (keyPair.privKey == null) return - onReady( - CryptoUtils.encryptNIP44v2( - decryptedContent, - keyPair.privKey, - toPublicKey.hexToByteArray(), + val id = Event.generateId(pubKey, createdAt, kind, tags, content) + val sig = CryptoUtils.sign(id, keyPair.privKey).toHexKey() + + onReady( + EventFactory.create( + id.toHexKey(), + pubKey, + createdAt, + kind, + tags, + content, + sig, + ) as T, ) - .encodePayload(), - ) - } - - override fun nip44Decrypt( - encryptedContent: String, - fromPublicKey: HexKey, - onReady: (String) -> Unit, - ) { - if (keyPair.privKey == null) return - - CryptoUtils.decryptNIP44( - payload = encryptedContent, - privateKey = keyPair.privKey, - pubKey = fromPublicKey.hexToByteArray(), - ) - ?.let { onReady(it) } - } - - private fun signPrivateZap( - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - onReady: (T) -> Unit, - ) { - if (keyPair.privKey == null) return - - val zappedEvent = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.let { it[1] } - val userHex = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.let { it[1] } ?: return - - // if it is a Zap for an Event, use event.id if not, use the user's pubkey - val idToGeneratePrivateKey = zappedEvent ?: userHex - - val encryptionPrivateKey = - LnZapRequestEvent.createEncryptionPrivateKey( - keyPair.privKey.toHexKey(), - idToGeneratePrivateKey, - createdAt, - ) - - val fullTagsNoAnon = tags.filter { t -> t.getOrNull(0) != "anon" }.toTypedArray() - - LnZapPrivateEvent.create(this, fullTagsNoAnon, content) { - val noteJson = it.toJson() - val encryptedContent = - LnZapRequestEvent.encryptPrivateZapMessage( - noteJson, - encryptionPrivateKey, - userHex.hexToByteArray(), - ) - - val newTags = - tags.filter { t -> t.getOrNull(0) != "anon" } + listOf(arrayOf("anon", encryptedContent)) - val newContent = "" - - NostrSignerInternal(KeyPair(encryptionPrivateKey)) - .signNormal(createdAt, kind, newTags.toTypedArray(), newContent, onReady) } - } - override fun decryptZapEvent( - event: LnZapRequestEvent, - onReady: (LnZapPrivateEvent) -> Unit, - ) { - if (keyPair.privKey == null) return + override fun nip04Encrypt( + decryptedContent: String, + toPublicKey: HexKey, + onReady: (String) -> Unit, + ) { + if (keyPair.privKey == null) return - val recipientPK = event.zappedAuthor().firstOrNull() - val recipientPost = event.zappedPost().firstOrNull() - val privateEvent = - if (recipientPK == pubKey) { - // if the receiver is logged in, these are the params. - val privateKeyToUse = keyPair.privKey - val pubkeyToUse = event.pubKey + onReady( + CryptoUtils.encryptNIP04( + decryptedContent, + keyPair.privKey, + toPublicKey.hexToByteArray(), + ), + ) + } - event.getPrivateZapEvent(privateKeyToUse, pubkeyToUse) - } else { - // if the sender is logged in, these are the params - val altPubkeyToUse = recipientPK - val altPrivateKeyToUse = - if (recipientPost != null) { - LnZapRequestEvent.createEncryptionPrivateKey( - keyPair.privKey.toHexKey(), - recipientPost, - event.createdAt, - ) - } else if (recipientPK != null) { - LnZapRequestEvent.createEncryptionPrivateKey( - keyPair.privKey.toHexKey(), - recipientPK, - event.createdAt, - ) - } else { - null - } + override fun nip04Decrypt( + encryptedContent: String, + fromPublicKey: HexKey, + onReady: (String) -> Unit, + ) { + if (keyPair.privKey == null) return try { - if (altPrivateKeyToUse != null && altPubkeyToUse != null) { - val altPubKeyFromPrivate = CryptoUtils.pubkeyCreate(altPrivateKeyToUse).toHexKey() + val sharedSecret = + CryptoUtils.getSharedSecretNIP04(keyPair.privKey, fromPublicKey.hexToByteArray()) - if (altPubKeyFromPrivate == event.pubKey) { - val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse) - - if (result == null) { - Log.w( - "Private ZAP Decrypt", - "Fail to decrypt Zap from ${event.id}", - ) - } - result - } else { - null - } - } else { - null - } + onReady(CryptoUtils.decryptNIP04(encryptedContent, sharedSecret)) } catch (e: Exception) { - Log.e("Account", "Failed to create pubkey for ZapRequest ${event.id}", e) - null + Log.w("NIP04Decrypt", "Error decrypting the message ${e.message} on $encryptedContent") } - } + } - privateEvent?.let { onReady(it) } - } + override fun nip44Encrypt( + decryptedContent: String, + toPublicKey: HexKey, + onReady: (String) -> Unit, + ) { + if (keyPair.privKey == null) return + + onReady( + CryptoUtils.encryptNIP44v2( + decryptedContent, + keyPair.privKey, + toPublicKey.hexToByteArray(), + ) + .encodePayload(), + ) + } + + override fun nip44Decrypt( + encryptedContent: String, + fromPublicKey: HexKey, + onReady: (String) -> Unit, + ) { + if (keyPair.privKey == null) return + + CryptoUtils.decryptNIP44( + payload = encryptedContent, + privateKey = keyPair.privKey, + pubKey = fromPublicKey.hexToByteArray(), + ) + ?.let { onReady(it) } + } + + private fun signPrivateZap( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + onReady: (T) -> Unit, + ) { + if (keyPair.privKey == null) return + + val zappedEvent = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.let { it[1] } + val userHex = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.let { it[1] } ?: return + + // if it is a Zap for an Event, use event.id if not, use the user's pubkey + val idToGeneratePrivateKey = zappedEvent ?: userHex + + val encryptionPrivateKey = + LnZapRequestEvent.createEncryptionPrivateKey( + keyPair.privKey.toHexKey(), + idToGeneratePrivateKey, + createdAt, + ) + + val fullTagsNoAnon = tags.filter { t -> t.getOrNull(0) != "anon" }.toTypedArray() + + LnZapPrivateEvent.create(this, fullTagsNoAnon, content) { + val noteJson = it.toJson() + val encryptedContent = + LnZapRequestEvent.encryptPrivateZapMessage( + noteJson, + encryptionPrivateKey, + userHex.hexToByteArray(), + ) + + val newTags = + tags.filter { t -> t.getOrNull(0) != "anon" } + listOf(arrayOf("anon", encryptedContent)) + val newContent = "" + + NostrSignerInternal(KeyPair(encryptionPrivateKey)) + .signNormal(createdAt, kind, newTags.toTypedArray(), newContent, onReady) + } + } + + override fun decryptZapEvent( + event: LnZapRequestEvent, + onReady: (LnZapPrivateEvent) -> Unit, + ) { + if (keyPair.privKey == null) return + + val recipientPK = event.zappedAuthor().firstOrNull() + val recipientPost = event.zappedPost().firstOrNull() + val privateEvent = + if (recipientPK == pubKey) { + // if the receiver is logged in, these are the params. + val privateKeyToUse = keyPair.privKey + val pubkeyToUse = event.pubKey + + event.getPrivateZapEvent(privateKeyToUse, pubkeyToUse) + } else { + // if the sender is logged in, these are the params + val altPubkeyToUse = recipientPK + val altPrivateKeyToUse = + if (recipientPost != null) { + LnZapRequestEvent.createEncryptionPrivateKey( + keyPair.privKey.toHexKey(), + recipientPost, + event.createdAt, + ) + } else if (recipientPK != null) { + LnZapRequestEvent.createEncryptionPrivateKey( + keyPair.privKey.toHexKey(), + recipientPK, + event.createdAt, + ) + } else { + null + } + + try { + if (altPrivateKeyToUse != null && altPubkeyToUse != null) { + val altPubKeyFromPrivate = CryptoUtils.pubkeyCreate(altPrivateKeyToUse).toHexKey() + + if (altPubKeyFromPrivate == event.pubKey) { + val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse) + + if (result == null) { + Log.w( + "Private ZAP Decrypt", + "Fail to decrypt Zap from ${event.id}", + ) + } + result + } else { + null + } + } else { + null + } + } catch (e: Exception) { + Log.e("Account", "Failed to create pubkey for ZapRequest ${event.id}", e) + null + } + } + + privateEvent?.let { onReady(it) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/Robohash.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/Robohash.kt index f3f7082ab..905f43983 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/Robohash.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/Robohash.kt @@ -28,349 +28,349 @@ import com.vitorpamplona.quartz.encoders.HexValidator import java.lang.StringBuilder object Robohash { - private fun encodeColor( - r: Int, - g: Int, - b: Int, - ): String { - return String( - CharArray(7) { - when (it) { - 0 -> '#' - 1 -> Hex.hexCode[(r shr 4) and 0xF] - 2 -> Hex.hexCode[r and 0xF] - 3 -> Hex.hexCode[(g shr 4) and 0xF] - 4 -> Hex.hexCode[g and 0xF] - 5 -> Hex.hexCode[(b shr 4) and 0xF] - 6 -> Hex.hexCode[b and 0xF] - else -> ' ' - } - }, - ) - } - - private fun byteMod10(byte: Byte): Int { - return byte.toUByte().toInt() % 10 - } - - private fun reduce( - start: Int, - channel: Byte, - ): Int { - return (start + (channel.toUByte().toInt() * 0.3906f)).toInt() - } - - private fun bytesToRGB( - r: Byte, - g: Byte, - b: Byte, - makeLight: Boolean, - ): String { - return if (makeLight) { - // > 150-256 color channels - encodeColor(reduce(150, r), reduce(150, g), reduce(150, b)) - } else { - // < 50-100 color channels - encodeColor(reduce(50, r), reduce(50, g), reduce(50, b)) + private fun encodeColor( + r: Int, + g: Int, + b: Int, + ): String { + return String( + CharArray(7) { + when (it) { + 0 -> '#' + 1 -> Hex.hexCode[(r shr 4) and 0xF] + 2 -> Hex.hexCode[r and 0xF] + 3 -> Hex.hexCode[(g shr 4) and 0xF] + 4 -> Hex.hexCode[g and 0xF] + 5 -> Hex.hexCode[(b shr 4) and 0xF] + 6 -> Hex.hexCode[b and 0xF] + else -> ' ' + } + }, + ) } - } - fun assemble( - msg: String, - isLightTheme: Boolean, - ): String { - val hash = - if (HexValidator.isHex(msg)) { - Hex.decode(msg) - } else { - Log.w("Robohash", "$msg is not a hex") - CryptoUtils.sha256(msg.toByteArray()) - } - val bgColor = bytesToRGB(hash[0], hash[1], hash[2], isLightTheme) - val fgColor = bytesToRGB(hash[3], hash[4], hash[5], !isLightTheme) - val body = bodies[byteMod10(hash[6])] - val face = faces[byteMod10(hash[7])] - val eye = eyes[byteMod10(hash[8])] - val mouth = mouths[byteMod10(hash[9])] - val accessory = accessories[byteMod10(hash[10])] + private fun byteMod10(byte: Byte): Int { + return byte.toUByte().toInt() % 10 + } - val capacity = - HEADER.length + - 74 + - body.style.length + - body.paths.length + - face.style.length + - face.paths.length + - eye.style.length + - eye.paths.length + - mouth.style.length + - mouth.paths.length + - accessory.style.length + - accessory.paths.length + - MID.length + - BACKGROUND.length + - END.length + private fun reduce( + start: Int, + channel: Byte, + ): Int { + return (start + (channel.toUByte().toInt() * 0.3906f)).toInt() + } - val result = StringBuilder(capacity) + private fun bytesToRGB( + r: Byte, + g: Byte, + b: Byte, + makeLight: Boolean, + ): String { + return if (makeLight) { + // > 150-256 color channels + encodeColor(reduce(150, r), reduce(150, g), reduce(150, b)) + } else { + // < 50-100 color channels + encodeColor(reduce(50, r), reduce(50, g), reduce(50, b)) + } + } - result.append(HEADER) + fun assemble( + msg: String, + isLightTheme: Boolean, + ): String { + val hash = + if (HexValidator.isHex(msg)) { + Hex.decode(msg) + } else { + Log.w("Robohash", "$msg is not a hex") + CryptoUtils.sha256(msg.toByteArray()) + } + val bgColor = bytesToRGB(hash[0], hash[1], hash[2], isLightTheme) + val fgColor = bytesToRGB(hash[3], hash[4], hash[5], !isLightTheme) + val body = bodies[byteMod10(hash[6])] + val face = faces[byteMod10(hash[7])] + val eye = eyes[byteMod10(hash[8])] + val mouth = mouths[byteMod10(hash[9])] + val accessory = accessories[byteMod10(hash[10])] - result.append(".cls-bg{fill:") - result.append(bgColor) - result.append(";}.cls-fill-1{fill:") - result.append(fgColor) - result.append(";}.cls-fill-2{fill:") - result.append(fgColor) - result.append(";}") + val capacity = + HEADER.length + + 74 + + body.style.length + + body.paths.length + + face.style.length + + face.paths.length + + eye.style.length + + eye.paths.length + + mouth.style.length + + mouth.paths.length + + accessory.style.length + + accessory.paths.length + + MID.length + + BACKGROUND.length + + END.length - result.append(body.style) - result.append(face.style) - result.append(eye.style) - result.append(mouth.style) - result.append(accessory.style) + val result = StringBuilder(capacity) - result.append(MID) + result.append(HEADER) - result.append(BACKGROUND) - result.append(body.paths) - result.append(face.paths) - result.append(eye.paths) - result.append(mouth.paths) - result.append(accessory.paths) + result.append(".cls-bg{fill:") + result.append(bgColor) + result.append(";}.cls-fill-1{fill:") + result.append(fgColor) + result.append(";}.cls-fill-2{fill:") + result.append(fgColor) + result.append(";}") - result.append(END) + result.append(body.style) + result.append(face.style) + result.append(eye.style) + result.append(mouth.style) + result.append(accessory.style) - val resultStr = result.toString() - check(resultStr.length == capacity) { "${resultStr.length} was different from $capacity" } - return resultStr - } + result.append(MID) - @Immutable private data class Part(val style: String, val paths: String) + result.append(BACKGROUND) + result.append(body.paths) + result.append(face.paths) + result.append(eye.paths) + result.append(mouth.paths) + result.append(accessory.paths) - const val HEADER = - "" - const val END = "" + result.append(END) - private const val BACKGROUND = """""" + val resultStr = result.toString() + check(resultStr.length == capacity) { "${resultStr.length} was different from $capacity" } + return resultStr + } - private val accessories: List = - listOf( - Part( - """.cls-00-2{fill:none;}.cls-00-2,.cls-00-3,.cls-00-4{stroke:#000;}.cls-00-2,.cls-00-3{stroke-linecap:round;stroke-linejoin:round;}.cls-00-3{fill-opacity:0.4;stroke-width:0.75px;}.cls-00-4{fill-opacity:0.2;stroke-miterlimit:10;stroke-width:0.5px;}""", - """""", - ), - Part( - """.cls-01-2{fill:#fff;fill-opacity:0.2;}.cls-01-3,.cls-01-4{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-01-4{stroke-width:0.75px;}""", - """""", - ), - Part( - """.cls-02-2{fill:#be1e2d;}.cls-02-11,.cls-02-2,.cls-02-5,.cls-02-6,.cls-02-7,.cls-02-9{stroke:#000;}.cls-02-11,.cls-02-2,.cls-02-5,.cls-02-9{stroke-miterlimit:10;}.cls-02-3{fill:#561317;}.cls-02-4{fill:#ed293b;}.cls-02-11,.cls-02-5,.cls-02-7{fill:none;}.cls-02-5,.cls-02-6{stroke-width:0.75px;}.cls-02-6{fill:#fff;}.cls-02-6,.cls-02-8{fill-opacity:0.2;}.cls-02-6,.cls-02-7{stroke-linecap:round;stroke-linejoin:round;}.cls-02-9{fill:#e6e7e8;}.cls-02-10{fill:#d0d2d3;}""", - """""", - ), - Part( - """.cls-03-2,.cls-03-3,.cls-03-8{fill:#fff;}.cls-03-2,.cls-03-3,.cls-03-7{fill-opacity:0.2;}.cls-03-2,.cls-03-4,.cls-03-5{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-03-2{stroke-width:0.75px;}.cls-03-4{fill:none;}.cls-03-5{fill:#ec1c24;}.cls-03-6{fill-opacity:0.1;}.cls-03-8{fill-opacity:0.4;}""", - """""", - ), - Part( - """.cls-04-2,.cls-04-3{fill:none;stroke-linecap:round;stroke-linejoin:round;}.cls-04-2,.cls-04-3,.cls-04-5{stroke:#000;}.cls-04-3,.cls-04-5{stroke-width:0.75px;}.cls-04-4,.cls-04-6{fill:#fff;}.cls-04-4{fill-opacity:0.4;}.cls-04-5{fill:#ec1c24;stroke-miterlimit:10;}.cls-04-6,.cls-04-7{fill-opacity:0.2;}""", - """""", - ), - Part( - """.cls-05-2{fill:#fff;fill-opacity:0.4;}.cls-05-2,.cls-05-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-05-3{fill:none;stroke-width:0.75px;}""", - """""", - ), - Part( - """.cls-06-2,.cls-06-4{fill-opacity:0.2;}.cls-06-3,.cls-06-6{fill:none;}.cls-06-3,.cls-06-5,.cls-06-6{stroke:#000;}.cls-06-3,.cls-06-5{stroke-miterlimit:10;}.cls-06-4{fill:#fff;}.cls-06-5{fill:#ec1c24;stroke-width:0.75px;}.cls-06-6{stroke-linecap:round;stroke-linejoin:round;}""", - """""", - ), - Part( - """.cls-07-10,.cls-07-2,.cls-07-4,.cls-07-8,.cls-07-9{fill:none;}.cls-07-10,.cls-07-2,.cls-07-3,.cls-07-4,.cls-07-5,.cls-07-6,.cls-07-8,.cls-07-9{stroke:#000;}.cls-07-2,.cls-07-3,.cls-07-4,.cls-07-5,.cls-07-6,.cls-07-8{stroke-linecap:round;}.cls-07-2,.cls-07-3,.cls-07-4,.cls-07-5,.cls-07-6,.cls-07-8,.cls-07-9{stroke-linejoin:round;}.cls-07-3,.cls-07-5{fill:#fff;}.cls-07-3,.cls-07-5,.cls-07-6,.cls-07-7{fill-opacity:0.2;}.cls-07-3,.cls-07-4{stroke-width:0.75px;}.cls-07-10,.cls-07-8,.cls-07-9{stroke-width:1.5px;}.cls-07-10{stroke-miterlimit:10;}""", - """""", - ), - Part( - """.cls-08-2{fill:none;}.cls-08-2,.cls-08-3,.cls-08-5,.cls-08-6,.cls-08-7{stroke:#000;stroke-miterlimit:10;}.cls-08-3,.cls-08-4{fill:#fff;}.cls-08-3,.cls-08-4,.cls-08-8{fill-opacity:0.2;}.cls-08-5{fill:#716558;}.cls-08-6{fill:#9a8479;}.cls-08-7{fill:#c1b49a;}""", - """""", - ), - Part( - """.cls-09-2{fill:none;}.cls-09-2,.cls-09-4{stroke:#000;stroke-miterlimit:10;}.cls-09-3,.cls-09-4{fill:#fff;}.cls-09-3{fill-opacity:0.2;}.cls-09-5{fill:#d0d2d3;}.cls-09-6{fill-opacity:0.4;}""", - """""", - ), - ) + @Immutable private data class Part(val style: String, val paths: String) - private val bodies: List = - listOf( - Part( - """.cls-10-2{fill:#fff;fill-opacity:0.4;}.cls-10-3,.cls-10-5,.cls-10-7{fill:none;}.cls-10-3,.cls-10-4,.cls-10-5,.cls-10-6,.cls-10-7,.cls-10-8{stroke:#000;stroke-miterlimit:10;}.cls-10-3{stroke-width:1.2px;}.cls-10-4,.cls-10-6,.cls-10-9{fill-opacity:0.2;}.cls-10-5{stroke-width:1.25px;}.cls-10-6{stroke-width:0.75px;}.cls-10-8{fill:#6d6e70;}""", - """""", - ), - Part( - """.cls-11-2{fill:#fff;}.cls-11-2,.cls-11-6{fill-opacity:0.2;}.cls-11-3{fill:none;}.cls-11-3,.cls-11-4{stroke:#000;stroke-miterlimit:10;}.cls-11-4{fill:#6d6e70;}.cls-11-5{opacity:0.2;}""", - """""", - ), - Part( - """.cls-12-2{fill:#fff;fill-opacity:0.4;}.cls-12-3,.cls-12-7{fill:none;}.cls-12-3,.cls-12-5,.cls-12-6,.cls-12-7{stroke:#000;}.cls-12-3,.cls-12-5,.cls-12-6{stroke-miterlimit:10;}.cls-12-4,.cls-12-6{fill-opacity:0.2;}.cls-12-5{fill:#6d6e70;}.cls-12-7{stroke-linecap:round;stroke-linejoin:round;}""", - """""", - ), - Part( - """.cls-13-2{fill:none;}.cls-13-2,.cls-13-5{stroke:#000;stroke-miterlimit:10;}.cls-13-3{fill:#fff;fill-opacity:0.4;}.cls-13-4{fill-opacity:0.2;}.cls-13-5{fill:#6d6e70;}""", - """""", - ), - Part( - """.cls-14-2{fill:none;}.cls-14-2,.cls-14-4{stroke:#000;stroke-miterlimit:10;}.cls-14-3,.cls-14-6{fill-opacity:0.2;}.cls-14-4{fill:#6d6e70;}.cls-14-5,.cls-14-6{fill:#fff;}.cls-14-5{fill-opacity:0.4;}""", - """""", - ), - Part( - """.cls-15-2{fill:#fff;}.cls-15-2,.cls-15-4{fill-opacity:0.2;}.cls-15-2,.cls-15-3,.cls-15-5{stroke:#000;stroke-miterlimit:10;}.cls-15-3{fill:none;}.cls-15-5{fill:#6d6e70;}""", - """""", - ), - Part( - """.cls-16-2,.cls-16-5{fill:none;}.cls-16-2,.cls-16-5,.cls-16-6,.cls-16-7,.cls-16-8,.cls-16-9{stroke:#000;}.cls-16-2{stroke-linecap:round;stroke-linejoin:round;}.cls-16-3,.cls-16-6,.cls-16-7{fill-opacity:0.2;}.cls-16-4,.cls-16-6{fill:#fff;}.cls-16-4{fill-opacity:0.4;}.cls-16-5,.cls-16-6,.cls-16-7,.cls-16-8,.cls-16-9{stroke-miterlimit:10;}.cls-16-8{fill:#f9ec31;}.cls-16-9{fill:#6d6e70;}""", - """""", - ), - Part( - """.cls-17-2{fill:#020202;}.cls-17-2,.cls-17-4,.cls-17-6,.cls-17-7{fill-opacity:0.4;}.cls-17-3{fill-opacity:0.2;}.cls-17-4,.cls-17-6{fill:#fff;}.cls-17-4,.cls-17-5,.cls-17-8{stroke:#000;stroke-miterlimit:10;}.cls-17-5{fill:none;}.cls-17-8{fill:#6d6e70;}""", - """""", - ), - Part( - """.cls-fill-2,.cls-18-3,.cls-18-5,.cls-18-6{stroke:#000;stroke-miterlimit:10;}.cls-18-3{fill:none;}.cls-18-4{fill:#fff;}.cls-18-4,.cls-18-5{fill-opacity:0.4;}.cls-18-6{fill:#6d6e70;}.cls-18-7{opacity:0.2;}.cls-18-8{fill-opacity:0.2;}""", - """""", - ), - Part( - """.cls-19-2{fill-opacity:0.6;}.cls-19-11,.cls-19-13,.cls-19-14,.cls-19-2,.cls-19-3,.cls-19-4,.cls-19-6,.cls-19-8{stroke:#000;}.cls-19-11,.cls-19-13,.cls-19-2,.cls-19-4,.cls-19-6,.cls-19-8{stroke-miterlimit:10;}.cls-19-11,.cls-19-14,.cls-19-3,.cls-19-8{fill:none;}.cls-19-14,.cls-19-3{stroke-linecap:round;stroke-linejoin:round;}.cls-19-10,.cls-19-12,.cls-19-4,.cls-19-5{fill:#fff;}.cls-19-12,.cls-19-4{fill-opacity:0.2;}.cls-19-4{stroke-opacity:0;}.cls-19-5{fill-opacity:0.1;}.cls-19-6{fill:#6d6e70;}.cls-19-7{fill:#58595b;}.cls-19-13,.cls-19-9{fill-opacity:0.4;}.cls-19-10{fill-opacity:0.5;}.cls-19-11,.cls-19-14{stroke-width:0.75px;}""", - """""", - ), - ) + const val HEADER = + "" + const val END = "" - private val eyes: List = - listOf( - Part( - """.cls-20-2{fill-opacity:0.4;}.cls-20-2,.cls-20-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-20-3{fill:#461917;stroke-width:0.5px;}""", - """""", - ), - Part( - """.cls-21-2{fill-opacity:0.2;}.cls-21-2,.cls-21-3,.cls-21-4,.cls-21-5{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-21-2,.cls-21-4,.cls-21-5{stroke-width:0.5px;}.cls-21-3,.cls-21-4{fill-opacity:0.4;}.cls-21-5{fill:#461917;}.cls-21-6{fill:#faaf40;}""", - """""", - ), - Part( - """.cls-22-2{opacity:0.4;}.cls-22-3{fill:#461917;}.cls-22-3,.cls-22-4,.cls-22-5{stroke:#000;}.cls-22-3,.cls-22-5{stroke-linecap:round;stroke-linejoin:round;}.cls-22-3,.cls-22-4{stroke-width:0.5px;}.cls-22-4{fill:#ec1c24;stroke-miterlimit:10;}.cls-22-5{fill:none;stroke-width:0.75px;}""", - """""", - ), - Part( - """.cls-23-2,.cls-23-3{fill:#fff;}.cls-23-2{fill-opacity:0.4;}.cls-23-3{fill-opacity:0.2;}.cls-23-3,.cls-23-4,.cls-23-5{stroke:#000;stroke-miterlimit:10;}.cls-23-4{fill:none;}.cls-23-4,.cls-23-5{stroke-width:0.75px;}.cls-23-5{fill:red;}""", - """""", - ), - Part( - """.cls-24-2{fill-opacity:0.4;stroke-miterlimit:10;}.cls-24-2,.cls-24-3,.cls-24-5{stroke:#000;}.cls-24-2,.cls-24-5{stroke-width:0.75px;}.cls-24-3,.cls-24-5{fill:#461917;stroke-linecap:round;stroke-linejoin:round;}.cls-24-3{stroke-width:0.5px;}.cls-24-4{fill:#ec1c24;}""", - """""", - ), - Part( - """.cls-25-2{fill-opacity:0.55;stroke-miterlimit:10;stroke-width:0.75px;}.cls-25-2,.cls-25-3{stroke:#000;}.cls-25-3{fill:#461917;stroke-linecap:round;stroke-linejoin:round;stroke-width:0.5px;}.cls-25-4{fill:#ec1c24;}""", - """""", - ), - Part( - """.cls-26-2{fill-opacity:0.6;}.cls-26-2,.cls-26-3,.cls-26-4,.cls-26-5,.cls-26-6{stroke:#000;}.cls-26-2,.cls-26-3,.cls-26-4,.cls-26-6{stroke-linecap:round;stroke-linejoin:round;}.cls-26-3{fill:#461917;}.cls-26-3,.cls-26-4,.cls-26-5{stroke-width:0.5px;}.cls-26-4,.cls-26-5{fill:#f9ec31;}.cls-26-5{stroke-miterlimit:10;}.cls-26-6{fill:none;}""", - """""", - ), - Part( - """.cls-27-2{fill-opacity:0.4;stroke-miterlimit:10;}.cls-27-2,.cls-27-3,.cls-27-4{stroke:#000;}.cls-27-3,.cls-27-4{fill:#461917;stroke-linecap:round;stroke-linejoin:round;}.cls-27-3{stroke-width:0.5px;}.cls-27-4{stroke-width:0.75px;}""", - """""", - ), - Part( - """.cls-28-2{fill:none;}.cls-28-2,.cls-28-3,.cls-28-4,.cls-28-6{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-28-2,.cls-28-4,.cls-28-6{stroke-width:0.75px;}.cls-28-3{fill:#461917;stroke-width:0.5px;}.cls-28-4{fill-opacity:0.4;}.cls-28-5{fill:#fff100;}.cls-28-6{fill:#fff;fill-opacity:0.2;}""", - """""", - ), - Part( - """.cls-29-2{fill:#fff;}.cls-29-2,.cls-29-4{fill-opacity:0.4;}.cls-29-3{fill:none;}.cls-29-3,.cls-29-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-29-4{stroke-width:0.75px;}""", - """""", - ), - ) + private const val BACKGROUND = """""" - private val faces: List = - listOf( - Part( - """.cls-30-2{fill:#fff;fill-opacity:0.4;}.cls-30-3,.cls-30-4{fill:none;}.cls-30-3,.cls-30-4,.cls-30-6{stroke:#000;}.cls-30-3,.cls-30-6{stroke-linecap:round;stroke-linejoin:round;}.cls-30-4{stroke-miterlimit:10;}.cls-30-5,.cls-30-6{fill-opacity:0.2;}.cls-30-6{stroke-width:0.75px;}""", - """""", - ), - Part( - """.cls-31-2,.cls-31-4{fill-opacity:0.2;}.cls-31-3{fill:none;}.cls-31-3,.cls-31-4{stroke:#000;stroke-miterlimit:10;}.cls-31-4,.cls-31-5{fill:#fff;}.cls-31-4{stroke-width:0.75px;}.cls-31-5{fill-opacity:0.4;}""", - """""", - ), - Part( - """.cls-32-2{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-32-3{fill-opacity:0.2;}.cls-32-4{fill:#fff;fill-opacity:0.4;}""", - """""", - ), - Part( - """.cls-33-2{fill-opacity:0.2;}.cls-33-3{fill:#fff;fill-opacity:0.4;}.cls-33-4{fill:none;stroke:#000;stroke-miterlimit:10;}""", - """""", - ), - Part( - """.cls-34-2,.cls-34-3{fill:#fff;}.cls-34-2{fill-opacity:0.4;}.cls-34-3,.cls-34-5{fill-opacity:0.2;}.cls-34-3,.cls-34-4{stroke:#000;stroke-miterlimit:10;}.cls-34-4{fill:none;}""", - """""", - ), - Part( - """.cls-35-2{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-35-3{fill-opacity:0.2;}.cls-35-4{fill:#fff;fill-opacity:0.4;}""", - """""", - ), - Part( - """.cls-36-2,.cls-36-6{fill:#fff;}.cls-36-2{fill-opacity:0.4;}.cls-36-3,.cls-36-4{fill:none;stroke:#000;stroke-miterlimit:10;}.cls-36-3{stroke-width:2px;}.cls-36-5,.cls-36-6{fill-opacity:0.2;}""", - """""", - ), - Part( - """.cls-37-2{fill:#fff;fill-opacity:0.4;}.cls-37-3{fill:none;}.cls-37-3,.cls-37-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-37-4{fill-opacity:0.2;}""", - """""", - ), - Part( - """.cls-38-2{fill:none;stroke:#000;stroke-miterlimit:10;}.cls-38-3,.cls-38-6{fill:#fff;}.cls-38-3,.cls-38-4{fill-opacity:0.2;}.cls-38-5{fill-opacity:0.1;}.cls-38-6{fill-opacity:0.4;}""", - """""", - ), - Part( - """.cls-39-2{fill:#fff;fill-opacity:0.4;}.cls-39-3{fill:none;}.cls-39-3,.cls-39-4{stroke:#000;stroke-miterlimit:10;}.cls-39-4{fill-opacity:0.2;stroke-width:0.75px;}""", - """""", - ), - ) + private val accessories: List = + listOf( + Part( + """.cls-00-2{fill:none;}.cls-00-2,.cls-00-3,.cls-00-4{stroke:#000;}.cls-00-2,.cls-00-3{stroke-linecap:round;stroke-linejoin:round;}.cls-00-3{fill-opacity:0.4;stroke-width:0.75px;}.cls-00-4{fill-opacity:0.2;stroke-miterlimit:10;stroke-width:0.5px;}""", + """""", + ), + Part( + """.cls-01-2{fill:#fff;fill-opacity:0.2;}.cls-01-3,.cls-01-4{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-01-4{stroke-width:0.75px;}""", + """""", + ), + Part( + """.cls-02-2{fill:#be1e2d;}.cls-02-11,.cls-02-2,.cls-02-5,.cls-02-6,.cls-02-7,.cls-02-9{stroke:#000;}.cls-02-11,.cls-02-2,.cls-02-5,.cls-02-9{stroke-miterlimit:10;}.cls-02-3{fill:#561317;}.cls-02-4{fill:#ed293b;}.cls-02-11,.cls-02-5,.cls-02-7{fill:none;}.cls-02-5,.cls-02-6{stroke-width:0.75px;}.cls-02-6{fill:#fff;}.cls-02-6,.cls-02-8{fill-opacity:0.2;}.cls-02-6,.cls-02-7{stroke-linecap:round;stroke-linejoin:round;}.cls-02-9{fill:#e6e7e8;}.cls-02-10{fill:#d0d2d3;}""", + """""", + ), + Part( + """.cls-03-2,.cls-03-3,.cls-03-8{fill:#fff;}.cls-03-2,.cls-03-3,.cls-03-7{fill-opacity:0.2;}.cls-03-2,.cls-03-4,.cls-03-5{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-03-2{stroke-width:0.75px;}.cls-03-4{fill:none;}.cls-03-5{fill:#ec1c24;}.cls-03-6{fill-opacity:0.1;}.cls-03-8{fill-opacity:0.4;}""", + """""", + ), + Part( + """.cls-04-2,.cls-04-3{fill:none;stroke-linecap:round;stroke-linejoin:round;}.cls-04-2,.cls-04-3,.cls-04-5{stroke:#000;}.cls-04-3,.cls-04-5{stroke-width:0.75px;}.cls-04-4,.cls-04-6{fill:#fff;}.cls-04-4{fill-opacity:0.4;}.cls-04-5{fill:#ec1c24;stroke-miterlimit:10;}.cls-04-6,.cls-04-7{fill-opacity:0.2;}""", + """""", + ), + Part( + """.cls-05-2{fill:#fff;fill-opacity:0.4;}.cls-05-2,.cls-05-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-05-3{fill:none;stroke-width:0.75px;}""", + """""", + ), + Part( + """.cls-06-2,.cls-06-4{fill-opacity:0.2;}.cls-06-3,.cls-06-6{fill:none;}.cls-06-3,.cls-06-5,.cls-06-6{stroke:#000;}.cls-06-3,.cls-06-5{stroke-miterlimit:10;}.cls-06-4{fill:#fff;}.cls-06-5{fill:#ec1c24;stroke-width:0.75px;}.cls-06-6{stroke-linecap:round;stroke-linejoin:round;}""", + """""", + ), + Part( + """.cls-07-10,.cls-07-2,.cls-07-4,.cls-07-8,.cls-07-9{fill:none;}.cls-07-10,.cls-07-2,.cls-07-3,.cls-07-4,.cls-07-5,.cls-07-6,.cls-07-8,.cls-07-9{stroke:#000;}.cls-07-2,.cls-07-3,.cls-07-4,.cls-07-5,.cls-07-6,.cls-07-8{stroke-linecap:round;}.cls-07-2,.cls-07-3,.cls-07-4,.cls-07-5,.cls-07-6,.cls-07-8,.cls-07-9{stroke-linejoin:round;}.cls-07-3,.cls-07-5{fill:#fff;}.cls-07-3,.cls-07-5,.cls-07-6,.cls-07-7{fill-opacity:0.2;}.cls-07-3,.cls-07-4{stroke-width:0.75px;}.cls-07-10,.cls-07-8,.cls-07-9{stroke-width:1.5px;}.cls-07-10{stroke-miterlimit:10;}""", + """""", + ), + Part( + """.cls-08-2{fill:none;}.cls-08-2,.cls-08-3,.cls-08-5,.cls-08-6,.cls-08-7{stroke:#000;stroke-miterlimit:10;}.cls-08-3,.cls-08-4{fill:#fff;}.cls-08-3,.cls-08-4,.cls-08-8{fill-opacity:0.2;}.cls-08-5{fill:#716558;}.cls-08-6{fill:#9a8479;}.cls-08-7{fill:#c1b49a;}""", + """""", + ), + Part( + """.cls-09-2{fill:none;}.cls-09-2,.cls-09-4{stroke:#000;stroke-miterlimit:10;}.cls-09-3,.cls-09-4{fill:#fff;}.cls-09-3{fill-opacity:0.2;}.cls-09-5{fill:#d0d2d3;}.cls-09-6{fill-opacity:0.4;}""", + """""", + ), + ) - private val mouths: List = - listOf( - Part( - """.cls-40-2{fill-opacity:0.4;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}""", - """""", - ), - Part( - """.cls-41-2,.cls-41-4{fill:none;}.cls-41-2,.cls-41-3,.cls-41-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-41-3{fill-opacity:0.4;}.cls-41-3,.cls-41-4{stroke-width:0.75px;}""", - """""", - ), - Part( - """.cls-42-2{fill-opacity:0.4;}.cls-42-2,.cls-42-5,.cls-42-6{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-42-3{fill:#ee4036;}.cls-42-4{fill:#f05a28;}.cls-42-5{fill:#faaf40;stroke-width:0.75px;}.cls-42-6{fill:none;}""", - """""", - ), - Part( - """.cls-43-2{fill:#f9ec31;}.cls-43-3{fill:#faaf40;}.cls-43-4,.cls-43-6{fill:none;}.cls-43-4,.cls-43-5,.cls-43-6{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-43-5{fill-opacity:0.4;}.cls-43-5,.cls-43-6{stroke-width:0.75px;}""", - """""", - ), - Part( - """.cls-44-2{fill:none;}.cls-44-2,.cls-44-5{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-44-3{opacity:0.4;}.cls-44-4{fill:#461917;}.cls-44-5{fill-opacity:0.4;stroke-width:0.75px;}""", - """""", - ), - Part( - """.cls-45-2{fill-opacity:0.4;stroke-miterlimit:10;}.cls-45-2,.cls-45-3{stroke:#000;}.cls-45-3{fill:#461917;stroke-linecap:round;stroke-linejoin:round;stroke-width:0.5px;}""", - """""", - ), - Part( - """.cls-46-2{fill-opacity:0.4;}.cls-46-3{fill:none;}.cls-46-3,.cls-46-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-46-4{fill:#461917;stroke-width:0.5px;}""", - """""", - ), - Part( - """.cls-47-2{fill-opacity:0.4;}.cls-47-2,.cls-47-3,.cls-47-4,.cls-47-5{stroke:#000;stroke-miterlimit:10;}.cls-47-3{fill:#f6921e;}.cls-47-4{fill:#f9ec31;stroke-width:0.75px;}.cls-47-5{fill:none;}""", - """""", - ), - Part( - """.cls-48-2{opacity:0.4;}.cls-48-3,.cls-48-4,.cls-48-5{fill:none;}.cls-48-3,.cls-48-4,.cls-48-5,.cls-48-6,.cls-48-7,.cls-48-8{stroke:#000;}.cls-48-3,.cls-48-5,.cls-48-7{stroke-miterlimit:10;}.cls-48-4,.cls-48-6,.cls-48-8{stroke-linecap:round;stroke-linejoin:round;}.cls-48-4,.cls-48-5{stroke-width:0.5px;}.cls-48-6{fill:#f6921e;stroke-width:1.2px;}.cls-48-7{fill:#f9ec31;}.cls-48-7,.cls-48-8{stroke-width:0.75px;}.cls-48-8{fill:#f05a27;}""", - """""", - ), - Part( - """.cls-49-2{fill:none;}.cls-49-2,.cls-49-3,.cls-49-5{stroke:#000;}.cls-49-2,.cls-49-3{stroke-linecap:round;stroke-linejoin:round;}.cls-49-3{opacity:0.1;}.cls-49-4{opacity:0.4;}.cls-49-5{fill-opacity:0.4;stroke-miterlimit:10;stroke-width:0.75px;}""", - """""", - ), - ) + private val bodies: List = + listOf( + Part( + """.cls-10-2{fill:#fff;fill-opacity:0.4;}.cls-10-3,.cls-10-5,.cls-10-7{fill:none;}.cls-10-3,.cls-10-4,.cls-10-5,.cls-10-6,.cls-10-7,.cls-10-8{stroke:#000;stroke-miterlimit:10;}.cls-10-3{stroke-width:1.2px;}.cls-10-4,.cls-10-6,.cls-10-9{fill-opacity:0.2;}.cls-10-5{stroke-width:1.25px;}.cls-10-6{stroke-width:0.75px;}.cls-10-8{fill:#6d6e70;}""", + """""", + ), + Part( + """.cls-11-2{fill:#fff;}.cls-11-2,.cls-11-6{fill-opacity:0.2;}.cls-11-3{fill:none;}.cls-11-3,.cls-11-4{stroke:#000;stroke-miterlimit:10;}.cls-11-4{fill:#6d6e70;}.cls-11-5{opacity:0.2;}""", + """""", + ), + Part( + """.cls-12-2{fill:#fff;fill-opacity:0.4;}.cls-12-3,.cls-12-7{fill:none;}.cls-12-3,.cls-12-5,.cls-12-6,.cls-12-7{stroke:#000;}.cls-12-3,.cls-12-5,.cls-12-6{stroke-miterlimit:10;}.cls-12-4,.cls-12-6{fill-opacity:0.2;}.cls-12-5{fill:#6d6e70;}.cls-12-7{stroke-linecap:round;stroke-linejoin:round;}""", + """""", + ), + Part( + """.cls-13-2{fill:none;}.cls-13-2,.cls-13-5{stroke:#000;stroke-miterlimit:10;}.cls-13-3{fill:#fff;fill-opacity:0.4;}.cls-13-4{fill-opacity:0.2;}.cls-13-5{fill:#6d6e70;}""", + """""", + ), + Part( + """.cls-14-2{fill:none;}.cls-14-2,.cls-14-4{stroke:#000;stroke-miterlimit:10;}.cls-14-3,.cls-14-6{fill-opacity:0.2;}.cls-14-4{fill:#6d6e70;}.cls-14-5,.cls-14-6{fill:#fff;}.cls-14-5{fill-opacity:0.4;}""", + """""", + ), + Part( + """.cls-15-2{fill:#fff;}.cls-15-2,.cls-15-4{fill-opacity:0.2;}.cls-15-2,.cls-15-3,.cls-15-5{stroke:#000;stroke-miterlimit:10;}.cls-15-3{fill:none;}.cls-15-5{fill:#6d6e70;}""", + """""", + ), + Part( + """.cls-16-2,.cls-16-5{fill:none;}.cls-16-2,.cls-16-5,.cls-16-6,.cls-16-7,.cls-16-8,.cls-16-9{stroke:#000;}.cls-16-2{stroke-linecap:round;stroke-linejoin:round;}.cls-16-3,.cls-16-6,.cls-16-7{fill-opacity:0.2;}.cls-16-4,.cls-16-6{fill:#fff;}.cls-16-4{fill-opacity:0.4;}.cls-16-5,.cls-16-6,.cls-16-7,.cls-16-8,.cls-16-9{stroke-miterlimit:10;}.cls-16-8{fill:#f9ec31;}.cls-16-9{fill:#6d6e70;}""", + """""", + ), + Part( + """.cls-17-2{fill:#020202;}.cls-17-2,.cls-17-4,.cls-17-6,.cls-17-7{fill-opacity:0.4;}.cls-17-3{fill-opacity:0.2;}.cls-17-4,.cls-17-6{fill:#fff;}.cls-17-4,.cls-17-5,.cls-17-8{stroke:#000;stroke-miterlimit:10;}.cls-17-5{fill:none;}.cls-17-8{fill:#6d6e70;}""", + """""", + ), + Part( + """.cls-fill-2,.cls-18-3,.cls-18-5,.cls-18-6{stroke:#000;stroke-miterlimit:10;}.cls-18-3{fill:none;}.cls-18-4{fill:#fff;}.cls-18-4,.cls-18-5{fill-opacity:0.4;}.cls-18-6{fill:#6d6e70;}.cls-18-7{opacity:0.2;}.cls-18-8{fill-opacity:0.2;}""", + """""", + ), + Part( + """.cls-19-2{fill-opacity:0.6;}.cls-19-11,.cls-19-13,.cls-19-14,.cls-19-2,.cls-19-3,.cls-19-4,.cls-19-6,.cls-19-8{stroke:#000;}.cls-19-11,.cls-19-13,.cls-19-2,.cls-19-4,.cls-19-6,.cls-19-8{stroke-miterlimit:10;}.cls-19-11,.cls-19-14,.cls-19-3,.cls-19-8{fill:none;}.cls-19-14,.cls-19-3{stroke-linecap:round;stroke-linejoin:round;}.cls-19-10,.cls-19-12,.cls-19-4,.cls-19-5{fill:#fff;}.cls-19-12,.cls-19-4{fill-opacity:0.2;}.cls-19-4{stroke-opacity:0;}.cls-19-5{fill-opacity:0.1;}.cls-19-6{fill:#6d6e70;}.cls-19-7{fill:#58595b;}.cls-19-13,.cls-19-9{fill-opacity:0.4;}.cls-19-10{fill-opacity:0.5;}.cls-19-11,.cls-19-14{stroke-width:0.75px;}""", + """""", + ), + ) + + private val eyes: List = + listOf( + Part( + """.cls-20-2{fill-opacity:0.4;}.cls-20-2,.cls-20-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-20-3{fill:#461917;stroke-width:0.5px;}""", + """""", + ), + Part( + """.cls-21-2{fill-opacity:0.2;}.cls-21-2,.cls-21-3,.cls-21-4,.cls-21-5{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-21-2,.cls-21-4,.cls-21-5{stroke-width:0.5px;}.cls-21-3,.cls-21-4{fill-opacity:0.4;}.cls-21-5{fill:#461917;}.cls-21-6{fill:#faaf40;}""", + """""", + ), + Part( + """.cls-22-2{opacity:0.4;}.cls-22-3{fill:#461917;}.cls-22-3,.cls-22-4,.cls-22-5{stroke:#000;}.cls-22-3,.cls-22-5{stroke-linecap:round;stroke-linejoin:round;}.cls-22-3,.cls-22-4{stroke-width:0.5px;}.cls-22-4{fill:#ec1c24;stroke-miterlimit:10;}.cls-22-5{fill:none;stroke-width:0.75px;}""", + """""", + ), + Part( + """.cls-23-2,.cls-23-3{fill:#fff;}.cls-23-2{fill-opacity:0.4;}.cls-23-3{fill-opacity:0.2;}.cls-23-3,.cls-23-4,.cls-23-5{stroke:#000;stroke-miterlimit:10;}.cls-23-4{fill:none;}.cls-23-4,.cls-23-5{stroke-width:0.75px;}.cls-23-5{fill:red;}""", + """""", + ), + Part( + """.cls-24-2{fill-opacity:0.4;stroke-miterlimit:10;}.cls-24-2,.cls-24-3,.cls-24-5{stroke:#000;}.cls-24-2,.cls-24-5{stroke-width:0.75px;}.cls-24-3,.cls-24-5{fill:#461917;stroke-linecap:round;stroke-linejoin:round;}.cls-24-3{stroke-width:0.5px;}.cls-24-4{fill:#ec1c24;}""", + """""", + ), + Part( + """.cls-25-2{fill-opacity:0.55;stroke-miterlimit:10;stroke-width:0.75px;}.cls-25-2,.cls-25-3{stroke:#000;}.cls-25-3{fill:#461917;stroke-linecap:round;stroke-linejoin:round;stroke-width:0.5px;}.cls-25-4{fill:#ec1c24;}""", + """""", + ), + Part( + """.cls-26-2{fill-opacity:0.6;}.cls-26-2,.cls-26-3,.cls-26-4,.cls-26-5,.cls-26-6{stroke:#000;}.cls-26-2,.cls-26-3,.cls-26-4,.cls-26-6{stroke-linecap:round;stroke-linejoin:round;}.cls-26-3{fill:#461917;}.cls-26-3,.cls-26-4,.cls-26-5{stroke-width:0.5px;}.cls-26-4,.cls-26-5{fill:#f9ec31;}.cls-26-5{stroke-miterlimit:10;}.cls-26-6{fill:none;}""", + """""", + ), + Part( + """.cls-27-2{fill-opacity:0.4;stroke-miterlimit:10;}.cls-27-2,.cls-27-3,.cls-27-4{stroke:#000;}.cls-27-3,.cls-27-4{fill:#461917;stroke-linecap:round;stroke-linejoin:round;}.cls-27-3{stroke-width:0.5px;}.cls-27-4{stroke-width:0.75px;}""", + """""", + ), + Part( + """.cls-28-2{fill:none;}.cls-28-2,.cls-28-3,.cls-28-4,.cls-28-6{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-28-2,.cls-28-4,.cls-28-6{stroke-width:0.75px;}.cls-28-3{fill:#461917;stroke-width:0.5px;}.cls-28-4{fill-opacity:0.4;}.cls-28-5{fill:#fff100;}.cls-28-6{fill:#fff;fill-opacity:0.2;}""", + """""", + ), + Part( + """.cls-29-2{fill:#fff;}.cls-29-2,.cls-29-4{fill-opacity:0.4;}.cls-29-3{fill:none;}.cls-29-3,.cls-29-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-29-4{stroke-width:0.75px;}""", + """""", + ), + ) + + private val faces: List = + listOf( + Part( + """.cls-30-2{fill:#fff;fill-opacity:0.4;}.cls-30-3,.cls-30-4{fill:none;}.cls-30-3,.cls-30-4,.cls-30-6{stroke:#000;}.cls-30-3,.cls-30-6{stroke-linecap:round;stroke-linejoin:round;}.cls-30-4{stroke-miterlimit:10;}.cls-30-5,.cls-30-6{fill-opacity:0.2;}.cls-30-6{stroke-width:0.75px;}""", + """""", + ), + Part( + """.cls-31-2,.cls-31-4{fill-opacity:0.2;}.cls-31-3{fill:none;}.cls-31-3,.cls-31-4{stroke:#000;stroke-miterlimit:10;}.cls-31-4,.cls-31-5{fill:#fff;}.cls-31-4{stroke-width:0.75px;}.cls-31-5{fill-opacity:0.4;}""", + """""", + ), + Part( + """.cls-32-2{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-32-3{fill-opacity:0.2;}.cls-32-4{fill:#fff;fill-opacity:0.4;}""", + """""", + ), + Part( + """.cls-33-2{fill-opacity:0.2;}.cls-33-3{fill:#fff;fill-opacity:0.4;}.cls-33-4{fill:none;stroke:#000;stroke-miterlimit:10;}""", + """""", + ), + Part( + """.cls-34-2,.cls-34-3{fill:#fff;}.cls-34-2{fill-opacity:0.4;}.cls-34-3,.cls-34-5{fill-opacity:0.2;}.cls-34-3,.cls-34-4{stroke:#000;stroke-miterlimit:10;}.cls-34-4{fill:none;}""", + """""", + ), + Part( + """.cls-35-2{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-35-3{fill-opacity:0.2;}.cls-35-4{fill:#fff;fill-opacity:0.4;}""", + """""", + ), + Part( + """.cls-36-2,.cls-36-6{fill:#fff;}.cls-36-2{fill-opacity:0.4;}.cls-36-3,.cls-36-4{fill:none;stroke:#000;stroke-miterlimit:10;}.cls-36-3{stroke-width:2px;}.cls-36-5,.cls-36-6{fill-opacity:0.2;}""", + """""", + ), + Part( + """.cls-37-2{fill:#fff;fill-opacity:0.4;}.cls-37-3{fill:none;}.cls-37-3,.cls-37-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-37-4{fill-opacity:0.2;}""", + """""", + ), + Part( + """.cls-38-2{fill:none;stroke:#000;stroke-miterlimit:10;}.cls-38-3,.cls-38-6{fill:#fff;}.cls-38-3,.cls-38-4{fill-opacity:0.2;}.cls-38-5{fill-opacity:0.1;}.cls-38-6{fill-opacity:0.4;}""", + """""", + ), + Part( + """.cls-39-2{fill:#fff;fill-opacity:0.4;}.cls-39-3{fill:none;}.cls-39-3,.cls-39-4{stroke:#000;stroke-miterlimit:10;}.cls-39-4{fill-opacity:0.2;stroke-width:0.75px;}""", + """""", + ), + ) + + private val mouths: List = + listOf( + Part( + """.cls-40-2{fill-opacity:0.4;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}""", + """""", + ), + Part( + """.cls-41-2,.cls-41-4{fill:none;}.cls-41-2,.cls-41-3,.cls-41-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-41-3{fill-opacity:0.4;}.cls-41-3,.cls-41-4{stroke-width:0.75px;}""", + """""", + ), + Part( + """.cls-42-2{fill-opacity:0.4;}.cls-42-2,.cls-42-5,.cls-42-6{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-42-3{fill:#ee4036;}.cls-42-4{fill:#f05a28;}.cls-42-5{fill:#faaf40;stroke-width:0.75px;}.cls-42-6{fill:none;}""", + """""", + ), + Part( + """.cls-43-2{fill:#f9ec31;}.cls-43-3{fill:#faaf40;}.cls-43-4,.cls-43-6{fill:none;}.cls-43-4,.cls-43-5,.cls-43-6{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-43-5{fill-opacity:0.4;}.cls-43-5,.cls-43-6{stroke-width:0.75px;}""", + """""", + ), + Part( + """.cls-44-2{fill:none;}.cls-44-2,.cls-44-5{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-44-3{opacity:0.4;}.cls-44-4{fill:#461917;}.cls-44-5{fill-opacity:0.4;stroke-width:0.75px;}""", + """""", + ), + Part( + """.cls-45-2{fill-opacity:0.4;stroke-miterlimit:10;}.cls-45-2,.cls-45-3{stroke:#000;}.cls-45-3{fill:#461917;stroke-linecap:round;stroke-linejoin:round;stroke-width:0.5px;}""", + """""", + ), + Part( + """.cls-46-2{fill-opacity:0.4;}.cls-46-3{fill:none;}.cls-46-3,.cls-46-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-46-4{fill:#461917;stroke-width:0.5px;}""", + """""", + ), + Part( + """.cls-47-2{fill-opacity:0.4;}.cls-47-2,.cls-47-3,.cls-47-4,.cls-47-5{stroke:#000;stroke-miterlimit:10;}.cls-47-3{fill:#f6921e;}.cls-47-4{fill:#f9ec31;stroke-width:0.75px;}.cls-47-5{fill:none;}""", + """""", + ), + Part( + """.cls-48-2{opacity:0.4;}.cls-48-3,.cls-48-4,.cls-48-5{fill:none;}.cls-48-3,.cls-48-4,.cls-48-5,.cls-48-6,.cls-48-7,.cls-48-8{stroke:#000;}.cls-48-3,.cls-48-5,.cls-48-7{stroke-miterlimit:10;}.cls-48-4,.cls-48-6,.cls-48-8{stroke-linecap:round;stroke-linejoin:round;}.cls-48-4,.cls-48-5{stroke-width:0.5px;}.cls-48-6{fill:#f6921e;stroke-width:1.2px;}.cls-48-7{fill:#f9ec31;}.cls-48-7,.cls-48-8{stroke-width:0.75px;}.cls-48-8{fill:#f05a27;}""", + """""", + ), + Part( + """.cls-49-2{fill:none;}.cls-49-2,.cls-49-3,.cls-49-5{stroke:#000;}.cls-49-2,.cls-49-3{stroke-linecap:round;stroke-linejoin:round;}.cls-49-3{opacity:0.1;}.cls-49-4{opacity:0.4;}.cls-49-5{fill-opacity:0.4;stroke-miterlimit:10;stroke-width:0.75px;}""", + """""", + ), + ) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/StringUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/StringUtils.kt index 8da44fbb4..a6f1e4947 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/StringUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/StringUtils.kt @@ -23,49 +23,49 @@ package com.vitorpamplona.quartz.utils import kotlin.math.min fun String.containsIgnoreCase(term: String): Boolean { - if (term.isEmpty()) return true // Empty string is contained + if (term.isEmpty()) return true // Empty string is contained - val whatUppercase = term.uppercase() - val whatLowercase = term.lowercase() + val whatUppercase = term.uppercase() + val whatLowercase = term.lowercase() - return containsIgnoreCase(whatLowercase, whatUppercase) + return containsIgnoreCase(whatLowercase, whatUppercase) } fun String.containsIgnoreCase( - whatLowercase: String, - whatUppercase: String, + whatLowercase: String, + whatUppercase: String, ): Boolean { - var myOffset: Int - var whatOffset: Int - val termLength = min(whatUppercase.length, whatLowercase.length) + var myOffset: Int + var whatOffset: Int + val termLength = min(whatUppercase.length, whatLowercase.length) - for (i in 0..this.length - termLength) { - if (this[i] != whatLowercase[0] && this[i] != whatUppercase[0]) continue + for (i in 0..this.length - termLength) { + if (this[i] != whatLowercase[0] && this[i] != whatUppercase[0]) continue - myOffset = i + 1 - whatOffset = 1 - while (whatOffset < termLength) { - if ( - this[myOffset] != whatUppercase[whatOffset] && this[myOffset] != whatLowercase[whatOffset] - ) { - break - } - myOffset++ - whatOffset++ + myOffset = i + 1 + whatOffset = 1 + while (whatOffset < termLength) { + if ( + this[myOffset] != whatUppercase[whatOffset] && this[myOffset] != whatLowercase[whatOffset] + ) { + break + } + myOffset++ + whatOffset++ + } + if (whatOffset == termLength) return true } - if (whatOffset == termLength) return true - } - return false + return false } fun String.containsAny(terms: List): Boolean { - if (terms.isEmpty()) return true // Empty string is contained + if (terms.isEmpty()) return true // Empty string is contained - if (terms.size == 1) { - return containsIgnoreCase(terms[0].lowercase, terms[0].uppercase) - } + if (terms.size == 1) { + return containsIgnoreCase(terms[0].lowercase, terms[0].uppercase) + } - return terms.any { containsIgnoreCase(it.lowercase, it.uppercase) } + return terms.any { containsIgnoreCase(it.lowercase, it.uppercase) } } class DualCase(val lowercase: String, val uppercase: String) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt index e1ef6b7d0..d5669942b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt @@ -23,30 +23,30 @@ package com.vitorpamplona.quartz.utils import com.vitorpamplona.quartz.crypto.CryptoUtils object TimeUtils { - const val ONE_MINUTE = 60 - const val FIVE_MINUTES = 5 * ONE_MINUTE - const val ONE_HOUR = 60 * ONE_MINUTE - const val EIGHT_HOURS = 8 * ONE_HOUR - const val ONE_DAY = 24 * ONE_HOUR - const val ONE_WEEK = 7 * ONE_DAY - const val ONE_MONTH = 30 * ONE_DAY - const val ONE_YEAR = 365 * ONE_DAY + const val ONE_MINUTE = 60 + const val FIVE_MINUTES = 5 * ONE_MINUTE + const val ONE_HOUR = 60 * ONE_MINUTE + const val EIGHT_HOURS = 8 * ONE_HOUR + const val ONE_DAY = 24 * ONE_HOUR + const val ONE_WEEK = 7 * ONE_DAY + const val ONE_MONTH = 30 * ONE_DAY + const val ONE_YEAR = 365 * ONE_DAY - fun now() = System.currentTimeMillis() / 1000 + fun now() = System.currentTimeMillis() / 1000 - fun oneMinuteAgo() = now() - ONE_MINUTE + fun oneMinuteAgo() = now() - ONE_MINUTE - fun fiveMinutesAgo() = now() - FIVE_MINUTES + fun fiveMinutesAgo() = now() - FIVE_MINUTES - fun oneHourAgo() = now() - ONE_HOUR + fun oneHourAgo() = now() - ONE_HOUR - fun oneHourAhead() = now() + ONE_HOUR + fun oneHourAhead() = now() + ONE_HOUR - fun oneDayAgo() = now() - ONE_DAY + fun oneDayAgo() = now() - ONE_DAY - fun eightHoursAgo() = now() - EIGHT_HOURS + fun eightHoursAgo() = now() - EIGHT_HOURS - fun oneWeekAgo() = now() - ONE_WEEK + fun oneWeekAgo() = now() - ONE_WEEK - fun randomWithinAWeek() = System.currentTimeMillis() / 1000 - CryptoUtils.randomInt(ONE_WEEK) + fun randomWithinAWeek() = System.currentTimeMillis() / 1000 - CryptoUtils.randomInt(ONE_WEEK) } diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Lud06Test.kt b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Lud06Test.kt index 037975c09..c7fadde3f 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Lud06Test.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Lud06Test.kt @@ -24,12 +24,12 @@ import org.junit.Assert.assertEquals import org.junit.Test class Lud06Test { - val lnTips = - "LNURL1DP68GURN8GHJ7MRW9E6XJURN9UH8WETVDSKKKMN0WAHZ7MRWW4EXCUP0XPURXEFEX9SKGCT9V5ER2V33X4NRGP2NE42" + val lnTips = + "LNURL1DP68GURN8GHJ7MRW9E6XJURN9UH8WETVDSKKKMN0WAHZ7MRWW4EXCUP0XPURXEFEX9SKGCT9V5ER2V33X4NRGP2NE42" - @Test() - fun parseLnUrlp() { - assertEquals("https://ln.tips/.well-known/lnurlp/0x3e91adaee25215f4", Lud06().toLnUrlp(lnTips)) - assertEquals("0x3e91adaee25215f4@ln.tips", Lud06().toLud16(lnTips)) - } + @Test() + fun parseLnUrlp() { + assertEquals("https://ln.tips/.well-known/lnurlp/0x3e91adaee25215f4", Lud06().toLnUrlp(lnTips)) + assertEquals("0x3e91adaee25215f4@ln.tips", Lud06().toLud16(lnTips)) + } } diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/NIP19ParserTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/NIP19ParserTest.kt index 7f05c4864..9bb72ac58 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/NIP19ParserTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/NIP19ParserTest.kt @@ -24,263 +24,263 @@ import org.junit.Assert.assertEquals import org.junit.Test class NIP19ParserTest { - @Test - fun nAddrParser() { - val result = - Nip19.uriToRoute( - "nostr:naddr1qqqqygzxpsj7dqha57pjk5k37gkn6g4nzakewtmqmnwryyhd3jfwlpgxtspsgqqqw4rs3xyxus", - ) - assertEquals( - "30023:460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c:", - result?.hex, - ) - } + @Test + fun nAddrParser() { + val result = + Nip19.uriToRoute( + "nostr:naddr1qqqqygzxpsj7dqha57pjk5k37gkn6g4nzakewtmqmnwryyhd3jfwlpgxtspsgqqqw4rs3xyxus", + ) + assertEquals( + "30023:460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c:", + result?.hex, + ) + } - @Test - fun nAddrParser2() { - val result = - Nip19.uriToRoute( - "nostr:naddr1qq8kwatfv3jj6amfwfjkwatpwfjqygxsm6lelvfda7qlg0tud9pfhduysy4vrexj65azqtdk4tr75j6xdspsgqqqw4rsg32ag8", - ) - assertEquals( - "30023:d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c:guide-wireguard", - result?.hex, - ) - } + @Test + fun nAddrParser2() { + val result = + Nip19.uriToRoute( + "nostr:naddr1qq8kwatfv3jj6amfwfjkwatpwfjqygxsm6lelvfda7qlg0tud9pfhduysy4vrexj65azqtdk4tr75j6xdspsgqqqw4rsg32ag8", + ) + assertEquals( + "30023:d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c:guide-wireguard", + result?.hex, + ) + } - @Test - fun nAddrParse3() { - val result = - Nip19.uriToRoute( - "naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", - ) - assertEquals(Nip19.Type.ADDRESS, result?.type) - assertEquals( - "30023:d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193:89de7920", - result?.hex, - ) - assertEquals("wss://relay.damus.io", result?.relay) - } + @Test + fun nAddrParse3() { + val result = + Nip19.uriToRoute( + "naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", + ) + assertEquals(Nip19.Type.ADDRESS, result?.type) + assertEquals( + "30023:d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193:89de7920", + result?.hex, + ) + assertEquals("wss://relay.damus.io", result?.relay) + } - @Test - fun nAddrATagParse3() { - val address = - ATag.parse( - "30023:d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193:89de7920", - "wss://relay.damus.io", - ) - assertEquals(30023, address?.kind) - assertEquals( - "d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193", - address?.pubKeyHex, - ) - assertEquals("89de7920", address?.dTag) - assertEquals("wss://relay.damus.io", address?.relay) - assertEquals( - "naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", - address?.toNAddr(), - ) - } + @Test + fun nAddrATagParse3() { + val address = + ATag.parse( + "30023:d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193:89de7920", + "wss://relay.damus.io", + ) + assertEquals(30023, address?.kind) + assertEquals( + "d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193", + address?.pubKeyHex, + ) + assertEquals("89de7920", address?.dTag) + assertEquals("wss://relay.damus.io", address?.relay) + assertEquals( + "naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", + address?.toNAddr(), + ) + } - @Test - fun nAddrFormatter() { - val address = - ATag( - 30023, - "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", - "", - null, - ) - assertEquals( - "naddr1qqqqygzxpsj7dqha57pjk5k37gkn6g4nzakewtmqmnwryyhd3jfwlpgxtspsgqqqw4rs3xyxus", - address.toNAddr(), - ) - } + @Test + fun nAddrFormatter() { + val address = + ATag( + 30023, + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "", + null, + ) + assertEquals( + "naddr1qqqqygzxpsj7dqha57pjk5k37gkn6g4nzakewtmqmnwryyhd3jfwlpgxtspsgqqqw4rs3xyxus", + address.toNAddr(), + ) + } - @Test - fun nAddrFormatter2() { - val address = - ATag( - 30023, - "d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c", - "guide-wireguard", - null, - ) - assertEquals( - "naddr1qq8kwatfv3jj6amfwfjkwatpwfjqygxsm6lelvfda7qlg0tud9pfhduysy4vrexj65azqtdk4tr75j6xdspsgqqqw4rsg32ag8", - address.toNAddr(), - ) - } + @Test + fun nAddrFormatter2() { + val address = + ATag( + 30023, + "d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c", + "guide-wireguard", + null, + ) + assertEquals( + "naddr1qq8kwatfv3jj6amfwfjkwatpwfjqygxsm6lelvfda7qlg0tud9pfhduysy4vrexj65azqtdk4tr75j6xdspsgqqqw4rsg32ag8", + address.toNAddr(), + ) + } - @Test - fun nAddrFormatter3() { - val address = - ATag( - 30023, - "d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193", - "89de7920", - "wss://relay.damus.io", - ) - assertEquals( - "naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", - address.toNAddr(), - ) - } + @Test + fun nAddrFormatter3() { + val address = + ATag( + 30023, + "d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193", + "89de7920", + "wss://relay.damus.io", + ) + assertEquals( + "naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", + address.toNAddr(), + ) + } - @Test - fun nAddrParserPablo() { - val result = - Nip19.uriToRoute( - "naddr1qq2hs7p30p6kcunxxamkgcnyd33xxve3veshyq3qyujphdcz69z6jafxpnldae3xtymdekfeatkt3r4qusr3w5krqspqxpqqqpaxjlg805f", - ) - assertEquals(Nip19.Type.ADDRESS, result?.type) - assertEquals( - "31337:27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402:xx1xulrf7wdbdlbc31far", - result?.hex, - ) - assertEquals(null, result?.relay) - assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result?.author) - assertEquals(31337, result?.kind) - } + @Test + fun nAddrParserPablo() { + val result = + Nip19.uriToRoute( + "naddr1qq2hs7p30p6kcunxxamkgcnyd33xxve3veshyq3qyujphdcz69z6jafxpnldae3xtymdekfeatkt3r4qusr3w5krqspqxpqqqpaxjlg805f", + ) + assertEquals(Nip19.Type.ADDRESS, result?.type) + assertEquals( + "31337:27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402:xx1xulrf7wdbdlbc31far", + result?.hex, + ) + assertEquals(null, result?.relay) + assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result?.author) + assertEquals(31337, result?.kind) + } - @Test - fun nAddrParserGizmo() { - val result = - Nip19.uriToRoute( - "naddr1qpqrvvfnvccrzdryxgunzvtxvgukge34xfjnqdpcv9sk2desxgmrscesvserzd3h8ycrywphvg6nsvf58ycnqef3v5mnsvt98pjnqdfs8ypzq3huhccxt6h34eupz3jeynjgjgek8lel2f4adaea0svyk94a3njdqvzqqqr4gudhrkyk", - ) - assertEquals(Nip19.Type.ADDRESS, result?.type) - assertEquals( - "30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:613f014d2911fb9df52e048aae70268c0d216790287b5814910e1e781e8e0509", - result?.hex, - ) - assertEquals(null, result?.relay) - assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) - assertEquals(30023, result?.kind) - } + @Test + fun nAddrParserGizmo() { + val result = + Nip19.uriToRoute( + "naddr1qpqrvvfnvccrzdryxgunzvtxvgukge34xfjnqdpcv9sk2desxgmrscesvserzd3h8ycrywphvg6nsvf58ycnqef3v5mnsvt98pjnqdfs8ypzq3huhccxt6h34eupz3jeynjgjgek8lel2f4adaea0svyk94a3njdqvzqqqr4gudhrkyk", + ) + assertEquals(Nip19.Type.ADDRESS, result?.type) + assertEquals( + "30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:613f014d2911fb9df52e048aae70268c0d216790287b5814910e1e781e8e0509", + result?.hex, + ) + assertEquals(null, result?.relay) + assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) + assertEquals(30023, result?.kind) + } - @Test - fun nAddrParserGizmo2() { - val result = - Nip19.uriToRoute( - "naddr1qq9rzd3h8y6nqwf5xyuqygzxljlrqe027xh8sy2xtyjwfzfrxcll8afxh4hh847psjckhkxwf5psgqqqw4rsty50fx", - ) - assertEquals(Nip19.Type.ADDRESS, result?.type) - assertEquals( - "30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418", - result?.hex, - ) - assertEquals(null, result?.relay) - assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) - assertEquals(30023, result?.kind) - } + @Test + fun nAddrParserGizmo2() { + val result = + Nip19.uriToRoute( + "naddr1qq9rzd3h8y6nqwf5xyuqygzxljlrqe027xh8sy2xtyjwfzfrxcll8afxh4hh847psjckhkxwf5psgqqqw4rsty50fx", + ) + assertEquals(Nip19.Type.ADDRESS, result?.type) + assertEquals( + "30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418", + result?.hex, + ) + assertEquals(null, result?.relay) + assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) + assertEquals(30023, result?.kind) + } - @Test - fun nEventParserTest() { - val result = - Nip19.uriToRoute("nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy") - assertEquals(Nip19.Type.EVENT, result?.type) - assertEquals("f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", result?.hex) - assertEquals(null, result?.relay) - assertEquals(null, result?.author) - assertEquals(null, result?.kind) - } + @Test + fun nEventParserTest() { + val result = + Nip19.uriToRoute("nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy") + assertEquals(Nip19.Type.EVENT, result?.type) + assertEquals("f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", result?.hex) + assertEquals(null, result?.relay) + assertEquals(null, result?.author) + assertEquals(null, result?.kind) + } - @Test - fun nEventParser() { - val result = - Nip19.uriToRoute( - "nostr:nevent1qqstvrl6wftd8ht4g0vrp6m30tjs6pdxcvk977g769dcvlptkzu4ftqppamhxue69uhkummnw3ezumt0d5pzp78lz8r60568sd2a8dx3wnj6gume02gxaf92vx4fk67qv5kpagt6qvzqqqqqqygqr86c", - ) - assertEquals(Nip19.Type.EVENT, result?.type) - assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result?.hex) - assertEquals("wss://nostr.mom", result?.relay) - assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result?.author) - assertEquals(1, result?.kind) - } + @Test + fun nEventParser() { + val result = + Nip19.uriToRoute( + "nostr:nevent1qqstvrl6wftd8ht4g0vrp6m30tjs6pdxcvk977g769dcvlptkzu4ftqppamhxue69uhkummnw3ezumt0d5pzp78lz8r60568sd2a8dx3wnj6gume02gxaf92vx4fk67qv5kpagt6qvzqqqqqqygqr86c", + ) + assertEquals(Nip19.Type.EVENT, result?.type) + assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result?.hex) + assertEquals("wss://nostr.mom", result?.relay) + assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result?.author) + assertEquals(1, result?.kind) + } - @Test - fun nEventParser2() { - val result = - Nip19.uriToRoute( - "nostr:nevent1qqsplpuwsgrrmq85rfup6w3w777rxmcmadu590emfx6z4msj2844euqpz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqye3a70w", - ) + @Test + fun nEventParser2() { + val result = + Nip19.uriToRoute( + "nostr:nevent1qqsplpuwsgrrmq85rfup6w3w777rxmcmadu590emfx6z4msj2844euqpz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqye3a70w", + ) - assertEquals(Nip19.Type.EVENT, result?.type) - assertEquals("1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", result?.hex) - assertEquals("wss://relay.damus.io", result?.relay) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) - assertEquals(1, result?.kind) - } + assertEquals(Nip19.Type.EVENT, result?.type) + assertEquals("1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", result?.hex) + assertEquals("wss://relay.damus.io", result?.relay) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) + assertEquals(1, result?.kind) + } - @Test - fun nEventParser3() { - val result = - Nip19.uriToRoute( - "nostr:nevent1qqsg6gechd3dhzx38n4z8a2lylzgsmmgeamhmtzz72m9ummsnf0xjfspsdmhxue69uhkummn9ekx7mpvwaehxw309ahx7um5wghx77r5wghxgetk93mhxue69uhhyetvv9ujumn0wd68ytnzvuk8wumn8ghj7mn0wd68ytn9d9h82mny0fmkzmn6d9njuumsv93k2trhwden5te0wfjkccte9ehx7um5wghxyctwvsk8wumn8ghj7un9d3shjtnyv9kh2uewd9hs3kqsdn", - ) + @Test + fun nEventParser3() { + val result = + Nip19.uriToRoute( + "nostr:nevent1qqsg6gechd3dhzx38n4z8a2lylzgsmmgeamhmtzz72m9ummsnf0xjfspsdmhxue69uhkummn9ekx7mpvwaehxw309ahx7um5wghx77r5wghxgetk93mhxue69uhhyetvv9ujumn0wd68ytnzvuk8wumn8ghj7mn0wd68ytn9d9h82mny0fmkzmn6d9njuumsv93k2trhwden5te0wfjkccte9ehx7um5wghxyctwvsk8wumn8ghj7un9d3shjtnyv9kh2uewd9hs3kqsdn", + ) - assertEquals(Nip19.Type.EVENT, result?.type) - assertEquals("8d2338bb62db88d13cea23f55f27c4886f68cf777dac42f2b65e6f709a5e6926", result?.hex) - assertEquals( - "wss://nos.lol,wss://nostr.oxtr.dev,wss://relay.nostr.bg,wss://nostr.einundzwanzig.space,wss://relay.nostr.band,wss://relay.damus.io", - result?.relay, - ) - } + assertEquals(Nip19.Type.EVENT, result?.type) + assertEquals("8d2338bb62db88d13cea23f55f27c4886f68cf777dac42f2b65e6f709a5e6926", result?.hex) + assertEquals( + "wss://nos.lol,wss://nostr.oxtr.dev,wss://relay.nostr.bg,wss://nostr.einundzwanzig.space,wss://relay.nostr.band,wss://relay.damus.io", + result?.relay, + ) + } - @Test - fun nEventParserInvalidChecksum() { - val result = - Nip19.uriToRoute( - "nostr:nevent1qqsyxq8v0730nz38dupnjzp5jegkyz4gu2ptwcps4v32hjnrap0q0espz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqyn3t9gj", - ) + @Test + fun nEventParserInvalidChecksum() { + val result = + Nip19.uriToRoute( + "nostr:nevent1qqsyxq8v0730nz38dupnjzp5jegkyz4gu2ptwcps4v32hjnrap0q0espz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqyn3t9gj", + ) - assertEquals(Nip19.Type.EVENT, result?.type) - assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result?.hex) - assertEquals("wss://relay.damus.io", result?.relay) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) - assertEquals(1, result?.kind) - } + assertEquals(Nip19.Type.EVENT, result?.type) + assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result?.hex) + assertEquals("wss://relay.damus.io", result?.relay) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) + assertEquals(1, result?.kind) + } - @Test - fun nEventFormatter() { - val nevent = - Nip19.createNEvent( - "f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", - null, - null, - null, - ) - assertEquals("nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy", nevent) - } + @Test + fun nEventFormatter() { + val nevent = + Nip19.createNEvent( + "f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", + null, + null, + null, + ) + assertEquals("nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy", nevent) + } - @Test - fun nEventFormatterWithExtraInfo() { - val nevent = - Nip19.createNEvent( - "f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", - "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194", - 40, - null, - ) - assertEquals( - "nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhqzypl62m6ad932k83u6sjwwkxrqq4cve0hkrvdem5la83g34m4rtqegqcyqqqqq2qh26va4", - nevent, - ) - } + @Test + fun nEventFormatterWithExtraInfo() { + val nevent = + Nip19.createNEvent( + "f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", + "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194", + 40, + null, + ) + assertEquals( + "nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhqzypl62m6ad932k83u6sjwwkxrqq4cve0hkrvdem5la83g34m4rtqegqcyqqqqq2qh26va4", + nevent, + ) + } - @Test - fun nEventFormatterWithFullInfo() { - val nevent = - Nip19.createNEvent( - "1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", - "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", - 1, - "wss://relay.damus.io", - ) - assertEquals( - "nevent1qqsplpuwsgrrmq85rfup6w3w777rxmcmadu590emfx6z4msj2844euqpz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqye3a70w", - nevent, - ) - } + @Test + fun nEventFormatterWithFullInfo() { + val nevent = + Nip19.createNEvent( + "1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + 1, + "wss://relay.damus.io", + ) + assertEquals( + "nevent1qqsplpuwsgrrmq85rfup6w3w777rxmcmadu590emfx6z4msj2844euqpz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqye3a70w", + nevent, + ) + } } diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Nip19Test.kt b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Nip19Test.kt index 6adccc3e5..f807527a6 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Nip19Test.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Nip19Test.kt @@ -25,77 +25,77 @@ import org.junit.Ignore import org.junit.Test class Nip19Test { - @Test() - fun uri_to_route_null() { - val actual = Nip19.uriToRoute(null) + @Test() + fun uri_to_route_null() { + val actual = Nip19.uriToRoute(null) - Assert.assertEquals(null, actual) - } + Assert.assertEquals(null, actual) + } - @Test() - fun uri_to_route_unknown() { - val actual = Nip19.uriToRoute("nostr:unknown") + @Test() + fun uri_to_route_unknown() { + val actual = Nip19.uriToRoute("nostr:unknown") - Assert.assertEquals(null, actual) - } + Assert.assertEquals(null, actual) + } - @Test() - fun uri_to_route_npub() { - val actual = - Nip19.uriToRoute("nostr:npub1hv7k2s755n697sptva8vkh9jz40lzfzklnwj6ekewfmxp5crwdjs27007y") + @Test() + fun uri_to_route_npub() { + val actual = + Nip19.uriToRoute("nostr:npub1hv7k2s755n697sptva8vkh9jz40lzfzklnwj6ekewfmxp5crwdjs27007y") - Assert.assertEquals(Nip19.Type.USER, actual?.type) - Assert.assertEquals( - "bb3d6543d4a4f45f402b674ecb5cb2155ff12456fcdd2d66d9727660d3037365", - actual?.hex, - ) - } + Assert.assertEquals(Nip19.Type.USER, actual?.type) + Assert.assertEquals( + "bb3d6543d4a4f45f402b674ecb5cb2155ff12456fcdd2d66d9727660d3037365", + actual?.hex, + ) + } - @Test() - fun uri_to_route_note() { - val actual = - Nip19.uriToRoute("nostr:note1stqea6wmwezg9x6yyr6qkukw95ewtdukyaztycws65l8wppjmtpscawevv") + @Test() + fun uri_to_route_note() { + val actual = + Nip19.uriToRoute("nostr:note1stqea6wmwezg9x6yyr6qkukw95ewtdukyaztycws65l8wppjmtpscawevv") - Assert.assertEquals(Nip19.Type.NOTE, actual?.type) - Assert.assertEquals( - "82c19ee9db7644829b4420f40b72ce2d32e5b7962744b261d0d53e770432dac3", - actual?.hex, - ) - } + Assert.assertEquals(Nip19.Type.NOTE, actual?.type) + Assert.assertEquals( + "82c19ee9db7644829b4420f40b72ce2d32e5b7962744b261d0d53e770432dac3", + actual?.hex, + ) + } - @Ignore("Test not implemented yet") - @Test() - fun uri_to_route_nprofile() { - val actual = Nip19.uriToRoute("nostr:nprofile") + @Ignore("Test not implemented yet") + @Test() + fun uri_to_route_nprofile() { + val actual = Nip19.uriToRoute("nostr:nprofile") - Assert.assertEquals(Nip19.Type.USER, actual?.type) - Assert.assertEquals("*", actual?.hex) - } + Assert.assertEquals(Nip19.Type.USER, actual?.type) + Assert.assertEquals("*", actual?.hex) + } - @Ignore("Test not implemented yet") - @Test() - fun uri_to_route_nevent() { - val actual = Nip19.uriToRoute("nostr:nevent") + @Ignore("Test not implemented yet") + @Test() + fun uri_to_route_nevent() { + val actual = Nip19.uriToRoute("nostr:nevent") - Assert.assertEquals(Nip19.Type.USER, actual?.type) - Assert.assertEquals("*", actual?.hex) - } + Assert.assertEquals(Nip19.Type.USER, actual?.type) + Assert.assertEquals("*", actual?.hex) + } - @Ignore("Test not implemented yet") - @Test() - fun uri_to_route_nrelay() { - val actual = Nip19.uriToRoute("nostr:nrelay") + @Ignore("Test not implemented yet") + @Test() + fun uri_to_route_nrelay() { + val actual = Nip19.uriToRoute("nostr:nrelay") - Assert.assertEquals(Nip19.Type.RELAY, actual?.type) - Assert.assertEquals("*", actual?.hex) - } + Assert.assertEquals(Nip19.Type.RELAY, actual?.type) + Assert.assertEquals("*", actual?.hex) + } - @Ignore("Test not implemented yet") - @Test() - fun uri_to_route_naddr() { - val actual = Nip19.uriToRoute("nostr:naddr") + @Ignore("Test not implemented yet") + @Test() + fun uri_to_route_naddr() { + val actual = Nip19.uriToRoute("nostr:naddr") - Assert.assertEquals(Nip19.Type.ADDRESS, actual?.type) - Assert.assertEquals("*", actual?.hex) - } + Assert.assertEquals(Nip19.Type.ADDRESS, actual?.type) + Assert.assertEquals("*", actual?.hex) + } } diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/TlvIntegerTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/TlvIntegerTest.kt index 6412fe556..82e0f6011 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/TlvIntegerTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/TlvIntegerTest.kt @@ -25,33 +25,33 @@ import org.junit.Assert.assertEquals import org.junit.Test class TlvIntegerTest { - fun to_int_32_length_smaller_than_4() { - Assert.assertNull(byteArrayOfInts(1, 2, 3).toInt32()) - } + fun to_int_32_length_smaller_than_4() { + Assert.assertNull(byteArrayOfInts(1, 2, 3).toInt32()) + } - fun to_int_32_length_bigger_than_4() { - Assert.assertNull(byteArrayOfInts(1, 2, 3, 4, 5).toInt32()) - } + fun to_int_32_length_bigger_than_4() { + Assert.assertNull(byteArrayOfInts(1, 2, 3, 4, 5).toInt32()) + } - @Test() - fun to_int_32_length_4() { - val actual = byteArrayOfInts(1, 2, 3, 4).toInt32() + @Test() + fun to_int_32_length_4() { + val actual = byteArrayOfInts(1, 2, 3, 4).toInt32() - assertEquals(16909060, actual) - } + assertEquals(16909060, actual) + } - @Test() - fun backAndForth() { - assertEquals(234, 234.to32BitByteArray().toInt32()) - assertEquals(1, 1.to32BitByteArray().toInt32()) - assertEquals(0, 0.to32BitByteArray().toInt32()) - assertEquals(1000, 1000.to32BitByteArray().toInt32()) + @Test() + fun backAndForth() { + assertEquals(234, 234.to32BitByteArray().toInt32()) + assertEquals(1, 1.to32BitByteArray().toInt32()) + assertEquals(0, 0.to32BitByteArray().toInt32()) + assertEquals(1000, 1000.to32BitByteArray().toInt32()) - assertEquals(-234, (-234).to32BitByteArray().toInt32()) - assertEquals(-1, (-1).to32BitByteArray().toInt32()) - assertEquals(-0, (-0).to32BitByteArray().toInt32()) - assertEquals(-1000, (-1000).to32BitByteArray().toInt32()) - } + assertEquals(-234, (-234).to32BitByteArray().toInt32()) + assertEquals(-1, (-1).to32BitByteArray().toInt32()) + assertEquals(-0, (-0).to32BitByteArray().toInt32()) + assertEquals(-1000, (-1000).to32BitByteArray().toInt32()) + } - private fun byteArrayOfInts(vararg ints: Int) = ByteArray(ints.size) { pos -> ints[pos].toByte() } + private fun byteArrayOfInts(vararg ints: Int) = ByteArray(ints.size) { pos -> ints[pos].toByte() } }