Compare commits

...
286 Commits
Author SHA1 Message Date
Vitor Pamplona 0854bd34ff v0.86.2 2024-04-05 14:08:27 -04:00
Vitor Pamplona 1738a775ef Fixes the lack of the Comparator interface for the Deletion Index. 2024-04-05 14:05:04 -04:00
Vitor Pamplona a6953872ea Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-05 11:39:34 -04:00
Vitor Pamplona d48714456c Fixes check for Deleted Addressable events. 2024-04-05 11:34:50 -04:00
Vitor PamplonaandGitHub c25aad482b Merge pull request #829 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-05 11:23:19 -04:00
Crowdin Bot cbebfd263b New Crowdin translations by GitHub Action 2024-04-05 15:21:28 +00:00
Vitor Pamplona 89dbe82191 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-05 11:19:23 -04:00
Vitor Pamplona 7bb72d0c2d Adds a Deletion event cache. 2024-04-05 11:19:15 -04:00
Vitor PamplonaandGitHub 9be4895080 Merge pull request #828 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-05 08:19:17 -04:00
Crowdin Bot 6250db01d1 New Crowdin translations by GitHub Action 2024-04-05 12:05:47 +00:00
Vitor PamplonaandGitHub 48f9045f1b Merge pull request #825 from greenart7c3/onion_url
add ws:// if not present in .onion urls
2024-04-05 08:03:52 -04:00
Vitor PamplonaandGitHub 818ca7e39e Merge pull request #826 from greenart7c3/draft_decryption_error
fix draft decryption error
2024-04-05 08:03:39 -04:00
Vitor PamplonaandGitHub e8675b8e45 Merge pull request #827 from davotoula/update_translations
added  translations for CS, DE, SV
2024-04-05 08:03:11 -04:00
David Kaspar cef7e17447 added translations for CS, DE, SV 2024-04-05 11:38:51 +01:00
greenart7c3 6b15a0db8e fix draft decryption error 2024-04-05 05:41:03 -03:00
greenart7c3 50c5845a11 add ws:// if not present in .onion urls 2024-04-05 05:30:51 -03:00
Vitor Pamplona 1b7ba3de01 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-04 23:06:08 -04:00
Vitor Pamplona 712063f5d2 Avoids double notifications. 2024-04-04 23:05:58 -04:00
Vitor Pamplona d92f23e274 LargeCache inner map should be private 2024-04-04 23:05:35 -04:00
Vitor PamplonaandGitHub 3b7f530c0b Merge pull request #824 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-04 18:15:14 -04:00
Crowdin Bot 623a8d377c New Crowdin translations by GitHub Action 2024-04-04 22:08:10 +00:00
Vitor Pamplona 79489d0b07 v0.86.1 2024-04-04 18:06:36 -04:00
Vitor Pamplona 827512b225 Avoids circular rendering of Drafts. 2024-04-04 18:06:27 -04:00
Vitor Pamplona 6acfadeb9b Reduces cache for expandable texts. 2024-04-04 18:06:09 -04:00
Vitor Pamplona e159af2cd7 v0.86.0 2024-04-04 17:21:03 -04:00
Vitor Pamplona 89c2e9d2e0 Changes precision of Zap Splits to 1% steps 2024-04-04 17:19:03 -04:00
Vitor Pamplona 06f6ab6719 Adds button to load Zap Splits from the cited users in the text 2024-04-04 17:18:42 -04:00
Vitor Pamplona 98c48e8b6b Fixes contract violation when sorting users. 2024-04-04 16:54:14 -04:00
Vitor Pamplona 25cde455d8 removes chat bubble animation when size changes 2024-04-04 15:58:19 -04:00
Vitor Pamplona ef0fdf553c Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-04 15:24:59 -04:00
Vitor Pamplona 719b950272 Fixing the use of filters that didn't discriminate the relay type setup 2024-04-04 15:24:04 -04:00
Vitor PamplonaandGitHub 2d02fad6b9 Merge pull request #823 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-04 15:16:49 -04:00
Crowdin Bot a39db5bf7b New Crowdin translations by GitHub Action 2024-04-04 19:01:46 +00:00
Vitor Pamplona 7fd37367fc refactoring of cache methods in GiftWraps 2024-04-04 14:59:49 -04:00
Vitor Pamplona e1c134830e Avoiding feed jitter when pressing the notification button twice. 2024-04-04 10:03:28 -04:00
Vitor Pamplona 621d1c7731 Migrating Refreshable feeds to the reusable box 2024-04-04 09:41:51 -04:00
Vitor Pamplona 7475143506 Continues the migration from LiveData to Flow 2024-04-04 08:56:15 -04:00
Vitor PamplonaandGitHub 2509d639bd Merge pull request #822 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-03 13:07:08 -04:00
Crowdin Bot 85dd5cf698 New Crowdin translations by GitHub Action 2024-04-03 17:02:53 +00:00
Vitor Pamplona 274ce6ad77 Fixing the spacing for channels 2024-04-03 12:26:57 -04:00
Vitor Pamplona 0e8d2fc33a adds save when closing the screen on new posts. 2024-04-03 10:20:19 -04:00
Vitor Pamplona b88723b68b Fixes wrong refactoring 2024-04-03 10:20:05 -04:00
Vitor Pamplona 638dba770d Moves the creation of Draft Notes to the IO Thread 2024-04-02 17:49:52 -04:00
Vitor Pamplona 4d7de6bc19 No need to switch to IO this early in the process 2024-04-02 16:08:59 -04:00
Vitor Pamplona fbf676bdb2 Reduces recomposition of the hash verification 2024-04-02 16:08:38 -04:00
Vitor PamplonaandGitHub 793780f02c Merge pull request #821 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-02 10:02:21 -04:00
Crowdin Bot adf31ed115 New Crowdin translations by GitHub Action 2024-04-02 13:54:47 +00:00
Vitor Pamplona 96e434bfcf Fixes the lack of following mark on chats 2024-04-02 09:52:52 -04:00
Vitor Pamplona c7563c938d Minor refactoring 2024-04-02 09:42:01 -04:00
Vitor Pamplona 4380393c5b No need to update UUID anymore. After deletion the draft can just be updated. 2024-04-02 09:23:08 -04:00
Vitor Pamplona 8125a7dabb Correctly moving the Reply line out of the renderer. 2024-04-01 18:32:36 -04:00
Vitor Pamplona e898d58239 Finishing the rendering of card notes for DMs and live chats 2024-04-01 17:07:27 -04:00
Vitor Pamplona 29a43f82e6 Fixes lack of blurhash in some videos 2024-04-01 15:26:02 -04:00
Vitor Pamplona 469b9c6acb Only changes the keep playing status if different 2024-04-01 15:25:49 -04:00
Vitor Pamplona 38d1bf9aec Updates firebase 2024-04-01 15:01:39 -04:00
Vitor Pamplona 18b57b8ac8 Reactivating hold to edit draft. 2024-04-01 14:14:58 -04:00
Vitor PamplonaandGitHub 7fc43c96d6 Merge pull request #819 from jiftechnify/meta-parser-benchmark
Add benchmark for MetaTagsParser
2024-03-29 17:46:35 -04:00
Vitor PamplonaandGitHub fc7d3a9519 Merge pull request #820 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-29 17:46:12 -04:00
Crowdin Bot 1667a78bb9 New Crowdin translations by GitHub Action 2024-03-29 21:44:07 +00:00
Vitor Pamplona 5fbd6c25d0 Fixes layout of the reply row in chats. 2024-03-29 17:41:13 -04:00
Vitor Pamplona d079d511e8 Fixes markers for DMs 2024-03-29 17:40:51 -04:00
Vitor Pamplona 6e1418cd54 - Adds a Draft Screen
- Migrating drafts to new architecture where the Draft Event is sent to the screen instead of the inner event.
- Fixes lots of deletion and indexing bugs
2024-03-29 17:38:31 -04:00
Vitor Pamplona cd84c07fcc Adds k-tag to the Deletion events. 2024-03-28 19:02:23 -04:00
jiftechnify 6eb2fbfa2f reduce creation of StringBuffers in meta tags parsing 2024-03-27 22:54:06 +09:00
jiftechnify fc6f460063 fix contact link in relay information dialog 2024-03-27 22:52:16 +09:00
jiftechnify 442cdfdf2a move MetaTagsParser to common module 2024-03-27 21:19:52 +09:00
Vitor Pamplona 539433014e Fixes Notification for Follows now showing Zaps 2024-03-26 21:05:24 -04:00
Vitor Pamplona d3f54a7082 Removes the Draft dependency for signer implementations 2024-03-26 16:56:58 -04:00
Vitor PamplonaandGitHub d3a0ae743a Merge pull request #749 from greenart7c3/main
save a draft while you are typing the post
2024-03-26 15:24:59 -04:00
Vitor Pamplona 6690d5391c Fixes controller comparison for keep playing 2024-03-26 15:06:55 -04:00
Vitor PamplonaandGitHub d61d684a27 Merge pull request #818 from jiftechnify/url-preview
Fix garbled URL preview for non UTF-8 HTML, with optimization
2024-03-26 13:40:15 -04:00
jiftechnify 4f84fad0cd remove jsoup version 2024-03-27 01:03:29 +09:00
Vitor Pamplona cd32c4db72 Makes a cache for Media Items 2024-03-26 11:56:10 -04:00
jiftechnify a71ce69cab support tags in quoted attribute value 2024-03-27 00:47:35 +09:00
Vitor Pamplona b45f9bd460 Avoids launching coroutines that were just launched. 2024-03-26 09:09:35 -04:00
Vitor Pamplona 75ac17b57d increases the time to notify to 15 minutes 2024-03-26 08:35:15 -04:00
Vitor Pamplona fa4745038f Uses the cached pool instead of the scheduled pool for translation services. 2024-03-26 08:34:45 -04:00
Vitor Pamplona 0182011487 Fixes NPE with the cached state. 2024-03-25 20:11:23 -04:00
Vitor Pamplona 37fdb8b2aa Removes unecessary parameters in the UserDisplay function 2024-03-25 17:58:54 -04:00
Vitor Pamplona 9ade18e1c1 Adds a bot field to the user info 2024-03-25 17:57:27 -04:00
Vitor Pamplona aff6588791 Removing unnecessary layouts from the top bar 2024-03-25 17:57:11 -04:00
Vitor Pamplona 4274d2ddbd Blocks previews from being too big 2024-03-25 17:54:41 -04:00
Vitor Pamplona 63600f4782 Removes time measuring log 2024-03-25 16:45:21 -04:00
Vitor Pamplona 5deb9af477 Avoids publishing with two equal hashtags. 2024-03-25 16:41:52 -04:00
Vitor Pamplona 38e701a363 Moves the Following Vector to a native composable. 2024-03-25 16:41:38 -04:00
greenart7c3andGitHub 6e6fa66c53 Merge branch 'main' into main 2024-03-25 17:34:22 -03:00
Vitor Pamplona 1f60d39cbf turning hashtag icons into programmable vectors 2024-03-25 16:02:36 -04:00
Vitor Pamplona 8ba474e79a Adjusting some of the icon sizes on the galleries 2024-03-25 14:23:36 -04:00
Vitor Pamplona 1123b3b3c1 Removes the need to observe author changes to event. 2024-03-25 13:51:08 -04:00
jiftechnify bffb9f3778 remove jsoup from dependencies 2024-03-26 02:42:15 +09:00
jiftechnify e11961695f parse HTML as little as possible 2024-03-26 02:31:06 +09:00
jiftechnify 042579ddfb remove unnecessary items from mata-tag canditates for getting URL info 2024-03-25 23:53:47 +09:00
jiftechnify d0aa7430ca optimize HTML charset detection 2024-03-25 23:53:47 +09:00
jiftechnify 3434c31487 fix garbled URL preview for non-UTF-8 HTML 2024-03-25 23:53:43 +09:00
Vitor Pamplona 7eefbee0e3 Realigning the reaction icons and texts between main feed and video feed. 2024-03-25 10:44:10 -04:00
greenart7c3 ed4d867622 save draft when toogling nip4 and nip 44 2024-03-25 07:48:30 -03:00
greenart7c3 27db2b91ab save draft when changing options from polls and classifieds 2024-03-25 07:32:31 -03:00
greenart7c3andGitHub a2316b6ed0 Merge branch 'main' into main 2024-03-25 07:08:08 -03:00
Vitor Pamplona a26b5490b2 updates compose 2024-03-24 18:53:11 -04:00
Vitor Pamplona 9990f458bf Moves Channel to LargeCache
Fixes filter for Chat Messages in discovery
2024-03-23 17:21:02 -04:00
Vitor Pamplona 0b40b6d1d8 Sorts chats by the latest message in Discovery 2024-03-23 17:19:54 -04:00
Vitor Pamplona 5a1c9f5a4a Ranks communities by the last post in each community in the discovery screen 2024-03-23 17:18:27 -04:00
Vitor Pamplona eb6d31cf2b Better filter to bring public chat messages and avoid public chat creation and metadata updates 2024-03-23 17:16:43 -04:00
Vitor Pamplona 2c5e07de87 removes the use of data classes to speed up comparisons. 2024-03-23 17:15:38 -04:00
Vitor Pamplona 8d70664cc1 Fixes the list selection on the Discovery page. 2024-03-23 17:15:19 -04:00
Vitor PamplonaandGitHub 102a34afca Merge pull request #814 from tyiu/fix-nip44-encrypt-decrypt-test
Fix NIP-44 encryptDecryptTest to decrypt with swapped private and public keys to follow NIP-44 documentation
2024-03-23 08:20:24 -04:00
Terry Yiu c843e07709 Fix Kotlin encryptDecryptTest to decrypt with swapped private and public keys to follow NIP-44 documentation 2024-03-22 23:51:57 -04:00
Vitor PamplonaandGitHub 3faa4983f0 Merge pull request #813 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-22 14:43:15 -04:00
Crowdin Bot 6f9dbbb8c3 New Crowdin translations by GitHub Action 2024-03-22 18:30:49 +00:00
greenart7c3andGitHub 62a114b981 Merge branch 'main' into main 2024-03-22 08:48:47 -03:00
Vitor Pamplona ff5612203d Fixes the width of muted messages on chat feeds. 2024-03-21 17:32:29 -04:00
Vitor Pamplona 6f0e4f1f19 Makes hidden cards full width on the discovery feed 2024-03-21 16:55:11 -04:00
Vitor Pamplona 8f5820f46d Fixes the reply finder for the reply row on compose 2024-03-21 16:54:50 -04:00
Vitor Pamplona 3086d3957d Moves from habla to njump 2024-03-21 09:27:57 -04:00
Vitor Pamplona c00319812a Reverts to make sure the logged in user's post do not appear in notifications. 2024-03-21 09:25:26 -04:00
Vitor Pamplona 67202c32d4 Migrates channel notes to LargeCache 2024-03-21 08:51:23 -04:00
Vitor Pamplona 64909bfb32 Fixes Chat preview images when no image has been set. 2024-03-21 08:50:37 -04:00
Vitor Pamplona c1756b75a7 Fixes double mention to Community headers when seeing a reply to a community post. 2024-03-21 08:42:14 -04:00
Vitor Pamplona bdf41f53fb Removes logs 2024-03-21 08:41:29 -04:00
Vitor Pamplona 3b982b8962 Fixes the centralizing of the counter after the list of participants in a live event. 2024-03-20 17:02:13 -04:00
Vitor Pamplona fd39ff24e1 Adds a space after the Channel header in the reply rows 2024-03-20 16:48:24 -04:00
Vitor Pamplona e659153730 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-03-20 16:48:04 -04:00
Vitor Pamplona f3c4b3255b Fixes missing context in some replies to blog posts. 2024-03-20 16:47:53 -04:00
Vitor PamplonaandGitHub f35f54166d Merge pull request #812 from greenart7c3/fix-amber-deep-sleep
add a lifeCycleOwner to register external signer on resume
2024-03-20 16:44:30 -04:00
greenart7c3 538c0493ed add a lifeCycleOwner to register external signer on resume 2024-03-20 17:20:35 -03:00
Vitor Pamplona ceca149eb7 Doesn't show edits of blog posts in the User's Profile 2024-03-20 16:03:13 -04:00
Vitor Pamplona 97cdc0bc7a Fixes layout issues of LongForm content when the image is not present. 2024-03-20 16:02:49 -04:00
Vitor Pamplona f2a8e51b20 Refactors horizontal dividers 2024-03-20 15:18:00 -04:00
greenart7c3andGitHub 8d7a3f4d5e Merge branch 'main' into main 2024-03-20 15:13:33 -03:00
greenart7c3 c087042f7d Merge branch 'main' into main 2024-03-20 15:10:32 -03:00
greenart7c3 644d2fc2bb add generic draft event to default permissions 2024-03-20 14:56:29 -03:00
Vitor Pamplona b819f24790 Reduces the space between chat bubbles. 2024-03-20 13:55:47 -04:00
greenart7c3 ea33cc77ed add edit draft in the dropdown menu and the long press popup 2024-03-20 14:50:53 -03:00
Vitor Pamplona 27fbf1c1ed Activates the chat simplified mode for the simplified setting. 2024-03-20 13:48:04 -04:00
Vitor Pamplona 3226e4e024 1. Refactors the use of dividers out of components
2. Refactors composables to load events, check hidden and check report
2024-03-20 13:47:47 -04:00
greenart7c3 090b643f43 add edit draft in the dropdown menu and the long press popup 2024-03-20 14:45:45 -03:00
Vitor Pamplona 943a4260ff Testing reduced line height 2024-03-20 11:49:12 -04:00
Vitor Pamplona 9867ac1689 Fixes videos not being able to seekTo the zero position. 2024-03-20 11:35:22 -04:00
Vitor Pamplona d26de39749 Fixes lack of zap amount refresh after zapping a note. 2024-03-20 11:35:03 -04:00
Vitor Pamplona 1072b7a5c5 Fixes following by geotags 2024-03-20 11:34:40 -04:00
greenart7c3 220ce75f19 add validations to draft notes 2024-03-20 10:25:41 -03:00
greenart7c3 bc180ae210 fix draft delete not working 2024-03-20 09:31:31 -03:00
greenart7c3 5910ef199f fix build after merge 2024-03-20 07:22:55 -03:00
greenart7c3andGitHub 499939ed68 Merge branch 'main' into main 2024-03-20 07:06:24 -03:00
Vitor Pamplona d15beb2ae5 Fixes too strict timing constraints for new posts. 2024-03-19 19:55:41 -04:00
Vitor Pamplona 87fafd9451 Reducing the amount of co-routines being launched in each LiveData update. 2024-03-19 19:30:49 -04:00
Vitor Pamplona bfbfcb6ed2 Avoids triggering an error when decoding invalid hexes. 2024-03-19 19:29:50 -04:00
Vitor Pamplona ffc0a7c6ed Avoids showing error machine-level details when paying for zaps on external wallets 2024-03-19 17:44:51 -04:00
Vitor Pamplona 77789379c0 Minimizes memory use to calculate zaps. 2024-03-19 17:44:30 -04:00
Vitor Pamplona a0a10b2cd0 Fixes some warnings 2024-03-19 17:34:26 -04:00
Vitor Pamplona d59b98089a Aligns the BOM between implementation and tests. 2024-03-19 17:33:54 -04:00
Vitor Pamplona 160e52bd91 Fixes a bug with the latest version of jackson 2024-03-19 17:33:34 -04:00
Vitor Pamplona d7e1a80465 Might be needed for those importing the AAR. 2024-03-19 17:33:09 -04:00
Vitor Pamplona a5496445d2 Simple refactoring. 2024-03-19 14:49:56 -04:00
Vitor Pamplona b75c3e3031 Refactoring caching systems for the Compose layer. 2024-03-19 14:17:15 -04:00
Vitor Pamplona e9830c61aa Removes unused addMargin param. 2024-03-19 11:50:01 -04:00
Vitor Pamplona 25f28d38d5 Only shows OTS to the respective Edit 2024-03-19 11:49:50 -04:00
Vitor Pamplona 410d6bd690 Fixes binary payloads in the search results. 2024-03-19 11:49:03 -04:00
Vitor Pamplona 99270662ee Optimizing the performance of Highlight rendering 2024-03-19 10:30:21 -04:00
Vitor Pamplona e40cd8d932 After clearing memory, only trigger liveData when it actually changed. 2024-03-19 10:07:21 -04:00
Vitor Pamplona 9858d98722 Avoids double request to show anyway when the user has hidden a post and ALSO the user's followers have reported the post. 2024-03-19 10:06:53 -04:00
Vitor Pamplona 6ab5488852 Starts with complete to avoid user shock 2024-03-19 10:06:16 -04:00
Vitor Pamplona 9651563b16 Fixing the test cases for nip96 2024-03-18 19:21:16 -04:00
Vitor Pamplona 9012bdad27 Fixes some of the testing cases 2024-03-18 18:19:05 -04:00
Vitor PamplonaandGitHub ef859c85a4 Merge pull request #810 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-18 18:09:18 -04:00
Crowdin Bot 959b2ac4e4 New Crowdin translations by GitHub Action 2024-03-18 22:08:29 +00:00
Vitor Pamplona 55a6f8829e Adds a large benchmark test for duplicate events. 2024-03-18 18:06:37 -04:00
greenart7c3 940fa2ee8d add debouncer 2024-03-18 17:47:15 -03:00
Vitor Pamplona 92d9c682f8 This should be better than launching a new thread after LaunchedEffect already lauched hers. 2024-03-18 16:38:40 -04:00
Vitor Pamplona f5a1007f88 Seeing if the performance improves when the factory becomes just another lambda 2024-03-18 16:23:00 -04:00
Vitor Pamplona d83acab84b - Solves infinite Quotation issue.
- Restructures NoteCompose for performance
- Restructures markAsRead to minimize threading cost.
2024-03-18 15:26:49 -04:00
greenart7c3 f6e5af3e98 support for dms, streams and communities 2024-03-18 16:18:12 -03:00
Vitor Pamplona ac56d02b9d Updates AGP to 8.3.1 2024-03-18 15:10:39 -04:00
Vitor PamplonaandGitHub 46a120450e Merge pull request #809 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-18 15:10:16 -04:00
Crowdin Bot 33e0107d3b New Crowdin translations by GitHub Action 2024-03-18 19:08:49 +00:00
greenart7c3 8b3e3e7af8 support Classifieds 2024-03-18 14:51:50 -03:00
greenart7c3 84faa7557e add support for polls 2024-03-18 14:37:51 -03:00
greenart7c3 f7ab925b1d fix delete on close 2024-03-18 14:21:58 -03:00
greenart7c3 204eaa4606 fix crash when loading draft from channels or lives 2024-03-18 11:16:13 -03:00
greenart7c3 1c249eed20 load post from draft 2024-03-18 10:56:36 -03:00
greenart7c3 3cc32ecd9a create a loadFromDraft method 2024-03-18 09:09:00 -03:00
greenart7c3 0a20d5484b show drafts as soon as its created 2024-03-18 08:39:55 -03:00
greenart7c3 6e4f1269dd add todo 2024-03-18 07:43:19 -03:00
greenart7c3 eba0837e52 fix draftnotes filter 2024-03-18 07:20:30 -03:00
greenart7c3andGitHub d682518ddb Merge branch 'main' into main 2024-03-18 07:07:32 -03:00
Vitor Pamplona 8c43f3492b Fixes some imports for benchmarks 2024-03-16 11:08:31 -04:00
Vitor PamplonaandGitHub 688abee205 Merge pull request #808 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-16 10:35:35 -04:00
Crowdin Bot d96ea0f8a2 New Crowdin translations by GitHub Action 2024-03-16 14:32:46 +00:00
Vitor Pamplona b19b60128f Updates to compose and jackson 2024-03-16 10:29:56 -04:00
Vitor Pamplona 78be5a9ecc Reorganizing classes in the commons lib 2024-03-15 21:28:13 -04:00
Vitor Pamplona 36d2a3e42c Isolating the LargeCache forEach method to allow quick time measurements on filters. 2024-03-15 21:14:06 -04:00
Vitor Pamplona fa7c2efaab Fixing size and alignment of the text when the live stream video is not present. 2024-03-15 21:13:15 -04:00
Vitor Pamplona 6edc634b82 Only download video, image and audio files in NIP-94 2024-03-15 19:55:14 -04:00
Vitor Pamplona 6bdf3e2625 Restructures Data Access filters and LocalCache to use a ConcurrentSkipList instead of ConcurrentHashMap 2024-03-15 19:32:43 -04:00
Vitor Pamplona 2d17200f03 Adds two additional helper methods to ATags and Classifieds. 2024-03-15 19:29:27 -04:00
Vitor Pamplona c74176684f Preparing to refactor the Relay classes. 2024-03-15 19:28:59 -04:00
Vitor Pamplona 1b9742597a Keeps cache size in 10 to account for recompositions 2024-03-15 19:26:34 -04:00
greenart7c3 f949d5624e add draft support for public chat screen 2024-03-15 13:35:54 -03:00
greenart7c3 2bc2890d08 add draft support for other event kinds 2024-03-15 12:35:14 -03:00
greenart7c3 f3f8bc1b65 show draft in the simplified view 2024-03-15 10:45:53 -03:00
greenart7c3 99e9514d6c remove drafts from shared prefs 2024-03-15 10:38:22 -03:00
greenart7c3andGitHub 8ade5b7e5f Merge branch 'main' into main 2024-03-15 09:18:38 -03:00
greenart7c3 e292affbe6 add draft in the home feed 2024-03-15 09:08:35 -03:00
Vitor Pamplona fbf4f6dd08 Small Refactoring 2024-03-14 14:09:13 -04:00
Vitor Pamplona b2508b3db5 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-03-14 11:55:32 -04:00
Vitor Pamplona 1abdb42552 Sets default to the simplified view. 2024-03-14 11:55:24 -04:00
Vitor Pamplona 2c54ba1a92 Corrects the starting point to show reactions on messages. 2024-03-14 11:55:13 -04:00
Vitor Pamplona f941397cc4 Fixes tickmarks on dropdowns 2024-03-14 11:54:53 -04:00
Vitor PamplonaandGitHub 69e31be37f Merge pull request #807 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-14 11:14:42 -04:00
Crowdin Bot 86c328c8a7 New Crowdin translations by GitHub Action 2024-03-14 15:13:23 +00:00
Vitor Pamplona 837865a699 Adds simplified views to the video and chat feeds. 2024-03-14 11:11:38 -04:00
Vitor Pamplona 1014e29289 Fixes some contract issues when follow and names are the same. 2024-03-14 09:54:43 -04:00
Vitor PamplonaandGitHub c1c5bc2039 Merge pull request #806 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-13 18:03:06 -04:00
Crowdin Bot 2d6aab954f New Crowdin translations by GitHub Action 2024-03-13 22:00:20 +00:00
Vitor Pamplona eadb28321c Adds missing classes to support WebServer conections in the Video Playback 2024-03-13 17:58:26 -04:00
Vitor Pamplona 99850573f7 Reduces video cache from 10 to 4 videos. 2024-03-13 17:58:01 -04:00
Vitor Pamplona 45974fb09b change the isOnlineCheck to prepare for nostr nests websocket-based streaming. 2024-03-13 17:57:31 -04:00
Vitor Pamplona f2dc2ef0d0 Uses the new default factory instead of the 3 separate methods. 2024-03-13 17:57:08 -04:00
Vitor Pamplona 8641bd36c3 Fixes Scheduled Tag in LiveStreams 2024-03-13 17:28:30 -04:00
Vitor Pamplona a034d2f96e Updating Jackson 2024-03-13 14:02:50 -04:00
greenart7c3 fa5d992010 add draftevent class 2024-03-13 13:24:22 -03:00
greenart7c3andGitHub 0d47e8b823 Merge branch 'main' into main 2024-03-13 11:24:12 -03:00
Vitor Pamplona e289730be5 Fixes the cut off when having massive uncut string chars 2024-03-13 09:42:38 -04:00
greenart7c3andGitHub 91b0d5b7fc Merge branch 'main' into main 2024-03-13 07:17:52 -03:00
Vitor Pamplona 0dbd58d8d7 Fixing some of the old display name structure 2024-03-12 17:51:27 -04:00
Vitor Pamplona e094b56b72 Deleting old Settings local db 2024-03-12 17:18:06 -04:00
Vitor Pamplona efb9d9268b Avoids parsing metadata events several times due to the json parser stopping coroutines. 2024-03-12 17:16:50 -04:00
Vitor Pamplona 82c4bf89df Avoids serializing temporary fields. 2024-03-12 16:32:43 -04:00
Vitor Pamplona 4a069fa73b Simplifies if a chatroom sender intersects with the follow list. 2024-03-12 16:21:18 -04:00
Vitor Pamplona 1cda191035 Moves the play button to the profile page. 2024-03-12 15:51:02 -04:00
Vitor Pamplona e5d4b2a145 Adds a simplified version to the UI. 2024-03-12 15:50:15 -04:00
Vitor PamplonaandGitHub 6ff5787e60 Merge pull request #805 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-12 11:23:20 -04:00
Crowdin Bot ffb73b1780 New Crowdin translations by GitHub Action 2024-03-12 15:14:40 +00:00
Vitor Pamplona 9a6a857a11 Brings the ZapForwarding icon to Compose 2024-03-12 11:11:46 -04:00
Vitor Pamplona abb9edc46b Refreshing the boostrap relay list. 2024-03-12 10:29:52 -04:00
Vitor Pamplona 5d78d9b046 updates dependencies 2024-03-12 10:20:09 -04:00
Vitor Pamplona f372a256c5 formatting 2024-03-11 19:46:00 -04:00
Vitor Pamplona e2fe1538b0 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-03-11 19:12:16 -04:00
Vitor Pamplona 7560a737cd Updated hashtag icons for performance 2024-03-11 19:10:22 -04:00
Vitor PamplonaandGitHub 24a7812432 Merge pull request #804 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-11 15:30:34 -04:00
Crowdin Bot df99c56c4a New Crowdin translations by GitHub Action 2024-03-11 18:38:52 +00:00
Vitor Pamplona e984487d9e avoids escaping errors from events inside strings 2024-03-11 13:35:56 -04:00
Vitor Pamplona afb5169ede Fitlers too many reposts out of the main feed. 2024-03-11 13:10:29 -04:00
Vitor Pamplona 64d6d6753c Centers Blank Note when post was hidden by the user. 2024-03-09 11:32:28 -05:00
Vitor Pamplona 0f69030cdf Improves Preview Utilities 2024-03-09 11:32:08 -05:00
Vitor Pamplona dd81c51fab Removes dependency of the Robohash from CryptoUtils 2024-03-09 11:30:55 -05:00
Vitor Pamplona 6cd04a7617 Fixes space after clickable user display 2024-03-08 21:51:56 -05:00
Vitor Pamplona 632bd77db3 Switches Robohash to Precompiled SVGs in order to reduce the memory burned of creating Strings with SVGs on the fly. 2024-03-08 18:41:07 -05:00
greenart7c3andGitHub 4938ba03a6 Merge branch 'main' into main 2024-03-08 12:35:23 -03:00
Vitor Pamplona e6da340879 resets the show full text flag after 20 views. 2024-03-06 15:02:59 -05:00
Vitor Pamplona c796cbd7be Fixes the fdroid flavor 2024-03-06 14:27:22 -05:00
Vitor Pamplona 2038994613 Holds the state of expanded text button between edits and translations. 2024-03-06 14:25:19 -05:00
Vitor Pamplona 5f76cdf721 Center the text of notes that couldn't be found. 2024-03-06 11:31:03 -05:00
Vitor Pamplona b8619e3b61 - User Metadata clean up upon receipt instead of in every rendering.
- Simpler/Faster UserDisplay renderings
- Removes co-routing use for Hashtags.
2024-03-06 10:32:08 -05:00
Vitor Pamplona da41fbb4c9 Updates secp256k1KmpJniAndroid 2024-03-05 19:05:03 -05:00
Vitor Pamplona 0dd553ae55 Fixes search on binary content. 2024-03-05 16:45:41 -05:00
Vitor PamplonaandGitHub 563663c131 Merge pull request #800 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-05 16:20:23 -05:00
Crowdin Bot dc3574bb6e New Crowdin translations by GitHub Action 2024-03-05 21:12:16 +00:00
Vitor Pamplona f6ffb87e20 Avoids allocating Strings for the JSON conversion when checking if a filter has changed. 2024-03-05 16:09:50 -05:00
Vitor Pamplona 3b3ca06c1c Massive refactoring to reduce the size of NoteCompose 2024-03-05 09:30:47 -05:00
Vitor Pamplona f73c9b5773 v0.85.3 2024-03-04 18:09:03 -05:00
Vitor Pamplona 5848255e72 Displaying issues and Patches in the Notification 2024-03-04 18:01:12 -05:00
Vitor Pamplona 1cf828b165 v0.85.2 2024-03-04 17:52:08 -05:00
Vitor PamplonaandGitHub 2e1f7d6587 Merge pull request #799 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-04 17:00:26 -05:00
Crowdin Bot c6977d97d3 New Crowdin translations by GitHub Action 2024-03-04 21:54:12 +00:00
Vitor Pamplona d12e886e9e Bringing GitIssues in the Notification 2024-03-04 16:52:38 -05:00
Vitor Pamplona 8299f4cfca Cleans up the fork information on new posts has been canceled or posted. 2024-03-04 16:20:59 -05:00
Vitor Pamplona 432b1e4902 Fixes forking information kept from a previous post. 2024-03-04 16:15:09 -05:00
Vitor Pamplona e9fd62dc26 Displaying correct edits on the new edit proposal 2024-03-04 15:37:29 -05:00
Vitor Pamplona f42ec3c149 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-03-04 15:25:43 -05:00
Vitor PamplonaandGitHub 43e1e0f23e Merge pull request #798 from greenart7c3/multiple-results-crash
fix crash parsing multiple results from amber
2024-03-04 15:17:52 -05:00
Vitor Pamplona 7bc393143c Adds link to the version notes from the drawer 2024-03-04 15:12:17 -05:00
greenart7c3 23718f51dd fix crash parsing multiple results from amber 2024-03-04 17:11:01 -03:00
Vitor Pamplona 8b2efecfbe Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-03-04 14:13:32 -05:00
Vitor Pamplona 9081d5a54b Adds nostr git for issue management software. 2024-03-04 14:13:23 -05:00
Vitor PamplonaandGitHub 709e3bdd3a Merge pull request #797 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-04 13:22:09 -05:00
Crowdin Bot 1f66ef717b New Crowdin translations by GitHub Action 2024-03-04 18:18:38 +00:00
Vitor Pamplona cd2b5d78a1 Don't show button to edit the post if the author of the original post is not the logged in user 2024-03-04 13:16:34 -05:00
greenart7c3 4d2c17cd1c fix draft on nip94 2024-02-28 11:16:19 -03:00
greenart7c3andGitHub 53987336c0 Merge branch 'main' into main 2024-02-28 10:59:39 -03:00
greenart7c3 cdd620987b implement reply draft 2024-01-24 09:55:31 -03:00
greenart7c3 2c086f76e2 open the post screen after editing the text 2024-01-24 08:40:55 -03:00
greenart7c3 99965ecd2d add an edit draft in the drawer 2024-01-24 08:22:35 -03:00
greenart7c3 ba7c59fdd5 draft was not saving in some places 2024-01-24 08:22:11 -03:00
greenart7c3 76a93f84c3 fix default value for the draft note 2024-01-24 08:21:04 -03:00
greenart7c3 26a1624399 save a draft while you are typing the post 2024-01-22 09:02:44 -03:00
397 changed files with 35305 additions and 15970 deletions
+4 -2
View File
@@ -241,12 +241,14 @@ dependencyResolutionManagement {
Add the dependency
```gradle
implementation('com.github.vitorpamplona.amethyst:quartz:v0.84.3')
implementation('com.github.vitorpamplona.amethyst:quartz:v0.85.1')
```
## Contributing
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) here are very welcome. Translations can be provided via [Crowdin](https://crowdin.com/project/amethyst-social)
Issues can be logged on: [https://gitworkshop.dev/repo/amethyst](https://gitworkshop.dev/repo/amethyst)
[GitHub issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) here are also welcome. Translations can be provided via [Crowdin](https://crowdin.com/project/amethyst-social)
You can also send patches through Nostr using [GitStr](https://github.com/fiatjaf/gitstr) to [this nostr address](https://patch34.pages.dev/naddr1qqyxzmt9w358jum5qyg8v6t5daezumn0wd68yvfwvdhk6qg7waehxw309ahx7um5wgkhqatz9emk2mrvdaexgetj9ehx2ap0qy2hwumn8ghj7un9d3shjtnwdaehgu3wvfnj7q3qgcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqxpqqqpmej720gac)
+9 -8
View File
@@ -12,9 +12,9 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
versionCode 360
versionName "0.85.1"
buildConfigField "String", "RELEASE_NOTES_ID", "\"d8da33fd13d129d86c53564aedefafbe3716f007c520431be4a8e488d3925afb\""
versionCode 365
versionName "0.86.2"
buildConfigField "String", "RELEASE_NOTES_ID", "\"a704a11334ed4fe6fc6ee6f8856f6f005da33644770616f1437f8b2b488b52b1\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -162,13 +162,13 @@ android {
}
dependencies {
implementation platform(libs.androidx.compose.bom)
implementation project(path: ':quartz')
implementation project(path: ':commons')
implementation libs.androidx.core.ktx
implementation libs.androidx.activity.compose
implementation platform(libs.androidx.compose.bom)
implementation libs.androidx.ui
implementation libs.androidx.ui.graphics
implementation libs.androidx.ui.tooling.preview
@@ -205,9 +205,6 @@ dependencies {
// Websockets API
implementation libs.okhttp
// HTML Parsing for Link Preview
implementation libs.jsoup
// Encrypted Key Storage
implementation libs.androidx.security.crypto.ktx
@@ -282,9 +279,13 @@ dependencies {
testImplementation libs.junit
testImplementation libs.mockk
androidTestImplementation platform(libs.androidx.compose.bom)
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.junit.ktx
androidTestImplementation libs.androidx.espresso.core
debugImplementation platform(libs.androidx.compose.bom)
debugImplementation libs.androidx.ui.tooling
debugImplementation libs.androidx.ui.test.manifest
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst
import android.graphics.Bitmap
import android.graphics.Color
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.FileHeader
@@ -29,70 +31,17 @@ import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import com.vitorpamplona.quartz.crypto.KeyPair
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertTrue
import junit.framework.TestCase.fail
import kotlinx.coroutines.runBlocking
import org.junit.Assert
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import java.util.Base64
import java.io.ByteArrayOutputStream
import kotlin.random.Random
@RunWith(AndroidJUnit4::class)
class ImageUploadTesting {
val contentType = "image/gif"
val image =
"R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzW" +
"lwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2c" +
"cMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjA" +
"J8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8A" +
"AF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMu" +
"QeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSH" +
"pzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGR" +
"s/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78A" +
"AAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMi" +
"wocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7G" +
"nwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euT" +
"eJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dt" +
"GCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWl" +
"Mc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPe" +
"iUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYI" +
"m4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZ" +
"cNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9" +
"aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3A" +
"DTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kV" +
"MyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDG" +
"qCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMW" +
"ZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bD" +
"GdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB77" +
"6aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJH" +
"gxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiA" +
"FB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPA" +
"gCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHg" +
"rhGSQJxCS+0pCZbEhAAOw=="
val contentTypePng = "image/png"
val imagePng =
"iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3" +
"/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXd" +
"tdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEn" +
"xBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nH" +
"L0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2ud" +
"LFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8" +
"Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoeP" +
"PQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/" +
"9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlw" +
"jlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN97" +
"9jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC1" +
"7MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2r" +
"eNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+h" +
"uNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66Pfyu" +
"Rj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMT" +
"hZ3kvgLI5AzFfo379UAAAAASUVORK5CYII="
private suspend fun testBase(server: Nip96MediaServers.ServerName) {
val serverInfo =
Nip96Retriever()
@@ -100,7 +49,15 @@ class ImageUploadTesting {
server.baseUrl,
)
val bytes = Base64.getDecoder().decode(imagePng)
val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888)
for (x in 0 until bitmap.width) {
for (y in 0 until bitmap.height) {
bitmap.setPixel(x, y, Color.rgb(Random.nextInt(), Random.nextInt(), Random.nextInt()))
}
}
val baos = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos)
val bytes = baos.toByteArray()
val inputStream = bytes.inputStream()
val account = Account(KeyPair())
@@ -110,7 +67,7 @@ class ImageUploadTesting {
.uploadImage(
inputStream,
bytes.size.toLong(),
contentTypePng,
"image/png",
alt = null,
sensitiveContent = null,
serverInfo,
@@ -124,31 +81,31 @@ class ImageUploadTesting {
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"))
Assert.assertTrue("${server.name}: Invalid result url", url.startsWith("http"))
val imageData: ByteArray =
ImageDownloader().waitAndGetImage(url)
?: run {
fail("Should not be null")
fail("${server.name}: Should not be null")
return
}
FileHeader.prepare(
imageData,
contentTypePng,
"image/png",
null,
onReady = {
if (dim != null) {
assertEquals(dim, it.dim)
// assertEquals("${server.name}: Invalid dimensions", it.dim, dim)
}
if (size != null) {
assertEquals(size, it.size.toString())
// assertEquals("${server.name}: Invalid size", it.size.toString(), size)
}
if (hash != null) {
assertEquals(hash, it.hash)
assertEquals("${server.name}: Invalid hash", it.hash, hash)
}
},
onError = { fail("It should not fail") },
onError = { fail("${server.name}: It should not fail") },
)
// delay(1000)
@@ -156,6 +113,14 @@ class ImageUploadTesting {
// assertTrue(Nip96Uploader(account).delete(ox, contentType, serverInfo))
}
@Test
fun runTestOnDefaultServers() =
runBlocking {
Nip96MediaServers.DEFAULT.forEach {
testBase(it)
}
}
@Test()
fun testNostrCheck() =
runBlocking {
@@ -163,12 +128,14 @@ class ImageUploadTesting {
}
@Test()
@Ignore("Not Working anymore")
fun testNostrage() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com"))
}
@Test()
@Ignore("Not Working anymore")
fun testSove() =
runBlocking {
testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent"))
@@ -181,6 +148,7 @@ class ImageUploadTesting {
}
@Test()
@Ignore("Not Working anymore")
fun testSovbit() =
runBlocking {
testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host"))
@@ -191,4 +159,17 @@ class ImageUploadTesting {
runBlocking {
testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat"))
}
@Test()
fun testNostrPic() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostpic.com", "https://nostpic.com"))
}
@Test()
@Ignore("Not Working anymore")
fun testNostrOnch() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostr.onch.services", "https://nostr.onch.services"))
}
}
@@ -31,17 +31,21 @@ import com.vitorpamplona.quartz.events.ImmutableListOfLists
fun TranslatableRichTextViewer(
content: String,
canPreview: Boolean,
quotesLeft: Int,
modifier: Modifier = Modifier,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
id: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) = ExpandableRichTextViewer(
content,
canPreview,
quotesLeft,
modifier,
tags,
backgroundColor,
id,
accountViewModel,
nav,
)
@@ -27,18 +27,12 @@ import android.util.Log
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.DefaultReactions
import com.vitorpamplona.amethyst.model.DefaultZapAmounts
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.model.parseBooleanType
import com.vitorpamplona.amethyst.model.parseConnectivityType
import com.vitorpamplona.amethyst.model.parseThemeType
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.checkNotInMainThread
@@ -354,15 +348,6 @@ object LocalPreferences {
return currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) }
}
suspend fun migrateOldSharedSettings(): Settings? {
val prefs = encryptedPreferences()
loadOldSharedSettings(prefs)?.let {
saveSharedSettings(it, prefs)
return it
}
return null
}
suspend fun saveSharedSettings(
sharedSettings: Settings,
prefs: SharedPreferences = encryptedPreferences(),
@@ -390,67 +375,6 @@ object LocalPreferences {
}
}
@Deprecated("Turned into a single JSON object")
suspend fun loadOldSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? {
with(prefs) {
if (!contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
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? =
@@ -56,10 +56,12 @@ import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.Contact
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.DeletionEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.EmojiPackEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.EmojiUrl
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileServersEvent
import com.vitorpamplona.quartz.events.FileStorageEvent
@@ -106,11 +108,15 @@ import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flattenMerge
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
@@ -207,178 +213,121 @@ class Account(
val saveable: AccountLiveData = AccountLiveData(this)
@Immutable
data class LiveFollowLists(
class LiveFollowLists(
val users: ImmutableSet<String> = persistentSetOf(),
val hashtags: ImmutableSet<String> = persistentSetOf(),
val geotags: ImmutableSet<String> = persistentSetOf(),
val communities: ImmutableSet<String> = persistentSetOf(),
)
class ListNameNotePair(val listName: String, val event: GeneralListEvent?)
@OptIn(ExperimentalCoroutinesApi::class)
val liveKind3Follows: StateFlow<LiveFollowLists> 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())
val liveKind3FollowsFlow: Flow<LiveFollowLists> =
userProfile().flow().follows.stateFlow.transformLatest {
emit(
LiveFollowLists(
it.user.cachedFollowingKeySet().toImmutableSet(),
it.user.cachedFollowingTagSet().toImmutableSet(),
it.user.cachedFollowingGeohashSet().toImmutableSet(),
it.user.cachedFollowingCommunitiesSet().toImmutableSet(),
),
)
}
val liveKind3Follows = liveKind3FollowsFlow.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
@OptIn(ExperimentalCoroutinesApi::class)
private val liveHomeList: Flow<ListNameNotePair> by lazy {
defaultHomeFollowList.flatMapLatest { listName ->
loadPeopleListFlowFromListName(listName)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private val liveHomeList: StateFlow<NoteState?> by lazy {
defaultHomeFollowList
.transformLatest {
LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let {
emit(it)
fun loadPeopleListFlowFromListName(listName: String): Flow<ListNameNotePair> {
return if (listName != GLOBAL_FOLLOWS && listName != KIND3_FOLLOWS) {
val note = LocalCache.checkGetOrCreateAddressableNote(listName)
note?.flow()?.metadata?.stateFlow?.mapLatest {
val noteEvent = it.note.event as? GeneralListEvent
ListNameNotePair(listName, noteEvent)
} ?: MutableStateFlow(ListNameNotePair(listName, null))
} else {
MutableStateFlow(ListNameNotePair(listName, null))
}
}
fun combinePeopleListFlows(
kind3FollowsSource: Flow<LiveFollowLists>,
peopleListFollowsSource: Flow<ListNameNotePair>,
): Flow<LiveFollowLists?> {
return combineTransform(kind3FollowsSource, peopleListFollowsSource) { kind3Follows, peopleListFollows ->
if (peopleListFollows.listName == GLOBAL_FOLLOWS) {
emit(null)
} else if (peopleListFollows.listName == KIND3_FOLLOWS) {
emit(kind3Follows)
} else if (peopleListFollows.event == null) {
emit(LiveFollowLists())
} else {
val result = waitToDecrypt(peopleListFollows.event)
if (result == null) {
emit(LiveFollowLists())
} else {
emit(result)
}
}
.flattenMerge()
.stateIn(scope, SharingStarted.Eagerly, null)
}
}
val liveHomeFollowLists: StateFlow<LiveFollowLists?> 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()) }
}
}
combinePeopleListFlows(liveKind3FollowsFlow, liveHomeList)
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
}
@OptIn(ExperimentalCoroutinesApi::class)
private val liveNotificationList: StateFlow<NoteState?> by lazy {
private val liveNotificationList: Flow<ListNameNotePair> by lazy {
defaultNotificationFollowList
.transformLatest {
LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let {
emit(it)
}
}
.flattenMerge()
.stateIn(scope, SharingStarted.Eagerly, null)
.transformLatest { listName ->
emit(loadPeopleListFlowFromListName(listName))
}.flattenMerge()
}
val liveNotificationFollowLists: StateFlow<LiveFollowLists?> 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()) }
}
}
combinePeopleListFlows(liveKind3FollowsFlow, liveNotificationList)
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
}
@OptIn(ExperimentalCoroutinesApi::class)
private val liveStoriesList: StateFlow<NoteState?> by lazy {
private val liveStoriesList: Flow<ListNameNotePair> by lazy {
defaultStoriesFollowList
.transformLatest {
LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let {
emit(it)
}
}
.flattenMerge()
.stateIn(scope, SharingStarted.Eagerly, null)
.transformLatest { listName ->
emit(loadPeopleListFlowFromListName(listName))
}.flattenMerge()
}
val liveStoriesFollowLists: StateFlow<LiveFollowLists?> 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()) }
}
}
combinePeopleListFlows(liveKind3FollowsFlow, liveStoriesList)
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
}
@OptIn(ExperimentalCoroutinesApi::class)
private val liveDiscoveryList: StateFlow<NoteState?> by lazy {
private val liveDiscoveryList: Flow<ListNameNotePair> by lazy {
defaultDiscoveryFollowList
.transformLatest {
LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let {
emit(it)
}
}
.flattenMerge()
.stateIn(scope, SharingStarted.Eagerly, null)
.transformLatest { listName ->
emit(loadPeopleListFlowFromListName(listName))
}.flattenMerge()
}
val liveDiscoveryFollowLists: StateFlow<LiveFollowLists?> 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()) }
}
}
combinePeopleListFlows(liveKind3FollowsFlow, liveDiscoveryList)
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
}
private fun decryptLiveFollows(
peopleListFollows: NoteState?,
listEvent: GeneralListEvent,
onReady: (LiveFollowLists) -> Unit,
) {
val listEvent = (peopleListFollows?.note?.event as? GeneralListEvent)
listEvent?.privateTags(signer) { privateTagList ->
listEvent.privateTags(signer) { privateTagList ->
onReady(
LiveFollowLists(
users =
@@ -396,6 +345,16 @@ class Account(
}
}
suspend fun waitToDecrypt(peopleListFollows: GeneralListEvent): LiveFollowLists? {
return withTimeoutOrNull(1000) {
suspendCancellableCoroutine { continuation ->
decryptLiveFollows(peopleListFollows) {
continuation.resume(it)
}
}
}
}
@Immutable
data class LiveHiddenUsers(
val hiddenUsers: ImmutableSet<String>,
@@ -572,7 +531,7 @@ class Account(
if (!isWriteable()) return
MetadataEvent.updateFromPast(
latest = userProfile().info?.latestMetadata,
latest = userProfile().latestMetadata,
name = name,
picture = picture,
banner = banner,
@@ -836,17 +795,18 @@ class Account(
}
}
suspend fun delete(note: Note) {
return delete(listOf(note))
fun delete(note: Note) {
delete(listOf(note))
}
suspend fun delete(notes: List<Note>) {
fun delete(notes: List<Note>) {
if (!isWriteable()) return
val myNotes = notes.filter { it.author == userProfile() }.mapNotNull { it.event?.id() }
val myEvents = notes.filter { it.author == userProfile() }
val myNoteVersions = myEvents.mapNotNull { it.event as? Event }
if (myNotes.isNotEmpty()) {
DeletionEvent.create(myNotes, signer) {
if (myNoteVersions.isNotEmpty()) {
DeletionEvent.create(myNoteVersions, signer) {
Client.send(it)
LocalCache.justConsume(it, null)
}
@@ -921,6 +881,7 @@ class Account(
fun timestamp(note: Note) {
if (!isWriteable()) return
if (note.isDraft()) return
val id = note.event?.id() ?: note.idHex
@@ -1310,6 +1271,7 @@ class Account(
relayList: List<Relay>? = null,
geohash: String? = null,
nip94attachments: List<Event>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@@ -1337,14 +1299,26 @@ class Account(
geohash = geohash,
nip94attachments = nip94attachments,
signer = signer,
isDraft = draftTag != null,
) {
Client.send(it, relayList = relayList)
LocalCache.justConsume(it, null)
if (draftTag != null) {
if (message.isBlank()) {
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent ->
Client.send(draftEvent, relayList = relayList)
LocalCache.justConsume(draftEvent, null)
}
}
} else {
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)
replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } }
addresses?.forEach {
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
Client.send(it, relayList = relayList)
}
}
}
}
@@ -1365,6 +1339,7 @@ class Account(
relayList: List<Relay>? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@@ -1388,26 +1363,52 @@ class Account(
nip94attachments = nip94attachments,
forkedFrom = forkedFrom,
signer = signer,
isDraft = draftTag != null,
) {
Client.send(it, relayList = relayList)
LocalCache.justConsume(it, null)
// broadcast replied notes
replyingTo?.let {
LocalCache.getNoteIfExists(replyingTo)?.event?.let {
Client.send(it, relayList = relayList)
if (draftTag != null) {
if (message.isBlank()) {
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
Client.send(draftEvent, relayList = relayList)
LocalCache.justConsume(draftEvent, null)
}
}
}
replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } }
addresses?.forEach {
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
Client.send(it, relayList = relayList)
} else {
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 sendPost(
fun deleteDraft(draftTag: String) {
val key = DraftEvent.createAddressTag(userProfile().pubkeyHex, draftTag)
LocalCache.getAddressableNoteIfExists(key)?.let {
val noteEvent = it.event
if (noteEvent is DraftEvent) {
noteEvent.createDeletedEvent(signer) {
Client.send(it)
LocalCache.justConsume(it, null)
}
}
delete(it)
}
}
suspend fun sendPost(
message: String,
replyTo: List<Note>?,
mentions: List<User>?,
@@ -1422,6 +1423,7 @@ class Account(
relayList: List<Relay>? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@@ -1445,20 +1447,32 @@ class Account(
nip94attachments = nip94attachments,
forkedFrom = forkedFrom,
signer = signer,
isDraft = draftTag != null,
) {
Client.send(it, relayList = relayList)
LocalCache.justConsume(it, null)
// broadcast replied notes
replyingTo?.let {
LocalCache.getNoteIfExists(replyingTo)?.event?.let {
Client.send(it, relayList = relayList)
if (draftTag != null) {
if (message.isBlank()) {
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
Client.send(draftEvent, relayList = relayList)
LocalCache.justConsume(draftEvent, null)
}
}
}
replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } }
addresses?.forEach {
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
Client.send(it, relayList = relayList)
} else {
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)
}
}
}
}
@@ -1502,6 +1516,7 @@ class Account(
relayList: List<Relay>? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@@ -1525,15 +1540,27 @@ class Account(
zapRaiserAmount = zapRaiserAmount,
geohash = geohash,
nip94attachments = nip94attachments,
isDraft = draftTag != null,
) {
Client.send(it, relayList = relayList)
LocalCache.justConsume(it, null)
if (draftTag != null) {
if (message.isBlank()) {
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
Client.send(draftEvent, relayList = relayList)
LocalCache.justConsume(draftEvent, null)
}
}
} else {
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)
// 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)
}
}
}
}
@@ -1549,6 +1576,7 @@ class Account(
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@@ -1566,9 +1594,21 @@ class Account(
geohash = geohash,
nip94attachments = nip94attachments,
signer = signer,
isDraft = draftTag != null,
) {
Client.send(it)
LocalCache.justConsume(it, null)
if (draftTag != null) {
if (message.isBlank()) {
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
Client.send(draftEvent)
LocalCache.justConsume(draftEvent, null)
}
}
} else {
Client.send(it)
LocalCache.justConsume(it, null)
}
}
}
@@ -1582,6 +1622,7 @@ class Account(
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@@ -1600,9 +1641,21 @@ class Account(
geohash = geohash,
nip94attachments = nip94attachments,
signer = signer,
isDraft = draftTag != null,
) {
Client.send(it)
LocalCache.justConsume(it, null)
if (draftTag != null) {
if (message.isBlank()) {
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
Client.send(draftEvent)
LocalCache.justConsume(draftEvent, null)
}
}
} else {
Client.send(it)
LocalCache.justConsume(it, null)
}
}
}
@@ -1616,6 +1669,7 @@ class Account(
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
) {
sendPrivateMessage(
message,
@@ -1627,6 +1681,7 @@ class Account(
zapRaiserAmount,
geohash,
nip94attachments,
draftTag,
)
}
@@ -1640,6 +1695,7 @@ class Account(
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@@ -1659,9 +1715,21 @@ class Account(
nip94attachments = nip94attachments,
signer = signer,
advertiseNip18 = false,
isDraft = draftTag != null,
) {
Client.send(it)
LocalCache.consume(it, null)
if (draftTag != null) {
if (message.isBlank()) {
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent ->
Client.send(draftEvent)
LocalCache.justConsume(draftEvent, null)
}
}
} else {
Client.send(it)
LocalCache.consume(it, null)
}
}
}
@@ -1676,6 +1744,7 @@ class Account(
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String? = null,
) {
if (!isWriteable()) return
@@ -1693,9 +1762,21 @@ class Account(
zapRaiserAmount = zapRaiserAmount,
geohash = geohash,
nip94attachments = nip94attachments,
draftTag = draftTag,
signer = signer,
) {
broadcastPrivately(it)
if (draftTag != null) {
if (message.isBlank()) {
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it.msg, emptyList(), signer) { draftEvent ->
Client.send(draftEvent)
LocalCache.justConsume(draftEvent, null)
}
}
} else {
broadcastPrivately(it)
}
}
}
@@ -1777,7 +1858,7 @@ class Account(
Client.send(event)
LocalCache.justConsume(event, null)
DeletionEvent.create(listOf(event.id), signer) { event2 ->
DeletionEvent.createForVersionOnly(listOf(event), signer) { event2 ->
Client.send(event2)
LocalCache.justConsume(event2, null)
}
@@ -1843,6 +1924,7 @@ class Account(
isPrivate: Boolean,
) {
if (!isWriteable()) return
if (note.isDraft()) return
if (note is AddressableNote) {
BookmarkListEvent.addReplaceable(
@@ -2209,13 +2291,18 @@ class Account(
}
fun cachedDecryptContent(note: Note): String? {
val event = note.event
return cachedDecryptContent(note.event)
}
fun cachedDecryptContent(event: EventInterface?): String? {
if (event == null) return null
return if (event is PrivateDmEvent && isWriteable()) {
event.cachedContentFor(signer)
} else if (event is LnZapRequestEvent && event.isPrivateZap() && isWriteable()) {
event.cachedPrivateZap()?.content
} else {
event?.content()
event.content()
}
}
@@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.commons.data.LargeCache
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
@@ -33,7 +35,6 @@ import com.vitorpamplona.quartz.encoders.toNote
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import kotlinx.coroutines.Dispatchers
import java.util.concurrent.ConcurrentHashMap
@Stable
class PublicChatChannel(idHex: String) : Channel(idHex) {
@@ -107,10 +108,9 @@ class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) {
@Stable
abstract class Channel(val idHex: String) {
var creator: User? = null
var updatedMetadataAt: Long = 0
val notes = ConcurrentHashMap<HexKey, Note>()
val notes = LargeCache<HexKey, Note>()
var lastNoteCreatedAt: Long = 0
open fun id() = Hex.decode(idHex)
@@ -131,7 +131,7 @@ abstract class Channel(val idHex: String) {
}
open fun profilePicture(): String? {
return creator?.profilePicture()
return creator?.info?.banner
}
open fun updateChannelInfo(
@@ -145,7 +145,11 @@ abstract class Channel(val idHex: String) {
}
fun addNote(note: Note) {
notes[note.idHex] = note
notes.put(note.idHex, note)
if ((note.createdAt() ?: 0) > lastNoteCreatedAt) {
lastNoteCreatedAt = note.createdAt() ?: 0
}
}
fun removeNote(note: Note) {
@@ -163,18 +167,18 @@ abstract class Channel(val idHex: String) {
fun pruneOldAndHiddenMessages(account: Account): Set<Note> {
val important =
notes.values
.filter { it.author?.let { it1 -> account.isHidden(it1) } == false }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
.take(1000)
notes.filter { key, it ->
it.author?.let { author -> account.isHidden(author) } == false
}
.sortedWith(DefaultFeedOrder)
.take(500)
.toSet()
val toBeRemoved = notes.values.filter { it !in important }.toSet()
val toBeRemoved = notes.filter { key, it -> it !in important }
toBeRemoved.forEach { notes.remove(it.idHex) }
return toBeRemoved
return toBeRemoved.toSet()
}
}
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
@Stable
class Chatroom() {
var authors: Set<User> = setOf()
var roomMessages: Set<Note> = setOf()
var subject: String? = null
var subjectCreatedAt: Long? = null
@@ -38,6 +39,12 @@ class Chatroom() {
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
msg.author?.let { author ->
if (author !in authors) {
authors += author
}
}
val newSubject = msg.event?.subject()
if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) {
@@ -51,8 +58,8 @@ class Chatroom() {
fun removeMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
if (msg in roomMessages) {
roomMessages = roomMessages - msg
roomMessages
.filter { it.event?.subject() != null }
@@ -66,7 +73,7 @@ class Chatroom() {
}
fun senderIntersects(keySet: Set<HexKey>): Boolean {
return roomMessages.any { it.author?.pubkeyHex in keySet }
return authors.any { it.pubkeyHex in keySet }
}
fun pruneMessagesToTheLatestOnly(): Set<Note> {
@@ -21,162 +21,91 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.Amethyst
import com.vitorpamplona.amethyst.commons.hashtags.Btc
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.Coffee
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.hashtags.Footstr
import com.vitorpamplona.amethyst.commons.hashtags.Grownostr
import com.vitorpamplona.amethyst.commons.hashtags.Lightning
import com.vitorpamplona.amethyst.commons.hashtags.Mate
import com.vitorpamplona.amethyst.commons.hashtags.Nostr
import com.vitorpamplona.amethyst.commons.hashtags.Plebs
import com.vitorpamplona.amethyst.commons.hashtags.Skull
import com.vitorpamplona.amethyst.commons.hashtags.Tunestr
import com.vitorpamplona.amethyst.commons.hashtags.Weed
import com.vitorpamplona.amethyst.commons.hashtags.Zap
import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.ui.components.HashTag
import com.vitorpamplona.amethyst.ui.components.RenderRegular
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.events.EmptyTagList
fun checkForHashtagWithIcon(
tag: String,
primary: Color,
): HashtagIcon? {
@Preview
@Composable
fun RenderHashTagIcons() {
val nav: (String) -> Unit = {}
ThemeComparisonColumn {
RenderRegular(
"Testing rendering of hashtags: #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate",
EmptyTagList,
) { word, state ->
when (word) {
is HashTagSegment -> HashTag(word, nav)
is RegularTextSegment -> Text(word.segmentText)
}
}
}
}
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
return when (tag.lowercase()) {
"₿itcoin",
"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),
)
"₿itcoin", "bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> bitcoin
"nostr", "nostrich", "nostriches", "thenostr" -> nostr
"lightning", "lightningnetwork" -> lightning
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> zap
"amethyst" -> amethyst
"cashu", "ecash", "nut", "nuts", "deeznuts" -> cashu
"plebs", "pleb", "plebchain" -> plebs
"coffee", "coffeechain", "cafe" -> coffee
"skullofsatoshi" -> skull
"grownostr", "gardening", "garden" -> growstr
"footstr" -> footstr
"tunestr", "music", "nowplaying" -> tunestr
"mate", "matechain", "matestr" -> matestr
"weed", "weedstr", "420", "cannabis", "marijuana" -> weed
else -> null
}
}
val bitcoin = HashtagIcon(CustomHashTagIcons.Btc, "Bitcoin", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val nostr = HashtagIcon(CustomHashTagIcons.Nostr, "Nostr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val lightning = HashtagIcon(CustomHashTagIcons.Lightning, "Lightning", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val zap = HashtagIcon(CustomHashTagIcons.Zap, "Zap", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val amethyst = HashtagIcon(CustomHashTagIcons.Amethyst, "Amethyst", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val cashu = HashtagIcon(CustomHashTagIcons.Cashu, "Cashu", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val plebs = HashtagIcon(CustomHashTagIcons.Plebs, "Pleb", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val coffee = HashtagIcon(CustomHashTagIcons.Coffee, "Coffee", Modifier.padding(start = 3.dp, bottom = 1.dp, top = 1.dp))
val skull = HashtagIcon(CustomHashTagIcons.Skull, "SkullofSatoshi", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val growstr = HashtagIcon(CustomHashTagIcons.Grownostr, "GrowNostr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val footstr = HashtagIcon(CustomHashTagIcons.Footstr, "Footstr", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val tunestr = HashtagIcon(CustomHashTagIcons.Tunestr, "Tunestr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val weed = HashtagIcon(CustomHashTagIcons.Weed, "Weed", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
val matestr = HashtagIcon(CustomHashTagIcons.Mate, "Mate", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
@Immutable
class HashtagIcon(
val icon: Int,
val icon: ImageVector,
val description: String,
val color: Color,
val modifier: Modifier,
val modifier: Modifier = Modifier,
)
File diff suppressed because it is too large Load Diff
@@ -47,6 +47,7 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.GenericRepostEvent
@@ -97,6 +98,14 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) {
fun dTag(): String? {
return (event as? AddressableEvent)?.dTag()
}
override fun wasOrShouldBeDeletedBy(
deletionEvents: Set<HexKey>,
deletionAddressables: Set<ATag>,
): Boolean {
val thisEvent = event
return deletionAddressables.contains(address) || (thisEvent != null && deletionEvents.contains(thisEvent.id()))
}
}
@Stable
@@ -171,7 +180,8 @@ open class Note(val idHex: String) {
event is LiveActivitiesEvent
) {
(event as? ChannelMessageEvent)?.channel()
?: (event as? ChannelMetadataEvent)?.channel() ?: (event as? ChannelCreateEvent)?.id
?: (event as? ChannelMetadataEvent)?.channel()
?: (event as? ChannelCreateEvent)?.id
?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag()
?: (event as? LiveActivitiesEvent)?.address()?.toTag()
} else {
@@ -183,6 +193,8 @@ open class Note(val idHex: String) {
open fun createdAt() = event?.createdAt()
fun isDraft() = event is DraftEvent
fun loadEvent(
event: Event,
author: User,
@@ -310,6 +322,12 @@ open class Note(val idHex: String) {
}
fun removeAllChildNotes(): List<Note> {
val repliesChanged = replies.isNotEmpty()
val reactionsChanged = reactions.isNotEmpty()
val zapsChanged = zaps.isNotEmpty() || zapPayments.isNotEmpty()
val boostsChanged = boosts.isNotEmpty()
val reportsChanged = reports.isNotEmpty()
val toBeRemoved =
replies +
reactions.values.flatten() +
@@ -330,11 +348,11 @@ open class Note(val idHex: String) {
relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
lastReactionsDownloadTime = emptyMap()
liveSet?.innerReplies?.invalidateData()
liveSet?.innerReactions?.invalidateData()
liveSet?.innerBoosts?.invalidateData()
liveSet?.innerReports?.invalidateData()
liveSet?.innerZaps?.invalidateData()
if (repliesChanged) liveSet?.innerReplies?.invalidateData()
if (reactionsChanged) liveSet?.innerReactions?.invalidateData()
if (boostsChanged) liveSet?.innerBoosts?.invalidateData()
if (reportsChanged) liveSet?.innerReports?.invalidateData()
if (zapsChanged) liveSet?.innerZaps?.invalidateData()
return toBeRemoved
}
@@ -529,7 +547,7 @@ open class Note(val idHex: String) {
option: Int?,
user: User,
account: Account,
remainingZapEvents: List<Pair<Note, Note?>>,
remainingZapEvents: Map<Note, Note?>,
onWasZappedByAuthor: () -> Unit,
) {
if (remainingZapEvents.isEmpty()) {
@@ -537,8 +555,8 @@ open class Note(val idHex: String) {
}
remainingZapEvents.forEach { next ->
val zapRequest = next.first.event as LnZapRequestEvent
val zapEvent = next.second?.event as? LnZapEvent
val zapRequest = next.key.event as LnZapRequestEvent
val zapEvent = next.value?.event as? LnZapEvent
if (!zapRequest.isPrivateZap()) {
// public events
@@ -582,7 +600,7 @@ open class Note(val idHex: String) {
account: Account,
onWasZappedByAuthor: () -> Unit,
) {
isZappedByCalculation(null, user, account, zaps.toList(), onWasZappedByAuthor)
isZappedByCalculation(null, user, account, zaps, onWasZappedByAuthor)
if (account.userProfile() == user) {
recursiveIsPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor)
}
@@ -594,7 +612,7 @@ open class Note(val idHex: String) {
account: Account,
onWasZappedByAuthor: () -> Unit,
) {
isZappedByCalculation(option, user, account, zaps.toList(), onWasZappedByAuthor)
isZappedByCalculation(option, user, account, zaps, onWasZappedByAuthor)
}
fun getReactionBy(user: User): String? {
@@ -921,6 +939,14 @@ open class Note(val idHex: String) {
createOrDestroyFlowSync(false)
}
}
open fun wasOrShouldBeDeletedBy(
deletionEvents: Set<HexKey>,
deletionAddressables: Set<ATag>,
): Boolean {
val thisEvent = event
return deletionEvents.contains(idHex) || (thisEvent is AddressableEvent && deletionAddressables.contains(thisEvent.address()))
}
}
@Stable
@@ -958,8 +984,6 @@ class NoteLiveSet(u: Note) {
val relays = innerRelays.map { it }
val zaps = innerZaps.map { it }
val authorChanges = innerMetadata.map { it.note.author }.distinctUntilChanged()
val hasEvent = innerMetadata.map { it.note.event != null }.distinctUntilChanged()
val hasReactions =
@@ -997,7 +1021,6 @@ class NoteLiveSet(u: Note) {
reports.hasObservers() ||
relays.hasObservers() ||
zaps.hasObservers() ||
authorChanges.hasObservers() ||
hasEvent.hasObservers() ||
hasReactions.hasObservers() ||
replyCount.hasObservers() ||
@@ -96,7 +96,7 @@ class ParticipantListBuilder {
it.replyTo?.forEach { addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) }
}
LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.values?.forEach {
LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.forEach { key, it ->
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
}
@@ -34,6 +34,7 @@ data class Settings(
val automaticallyShowProfilePictures: ConnectivityType = ConnectivityType.ALWAYS,
val dontShowPushNotificationSelector: Boolean = false,
val dontAskForNotificationPermissions: Boolean = false,
val featureSet: FeatureSetType = FeatureSetType.COMPLETE,
)
enum class ThemeType(val screenCode: Int, val resourceId: Int) {
@@ -59,6 +60,11 @@ enum class ConnectivityType(val prefCode: Boolean?, val screenCode: Int, val res
NEVER(false, 2, R.string.connectivity_type_never),
}
enum class FeatureSetType(val screenCode: Int, val resourceId: Int) {
COMPLETE(0, R.string.ui_feature_set_type_complete),
SIMPLIFIED(1, R.string.ui_feature_set_type_simplified),
}
fun parseConnectivityType(code: Boolean?): ConnectivityType {
return when (code) {
ConnectivityType.ALWAYS.prefCode -> ConnectivityType.ALWAYS
@@ -81,6 +87,16 @@ fun parseConnectivityType(screenCode: Int): ConnectivityType {
}
}
fun parseFeatureSetType(screenCode: Int): FeatureSetType {
return when (screenCode) {
FeatureSetType.COMPLETE.screenCode -> FeatureSetType.COMPLETE
FeatureSetType.SIMPLIFIED.screenCode -> FeatureSetType.SIMPLIFIED
else -> {
FeatureSetType.COMPLETE
}
}
}
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),
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.RepostEvent
import kotlin.time.measureTimedValue
@@ -78,7 +80,7 @@ class ThreadAssembler {
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet<Note>()
if (note.event != null) {
val thread = mutableSetOf<Note>()
val thread = OnlyLatestVersionSet()
val threadRoot = searchRoot(note, thread) ?: note
@@ -87,7 +89,7 @@ class ThreadAssembler {
// did not added them.
note.replies.forEach { loadDown(it, thread) }
thread.toSet()
thread
} else {
setOf(note)
}
@@ -109,3 +111,87 @@ class ThreadAssembler {
}
}
}
class OnlyLatestVersionSet : MutableSet<Note> {
val map = hashMapOf<ATag, Long>()
val set = hashSetOf<Note>()
override fun add(element: Note): Boolean {
val loadedCreatedAt = element.createdAt()
val noteEvent = element.event
return if (element is AddressableNote && loadedCreatedAt != null) {
innerAdd(element.address, element, loadedCreatedAt)
} else if (noteEvent is AddressableEvent && loadedCreatedAt != null) {
innerAdd(noteEvent.address(), element, loadedCreatedAt)
} else {
set.add(element)
}
}
private fun innerAdd(
address: ATag,
element: Note,
loadedCreatedAt: Long,
): Boolean {
val existing = map.get(address)
return if (existing == null) {
map.put(address, loadedCreatedAt)
set.add(element)
} else {
if (loadedCreatedAt > existing) {
map.put(address, loadedCreatedAt)
set.add(element)
} else {
false
}
}
}
override fun addAll(elements: Collection<Note>): Boolean {
return elements.map { add(it) }.any()
}
override val size: Int
get() = set.size
override fun clear() {
set.clear()
map.clear()
}
override fun isEmpty(): Boolean {
return set.isEmpty()
}
override fun containsAll(elements: Collection<Note>): Boolean {
return set.containsAll(elements)
}
override fun contains(element: Note): Boolean {
return set.contains(element)
}
override fun iterator(): MutableIterator<Note> {
return set.iterator()
}
override fun retainAll(elements: Collection<Note>): Boolean {
return set.retainAll(elements)
}
override fun removeAll(elements: Collection<Note>): Boolean {
return elements.map { remove(it) }.any()
}
override fun remove(element: Note): Boolean {
element.address()?.let {
map.remove(it)
}
(element.event as? AddressableEvent)?.address()?.let {
map.remove(it)
}
return set.remove(element)
}
}
@@ -26,8 +26,6 @@ import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Stable
object UrlCachedPreviewer {
@@ -37,46 +35,44 @@ object UrlCachedPreviewer {
suspend fun previewInfo(
url: String,
onReady: suspend (UrlPreviewState) -> Unit,
) = withContext(Dispatchers.IO) {
) {
cache[url]?.let {
onReady(it)
return@withContext
return
}
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
}
cache.put(url, state)
onReady(state)
}
override suspend fun onFailed(throwable: Throwable) =
withContext(Dispatchers.IO) {
cache[url]?.let {
override suspend fun onComplete(urlInfo: UrlInfoItem) {
cache[url]?.let {
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
onReady(it)
return@withContext
return
}
}
val state =
if (urlInfo.fetchComplete() && urlInfo.url == url) {
UrlPreviewState.Loaded(urlInfo)
} else {
UrlPreviewState.Empty
}
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
cache.put(url, state)
onReady(state)
cache.put(url, state)
onReady(state)
}
override suspend fun onFailed(throwable: Throwable) {
cache[url]?.let {
onReady(it)
return
}
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
cache.put(url, state)
onReady(state)
}
},
)
.fetchUrlPreview()
@@ -52,6 +52,7 @@ import java.math.BigDecimal
class User(val pubkeyHex: String) {
var info: UserMetadata? = null
var latestMetadata: MetadataEvent? = null
var latestContactList: ContactListEvent? = null
var latestBookmarkList: BookmarkListEvent? = null
@@ -80,7 +81,7 @@ class User(val pubkeyHex: String) {
override fun toString(): String = pubkeyHex
fun toBestShortFirstName(): String {
val fullName = bestDisplayName() ?: bestUsername() ?: return pubkeyDisplayHex()
val fullName = toBestDisplayName()
val names = fullName.split(' ')
@@ -96,23 +97,14 @@ class User(val pubkeyHex: String) {
}
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 }
return info?.bestName() ?: pubkeyDisplayHex()
}
fun nip05(): String? {
return info?.nip05?.ifBlank { null }
return info?.nip05
}
fun profilePicture(): String? {
if (info?.picture.isNullOrBlank()) info?.picture = null
return info?.picture
}
@@ -135,6 +127,7 @@ class User(val pubkeyHex: String) {
// Update following of the current user
liveSet?.innerFollows?.invalidateData()
flowSet?.follows?.invalidateData()
// Update Followers of the past user list
// Update Followers of the new contact list
@@ -285,6 +278,18 @@ class User(val pubkeyHex: String) {
}
}
fun removeMessage(
room: ChatroomKey,
msg: Note,
) {
checkNotInMainThread()
val privateChatroom = getOrCreatePrivateChatroom(room)
if (msg in privateChatroom.roomMessages) {
privateChatroom.removeMessageSync(msg)
liveSet?.innerMessages?.invalidateData()
}
}
fun addRelayBeingUsed(
relay: Relay,
eventTime: Long,
@@ -307,9 +312,8 @@ class User(val pubkeyHex: String) {
latestMetadata: MetadataEvent,
) {
info = newUserInfo
info?.latestMetadata = latestMetadata
info?.updatedMetadataAt = latestMetadata.createdAt
info?.tags = latestMetadata.tags.toImmutableListOfLists()
info?.cleanBlankNames()
if (newUserInfo.lud16.isNullOrBlank()) {
info?.lud06?.let {
@@ -363,7 +367,7 @@ class User(val pubkeyHex: String) {
}
suspend fun transientFollowerCount(): Int {
return LocalCache.userListCache.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
return LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
fun cachedFollowingKeySet(): Set<HexKey> {
@@ -387,13 +391,13 @@ class User(val pubkeyHex: String) {
}
suspend fun cachedFollowerCount(): Int {
return LocalCache.userListCache.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
return LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
val messagesToUser = privateChatrooms[key] ?: return false
return messagesToUser.roomMessages.any { this.pubkeyHex == it.author?.pubkeyHex }
return messagesToUser.authors.any { this == it }
}
fun hasReport(
@@ -471,14 +475,16 @@ class User(val pubkeyHex: String) {
@Stable
class UserFlowSet(u: User) {
// Observers line up here.
val follows = UserBundledRefresherFlow(u)
val relays = UserBundledRefresherFlow(u)
fun isInUse(): Boolean {
return relays.stateFlow.subscriptionCount.value > 0
return relays.stateFlow.subscriptionCount.value > 0 || follows.stateFlow.subscriptionCount.value > 0
}
fun destroy() {
relays.destroy()
follows.destroy()
}
}
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst.service
import android.util.LruCache
import com.vitorpamplona.amethyst.commons.RichTextParser
import com.vitorpamplona.amethyst.commons.RichTextViewerState
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.quartz.events.ImmutableListOfLists
object CachedRichTextParser {
@@ -32,8 +32,7 @@ object Nip96MediaServers {
listOf(
ServerName("Nostr.Build", "https://nostr.build"),
ServerName("NostrCheck.me", "https://nostrcheck.me"),
ServerName("Nostrage", "https://nostrage.com"),
ServerName("Sove", "https://sove.rent"),
ServerName("NostPic", "https://nostpic.com"),
ServerName("Sovbit", "https://files.sovbit.host"),
ServerName("Void.cat", "https://void.cat"),
)
@@ -200,8 +200,6 @@ class Nip96Uploader(val account: Account?) {
nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) }
println(server.apiUrl.removeSuffix("/") + "/$hash.$extension")
val request =
requestBuilder
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
@@ -35,13 +35,21 @@ import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.BadgeAwardEvent
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.CalendarDateSlotEvent
import com.vitorpamplona.quartz.events.CalendarRSVPEvent
import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.GitIssueEvent
import com.vitorpamplona.quartz.events.GitPatchEvent
import com.vitorpamplona.quartz.events.GitReplyEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.events.MetadataEvent
@@ -120,24 +128,12 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
}
fun createAccountAcceptedAwardsFilter(): TypedFilter {
fun createAccountSettingsFilter(): 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),
kinds = listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, MuteListEvent.KIND, BadgeProfilesEvent.KIND, EmojiPackSelectionEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 100,
),
@@ -149,7 +145,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(ReportEvent.KIND),
kinds = listOf(DraftEvent.KIND, ReportEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
latestEOSEs.users[account.userProfile()]
@@ -204,6 +200,36 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
}
fun createNotificationFilter2(): 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(
GitReplyEvent.KIND,
GitIssueEvent.KIND,
GitPatchEvent.KIND,
HighlightEvent.KIND,
CalendarDateSlotEvent.KIND,
CalendarTimeSlotEvent.KIND,
CalendarRSVPEvent.KIND,
),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 400,
since = since,
),
)
}
fun createGiftWrapsToMeFilter() =
TypedFilter(
types = COMMON_FEED_TYPES,
@@ -237,22 +263,53 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
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
when (event) {
is DraftEvent -> {
// Avoid decrypting over and over again if the event already exist.
event.cachedGift(account.signer) { this.consume(it, relay) }
}
if (!event.isDeleted()) {
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
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
// decrypts
event.cachedDraft(account.signer) {}
event.cachedGossip(account.signer) { LocalCache.justConsume(it, relay) }
} else {
LocalCache.justConsume(event, relay)
LocalCache.justConsume(event, relay)
}
}
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) }
}
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) }
}
is LnZapEvent -> {
// 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.zapRequest?.let {
if (it.isPrivateZap()) {
it.decryptPrivateZap(account.signer) {}
}
}
}
else -> {
LocalCache.justConsume(event, relay)
}
}
}
}
@@ -297,10 +354,10 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
createAccountContactListFilter(),
createAccountRelayListFilter(),
createNotificationFilter(),
createNotificationFilter2(),
createGiftWrapsToMeFilter(),
createAccountReportsFilter(),
createAccountAcceptedAwardsFilter(),
createAccountBookmarkListFilter(),
createAccountSettingsFilter(),
createAccountLastPostsListFilter(),
createOtherAccountsBaseFilter(),
)
@@ -312,7 +369,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountRelayListFilter(),
createAccountBookmarkListFilter(),
createAccountSettingsFilter(),
)
.ifEmpty { null }
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.Subscription
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
@@ -221,7 +222,7 @@ abstract class NostrDataSource(val debugName: String) {
// 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() }
val currentFilters = activeSubscriptions.associate { it.id to it.typedFilters }
changingFilters.getAndSet(true)
@@ -245,7 +246,7 @@ abstract class NostrDataSource(val debugName: String) {
Client.close(updatedSubscription.id)
} else {
// was active and is still active, check if it has changed.
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
if (updatedSubscription.hasChangedFiltersFrom(currentFilters[updatedSubscription.id])) {
Client.close(updatedSubscription.id)
if (active) {
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
@@ -265,7 +266,7 @@ abstract class NostrDataSource(val debugName: String) {
// 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 (updatedSubscription.hasChangedFiltersFrom(currentFilters[updatedSubscription.id])) {
if (active) {
Log.d(
this@NostrDataSource.javaClass.simpleName,
@@ -293,7 +294,13 @@ abstract class NostrDataSource(val debugName: String) {
eventId: String,
relay: Relay,
) {
LocalCache.getNoteIfExists(eventId)?.addRelay(relay)
val note = LocalCache.getNoteIfExists(eventId)
val noteEvent = note?.event
if (noteEvent is AddressableEvent) {
LocalCache.getAddressableNoteIfExists(noteEvent.address().toTag())?.addRelay(relay)
} else {
note?.addRelay(relay)
}
}
open fun markAsEOSE(
@@ -178,9 +178,8 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
filter =
JsonFilter(
authors = follows,
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
limit = 300,
kinds = listOf(ChannelMessageEvent.KIND),
limit = 500,
since =
latestEOSEs.users[account.userProfile()]
?.followList
@@ -194,7 +193,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
filter =
JsonFilter(
ids = followChats,
kinds = listOf(ChannelCreateEvent.KIND),
kinds = listOf(ChannelCreateEvent.KIND, ChannelMessageEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.DeletionEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GitReplyEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
@@ -57,29 +58,45 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
}
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,
),
listOf(
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,
),
),
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(
DeletionEvent.KIND,
),
tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 10,
),
),
)
}
}.flatten()
}
private fun createAddressFilter(): List<TypedFilter>? {
@@ -147,6 +164,20 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
limit = 1000,
),
),
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(
DeletionEvent.KIND,
),
tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 10,
),
),
)
}.flatten()
}
@@ -35,7 +35,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
fun createUserMetadataFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
val firstTimers = usersToWatch.filter { it.info?.latestMetadata == null }.map { it.pubkeyHex }
val firstTimers = usersToWatch.filter { it.latestMetadata == null }.map { it.pubkeyHex }
if (firstTimers.isEmpty()) return null
@@ -54,7 +54,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
fun createUserMetadataStatusReportFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
val secondTimers = usersToWatch.filter { it.info?.latestMetadata != null }
val secondTimers = usersToWatch.filter { it.latestMetadata != null }
if (secondTimers.isEmpty()) return null
@@ -91,7 +91,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
checkNotInMainThread()
usersToWatch.forEach {
if (it.info?.latestMetadata != null) {
if (it.latestMetadata != null) {
val eose = it.latestEOSEs[relayUrl]
if (eose == null) {
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time))
@@ -40,6 +40,8 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
var job: Job? = null
val SUPPORTED_VIDEO_MIME_TYPES = listOf("image/jpeg", "image/gif", "image/png", "image/webp", "video/mp4", "video/mpeg", "video/webm", "audio/aac", "audio/mpeg", "audio/webm", "audio/wav")
override fun start() {
job?.cancel()
job =
@@ -68,6 +70,7 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
authors = follows,
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
limit = 200,
tags = mapOf("m" to SUPPORTED_VIDEO_MIME_TYPES),
since =
latestEOSEs.users[account.userProfile()]
?.followList
@@ -93,6 +96,7 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
"m" to SUPPORTED_VIDEO_MIME_TYPES,
),
limit = 100,
since =
@@ -120,6 +124,7 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
"m" to SUPPORTED_VIDEO_MIME_TYPES,
),
limit = 100,
since =
@@ -24,7 +24,11 @@ import android.util.Log
import android.util.LruCache
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.quartz.crypto.CryptoUtils
import okhttp3.EventListener
import okhttp3.Protocol
import okhttp3.Request
import okio.ByteString.Companion.toByteString
import kotlin.coroutines.cancellation.CancellationException
@Immutable data class OnlineCheckResult(val timeInMs: Long, val online: Boolean)
@@ -49,21 +53,44 @@ object OnlineChecker {
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 =
HttpClientManager.getHttpClient().newCall(request).execute().use {
checkNotInMainThread()
it.isSuccessful
if (url.startsWith("wss")) {
val request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url.replace("wss+livekit://", "wss://"))
.header("Upgrade", "websocket")
.header("Connection", "Upgrade")
.header("Sec-WebSocket-Key", CryptoUtils.random(16).toByteString().base64())
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Extensions", "permessage-deflate")
.build()
val client =
HttpClientManager.getHttpClient().newBuilder()
.eventListener(EventListener.NONE)
.protocols(listOf(Protocol.HTTP_1_1))
.build()
client.newCall(request).execute().use {
checkNotInMainThread()
it.isSuccessful
}
} else {
val request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
HttpClientManager.getHttpClient().newCall(request).execute().use {
checkNotInMainThread()
it.isSuccessful
}
}
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
result
} catch (e: Exception) {
@@ -64,15 +64,17 @@ class EventNotificationConsumer(private val applicationContext: Context) {
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)
if (!LocalCache.hasConsumed(notificationEvent) && LocalCache.justVerify(notificationEvent)) {
unwrapAndConsume(notificationEvent, account) { innerEvent ->
if (!LocalCache.hasConsumed(innerEvent)) {
if (innerEvent is PrivateDmEvent) {
notify(innerEvent, account)
} else if (innerEvent is LnZapEvent) {
notify(innerEvent, account)
} else if (innerEvent is ChatMessageEvent) {
notify(innerEvent, account)
}
}
}
}
}
@@ -108,7 +110,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
acc: Account,
) {
if (
event.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted
event.createdAt > TimeUtils.fifteenMinutesAgo() && // old event being re-broadcasted
event.pubKey != acc.userProfile().pubkeyHex
) { // from the user
@@ -148,7 +150,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
val note = LocalCache.getNoteIfExists(event.id) ?: return
// old event being re-broadcast
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
if (acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
val followingKeySet = acc.followingKeySet()
@@ -187,7 +189,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
val noteZapEvent = LocalCache.getNoteIfExists(event.id) ?: return
// old event being re-broadcast
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
val noteZapped =
@@ -195,7 +197,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
if (acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
if (event.isTaggedUser(acc.userProfile().pubkeyHex)) {
val amount = showAmount(event.amount)
(noteZapRequest.event as? LnZapRequestEvent)?.let { event ->
acc.decryptZapContentAuthor(noteZapRequest) {
@@ -27,6 +27,7 @@ import android.util.LruCache
import androidx.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.Player.PositionInfo
import androidx.media3.common.Player.STATE_IDLE
import androidx.media3.common.Player.STATE_READY
import androidx.media3.exoplayer.ExoPlayer
@@ -143,6 +144,14 @@ class MultiPlayerPlaybackManager(
}
}
}
override fun onPositionDiscontinuity(
oldPosition: PositionInfo,
newPosition: PositionInfo,
reason: Int,
) {
cachedPositions.add(uri, newPosition.positionMs)
}
},
)
@@ -23,62 +23,73 @@ package com.vitorpamplona.amethyst.service.playback
import android.content.Intent
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.HttpClientManager
import okhttp3.OkHttpClient
class WssOrHttpFactory(httpClient: OkHttpClient) : MediaSource.Factory {
@UnstableApi
val http = DefaultMediaSourceFactory(OkHttpDataSource.Factory(httpClient))
@UnstableApi
val wss = DefaultMediaSourceFactory(WssStreamDataSource.Factory(httpClient))
@OptIn(UnstableApi::class)
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
http.setDrmSessionManagerProvider(drmSessionManagerProvider)
wss.setDrmSessionManagerProvider(drmSessionManagerProvider)
return this
}
@OptIn(UnstableApi::class)
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
http.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
wss.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
return this
}
@OptIn(UnstableApi::class)
override fun getSupportedTypes(): IntArray {
return http.supportedTypes
}
@OptIn(UnstableApi::class)
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
return if (mediaItem.mediaId.startsWith("wss")) {
wss.createMediaSource(mediaItem)
} else {
http.createMediaSource(mediaItem)
}
}
}
@UnstableApi // Extend MediaSessionService
class PlaybackService : MediaSessionService() {
private var videoViewedPositionCache = VideoViewedPositionCache()
private var managerHls: MultiPlayerPlaybackManager? = null
private var managerProgressive: MultiPlayerPlaybackManager? = null
private var managerLocal: MultiPlayerPlaybackManager? = null
private var managerAllInOne: MultiPlayerPlaybackManager? = null
fun newHslDataSource(): MediaSource.Factory {
return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClientManager.getHttpClient()))
fun newAllInOneDataSource(): MediaSource.Factory {
// This might be needed for live kit.
// return WssOrHttpFactory(HttpClientManager.getHttpClient())
return DefaultMediaSourceFactory(OkHttpDataSource.Factory(HttpClientManager.getHttpClient()))
}
fun newProgressiveDataSource(): MediaSource.Factory {
return ProgressiveMediaSource.Factory(
(applicationContext as Amethyst).videoCache.get(HttpClientManager.getHttpClient()),
)
}
fun lazyHlsDS(): MultiPlayerPlaybackManager {
managerHls?.let {
fun lazyDS(): MultiPlayerPlaybackManager {
managerAllInOne?.let {
return it
}
val newInstance = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
managerHls = newInstance
return newInstance
}
fun lazyProgressiveDS(): MultiPlayerPlaybackManager {
managerProgressive?.let {
return it
}
val newInstance =
MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
managerProgressive = newInstance
return newInstance
}
fun lazyLocalDS(): MultiPlayerPlaybackManager {
managerLocal?.let {
return it
}
val newInstance = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache)
managerLocal = newInstance
val newInstance = MultiPlayerPlaybackManager(newAllInOneDataSource(), videoViewedPositionCache)
managerAllInOne = newInstance
return newInstance
}
@@ -94,15 +105,11 @@ class PlaybackService : MediaSessionService() {
}
private fun onProxyUpdated() {
val toDestroyHls = managerHls
val toDestroyProgressive = managerProgressive
val toDestroyAllInOne = managerAllInOne
managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
managerProgressive =
MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
managerAllInOne = MultiPlayerPlaybackManager(newAllInOneDataSource(), videoViewedPositionCache)
toDestroyHls?.releaseAppPlayers()
toDestroyProgressive?.releaseAppPlayers()
toDestroyAllInOne?.releaseAppPlayers()
}
override fun onTaskRemoved(rootIntent: Intent?) {
@@ -116,23 +123,11 @@ class PlaybackService : MediaSessionService() {
HttpClientManager.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated)
managerHls?.releaseAppPlayers()
managerLocal?.releaseAppPlayers()
managerProgressive?.releaseAppPlayers()
managerAllInOne?.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,
@@ -141,38 +136,18 @@ class PlaybackService : MediaSessionService() {
super.onUpdateNotification(session, startInForegroundRequired)
// Overrides the notification with any player actually playing
managerHls?.playingContent()?.forEach {
managerAllInOne?.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 {
managerAllInOne?.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
@@ -182,9 +157,9 @@ class PlaybackService : MediaSessionService() {
val uri = controllerInfo.connectionHints.getString("uri") ?: return null
val callbackUri = controllerInfo.connectionHints.getString("callbackUri")
val manager = getAppropriateMediaSessionManager(uri)
val manager = lazyDS()
return manager?.getMediaSession(
return manager.getMediaSession(
id,
uri,
callbackUri,
@@ -0,0 +1,54 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import java.util.concurrent.ConcurrentSkipListSet
class WssDataStreamCollector : WebSocketListener() {
private val wssData = ConcurrentSkipListSet<ByteString>()
override fun onMessage(
webSocket: WebSocket,
bytes: ByteString,
) {
wssData.add(bytes)
}
override fun onClosing(
webSocket: WebSocket,
code: Int,
reason: String,
) {
super.onClosing(webSocket, code, reason)
wssData.removeAll(wssData)
}
fun canStream(): Boolean {
return wssData.size > 0
}
fun getNextStream(): ByteString {
return wssData.pollFirst()
}
}
@@ -0,0 +1,112 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback
import android.net.Uri
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.BaseDataSource
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import kotlin.math.min
@OptIn(UnstableApi::class)
class WssStreamDataSource(val httpClient: OkHttpClient) : BaseDataSource(true) {
val dataStreamCollector: WssDataStreamCollector = WssDataStreamCollector()
var webSocketClient: WebSocket? = null
private var currentByteStream: ByteArray? = null
private var currentPosition = 0
private var remainingBytes = 0
override fun open(dataSpec: DataSpec): Long {
// Form the request and open the socket.
// Provide the listener
// which collects the data for us (Previous class).
webSocketClient =
httpClient.newWebSocket(
Request.Builder().apply {
dataSpec.httpRequestHeaders.forEach { entry ->
addHeader(entry.key, entry.value)
}
}.url(dataSpec.uri.toString()).build(),
dataStreamCollector,
)
return -1 // Return -1 as the size is unknown (streaming)
}
override fun getUri(): Uri? {
webSocketClient?.request()?.url?.let {
return Uri.parse(it.toString())
}
return null
}
override fun read(
target: ByteArray,
offset: Int,
length: Int,
): Int {
// return 0 (nothing read) when no data present...
if (currentByteStream == null && !dataStreamCollector.canStream()) {
return 0
}
// parse one (data) ByteString at a time.
// reset the current position and remaining bytes
// for every new data
if (currentByteStream == null) {
currentByteStream = dataStreamCollector.getNextStream().toByteArray()
currentPosition = 0
remainingBytes = currentByteStream?.size ?: 0
}
val readSize = min(length, remainingBytes)
currentByteStream?.copyInto(target, offset, currentPosition, currentPosition + readSize)
currentPosition += readSize
remainingBytes -= readSize
// once the data is read set currentByteStream to null
// so the next data would be collected to process in next
// iteration.
if (remainingBytes == 0) {
currentByteStream = null
}
return readSize
}
override fun close() {
// close the socket and relase the resources
webSocketClient?.cancel()
}
// Factory class for DataSource
class Factory(val okHttpClient: OkHttpClient) : DataSource.Factory {
override fun createDataSource(): DataSource = WssStreamDataSource(okHttpClient)
}
}
@@ -21,18 +21,14 @@
package com.vitorpamplona.amethyst.service.previews
import kotlinx.coroutines.CancellationException
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) {
if (t is CancellationException) throw t
callback?.onFailed(t)
}
try {
fetch(timeOut)
} catch (t: Throwable) {
if (t is CancellationException) throw t
callback?.onFailed(t)
}
private suspend fun fetch(timeOut: Int = 30000) {
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.service.previews
import com.vitorpamplona.amethyst.commons.preview.MetaTag
import com.vitorpamplona.amethyst.commons.preview.MetaTagsParser
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.Dispatchers
@@ -27,60 +29,39 @@ import kotlinx.coroutines.withContext
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import okio.BufferedSource
import okio.ByteString.Companion.decodeHex
import okio.Options
import java.nio.charset.Charset
private const val ELEMENT_TAG_META = "meta"
private const val ATTRIBUTE_VALUE_PROPERTY = "property"
private const val ATTRIBUTE_VALUE_NAME = "name"
private const val ATTRIBUTE_VALUE_ITEMPROP = "itemprop"
private const val ATTRIBUTE_VALUE_CHARSET = "charset"
private const val ATTRIBUTE_VALUE_HTTP_EQUIV = "http-equiv"
// for <meta itemprop=... to get title
private val META_X_TITLE =
arrayOf(
"og:title",
"\"og:title\"",
"'og:title'",
"name",
"\"name\"",
"'name'",
"twitter:title",
"\"twitter:title\"",
"'twitter:title'",
"title",
"\"title\"",
"'title'",
)
// for <meta itemprop=... to get description
private val META_X_DESCRIPTION =
arrayOf(
"og:description",
"\"og:description\"",
"'og:description'",
"description",
"\"description\"",
"'description'",
"twitter:description",
"\"twitter:description\"",
"'twitter:description'",
"description",
"\"description\"",
"'description'",
)
// for <meta itemprop=... to get image
private val META_X_IMAGE =
arrayOf(
"og:image",
"\"og:image\"",
"'og:image'",
"image",
"\"image\"",
"'image'",
"twitter:image",
"\"twitter:image\"",
"'twitter:image'",
"image",
)
private const val CONTENT = "content"
@@ -95,14 +76,12 @@ suspend fun getDocument(
checkNotInMainThread()
if (it.isSuccessful) {
val mimeType =
it.headers.get("Content-Type")?.toMediaType()
it.headers["Content-Type"]?.toMediaType()
?: throw IllegalArgumentException(
"Website returned unknown mimetype: ${it.headers.get("Content-Type")}",
"Website returned unknown mimetype: ${it.headers["Content-Type"]}",
)
if (mimeType.type == "text" && mimeType.subtype == "html") {
val document = Jsoup.parse(it.body.string())
parseHtml(url, document, mimeType)
parseHtml(url, it.body.source(), mimeType)
} else if (mimeType.type == "image") {
UrlInfoItem(url, image = url, mimeType = mimeType)
} else if (mimeType.type == "video") {
@@ -120,65 +99,141 @@ suspend fun getDocument(
suspend fun parseHtml(
url: String,
document: Document,
source: BufferedSource,
type: MediaType,
): UrlInfoItem =
withContext(Dispatchers.IO) {
val metaTags = document.getElementsByTag(ELEMENT_TAG_META)
// sniff charset from Content-Type header or BOM
val sniffedCharset = type.charset() ?: source.readBomAsCharset()
if (sniffedCharset != null) {
val metaTags = MetaTagsParser.parse(source.readByteArray().toString(sniffedCharset))
return@withContext extractUrlInfo(url, metaTags, type)
}
var title: String = ""
var description: String = ""
var image: String = ""
// if sniffing was failed, detect charset from content
val bodyBytes = source.readByteArray()
val charset = detectCharset(bodyBytes)
val metaTags = MetaTagsParser.parse(bodyBytes.toString(charset))
return@withContext extractUrlInfo(url, metaTags, type)
}
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)
}
}
// taken from okhttp
private val UNICODE_BOMS =
Options.of(
// UTF-8
"efbbbf".decodeHex(),
// UTF-16BE
"feff".decodeHex(),
// UTF-16LE
"fffe".decodeHex(),
// UTF-32BE
"0000ffff".decodeHex(),
// UTF-32LE
"ffff0000".decodeHex(),
)
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)
}
}
private fun BufferedSource.readBomAsCharset(): Charset? {
return when (select(UNICODE_BOMS)) {
0 -> Charsets.UTF_8
1 -> Charsets.UTF_16BE
2 -> Charsets.UTF_16LE
3 -> Charsets.UTF_32BE
4 -> Charsets.UTF_32LE
-1 -> null
else -> throw AssertionError()
}
}
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)
}
}
private val RE_CONTENT_TYPE_CHARSET = Regex("""charset=([^;]+)""")
if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) {
return@withContext UrlInfoItem(url, title, description, image, type)
private fun detectCharset(bodyBytes: ByteArray): Charset {
// try to detect charset from meta tags parsed from first 1024 bytes of body
val firstPart = String(bodyBytes, 0, 1024, Charset.forName("utf-8"))
val metaTags = MetaTagsParser.parse(firstPart)
metaTags.forEach { meta ->
val charsetAttr = meta.attr(ATTRIBUTE_VALUE_CHARSET)
if (charsetAttr.isNotEmpty()) {
runCatching { Charset.forName(charsetAttr) }.getOrNull()?.let {
return it
}
}
return@withContext UrlInfoItem(url, title, description, image, type)
if (meta.attr(ATTRIBUTE_VALUE_HTTP_EQUIV).lowercase() == "content-type") {
RE_CONTENT_TYPE_CHARSET.find(meta.attr(CONTENT))
?.let {
runCatching { Charset.forName(it.groupValues[1]) }.getOrNull()
}?.let {
return it
}
}
}
// defaults to UTF-8
return Charset.forName("utf-8")
}
private fun extractUrlInfo(
url: String,
metaTags: Sequence<MetaTag>,
type: MediaType,
): UrlInfoItem {
var title: String = ""
var description: String = ""
var image: String = ""
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_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()) {
return UrlInfoItem(url, title, description, image, type)
}
}
return UrlInfoItem(url, title, description, image, type)
}
@@ -98,7 +98,7 @@ object Client : RelayPool.Listener {
checkNotInMainThread()
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilter(subscriptionId)
RelayPool.sendFilter(subscriptionId, filters)
}
fun sendFilterOnlyIfDisconnected(
@@ -134,7 +134,7 @@ object Client : RelayPool.Listener {
newSporadicRelay(
relay,
feedTypes,
onConnected = { relay -> relay.send(signedEvent) },
onConnected = { myRelay -> myRelay.send(signedEvent) },
onDone = onDone,
)
}
@@ -152,7 +152,9 @@ object Client : RelayPool.Listener {
RelayPool.addRelay(relay)
relay.connectAndRun {
allSubscriptions().forEach { relay.sendFilter(requestId = it) }
allSubscriptions().forEach {
relay.sendFilter(it.key, it.value)
}
onConnected(relay)
@@ -264,8 +266,8 @@ object Client : RelayPool.Listener {
listeners = listeners.minus(listener)
}
fun allSubscriptions(): Set<String> {
return subscriptions.keys
fun allSubscriptions(): Map<String, List<TypedFilter>> {
return subscriptions
}
fun getSubscriptionFilters(subId: String): List<TypedFilter> {
@@ -35,144 +35,29 @@ object Constants {
val defaultRelays =
arrayOf(
// Free relays for only DMs and Follows due to the amount of spam
// Free relays for only DMs, Chats and Follows due to the amount of spam
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.orangepill.dev", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nostr.fmt.wiz.biz", read = true, write = false, feedTypes = activeTypesChats),
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,
),
// Global
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesGlobalChats),
// 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,
),
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats),
// Supporting NIP-50
RelaySetupInfo(
"wss://relay.nostr.band",
read = true,
write = false,
feedTypes = activeTypesSearch,
),
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,
),
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://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,
),
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch),
)
val forcedRelaysForSearchSet = forcedRelayForSearch.map { it.url }
}
@@ -344,19 +344,23 @@ class Relay(
afterEOSEPerSubscription = LinkedHashMap(afterEOSEPerSubscription.size)
}
fun sendFilter(requestId: String) {
fun sendFilter(
requestId: String,
filters: List<TypedFilter>,
) {
checkNotInMainThread()
if (read) {
if (isConnected()) {
if (isReady) {
val filters =
Client.getSubscriptionFilters(requestId).filter { filter ->
val relayFilters =
filters.filter { filter ->
activeTypes.any { it in filter.types }
}
if (filters.isNotEmpty()) {
if (relayFilters.isNotEmpty()) {
val request =
filters.joinToStringLimited(
relayFilters.joinToStringLimited(
separator = ",",
limit = 20,
prefix = """["REQ","$requestId",""",
@@ -423,7 +427,9 @@ class Relay(
fun renewFilters() {
// Force update all filters after AUTH.
Client.allSubscriptions().forEach { sendFilter(requestId = it) }
Client.allSubscriptions().forEach {
sendFilter(requestId = it.key, it.value)
}
}
fun send(signedEvent: EventInterface) {
@@ -452,7 +458,7 @@ class Relay(
eventUploadCounterInBytes += event.bytesUsedInMemory()
// Sends everything.
Client.allSubscriptions().forEach { sendFilter(requestId = it) }
renewFilters()
}
}
}
@@ -77,8 +77,13 @@ object RelayPool : Relay.Listener {
relays.forEach { it.connect() }
}
fun sendFilter(subscriptionId: String) {
relays.forEach { it.sendFilter(subscriptionId) }
fun sendFilter(
subscriptionId: String,
filters: List<TypedFilter>,
) {
relays.forEach { relay ->
relay.sendFilter(subscriptionId, filters)
}
}
fun connectAndSendFiltersIfDisconnected() {
@@ -20,8 +20,6 @@
*/
package com.vitorpamplona.amethyst.service.relays
import com.fasterxml.jackson.databind.JsonNode
import com.vitorpamplona.quartz.events.Event
import java.util.UUID
data class Subscription(
@@ -37,23 +35,36 @@ data class Subscription(
onEOSE?.let { it(time, relay) }
}
fun toJson(): String {
return Event.mapper.writeValueAsString(toJsonObject())
}
fun hasChangedFiltersFrom(otherFilters: List<TypedFilter>?): Boolean {
if (typedFilters == null && otherFilters == null) return false
if (typedFilters?.size != otherFilters?.size) return true
fun toJsonObject(): JsonNode {
val factory = Event.mapper.nodeFactory
typedFilters?.forEachIndexed { index, typedFilter ->
val otherFilter = otherFilters?.getOrNull(index) ?: return true
return factory.objectNode().apply {
put("id", id)
typedFilters?.also { filters ->
replace(
"typedFilters",
factory.arrayNode(filters.size).apply {
filters.forEach { filter -> add(filter.toJsonObject()) }
},
)
// Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed.
// fast check
if (typedFilter.filter.authors?.size != otherFilter.filter.authors?.size ||
typedFilter.filter.ids?.size != otherFilter.filter.ids?.size ||
typedFilter.filter.tags?.size != otherFilter.filter.tags?.size ||
typedFilter.filter.kinds?.size != otherFilter.filter.kinds?.size ||
typedFilter.filter.limit != otherFilter.filter.limit ||
typedFilter.filter.search?.length != otherFilter.filter.search?.length ||
typedFilter.filter.until != otherFilter.filter.until
) {
return true
}
// deep check
if (typedFilter.filter.ids != otherFilter.filter.ids ||
typedFilter.filter.authors != otherFilter.filter.authors ||
typedFilter.filter.tags != otherFilter.filter.tags ||
typedFilter.filter.kinds != otherFilter.filter.kinds ||
typedFilter.filter.search != otherFilter.filter.search
) {
return true
}
}
return false
}
}
@@ -20,73 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relays
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.node.ArrayNode
import com.vitorpamplona.quartz.events.Event
class TypedFilter(
val types: Set<FeedType>,
val filter: JsonFilter,
) {
fun toJson(): String {
return Event.mapper.writeValueAsString(toJsonObject())
}
fun toJsonObject(): JsonNode {
val factory = Event.mapper.nodeFactory
return factory.objectNode().apply {
replace("types", typesToJson(types))
replace("filter", filterToJson(filter))
}
}
fun typesToJson(types: Set<FeedType>): 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 {
replace(
"ids",
factory.arrayNode(filter.ids.size).apply { filter.ids.forEach { add(it) } },
)
}
filter.authors?.run {
replace(
"authors",
factory.arrayNode(filter.authors.size).apply { filter.authors.forEach { add(it) } },
)
}
filter.kinds?.run {
replace(
"kinds",
factory.arrayNode(filter.kinds.size).apply { filter.kinds.forEach { add(it) } },
)
}
filter.tags?.run {
entries.forEach { kv ->
replace(
"#${kv.key}",
factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } },
)
}
}
/*
Does not include since in the json comparison
filter.since?.run {
val jsonObjectSince = JsonObject()
entries.forEach { sincePairs ->
jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}")
}
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) }
}
}
}
)
@@ -46,6 +46,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
@@ -87,7 +88,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.components.BechLink
@@ -98,6 +99,7 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
@@ -268,6 +270,7 @@ fun EditPostView(
makeItShort = true,
unPackReply = false,
isQuotedNote = true,
quotesLeft = 1,
modifier = MaterialTheme.colorScheme.replyModifier,
accountViewModel = accountViewModel,
nav = nav,
@@ -312,11 +315,12 @@ fun EditPostView(
val backgroundColor = remember { mutableStateOf(bgColor) }
BechLink(
myUrlPreview,
true,
backgroundColor,
accountViewModel,
nav,
word = myUrlPreview,
canPreview = true,
quotesLeft = 1,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
} else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) {
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
@@ -385,7 +389,7 @@ fun EditPostView(
fontWeight = FontWeight.W500,
)
Divider()
HorizontalDivider(thickness = DividerThickness)
MyTextField(
value = postViewModel.subject,
@@ -446,6 +450,9 @@ fun ShowUserSuggestionListForEdit(
key = { _, item -> item.pubkeyHex },
) { _, item ->
UserLine(item, accountViewModel) { editPostViewModel.autocompleteWithUser(item) }
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
@@ -31,8 +31,8 @@ import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.RichTextParser
import com.vitorpamplona.amethyst.commons.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -257,7 +257,7 @@ open class EditPostViewModel() : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
userSuggestions =
LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }))
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex }))
.reversed()
}
} else {
@@ -37,7 +37,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -142,7 +142,10 @@ fun JoinUserOrChannelView(
) {
Surface {
Column(
modifier = Modifier.padding(10.dp).heightIn(min = 500.dp),
modifier =
Modifier
.padding(10.dp)
.heightIn(min = 500.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -267,7 +270,10 @@ private fun SearchEditTextForJoin(
}
Row(
modifier = Modifier.padding(horizontal = 10.dp).fillMaxWidth(),
modifier =
Modifier
.padding(horizontal = 10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -280,7 +286,8 @@ private fun SearchEditTextForJoin(
},
leadingIcon = { SearchIcon(modifier = Size20Modifier, Color.Unspecified) },
modifier =
Modifier.weight(1f, true)
Modifier
.weight(1f, true)
.defaultMinSize(minHeight = 20.dp)
.focusRequester(focusRequester)
.onFocusChanged {
@@ -330,7 +337,11 @@ private fun RenderSearchResults(
}
Row(
modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(vertical = 10.dp),
modifier =
Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(vertical = 10.dp),
) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
@@ -346,6 +357,10 @@ private fun RenderSearchResults(
searchBarViewModel.clear()
}
HorizontalDivider(
thickness = DividerThickness,
)
}
itemsIndexed(
@@ -356,6 +371,10 @@ private fun RenderSearchResults(
nav("Channel/${item.idHex}")
searchBarViewModel.clear()
}
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
@@ -393,36 +412,30 @@ fun UserComposeForChat(
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
Column(
Row(
modifier =
Modifier.clickable(
onClick = onClick,
).padding(
start = 12.dp,
end = 12.dp,
top = 10.dp,
bottom = 10.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
ClickableUserPicture(baseUser, Size55dp, accountViewModel)
Column(
modifier =
Modifier.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp,
),
verticalAlignment = Alignment.CenterVertically,
Modifier
.padding(start = 10.dp)
.weight(1f),
) {
ClickableUserPicture(baseUser, Size55dp, accountViewModel)
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,
)
}
}
@@ -45,7 +45,9 @@ fun NewPollOption(
Row {
val deleteIcon: @Composable (() -> Unit) = {
IconButton(
onClick = { pollViewModel.pollOptions.remove(optionIndex) },
onClick = {
pollViewModel.removePollOption(optionIndex)
},
) {
Icon(
imageVector = Icons.Default.Delete,
@@ -57,7 +59,9 @@ fun NewPollOption(
OutlinedTextField(
modifier = Modifier.weight(1F),
value = pollViewModel.pollOptions[optionIndex] ?: "",
onValueChange = { pollViewModel.pollOptions[optionIndex] = it },
onValueChange = {
pollViewModel.updatePollOption(optionIndex, it)
},
label = {
Text(
text = stringResource(R.string.poll_option_index).format(optionIndex + 1),
@@ -56,8 +56,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material.icons.filled.LocationOff
@@ -66,12 +64,11 @@ import androidx.compose.material.icons.filled.Sell
import androidx.compose.material.icons.filled.ShowChart
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
@@ -128,7 +125,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.Nip96MediaServers
@@ -147,6 +144,7 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.PollIcon
import com.vitorpamplona.amethyst.ui.note.RegularPostIcon
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ZapSplitIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MyTextField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowUserSuggestionList
@@ -154,6 +152,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
@@ -168,18 +167,21 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
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 kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.lang.Math.round
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
@Composable
fun NewPostView(
onClose: () -> Unit,
@@ -187,6 +189,7 @@ fun NewPostView(
quote: Note? = null,
fork: Note? = null,
version: Note? = null,
draft: Note? = null,
enableMessageInterface: Boolean = false,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
@@ -201,10 +204,21 @@ fun NewPostView(
var showRelaysDialog by remember { mutableStateOf(false) }
var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() }
LaunchedEffect(Unit) {
postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version)
LaunchedEffect(key1 = postViewModel.draftTag) {
launch(Dispatchers.IO) {
postViewModel.draftTextChanges
.receiveAsFlow()
.debounce(1000)
.collectLatest {
postViewModel.sendDraft(relayList = relayList)
}
}
}
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version, draft)
postViewModel.imageUploadingError.collect { error ->
withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() }
}
@@ -221,7 +235,12 @@ fun NewPostView(
}
Dialog(
onDismissRequest = { onClose() },
onDismissRequest = {
scope.launch {
postViewModel.sendDraftSync(relayList = relayList)
onClose()
}
},
properties =
DialogProperties(
usePlatformDefaultWidth = false,
@@ -280,8 +299,9 @@ fun NewPostView(
Spacer(modifier = StdHorzSpacer)
CloseButton(
onPress = {
postViewModel.cancel()
scope.launch {
postViewModel.sendDraftSync(relayList = relayList)
postViewModel.cancel()
delay(100)
onClose()
}
@@ -338,6 +358,7 @@ fun NewPostView(
makeItShort = true,
unPackReply = false,
isQuotedNote = true,
quotesLeft = 1,
modifier = MaterialTheme.colorScheme.replyModifier,
accountViewModel = accountViewModel,
nav = nav,
@@ -352,7 +373,7 @@ fun NewPostView(
}
}
if (enableMessageInterface) {
if (postViewModel.wantsDirectMessage) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
@@ -415,11 +436,12 @@ fun NewPostView(
val backgroundColor = remember { mutableStateOf(bgColor) }
BechLink(
myUrlPreview,
true,
backgroundColor,
accountViewModel,
nav,
word = myUrlPreview,
canPreview = true,
quotesLeft = 1,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
} else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) {
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
@@ -581,7 +603,7 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) {
}
MarkAsSensitive(postViewModel) {
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
postViewModel.toggleMarkAsSensitive()
}
AddGeoHash(postViewModel) {
@@ -725,7 +747,7 @@ fun ContentSensitivityExplainer(postViewModel: NewPostViewModel) {
)
}
Divider()
HorizontalDivider(thickness = DividerThickness)
Text(
text = stringResource(R.string.add_sensitive_content_explainer),
@@ -772,7 +794,7 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) {
)
}
Divider()
HorizontalDivider(thickness = DividerThickness)
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -806,7 +828,7 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) {
)
}
Divider()
HorizontalDivider(thickness = DividerThickness)
}
}
@@ -827,7 +849,9 @@ fun SellProduct(postViewModel: NewPostViewModel) {
MyTextField(
value = postViewModel.title,
onValueChange = { postViewModel.title = it },
onValueChange = {
postViewModel.updateTitle(it)
},
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
@@ -847,7 +871,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
)
}
Divider()
HorizontalDivider(thickness = DividerThickness)
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -863,13 +887,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
modifier = Modifier.fillMaxWidth(),
value = postViewModel.price,
onValueChange = {
runCatching {
if (it.text.isEmpty()) {
postViewModel.price = TextFieldValue("")
} else if (it.text.toLongOrNull() != null) {
postViewModel.price = it
}
}
postViewModel.updatePrice(it)
},
placeholder = {
Text(
@@ -890,7 +908,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
)
}
Divider()
HorizontalDivider(thickness = DividerThickness)
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -934,7 +952,9 @@ fun SellProduct(postViewModel: NewPostViewModel) {
TextSpinner(
placeholder = conditionTypes.filter { it.first == postViewModel.condition }.first().second,
options = conditionOptions,
onSelect = { postViewModel.condition = conditionTypes[it].first },
onSelect = {
postViewModel.updateCondition(conditionTypes[it].first)
},
modifier =
Modifier
.weight(1f)
@@ -955,7 +975,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
}
}
Divider()
HorizontalDivider(thickness = DividerThickness)
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -998,7 +1018,9 @@ fun SellProduct(postViewModel: NewPostViewModel) {
categoryTypes.filter { it.second == postViewModel.category.text }.firstOrNull()?.second
?: "",
options = categoryOptions,
onSelect = { postViewModel.category = TextFieldValue(categoryTypes[it].second) },
onSelect = {
postViewModel.updateCategory(TextFieldValue(categoryTypes[it].second))
},
modifier =
Modifier
.weight(1f)
@@ -1019,7 +1041,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
}
}
Divider()
HorizontalDivider(thickness = DividerThickness)
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -1033,7 +1055,9 @@ fun SellProduct(postViewModel: NewPostViewModel) {
MyTextField(
value = postViewModel.locationText,
onValueChange = { postViewModel.locationText = it },
onValueChange = {
postViewModel.updateLocation(it)
},
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
@@ -1053,7 +1077,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
)
}
Divider()
HorizontalDivider(thickness = DividerThickness)
}
}
@@ -1072,40 +1096,21 @@ fun FowardZapTo(
.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.AutoMirrored.Outlined.ArrowForwardIos,
contentDescription = stringResource(id = R.string.zaps),
modifier =
Modifier
.size(13.dp)
.align(Alignment.CenterEnd),
tint = BitcoinOrange,
)
}
ZapSplitIcon()
Text(
text = stringResource(R.string.zap_split_title),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp),
modifier = Modifier.padding(horizontal = 10.dp).weight(1f),
)
OutlinedButton(onClick = { postViewModel.updateZapFromText() }) {
Text(text = stringResource(R.string.load_from_text))
}
}
Divider()
HorizontalDivider(thickness = DividerThickness)
Text(
text = stringResource(R.string.zap_split_explainer),
@@ -1123,7 +1128,7 @@ fun FowardZapTo(
Spacer(modifier = DoubleHorzSpacer)
Column(modifier = Modifier.weight(1f)) {
UsernameDisplay(splitItem.key, showPlayButton = false)
UsernameDisplay(splitItem.key)
Text(
text = String.format("%.0f%%", splitItem.percentage * 100),
maxLines = 1,
@@ -1138,7 +1143,7 @@ fun FowardZapTo(
Slider(
value = splitItem.percentage,
onValueChange = { sliderValue ->
val rounded = (round(sliderValue * 20)) / 20.0f
val rounded = (round(sliderValue * 100)) / 100.0f
postViewModel.updateZapPercentage(index, rounded)
},
modifier = Modifier.weight(1.5f),
@@ -1209,7 +1214,7 @@ fun LocationAsHash(postViewModel: NewPostViewModel) {
DisplayLocationObserver(postViewModel)
}
Divider()
HorizontalDivider(thickness = DividerThickness)
Text(
text = stringResource(R.string.geohash_explainer),
@@ -1279,8 +1284,7 @@ fun Notifying(
mentions.forEachIndexed { idx, user ->
val innerUserState by user.live().metadata.observeAsState()
innerUserState?.user?.let { myUser ->
val tags =
remember(innerUserState) { myUser.info?.latestMetadata?.tags?.toImmutableListOfLists() }
val tags = myUser.info?.tags
Button(
shape = ButtonBorder,
@@ -1435,50 +1439,10 @@ private fun ForwardZapTo(
IconButton(
onClick = { onClick() },
) {
Box(
Modifier
.height(20.dp)
.width(25.dp),
) {
if (!postViewModel.wantsForwardZapTo) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.add_zap_split),
modifier =
Modifier
.size(20.dp)
.align(Alignment.CenterStart),
tint = MaterialTheme.colorScheme.onBackground,
)
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos,
contentDescription = null,
modifier =
Modifier
.size(13.dp)
.align(Alignment.CenterEnd),
tint = MaterialTheme.colorScheme.onBackground,
)
} else {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.cancel_zap_split),
modifier =
Modifier
.size(20.dp)
.align(Alignment.CenterStart),
tint = BitcoinOrange,
)
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos,
contentDescription = null,
modifier =
Modifier
.size(13.dp)
.align(Alignment.CenterEnd),
tint = BitcoinOrange,
)
}
if (!postViewModel.wantsForwardZapTo) {
ZapSplitIcon(tint = MaterialTheme.colorScheme.onBackground)
} else {
ZapSplitIcon(tint = BitcoinOrange)
}
}
}
@@ -1722,7 +1686,7 @@ fun ImageVideoDescription(
}
}
Divider()
HorizontalDivider(thickness = DividerThickness)
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -35,8 +35,8 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.commons.RichTextParser
import com.vitorpamplona.amethyst.commons.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -49,12 +49,15 @@ import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.components.Split
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageEvent
@@ -69,10 +72,13 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
enum class UserSuggestionAnchor {
MAIN_MESSAGE,
@@ -82,6 +88,8 @@ enum class UserSuggestionAnchor {
@Stable
open class NewPostViewModel() : ViewModel() {
var draftTag: String by mutableStateOf(UUID.randomUUID().toString())
var accountViewModel: AccountViewModel? = null
var account: Account? = null
var requiresNIP24: Boolean = false
@@ -164,6 +172,8 @@ open class NewPostViewModel() : ViewModel() {
// NIP24 Wrapped DMs / Group messages
var nip24 by mutableStateOf(false)
val draftTextChanges = Channel<String>(Channel.CONFLATED)
fun lnAddress(): String? {
return account?.userProfile()?.info?.lnAddress()
}
@@ -182,132 +192,280 @@ open class NewPostViewModel() : ViewModel() {
quote: Note?,
fork: Note?,
version: Note?,
draft: 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)
}
val noteEvent = draft?.event
val noteAuthor = draft?.author
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)
if (draft != null && noteEvent is DraftEvent && noteAuthor != null) {
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.createTempDraftNote(noteEvent) { innerNote ->
if (innerNote != null) {
val oldTag = (draft.event as? AddressableEvent)?.dTag()
if (oldTag != null) {
draftTag = oldTag
}
loadFromDraft(innerNote, accountViewModel)
}
}
}
}
?: run {
eTags = null
pTags = null
} else {
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
}
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
contentToAddUrl = null
quote?.let {
message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}")
urlPreview = findUrlInMessage()
it.author?.let { quotedUser ->
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
if (forwardZapTo.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) {
forwardZapTo.addItem(quotedUser)
}
if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) {
forwardZapTo.addItem(accountViewModel.userProfile())
}
val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex }
forwardZapTo.updatePercentage(pos, 0.9f)
}
}
}
fork?.let {
message = TextFieldValue(version?.event?.content() ?: it.event?.content() ?: "")
urlPreview = findUrlInMessage()
it.event?.isSensitive()?.let {
if (it) wantsToMarkAsSensitive = true
}
it.event?.zapraiserAmount()?.let {
zapRaiserAmount = it
}
it.event?.zapSplitSetup()?.let {
val totalWeight = it.sumOf { if (it.isLnAddress) 0.0 else it.weight }
it.forEach {
if (!it.isLnAddress) {
forwardZapTo.addItem(LocalCache.getOrCreateUser(it.lnAddressOrPubKeyHex), (it.weight / totalWeight).toFloat())
}
}
}
// Only adds if it is not already set up.
if (forwardZapTo.items.isEmpty()) {
it.author?.let { forkedAuthor ->
if (forkedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
if (forwardZapTo.items.none { it.key.pubkeyHex == forkedAuthor.pubkeyHex }) forwardZapTo.addItem(forkedAuthor)
if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) forwardZapTo.addItem(accountViewModel.userProfile())
val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == forkedAuthor.pubkeyHex }
forwardZapTo.updatePercentage(pos, 0.8f)
}
}
}
it.author?.let {
if (this.pTags == null) {
this.pTags = listOf(it)
} else if (this.pTags?.contains(it) != true) {
this.pTags = listOf(it) + (this.pTags ?: emptyList())
}
}
forkedFromNote = it
} ?: run {
forkedFromNote = null
}
if (!forwardZapTo.items.isEmpty()) {
wantsForwardZapTo = true
}
}
}
private fun loadFromDraft(
draft: Note,
accountViewModel: AccountViewModel,
) {
Log.d("draft", draft.event!!.toJson())
val draftEvent = draft.event ?: return
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
val localfowardZapTo = draftEvent.tags().filter { it.size > 1 && it[0] == "zap" }
forwardZapTo = Split()
localfowardZapTo.forEach {
val user = LocalCache.getOrCreateUser(it[1])
val value = it.last().toFloatOrNull() ?: 0f
forwardZapTo.addItem(user, value)
}
forwardZapToEditting = TextFieldValue("")
wantsForwardZapTo = localfowardZapTo.isNotEmpty()
quote?.let {
message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}")
urlPreview = findUrlInMessage()
it.author?.let { quotedUser ->
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
if (forwardZapTo.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) {
forwardZapTo.addItem(quotedUser)
}
if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) {
forwardZapTo.addItem(accountViewModel.userProfile())
}
val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex }
forwardZapTo.updatePercentage(pos, 0.9f)
}
}
wantsToMarkAsSensitive = draftEvent.tags().any { it.size > 1 && it[0] == "content-warning" }
wantsToAddGeoHash = draftEvent.tags().any { it.size > 1 && it[0] == "g" }
val zapraiser = draftEvent.tags().filter { it.size > 1 && it[0] == "zapraiser" }
wantsZapraiser = zapraiser.isNotEmpty()
zapRaiserAmount = null
if (wantsZapraiser) {
zapRaiserAmount = zapraiser.first()[1].toLongOrNull() ?: 0
}
fork?.let {
message = TextFieldValue(version?.event?.content() ?: it.event?.content() ?: "")
urlPreview = findUrlInMessage()
it.event?.isSensitive()?.let {
if (it) wantsToMarkAsSensitive = true
eTags =
draftEvent.tags().filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) != "fork" }.mapNotNull {
val note = LocalCache.checkGetOrCreateNote(it[1])
note
}
it.event?.zapraiserAmount()?.let {
zapRaiserAmount = it
}
it.event?.zapSplitSetup()?.let {
val totalWeight = it.sumOf { if (it.isLnAddress) 0.0 else it.weight }
it.forEach {
if (!it.isLnAddress) {
forwardZapTo.addItem(LocalCache.getOrCreateUser(it.lnAddressOrPubKeyHex), (it.weight / totalWeight).toFloat())
}
if (draftEvent !is PrivateDmEvent && draftEvent !is ChatMessageEvent) {
pTags =
draftEvent.tags().filter { it.size > 1 && it[0] == "p" }.map {
LocalCache.getOrCreateUser(it[1])
}
}
// Only adds if it is not already set up.
if (forwardZapTo.items.isEmpty()) {
it.author?.let { forkedAuthor ->
if (forkedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
if (forwardZapTo.items.none { it.key.pubkeyHex == forkedAuthor.pubkeyHex }) forwardZapTo.addItem(forkedAuthor)
if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) forwardZapTo.addItem(accountViewModel.userProfile())
val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == forkedAuthor.pubkeyHex }
forwardZapTo.updatePercentage(pos, 0.8f)
}
}
}
it.author?.let {
if (this.pTags == null) {
this.pTags = listOf(it)
} else if (this.pTags?.contains(it) != true) {
this.pTags = listOf(it) + (this.pTags ?: emptyList())
}
}
forkedFromNote = it
}
if (!forwardZapTo.items.isEmpty()) {
draftEvent.tags().filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && it.get(3) == "fork" }.forEach {
val note = LocalCache.checkGetOrCreateNote(it[1])
forkedFromNote = note
}
originalNote =
draftEvent.tags().filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "reply" }.map {
LocalCache.checkGetOrCreateNote(it[1])
}.firstOrNull()
if (originalNote == null) {
originalNote =
draftEvent.tags().filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "root" }.map {
LocalCache.checkGetOrCreateNote(it[1])
}.firstOrNull()
}
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
if (forwardZapTo.items.isNotEmpty()) {
wantsForwardZapTo = true
}
val polls = draftEvent.tags().filter { it.size > 1 && it[0] == "poll_option" }
wantsPoll = polls.isNotEmpty()
polls.forEach {
pollOptions[it[1].toInt()] = it[2]
}
val minMax = draftEvent.tags().filter { it.size > 1 && (it[0] == "value_minimum" || it[0] == "value_maximum") }
minMax.forEach {
if (it[0] == "value_maximum") {
valueMaximum = it[1].toInt()
} else if (it[0] == "value_minimum") {
valueMinimum = it[1].toInt()
}
}
wantsProduct = draftEvent.kind() == 30402
title = TextFieldValue(draftEvent.tags().filter { it.size > 1 && it[0] == "title" }.map { it[1] }?.firstOrNull() ?: "")
price = TextFieldValue(draftEvent.tags().filter { it.size > 1 && it[0] == "price" }.map { it[1] }?.firstOrNull() ?: "")
category = TextFieldValue(draftEvent.tags().filter { it.size > 1 && it[0] == "t" }.map { it[1] }?.firstOrNull() ?: "")
locationText = TextFieldValue(draftEvent.tags().filter { it.size > 1 && it[0] == "location" }.map { it[1] }?.firstOrNull() ?: "")
condition = ClassifiedsEvent.CONDITION.entries.firstOrNull {
it.value == draftEvent.tags().filter { it.size > 1 && it[0] == "condition" }.map { it[1] }.firstOrNull()
} ?: ClassifiedsEvent.CONDITION.USED_LIKE_NEW
wantsDirectMessage = draftEvent is PrivateDmEvent || draftEvent is ChatMessageEvent
draftEvent.subject()?.let {
subject = TextFieldValue()
}
message =
if (draftEvent is PrivateDmEvent) {
val recepientNpub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() }
toUsers = TextFieldValue("@$recepientNpub")
TextFieldValue(draftEvent.cachedContentFor(accountViewModel.account.signer) ?: "")
} else {
TextFieldValue(draftEvent.content())
}
requiresNIP24 = draftEvent is ChatMessageEvent
nip24 = draftEvent is ChatMessageEvent
if (draftEvent is ChatMessageEvent) {
toUsers =
TextFieldValue(
draftEvent.recipientsPubKey().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
)
}
urlPreview = findUrlInMessage()
}
fun sendPost(relayList: List<Relay>? = null) {
viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) }
viewModelScope.launch(Dispatchers.IO) {
innerSendPost(relayList, null)
accountViewModel?.deleteDraft(draftTag)
cancel()
}
}
suspend fun innerSendPost(relayList: List<Relay>? = null) {
fun sendDraft(relayList: List<Relay>? = null) {
viewModelScope.launch {
sendDraftSync(relayList)
}
}
suspend fun sendDraftSync(relayList: List<Relay>? = null) {
innerSendPost(relayList, draftTag)
}
private suspend fun innerSendPost(
relayList: List<Relay>? = null,
localDraft: String?,
) = withContext(Dispatchers.IO) {
if (accountViewModel == null) {
cancel()
return
return@withContext
}
val tagger =
NewMessageTagger(message.text, pTags, eTags, originalNote?.channelHex(), accountViewModel!!)
val tagger = NewMessageTagger(message.text, pTags, eTags, originalNote?.channelHex(), accountViewModel!!)
tagger.run()
val toUsersTagger = NewMessageTagger(toUsers.text, null, null, null, accountViewModel!!)
@@ -361,6 +519,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else {
account?.sendChannelMessage(
@@ -373,6 +532,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
}
} else if (originalNote?.event is PrivateDmEvent) {
@@ -386,6 +546,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else if (originalNote?.event is ChatMessageEvent) {
val receivers =
@@ -407,6 +568,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else if (!dmUsers.isNullOrEmpty()) {
if (nip24 || dmUsers.size > 1) {
@@ -421,6 +583,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else {
account?.sendPrivateMessage(
@@ -433,6 +596,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
}
} else if (originalNote?.event is GitIssueEvent) {
@@ -473,24 +637,26 @@ open class NewPostViewModel() : ViewModel() {
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else {
if (wantsPoll) {
account?.sendPoll(
tagger.message,
tagger.eTags,
tagger.pTags,
pollOptions,
valueMaximum,
valueMinimum,
consensusThreshold,
closedAt,
zapReceiver,
wantsToMarkAsSensitive,
localZapRaiserAmount,
relayList,
geoHash,
message = tagger.message,
replyTo = tagger.eTags,
mentions = tagger.pTags,
pollOptions = pollOptions,
valueMaximum = valueMaximum,
valueMinimum = valueMinimum,
consensusThreshold = consensusThreshold,
closedAt = closedAt,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else if (wantsProduct) {
account?.sendClassifieds(
@@ -509,6 +675,7 @@ open class NewPostViewModel() : ViewModel() {
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else {
// adds markers
@@ -545,11 +712,10 @@ open class NewPostViewModel() : ViewModel() {
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
}
}
cancel()
}
fun upload(
@@ -627,6 +793,8 @@ open class NewPostViewModel() : ViewModel() {
toUsers = TextFieldValue("")
subject = TextFieldValue("")
forkedFromNote = null
contentToAddUrl = null
urlPreview = null
isUploadingImage = false
@@ -648,6 +816,9 @@ open class NewPostViewModel() : ViewModel() {
wantsProduct = false
condition = ClassifiedsEvent.CONDITION.USED_LIKE_NEW
locationText = TextFieldValue("")
title = TextFieldValue("")
category = TextFieldValue("")
price = TextFieldValue("")
wantsForwardZapTo = false
@@ -660,9 +831,17 @@ open class NewPostViewModel() : ViewModel() {
userSuggestionAnchor = null
userSuggestionsMainMessage = null
draftTag = UUID.randomUUID().toString()
NostrSearchEventOrUserDataSource.clear()
}
fun deleteDraft() {
viewModelScope.launch(Dispatchers.IO) {
accountViewModel?.deleteDraft(draftTag)
}
}
open fun findUrlInMessage(): String? {
return message.text.split('\n').firstNotNullOfOrNull { paragraph ->
paragraph.split(' ').firstOrNull { word: String ->
@@ -675,6 +854,10 @@ open class NewPostViewModel() : ViewModel() {
pTags = pTags?.filter { it != userToRemove }
}
private fun saveDraft() {
draftTextChanges.trySend("")
}
open fun updateMessage(it: TextFieldValue) {
message = it
urlPreview = findUrlInMessage()
@@ -689,7 +872,7 @@ open class NewPostViewModel() : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
userSuggestions =
LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }))
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex }))
.reversed()
}
} else {
@@ -697,6 +880,8 @@ open class NewPostViewModel() : ViewModel() {
userSuggestions = emptyList()
}
}
saveDraft()
}
open fun updateToUsers(it: TextFieldValue) {
@@ -712,7 +897,7 @@ open class NewPostViewModel() : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
userSuggestions =
LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }))
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex }))
.reversed()
}
} else {
@@ -720,10 +905,12 @@ open class NewPostViewModel() : ViewModel() {
userSuggestions = emptyList()
}
}
saveDraft()
}
open fun updateSubject(it: TextFieldValue) {
subject = it
saveDraft()
}
open fun updateZapForwardTo(it: TextFieldValue) {
@@ -741,6 +928,7 @@ open class NewPostViewModel() : ViewModel() {
compareBy(
{ account?.isFollowing(it) },
{ it.toBestDisplayName() },
{ it.pubkeyHex },
),
)
.reversed()
@@ -768,16 +956,6 @@ open class NewPostViewModel() : ViewModel() {
} 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
val wordToInsert = "@${item.pubkeyNpub()}"
forwardZapTo = item
forwardZapToEditting = TextFieldValue(
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(" ")
@@ -795,6 +973,8 @@ open class NewPostViewModel() : ViewModel() {
userSuggestionsMainMessage = null
userSuggestions = emptyList()
}
saveDraft()
}
private fun newStateMapPollOptions(): SnapshotStateMap<Int, String> {
@@ -865,6 +1045,7 @@ open class NewPostViewModel() : ViewModel() {
message = message.insertUrlAtCursor(imageUrl)
urlPreview = findUrlInMessage()
saveDraft()
}
},
onError = {
@@ -909,6 +1090,7 @@ open class NewPostViewModel() : ViewModel() {
}
urlPreview = findUrlInMessage()
saveDraft()
}
},
onError = {
@@ -929,6 +1111,7 @@ open class NewPostViewModel() : ViewModel() {
locUtil?.let {
location =
it.locationStateFlow.mapLatest { it.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() }
saveDraft()
}
viewModelScope.launch(Dispatchers.IO) { locUtil?.start() }
}
@@ -953,6 +1136,9 @@ open class NewPostViewModel() : ViewModel() {
} else {
nip24 = !nip24
}
if (message.text.isNotBlank()) {
saveDraft()
}
}
fun updateMinZapAmountForPoll(textMin: String) {
@@ -972,6 +1158,7 @@ open class NewPostViewModel() : ViewModel() {
}
checkMinMax()
saveDraft()
}
fun updateMaxZapAmountForPoll(textMax: String) {
@@ -991,6 +1178,7 @@ open class NewPostViewModel() : ViewModel() {
}
checkMinMax()
saveDraft()
}
fun checkMinMax() {
@@ -1009,6 +1197,72 @@ open class NewPostViewModel() : ViewModel() {
) {
forwardZapTo.updatePercentage(index, sliderValue)
}
fun updateZapFromText() {
viewModelScope.launch(Dispatchers.Default) {
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), null, accountViewModel!!)
tagger.run()
tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.items.any { it.key == taggedUser }) {
forwardZapTo.addItem(taggedUser)
}
}
}
}
fun updateZapRaiserAmount(newAmount: Long?) {
zapRaiserAmount = newAmount
saveDraft()
}
fun removePollOption(optionIndex: Int) {
pollOptions.remove(optionIndex)
saveDraft()
}
fun updatePollOption(
optionIndex: Int,
text: String,
) {
pollOptions[optionIndex] = text
saveDraft()
}
fun toggleMarkAsSensitive() {
wantsToMarkAsSensitive = !wantsToMarkAsSensitive
saveDraft()
}
fun updateTitle(it: TextFieldValue) {
title = it
saveDraft()
}
fun updatePrice(it: TextFieldValue) {
runCatching {
if (it.text.isEmpty()) {
price = TextFieldValue("")
} else if (it.text.toLongOrNull() != null) {
price = it
}
}
saveDraft()
}
fun updateCondition(newCondition: ClassifiedsEvent.CONDITION) {
condition = newCondition
saveDraft()
}
fun updateCategory(value: TextFieldValue) {
category = value
saveDraft()
}
fun updateLocation(it: TextFieldValue) {
locationText = it
saveDraft()
}
}
enum class GeohashPrecision(val digits: Int) {
@@ -900,7 +900,15 @@ fun EditableServerConfig(
onClick = {
if (url.isNotBlank() && url != "/") {
var addedWSS =
if (!url.startsWith("wss://") && !url.startsWith("ws://")) "wss://$url" else url
if (!url.startsWith("wss://") && !url.startsWith("ws://")) {
if (url.endsWith(".onion") || url.endsWith(".onion/")) {
"ws://$url"
} else {
"wss://$url"
}
} else {
url
}
if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1)
onNewRelay(RelaySetupInfo(addedWSS, read, write, feedTypes = FeedType.values().toSet()))
url = ""
@@ -68,7 +68,7 @@ class NewUserMetadataViewModel : ViewModel() {
account.userProfile().let {
// userName.value = it.bestUsername() ?: ""
displayName.value = it.bestDisplayName() ?: ""
displayName.value = it.info?.bestName() ?: ""
about.value = it.info?.about ?: ""
picture.value = it.info?.picture ?: ""
banner.value = it.info?.banner ?: ""
@@ -82,7 +82,7 @@ class NewUserMetadataViewModel : ViewModel() {
mastodon.value = ""
// TODO: Validate Telegram input, somehow.
it.info?.latestMetadata?.identityClaims()?.forEach {
it.latestMetadata?.identityClaims()?.forEach {
when (it) {
is TwitterIdentity -> twitter.value = it.toProofUrl()
is GitHubIdentity -> github.value = it.toProofUrl()
@@ -65,9 +65,11 @@ fun NotifyRequestDialog(
TranslatableRichTextViewer(
textContent,
canPreview = true,
quotesLeft = 1,
Modifier.fillMaxWidth(),
EmptyTagList,
background,
textContent,
accountViewModel,
nav,
)
@@ -294,7 +294,6 @@ private fun DisplayOwnerInformation(
UserCompose(
baseUser = it,
accountViewModel = accountViewModel,
showDiviser = false,
nav = nav,
)
}
@@ -35,8 +35,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.Divider
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
@@ -53,7 +53,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
@@ -65,6 +64,8 @@ import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.viewmodel.compose.viewModel
import com.fasterxml.jackson.databind.node.TextNode
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.service.CachedCashuProcessor
import com.vitorpamplona.amethyst.service.CashuToken
@@ -76,6 +77,7 @@ import com.vitorpamplona.amethyst.ui.note.ZapIcon
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
@@ -181,7 +183,7 @@ fun CashuPreview(
.padding(bottom = 10.dp),
) {
Icon(
painter = painterResource(R.drawable.cashu),
imageVector = CustomHashTagIcons.Cashu,
null,
modifier = Size20Modifier,
tint = Color.Unspecified,
@@ -195,7 +197,7 @@ fun CashuPreview(
)
}
Divider()
HorizontalDivider(thickness = DividerThickness)
Text(
text = "${token.totalAmount} ${stringResource(id = R.string.sats)}",
@@ -319,7 +321,7 @@ fun CashuPreviewNew(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(R.drawable.cashu),
imageVector = CustomHashTagIcons.Cashu,
null,
modifier = Modifier.size(13.dp),
tint = Color.Unspecified,
@@ -26,13 +26,18 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.AnnotatedString
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ClickableNoteTag(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val route = routeFor(baseNote, accountViewModel.userProfile())
ClickableText(
text = AnnotatedString("@${baseNote.idNote().toShortenHex()}"),
onClick = { nav("Note/${baseNote.idHex}") },
@@ -65,10 +65,10 @@ import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
@@ -286,29 +286,17 @@ private fun RenderUserAsClickableText(
additionalChars: String,
nav: (String) -> Unit,
) {
val userState by baseUser.live().metadata.observeAsState()
val route = remember { "User/${baseUser.pubkeyHex}" }
val userState by baseUser.live().userMetadataInfo.observeAsState()
val userDisplayName by
remember(userState) { derivedStateOf { userState?.user?.toBestDisplayName() } }
val userTags by
remember(userState) {
derivedStateOf { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
}
userDisplayName?.let {
userState?.bestName()?.let {
CreateClickableTextWithEmoji(
clickablePart = it,
suffix = additionalChars.ifBlank { null },
maxLines = 1,
route = route,
route = "User/${baseUser.pubkeyHex}",
nav = nav,
tags = userTags,
tags = userState?.tags ?: EmptyTagList,
)
additionalChars.ifBlank { null }?.let {
Text(text = it, maxLines = 1)
}
}
}
@@ -587,6 +575,7 @@ fun CreateClickableTextWithEmoji(
@Composable
fun CreateClickableTextWithEmoji(
clickablePart: String,
suffix: String? = null,
maxLines: Int = Int.MAX_VALUE,
overrideColor: Color? = null,
fontWeight: FontWeight = FontWeight.Normal,
@@ -599,9 +588,16 @@ fun CreateClickableTextWithEmoji(
text = clickablePart,
tags = tags,
onRegularText = {
CreateClickableText(it, null, maxLines, overrideColor, fontWeight, fontSize, route, nav)
CreateClickableText(it, suffix, maxLines, overrideColor, fontWeight, fontSize, route, nav)
},
onEmojiText = {
val nonClickablePartStyle =
SpanStyle(
fontSize = fontSize,
color = overrideColor ?: MaterialTheme.colorScheme.onBackground,
fontWeight = fontWeight,
)
val clickablePartStyle =
SpanStyle(
fontSize = fontSize,
@@ -613,6 +609,8 @@ fun CreateClickableTextWithEmoji(
it,
maxLines,
clickablePartStyle,
suffix,
nonClickablePartStyle,
) {
nav(route)
}
@@ -625,6 +623,8 @@ fun ClickableInLineIconRenderer(
wordsInOrder: ImmutableList<Nip30CustomEmoji.Renderable>,
maxLines: Int = Int.MAX_VALUE,
style: SpanStyle,
suffix: String? = null,
nonClickableStype: SpanStyle? = null,
onClick: (Int) -> Unit,
) {
val placeholderSize =
@@ -652,7 +652,10 @@ fun ClickableInLineIconRenderer(
AsyncImage(
model = value.url,
contentDescription = null,
modifier = Modifier.fillMaxSize().padding(1.dp),
modifier =
Modifier
.fillMaxSize()
.padding(1.dp),
)
},
)
@@ -675,6 +678,12 @@ fun ClickableInLineIconRenderer(
}
}
}
if (suffix != null && nonClickableStype != null) {
withStyle(nonClickableStype) {
append(suffix)
}
}
}
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
@@ -728,7 +737,10 @@ fun InLineIconRenderer(
AsyncImage(
model = value.url,
contentDescription = null,
modifier = Modifier.fillMaxSize().padding(horizontal = 0.dp),
modifier =
Modifier
.fillMaxSize()
.padding(horizontal = 0.dp),
)
},
)
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.components
import android.util.LruCache
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -43,7 +44,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ExpandableTextCutOffCalculator
import com.vitorpamplona.amethyst.commons.richtext.ExpandableTextCutOffCalculator
import com.vitorpamplona.amethyst.ui.note.getGradient
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
@@ -51,17 +52,31 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground
import com.vitorpamplona.quartz.events.ImmutableListOfLists
object ShowFullTextCache {
val cache = LruCache<String, Boolean>(10)
}
@Composable
fun ExpandableRichTextViewer(
content: String,
canPreview: Boolean,
quotesLeft: Int,
modifier: Modifier,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
id: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
var showFullText by remember { mutableStateOf(false) }
var showFullText by remember {
val cached = ShowFullTextCache.cache[id]
if (cached == null) {
ShowFullTextCache.cache.put(id, false)
mutableStateOf(false)
} else {
mutableStateOf(cached)
}
}
val whereToCut = remember(content) { ExpandableTextCutOffCalculator.indexToCutOff(content) }
@@ -80,6 +95,7 @@ fun ExpandableRichTextViewer(
RichTextViewer(
text,
canPreview,
quotesLeft,
modifier.align(Alignment.TopStart),
tags,
backgroundColor,
@@ -96,7 +112,10 @@ fun ExpandableRichTextViewer(
.fillMaxWidth()
.background(getGradient(backgroundColor)),
) {
ShowMoreButton { showFullText = !showFullText }
ShowMoreButton {
showFullText = !showFullText
ShowFullTextCache.cache.put(id, showFullText)
}
}
}
}
@@ -28,7 +28,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
@@ -44,17 +44,19 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.hashtags.Lightning
import com.vitorpamplona.amethyst.service.lnurl.CachedLnInvoiceParser
import com.vitorpamplona.amethyst.service.lnurl.InvoiceAmount
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
@@ -134,7 +136,7 @@ fun InvoicePreview(
.padding(bottom = 10.dp),
) {
Icon(
painter = painterResource(R.drawable.lightning),
imageVector = CustomHashTagIcons.Lightning,
null,
modifier = Size20Modifier,
tint = Color.Unspecified,
@@ -148,7 +150,7 @@ fun InvoicePreview(
)
}
Divider()
HorizontalDivider(thickness = DividerThickness)
amount?.let {
Text(
@@ -28,7 +28,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -44,7 +44,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -52,9 +51,12 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.hashtags.Lightning
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -117,7 +119,7 @@ fun InvoiceRequest(
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
) {
Icon(
painter = painterResource(R.drawable.lightning),
imageVector = CustomHashTagIcons.Lightning,
null,
modifier = Size20Modifier,
tint = Color.Unspecified,
@@ -131,7 +133,7 @@ fun InvoiceRequest(
)
}
Divider()
HorizontalDivider(thickness = DividerThickness)
var message by remember { mutableStateOf("") }
var amount by remember { mutableStateOf(1000L) }
@@ -27,9 +27,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.commons.MediaUrlImage
import com.vitorpamplona.amethyst.commons.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.components
import android.util.Log
import android.util.Patterns
import com.vitorpamplona.amethyst.commons.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.ATag
@@ -52,7 +52,6 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle
@@ -69,24 +68,25 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.markdown.MarkdownParseOptions
import com.halilibo.richtext.ui.material3.Material3RichText
import com.vitorpamplona.amethyst.commons.BechSegment
import com.vitorpamplona.amethyst.commons.CashuSegment
import com.vitorpamplona.amethyst.commons.EmailSegment
import com.vitorpamplona.amethyst.commons.EmojiSegment
import com.vitorpamplona.amethyst.commons.HashIndexEventSegment
import com.vitorpamplona.amethyst.commons.HashIndexUserSegment
import com.vitorpamplona.amethyst.commons.HashTagSegment
import com.vitorpamplona.amethyst.commons.ImageSegment
import com.vitorpamplona.amethyst.commons.InvoiceSegment
import com.vitorpamplona.amethyst.commons.LinkSegment
import com.vitorpamplona.amethyst.commons.MediaUrlImage
import com.vitorpamplona.amethyst.commons.PhoneSegment
import com.vitorpamplona.amethyst.commons.RegularTextSegment
import com.vitorpamplona.amethyst.commons.RichTextParser
import com.vitorpamplona.amethyst.commons.RichTextViewerState
import com.vitorpamplona.amethyst.commons.SchemelessUrlSegment
import com.vitorpamplona.amethyst.commons.Segment
import com.vitorpamplona.amethyst.commons.WithdrawSegment
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
import com.vitorpamplona.amethyst.commons.richtext.BechSegment
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
import com.vitorpamplona.amethyst.commons.richtext.EmailSegment
import com.vitorpamplona.amethyst.commons.richtext.EmojiSegment
import com.vitorpamplona.amethyst.commons.richtext.HashIndexEventSegment
import com.vitorpamplona.amethyst.commons.richtext.HashIndexUserSegment
import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
import com.vitorpamplona.amethyst.commons.richtext.ImageSegment
import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment
import com.vitorpamplona.amethyst.commons.richtext.LinkSegment
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment
import com.vitorpamplona.amethyst.commons.richtext.Segment
import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.HashtagIcon
import com.vitorpamplona.amethyst.model.Note
@@ -98,7 +98,6 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadedBechLink
import com.vitorpamplona.amethyst.ui.theme.Font17SP
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle
@@ -129,6 +128,7 @@ fun isMarkdown(content: String): Boolean {
fun RichTextViewer(
content: String,
canPreview: Boolean,
quotesLeft: Int,
modifier: Modifier,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
@@ -139,7 +139,7 @@ fun RichTextViewer(
if (remember(content) { isMarkdown(content) }) {
RenderContentAsMarkdown(content, tags, accountViewModel, nav)
} else {
RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, nav)
RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, accountViewModel, nav)
}
}
}
@@ -277,6 +277,7 @@ private fun RenderRegular(
content: String,
tags: ImmutableListOfLists<String>,
canPreview: Boolean,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
@@ -287,6 +288,7 @@ private fun RenderRegular(
word,
state,
backgroundColor,
quotesLeft,
accountViewModel,
nav,
)
@@ -304,7 +306,7 @@ private fun RenderRegular(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RenderRegular(
fun RenderRegular(
content: String,
tags: ImmutableListOfLists<String>,
wordRenderer: @Composable (Segment, RichTextViewerState) -> Unit,
@@ -318,7 +320,7 @@ private fun RenderRegular(
val textStyle =
remember(currentTextStyle) {
currentTextStyle.copy(
lineHeight = 1.4.em,
lineHeight = 1.3.em,
)
}
@@ -394,10 +396,10 @@ private fun RenderWordWithoutPreview(
is CashuSegment -> Text(word.segmentText)
is EmailSegment -> ClickableEmail(word.segmentText)
is PhoneSegment -> ClickablePhone(word.segmentText)
is BechSegment -> BechLink(word.segmentText, false, backgroundColor, accountViewModel, nav)
is BechSegment -> BechLink(word.segmentText, false, 0, backgroundColor, accountViewModel, nav)
is HashTagSegment -> HashTag(word, nav)
is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
is HashIndexEventSegment -> TagLink(word, false, backgroundColor, accountViewModel, nav)
is HashIndexEventSegment -> TagLink(word, false, 0, backgroundColor, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
is RegularTextSegment -> Text(word.segmentText)
}
@@ -408,6 +410,7 @@ private fun RenderWordWithPreview(
word: Segment,
state: RichTextViewerState,
backgroundColor: MutableState<Color>,
quotesLeft: Int,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -420,10 +423,10 @@ private fun RenderWordWithPreview(
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 BechSegment -> BechLink(word.segmentText, true, quotesLeft, backgroundColor, accountViewModel, nav)
is HashTagSegment -> HashTag(word, nav)
is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
is HashIndexEventSegment -> TagLink(word, true, backgroundColor, accountViewModel, nav)
is HashIndexEventSegment -> TagLink(word, true, quotesLeft, backgroundColor, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
is RegularTextSegment -> Text(word.segmentText)
}
@@ -558,15 +561,15 @@ fun ObserveNIP19(
accountViewModel: AccountViewModel,
onRefresh: () -> Unit,
) {
when (val parsed = entity) {
is Nip19Bech32.NPub -> ObserveNIP19User(parsed.hex, accountViewModel, onRefresh)
is Nip19Bech32.NProfile -> ObserveNIP19User(parsed.hex, accountViewModel, onRefresh)
when (entity) {
is Nip19Bech32.NPub -> ObserveNIP19User(entity.hex, accountViewModel, onRefresh)
is Nip19Bech32.NProfile -> ObserveNIP19User(entity.hex, accountViewModel, onRefresh)
is Nip19Bech32.Note -> ObserveNIP19Event(parsed.hex, accountViewModel, onRefresh)
is Nip19Bech32.NEvent -> ObserveNIP19Event(parsed.hex, accountViewModel, onRefresh)
is Nip19Bech32.NEmbed -> ObserveNIP19Event(parsed.event.id, accountViewModel, onRefresh)
is Nip19Bech32.Note -> ObserveNIP19Event(entity.hex, accountViewModel, onRefresh)
is Nip19Bech32.NEvent -> ObserveNIP19Event(entity.hex, accountViewModel, onRefresh)
is Nip19Bech32.NEmbed -> ObserveNIP19Event(entity.event.id, accountViewModel, onRefresh)
is Nip19Bech32.NAddress -> ObserveNIP19Event(parsed.atag, accountViewModel, onRefresh)
is Nip19Bech32.NAddress -> ObserveNIP19Event(entity.atag, accountViewModel, onRefresh)
is Nip19Bech32.NSec -> {}
is Nip19Bech32.NRelay -> {}
@@ -643,26 +646,24 @@ private fun ObserveUser(
fun BechLink(
word: String,
canPreview: Boolean,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
var loadedLink by remember { mutableStateOf<LoadedBechLink?>(null) }
val loadedLink by produceCachedState(cache = accountViewModel.bechLinkCache, key = word)
if (loadedLink == null) {
LaunchedEffect(key1 = word) {
accountViewModel.parseNIP19(word) { loadedLink = it }
}
}
val baseNote = loadedLink?.baseNote
if (canPreview && loadedLink?.baseNote != null) {
if (canPreview && quotesLeft > 0 && baseNote != null) {
Row {
DisplayFullNote(
loadedLink?.baseNote!!,
accountViewModel,
backgroundColor,
nav,
loadedLink?.nip19?.additionalChars?.ifBlank { null },
note = baseNote,
extraChars = loadedLink?.nip19?.additionalChars?.ifBlank { null },
quotesLeft = quotesLeft,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
} else if (loadedLink?.nip19 != null) {
@@ -683,17 +684,19 @@ fun BechLink(
@Composable
private fun DisplayFullNote(
it: Note,
accountViewModel: AccountViewModel,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
note: Note,
extraChars: String?,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
NoteCompose(
baseNote = it,
baseNote = note,
accountViewModel = accountViewModel,
modifier = MaterialTheme.colorScheme.innerPostModifier,
parentBackgroundColor = backgroundColor,
quotesLeft = quotesLeft - 1,
isQuotedNote = true,
nav = nav,
)
@@ -712,46 +715,38 @@ fun HashTag(
) {
val primary = MaterialTheme.colorScheme.primary
val background = MaterialTheme.colorScheme.onBackground
val hashtagIcon: HashtagIcon? =
remember(segment.segmentText) { checkForHashtagWithIcon(segment.hashtag, primary) }
val regularText = remember { SpanStyle(color = background) }
val clickableTextStyle = remember { SpanStyle(color = primary) }
val hashtagIcon: HashtagIcon? = checkForHashtagWithIcon(segment.hashtag)
val annotatedTermsString =
remember(segment.segmentText) {
buildAnnotatedString {
withStyle(clickableTextStyle) {
withStyle(SpanStyle(color = primary)) {
pushStringAnnotation("routeToHashtag", "")
append("#${segment.hashtag}")
pop()
}
if (hashtagIcon != null) {
withStyle(clickableTextStyle) {
withStyle(SpanStyle(color = primary)) {
pushStringAnnotation("routeToHashtag", "")
appendInlineContent("inlineContent", "[icon]")
pop()
}
}
segment.extras?.let { withStyle(regularText) { append(it) } }
segment.extras?.let { withStyle(SpanStyle(color = background)) { append(it) } }
}
}
val inlineContent =
if (hashtagIcon != null) {
mapOf("inlineContent" to InlineIcon(hashtagIcon))
} else {
emptyMap()
}
val pressIndicator = remember { Modifier.clickable { nav("Hashtag/${segment.hashtag}") } }
Text(
text = annotatedTermsString,
modifier = pressIndicator,
inlineContent = inlineContent,
modifier = remember { Modifier.clickable { nav("Hashtag/${segment.hashtag}") } },
inlineContent =
if (hashtagIcon != null) {
mapOf("inlineContent" to InlineIcon(hashtagIcon))
} else {
emptyMap()
},
)
}
@@ -765,9 +760,9 @@ private fun InlineIcon(hashtagIcon: HashtagIcon) =
),
) {
Icon(
painter = painterResource(hashtagIcon.icon),
imageVector = hashtagIcon.icon,
contentDescription = hashtagIcon.description,
tint = hashtagIcon.color,
tint = Color.Unspecified,
modifier = hashtagIcon.modifier,
)
}
@@ -814,6 +809,7 @@ fun LoadNote(
fun TagLink(
word: HashIndexEventSegment,
canPreview: Boolean,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
@@ -827,6 +823,7 @@ fun TagLink(
it,
word.extras,
canPreview,
quotesLeft,
accountViewModel,
backgroundColor,
nav,
@@ -841,21 +838,23 @@ private fun DisplayNoteFromTag(
baseNote: Note,
addedChars: String?,
canPreview: Boolean,
quotesLeft: Int,
accountViewModel: AccountViewModel,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
) {
if (canPreview) {
if (canPreview && quotesLeft > 0) {
NoteCompose(
baseNote = baseNote,
accountViewModel = accountViewModel,
modifier = MaterialTheme.colorScheme.innerPostModifier,
parentBackgroundColor = backgroundColor,
isQuotedNote = true,
quotesLeft = quotesLeft - 1,
nav = nav,
)
} else {
ClickableNoteTag(baseNote, nav)
ClickableNoteTag(baseNote, accountViewModel, nav)
}
addedChars?.ifBlank { null }?.let { Text(text = it) }
@@ -866,18 +865,14 @@ private fun DisplayUserFromTag(
baseUser: User,
nav: (String) -> Unit,
) {
val route = remember { "User/${baseUser.pubkeyHex}" }
val hex = remember { baseUser.pubkeyDisplayHex() }
val meta by baseUser.live().userMetadataInfo.observeAsState(baseUser.info)
Crossfade(targetState = meta, label = "DisplayUserFromTag") {
Row {
val displayName = remember(it) { it?.bestDisplayName() ?: it?.bestUsername() ?: hex }
CreateClickableTextWithEmoji(
clickablePart = displayName,
clickablePart = remember(meta) { it?.bestName() ?: baseUser.pubkeyDisplayHex() },
maxLines = 1,
route = route,
route = "User/${baseUser.pubkeyHex}",
nav = nav,
tags = it?.tags,
)
@@ -30,7 +30,7 @@ import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.request.ImageRequest
import coil.request.Options
import com.vitorpamplona.amethyst.commons.Robohash
import com.vitorpamplona.amethyst.commons.robohash.Robohash
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import okio.buffer
import okio.source
@@ -73,6 +73,7 @@ class HashImageFetcher(
}
}
@Deprecated("Use the RobohashAssembler instead")
object RobohashImageRequest {
fun build(
context: Context,
@@ -36,12 +36,12 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.DefaultAlpha
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import coil.decode.DataSource
import coil.fetch.DrawableResult
@@ -49,6 +49,7 @@ import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.request.ImageRequest
import coil.request.Options
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.theme.isLight
import java.util.Base64
@@ -58,31 +59,18 @@ 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,
) {
val context = LocalContext.current
val isLightTheme = MaterialTheme.colorScheme.isLight
val imageRequest = remember(robot) { RobohashImageRequest.build(context, robot, isLightTheme) }
val robotPainter =
remember(robot) {
CachedRobohash.get(robot, isLightTheme)
}
AsyncImage(
model = imageRequest,
Image(
imageVector = robotPainter,
contentDescription = contentDescription,
modifier = modifier,
transform = transform,
onState = onState,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality,
)
}
@@ -101,10 +89,6 @@ fun RobohashFallbackAsyncImage(
) {
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,") } }
@@ -124,6 +108,11 @@ fun RobohashFallbackAsyncImage(
colorFilter = colorFilter,
)
} else {
val painter =
rememberVectorPainter(
image = CachedRobohash.get(robot, isLightTheme),
)
AsyncImage(
model = model,
contentDescription = contentDescription,
@@ -139,8 +128,10 @@ fun RobohashFallbackAsyncImage(
)
}
} else {
val robotPainter = CachedRobohash.get(robot, isLightTheme)
Image(
painter = painter,
imageVector = robotPainter,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
@@ -30,7 +30,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size24dp
@Composable
@@ -75,7 +76,7 @@ fun SelectTextDialog(
}
Text(text = stringResource(R.string.select_text_dialog_top))
}
Divider()
HorizontalDivider(thickness = DividerThickness)
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
@@ -34,7 +34,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
@@ -182,7 +182,7 @@ fun <T> SpinnerSelectionDialog(
fontWeight = FontWeight.Bold,
)
}
Divider(color = Color.LightGray, thickness = DividerThickness)
HorizontalDivider(color = Color.LightGray, thickness = DividerThickness)
}
}
itemsIndexed(options) { index, item ->
@@ -192,7 +192,7 @@ fun <T> SpinnerSelectionDialog(
Column { onRenderItem(item) }
}
if (index < options.lastIndex) {
Divider(color = Color.LightGray, thickness = DividerThickness)
HorizontalDivider(color = Color.LightGray, thickness = DividerThickness)
}
}
}
@@ -25,6 +25,7 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
@@ -41,6 +42,7 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
@@ -106,8 +108,8 @@ fun UrlPreviewCard(
AsyncImage(
model = previewInfo.imageUrlFullPath,
contentDescription = stringResource(R.string.preview_card_image_for, previewInfo.url),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth(),
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxWidth().heightIn(max = 200.dp),
)
Spacer(modifier = StdVertSpacer)
@@ -57,7 +57,6 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -91,6 +90,8 @@ import androidx.media3.session.MediaController
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.linc.audiowaveform.infiniteLinearGradient
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
import com.vitorpamplona.amethyst.service.playback.PlaybackClientController
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
import com.vitorpamplona.amethyst.ui.note.LyricsIcon
@@ -282,7 +283,7 @@ fun VideoView(
}
@Composable
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@OptIn(androidx.media3.common.util.UnstableApi::class)
fun VideoViewInner(
videoUri: String,
defaultToStart: Boolean = false,
@@ -328,6 +329,43 @@ fun VideoViewInner(
}
}
val mediaItemCache = MediaItemCache()
@Immutable
data class MediaItemData(
val videoUri: String,
val authorName: String? = null,
val title: String? = null,
val artworkUri: String? = null,
)
class MediaItemCache() : GenericBaseCache<MediaItemData, MediaItem>(20) {
override suspend fun compute(data: MediaItemData): MediaItem? {
return MediaItem.Builder()
.setMediaId(data.videoUri)
.setUri(data.videoUri)
.setMediaMetadata(
MediaMetadata.Builder()
.setArtist(data.authorName?.ifBlank { null })
.setTitle(data.title?.ifBlank { null } ?: data.videoUri)
.setArtworkUri(
try {
if (data.artworkUri != null) {
Uri.parse(data.artworkUri)
} else {
null
}
} catch (e: Exception) {
if (e is CancellationException) throw e
null
},
)
.build(),
)
.build()
}
}
@Composable
fun GetMediaItem(
videoUri: String,
@@ -336,51 +374,15 @@ fun GetMediaItem(
authorName: String?,
inner: @Composable (State<MediaItem>) -> Unit,
) {
val mediaItem =
produceState<MediaItem?>(
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) {
if (e is CancellationException) throw e
null
},
)
.build(),
)
.build()
}
val data = remember(videoUri) { MediaItemData(videoUri, title, artworkUri, authorName) }
val mediaItem by produceCachedState(cache = mediaItemCache, key = data)
mediaItem.value?.let {
mediaItem?.let {
val myState = remember(videoUri) { mutableStateOf(it) }
inner(myState)
}
}
@Immutable
sealed class MediaControllerState {
@Immutable object NotStarted : MediaControllerState()
@Immutable object Loading : MediaControllerState()
@Stable class Loaded(val instance: MediaController) : MediaControllerState()
}
@Composable
@OptIn(UnstableApi::class)
fun GetVideoController(
@@ -394,12 +396,11 @@ fun GetVideoController(
val controller =
remember(videoUri) {
val globalMutex = keepPlayingMutex
mutableStateOf<MediaControllerState>(
if (videoUri == globalMutex?.currentMediaItem?.mediaId) {
MediaControllerState.Loaded(globalMutex)
mutableStateOf(
if (videoUri == keepPlayingMutex?.currentMediaItem?.mediaId) {
keepPlayingMutex
} else {
MediaControllerState.NotStarted
null
},
)
}
@@ -419,9 +420,7 @@ fun GetVideoController(
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
if (controller.value == null) {
scope.launch(Dispatchers.IO) {
Log.d("PlaybackService", "Preparing Video $videoUri ")
PlaybackClientController.prepareController(
@@ -432,29 +431,27 @@ fun GetVideoController(
) {
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
it.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
it.volume = if (defaultToStart) 0f else 1f
}
}
newState.instance.setMediaItem(mediaItem.value)
newState.instance.prepare()
it.setMediaItem(mediaItem.value)
it.prepare()
controller.value = newState
controller.value = it
}
}
}
} else if (controller.value is MediaControllerState.Loaded) {
(controller.value as? MediaControllerState.Loaded)?.instance?.let {
} else {
// has been loaded. prepare to play
controller.value?.let {
scope.launch(Dispatchers.Main) {
if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) {
if (it.isPlaying) {
@@ -466,7 +463,10 @@ fun GetVideoController(
it.volume = if (defaultToStart) 0f else 1f
}
it.setMediaItem(mediaItem.value)
if (mediaItem.value != it.currentMediaItem) {
it.setMediaItem(mediaItem.value)
}
it.prepare()
}
}
@@ -477,11 +477,11 @@ fun GetVideoController(
GlobalScope.launch(Dispatchers.Main) {
if (!keepPlaying.value) {
// Stops and releases the media.
(controller.value as? MediaControllerState.Loaded)?.instance?.let {
controller.value?.let {
it.stop()
it.release()
Log.d("PlaybackService", "Releasing Video $videoUri ")
controller.value = MediaControllerState.NotStarted
controller.value = null
}
}
}
@@ -496,12 +496,9 @@ fun GetVideoController(
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
if (controller.value == null) {
scope.launch(Dispatchers.IO) {
Log.d("PlaybackService", "Preparing Video from Resume $videoUri ")
PlaybackClientController.prepareController(
uid,
videoUri,
@@ -510,25 +507,22 @@ fun GetVideoController(
) {
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
it.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
it.volume = if (defaultToStart) 0f else 1f
}
}
newState.instance.setMediaItem(mediaItem.value)
newState.instance.prepare()
it.setMediaItem(mediaItem.value)
it.prepare()
controller.value = newState
controller.value = it
}
}
}
@@ -538,11 +532,11 @@ fun GetVideoController(
GlobalScope.launch(Dispatchers.Main) {
if (!keepPlaying.value) {
// Stops and releases the media.
(controller.value as? MediaControllerState.Loaded)?.instance?.let {
controller.value?.let {
Log.d("PlaybackService", "Releasing Video from Pause $videoUri ")
it.stop()
it.release()
controller.value = MediaControllerState.NotStarted
controller.value = null
}
}
}
@@ -553,7 +547,9 @@ fun GetVideoController(
onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) }
}
(controller.value as? MediaControllerState.Loaded)?.let { inner(it.instance, keepPlaying) }
controller.value?.let {
inner(it, keepPlaying)
}
}
// background playing mutex.
@@ -669,43 +665,6 @@ private fun RenderVideoPlayer(
}
}
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) {
@@ -719,7 +678,39 @@ private fun RenderVideoPlayer(
AndroidView(
modifier = myModifier,
factory = factory,
factory = { 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) }
},
)
}
},
)
waveform?.let { Waveform(it, controller, remember { Modifier.align(Alignment.Center) }) }
@@ -892,13 +883,17 @@ fun ControlWhenPlayerIsActive(
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
if (view.keepScreenOn != isPlaying) {
view.keepScreenOn = isPlaying
}
}
}
controller.addListener(listener)
onDispose {
view.keepScreenOn = false
if (view.keepScreenOn) {
view.keepScreenOn = false
}
controller.removeListener(listener)
}
}
@@ -25,7 +25,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -34,14 +34,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.hashtags.Lightning
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -58,7 +60,7 @@ fun ZapRaiserRequest(
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
) {
Icon(
painter = painterResource(R.drawable.lightning),
imageVector = CustomHashTagIcons.Lightning,
null,
modifier = Size20Modifier,
tint = Color.Unspecified,
@@ -72,7 +74,7 @@ fun ZapRaiserRequest(
)
}
Divider()
HorizontalDivider(thickness = DividerThickness)
Text(
text = stringResource(R.string.zapraiser_explainer),
@@ -92,9 +94,9 @@ fun ZapRaiserRequest(
onValueChange = {
runCatching {
if (it.isEmpty()) {
newPostViewModel.zapRaiserAmount = null
newPostViewModel.updateZapRaiserAmount(null)
} else {
newPostViewModel.zapRaiserAmount = it.toLongOrNull()
newPostViewModel.updateZapRaiserAmount(it.toLongOrNull())
}
}
},
@@ -106,13 +106,13 @@ import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.BaseMediaContent
import com.vitorpamplona.amethyst.commons.MediaLocalImage
import com.vitorpamplona.amethyst.commons.MediaLocalVideo
import com.vitorpamplona.amethyst.commons.MediaPreloadedContent
import com.vitorpamplona.amethyst.commons.MediaUrlContent
import com.vitorpamplona.amethyst.commons.MediaUrlImage
import com.vitorpamplona.amethyst.commons.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.MediaLocalImage
import com.vitorpamplona.amethyst.commons.richtext.MediaLocalVideo
import com.vitorpamplona.amethyst.commons.richtext.MediaPreloadedContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.service.BlurHashRequester
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
@@ -501,8 +501,6 @@ private fun AddedImageFeatures(
ImageUrlWithDownloadButton(content.url, showImage)
}
} else {
var verifiedHash by remember(content.url) { mutableStateOf<Boolean?>(null) }
when (painter.value) {
null,
is AsyncImagePainter.State.Loading,
@@ -528,24 +526,32 @@ private fun AddedImageFeatures(
}
}
is AsyncImagePainter.State.Success -> {
if (content.hash != null) {
LaunchedEffect(key1 = content.url) {
launch(Dispatchers.IO) {
val newVerifiedHash = verifyHash(content)
if (newVerifiedHash != verifiedHash) {
verifiedHash = newVerifiedHash
}
}
}
}
verifiedHash?.let { HashVerificationSymbol(it, verifiedModifier) }
ShowHash(content, verifiedModifier)
}
else -> {}
}
}
}
@Composable
fun ShowHash(
content: MediaUrlContent,
verifiedModifier: Modifier,
) {
var verifiedHash by remember(content.url) { mutableStateOf<Boolean?>(null) }
if (content.hash != null) {
LaunchedEffect(key1 = content.url) {
val newVerifiedHash = verifyHash(content)
if (newVerifiedHash != verifiedHash) {
verifiedHash = newVerifiedHash
}
}
}
verifiedHash?.let { HashVerificationSymbol(it, verifiedModifier) }
}
fun aspectRatio(dim: String?): Float? {
if (dim == null) return null
if (dim == "0x0") return null
@@ -45,7 +45,6 @@ class BookmarkPrivateFeedFilter(val account: Account) : FeedFilter<Note>() {
return notes
.plus(addresses)
.toSet()
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
.sortedWith(DefaultFeedOrder)
}
}
@@ -40,7 +40,6 @@ class BookmarkPublicFeedFilter(val account: Account) : FeedFilter<Note>() {
return notes
.plus(addresses)
.toSet()
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
.sortedWith(DefaultFeedOrder)
}
}
@@ -31,10 +31,11 @@ class ChannelFeedFilter(val channel: Channel, val account: Account) : AdditiveFe
// returns the last Note of each user.
override fun feed(): List<Note> {
return channel.notes.values
.filter { account.isAcceptable(it) }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
return sort(
channel.notes.filterIntoSet { key, it ->
account.isAcceptable(it)
},
)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
@@ -44,6 +45,6 @@ class ChannelFeedFilter(val channel: Channel, val account: Account) : AdditiveFe
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
return collection.sortedWith(DefaultFeedOrder)
}
}
@@ -47,6 +47,6 @@ class ChatroomFeedFilter(val withUser: ChatroomKey, val account: Account) :
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
return collection.sortedWith(DefaultFeedOrder)
}
}
@@ -56,15 +56,12 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
.selectedChatsFollowList()
.mapNotNull { LocalCache.getChannelIfExists(it) }
.mapNotNull { it ->
it.notes.values
.filter { account.isAcceptable(it) && it.event != null }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.lastOrNull()
it.notes.filter { key, it -> account.isAcceptable(it) && it.event != null }
.sortedWith(DefaultFeedOrder)
.firstOrNull()
}
return (privateMessages + publicChannels)
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
return (privateMessages + publicChannels).sortedWith(DefaultFeedOrder)
}
override fun updateListWith(
@@ -197,6 +194,6 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
return collection.sortedWith(DefaultFeedOrder)
}
}
@@ -138,6 +138,6 @@ class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
return collection.sortedWith(DefaultFeedOrder)
}
}
@@ -24,16 +24,22 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
class CommunityFeedFilter(val note: AddressableNote, val account: Account) :
AdditiveFeedFilter<Note>() {
class CommunityFeedFilter(val note: AddressableNote, val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + note.idHex
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.noteListCache))
val myPubKey = account.userProfile().pubkeyHex
val result =
LocalCache.notes.mapFlattenIntoSet { _, it ->
filterMap(it, myPubKey)
}
return sort(result)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
@@ -41,31 +47,36 @@ class CommunityFeedFilter(val note: AddressableNote, val account: Account) :
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
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 myPubKey = account.userProfile().pubkeyHex
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 collection.mapNotNull {
filterMap(it, myPubKey)
}.flatten().toSet()
}
return myUnapprovedPosts + approvedPosts
private fun filterMap(
note: Note,
myPubKey: HexKey,
): List<Note>? {
return if (
// Only Approvals
note.event is CommunityPostApprovalEvent &&
// Of the given community
note.event?.isTaggedAddressableNote(this.note.idHex) == true
) {
// if it is my post, bring on
if (note.author?.pubkeyHex == myPubKey && note.isNewThread()) {
listOf(note)
} else {
// brings the actual posts, not the approvals
note.replyTo?.filter { it.isNewThread() }
}
} else {
null
}
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
return collection.sortedWith(DefaultFeedOrder)
}
}
@@ -0,0 +1,25 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Note
val DefaultFeedOrder = compareBy<Note>({ it.createdAt() }, { it.idHex }).reversed()
@@ -21,15 +21,13 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.IsInPublicChatChannel
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.utils.TimeUtils
open class DiscoverChatFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
@@ -44,65 +42,81 @@ open class DiscoverChatFeedFilter(val account: Account) : AdditiveFeedFilter<Not
}
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val allChannelNotes =
LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) }
LocalCache.channels.mapNotNullIntoSet { _, channel ->
if (channel is PublicChatChannel) {
val note = LocalCache.getNoteIfExists(channel.idHex)
val noteEvent = note?.event
val notes = innerApplyFilter(allChannelNotes)
if (noteEvent == null || params.match(noteEvent)) {
note
} else {
null
}
} else {
null
}
}
return sort(notes)
return sort(allChannelNotes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
fun buildFilterParams(account: Account): FilterByListParams {
return FilterByListParams.create(
userHex = account.userProfile().pubkeyHex,
selectedListName = account.defaultDiscoveryFollowList.value,
followLists = account.liveDiscoveryFollowLists.value,
hiddenUsers = account.flowHiddenUsers.value,
)
}
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val now = TimeUtils.now()
val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS
val isHiddenList = showHiddenKey()
val params = buildFilterParams(account)
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 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
return collection.mapNotNullTo(HashSet()) { note ->
// note event here will never be null
val noteEvent = note.event
if (noteEvent is ChannelCreateEvent && params.match(noteEvent)) {
if ((LocalCache.getChannelIfExists(noteEvent.id)?.notes?.size() ?: 0) > 0) {
note
} else {
null
}
.filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true }
.filter { (it.createdAt() ?: 0) <= now }
.toSet()
return activities
} else if (noteEvent is IsInPublicChatChannel) {
val channel = noteEvent.channel()?.let { LocalCache.checkGetOrCreateNote(it) }
if (channel != null &&
(channel.event == null || (channel.event is ChannelCreateEvent && params.match(channel.event)))
) {
if ((LocalCache.getChannelIfExists(channel.idHex)?.notes?.size() ?: 0) > 0) {
channel
} else {
null
}
} else {
null
}
} else {
null
}
}
}
override fun sort(collection: Set<Note>): List<Note> {
val followingKeySet =
account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users
val counter = ParticipantListBuilder()
val participantCounts =
collection.associate { it to counter.countFollowsThatParticipateOn(it, followingKeySet) }
val lastNote =
collection.associateWith { note ->
LocalCache.getChannelIfExists(note.idHex)?.lastNoteCreatedAt ?: 0
}
return collection
.sortedWith(
compareBy(
{ participantCounts[it] },
{ lastNote[it] },
{ it.createdAt() },
{ it.idHex },
),
@@ -21,15 +21,13 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.utils.TimeUtils
open class DiscoverCommunityFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
@@ -44,9 +42,27 @@ open class DiscoverCommunityFeedFilter(val account: Account) : AdditiveFeedFilte
}
override fun feed(): List<Note> {
val allNotes = LocalCache.addressables.values
val filterParams =
FilterByListParams.create(
userHex = account.userProfile().pubkeyHex,
selectedListName = account.defaultDiscoveryFollowList.value,
followLists = account.liveDiscoveryFollowLists.value,
hiddenUsers = account.flowHiddenUsers.value,
)
val notes = innerApplyFilter(allNotes)
// Here we only need to look for CommunityDefinition Events
val notes =
LocalCache.addressables.mapNotNullIntoSet { key, note ->
val noteEvent = note.event
if (noteEvent == null && shouldInclude(ATag.parseAtagUnckecked(key), filterParams)) {
// send unloaded communities to the screen
note
} else if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent)) {
note
} else {
null
}
}
return sort(notes)
}
@@ -56,57 +72,54 @@ open class DiscoverCommunityFeedFilter(val account: Account) : AdditiveFeedFilte
}
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val now = TimeUtils.now()
val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS
val isHiddenList = showHiddenKey()
// here, we need to look for CommunityDefinition in new collection AND new CommunityDefinition from Post Approvals
val filterParams =
FilterByListParams.create(
userHex = account.userProfile().pubkeyHex,
selectedListName = account.defaultDiscoveryFollowList.value,
followLists = account.liveDiscoveryFollowLists.value,
hiddenUsers = account.flowHiddenUsers.value,
)
val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet()
val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet()
val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet()
return collection.mapNotNull { note ->
// note event here will never be null
val noteEvent = note.event
if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent)) {
listOf(note)
} else if (noteEvent is CommunityPostApprovalEvent) {
noteEvent.communities().mapNotNull {
val definitionNote = LocalCache.getOrCreateAddressableNote(it)
val definitionEvent = definitionNote.event
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
if (definitionEvent == null && shouldInclude(it, filterParams)) {
definitionNote
} else if (definitionEvent is CommunityDefinitionEvent && filterParams.match(definitionEvent)) {
definitionNote
} else {
null
}
}
.filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true }
.filter { (it.createdAt() ?: 0) <= now }
.toSet()
return activities
} else {
null
}
}.flatten().toSet()
}
private fun shouldInclude(
aTag: ATag?,
params: FilterByListParams,
) = aTag != null && aTag.kind == CommunityDefinitionEvent.KIND && params.match(aTag)
override fun sort(collection: Set<Note>): List<Note> {
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) }
val lastNote =
collection.associateWith { note ->
note.boosts.maxOfOrNull { it.createdAt() ?: 0 } ?: 0
}
return collection
.sortedWith(
compareBy(
{ participantCounts[it] },
{ allParticipants[it] },
{ lastNote[it] },
{ it.createdAt() },
{ it.idHex },
),
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
@@ -31,7 +30,6 @@ import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_PLANNED
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.utils.TimeUtils
open class DiscoverLiveFeedFilter(
val account: Account,
@@ -50,9 +48,8 @@ open class DiscoverLiveFeedFilter(
}
override fun feed(): List<Note> {
val allChannelNotes =
LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) }
val allMessageNotes = LocalCache.channels.values.map { it.notes.values }.flatten()
val allChannelNotes = LocalCache.channels.mapNotNull { _, channel -> LocalCache.getNoteIfExists(channel.idHex) }
val allMessageNotes = LocalCache.channels.map { _, channel -> channel.notes.filter { key, it -> it.event is LiveActivitiesEvent } }.flatten()
val notes = innerApplyFilter(allChannelNotes + allMessageNotes)
@@ -64,33 +61,15 @@ open class DiscoverLiveFeedFilter(
}
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val now = TimeUtils.now()
val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS
val isHiddenList = showHiddenKey()
val filterParams =
FilterByListParams.create(
userHex = account.userProfile().pubkeyHex,
selectedListName = account.defaultDiscoveryFollowList.value,
followLists = account.liveDiscoveryFollowLists.value,
hiddenUsers = account.flowHiddenUsers.value,
)
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
return collection.filterTo(HashSet()) { it.event is LiveActivitiesEvent && filterParams.match(it.event) }
}
override fun sort(collection: Set<Note>): List<Note> {
@@ -21,13 +21,11 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.utils.TimeUtils
open class DiscoverMarketplaceFeedFilter(
val account: Account,
@@ -46,10 +44,13 @@ open class DiscoverMarketplaceFeedFilter(
}
override fun feed(): List<Note> {
val classifieds =
LocalCache.addressables.filter { it.value.event is ClassifiedsEvent }.map { it.value }
val params = buildFilterParams(account)
val notes = innerApplyFilter(classifieds)
val notes =
LocalCache.addressables.filterIntoSet { _, it ->
val noteEvent = it.event
noteEvent is ClassifiedsEvent && noteEvent.isWellFormed() && params.match(noteEvent)
}
return sort(notes)
}
@@ -58,35 +59,22 @@ open class DiscoverMarketplaceFeedFilter(
return innerApplyFilter(collection)
}
fun buildFilterParams(account: Account): FilterByListParams {
return FilterByListParams.create(
account.userProfile().pubkeyHex,
account.defaultDiscoveryFollowList.value,
account.liveDiscoveryFollowLists.value,
account.flowHiddenUsers.value,
)
}
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val now = TimeUtils.now()
val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS
val isHiddenList = showHiddenKey()
val params = buildFilterParams(account)
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()
return activities
return collection.filterTo(HashSet()) {
val noteEvent = it.event
noteEvent is ClassifiedsEvent && noteEvent.isWellFormed() && params.match(noteEvent)
}
}
override fun sort(collection: Set<Note>): List<Note> {
@@ -21,35 +21,36 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
import com.vitorpamplona.quartz.events.DraftEvent
class DiscoverLiveNowFeedFilter(
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
class DraftEventsFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return collection.filterTo(HashSet()) {
acceptableEvent(it)
}
}
override fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val allItems = super.innerApplyFilter(collection)
val onlineOnly =
allItems.filter {
val noteEvent = it.event as? LiveActivitiesEvent
noteEvent?.status() == STATUS_LIVE && OnlineChecker.isOnline(noteEvent.streaming())
override fun feed(): List<Note> {
val drafts =
LocalCache.addressables.filterIntoSet { _, note ->
acceptableEvent(note)
}
return onlineOnly.toSet()
return sort(drafts)
}
fun acceptableEvent(it: Note): Boolean {
val noteEvent = it.event
return noteEvent is DraftEvent && noteEvent.pubKey == account.userProfile().pubkeyHex
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(DefaultFeedOrder)
}
}
@@ -0,0 +1,103 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.utils.TimeUtils
class FilterByListParams(
val isGlobal: Boolean,
val isHiddenList: Boolean,
val followLists: Account.LiveFollowLists?,
val hiddenLists: Account.LiveHiddenUsers,
val now: Long = TimeUtils.oneMinuteFromNow(),
) {
fun isNotHidden(userHex: String) = !(hiddenLists.hiddenUsers.contains(userHex) || hiddenLists.spammers.contains(userHex))
fun isNotInTheFuture(noteEvent: Event) = noteEvent.createdAt <= now
fun isEventInList(noteEvent: Event): Boolean {
if (followLists == null) return false
return if (noteEvent is LiveActivitiesEvent) {
noteEvent.participantsIntersect(followLists.users) ||
noteEvent.isTaggedHashes(followLists.hashtags) ||
noteEvent.isTaggedGeoHashes(followLists.geotags) ||
noteEvent.isTaggedAddressableNotes(followLists.communities)
} else {
noteEvent.pubKey in followLists.users ||
noteEvent.isTaggedHashes(followLists.hashtags) ||
noteEvent.isTaggedGeoHashes(followLists.geotags) ||
noteEvent.isTaggedAddressableNotes(followLists.communities)
}
}
fun isATagInList(aTag: ATag): Boolean {
if (followLists == null) return false
return aTag.pubKeyHex in followLists.users
}
fun match(
noteEvent: EventInterface?,
isGlobalRelay: Boolean = true,
) = if (noteEvent is Event) match(noteEvent, isGlobalRelay) else false
fun match(
noteEvent: Event,
isGlobalRelay: Boolean = true,
) = ((isGlobal && isGlobalRelay) || isEventInList(noteEvent)) &&
(isHiddenList || isNotHidden(noteEvent.pubKey)) &&
isNotInTheFuture(noteEvent)
fun match(aTag: ATag?) =
aTag != null &&
(isGlobal || isATagInList(aTag)) &&
(isHiddenList || isNotHidden(aTag.pubKeyHex))
companion object {
fun showHiddenKey(
selectedListName: String,
userHex: String,
) = selectedListName == PeopleListEvent.blockListFor(userHex) || selectedListName == MuteListEvent.blockListFor(userHex)
fun create(
userHex: String,
selectedListName: String,
followLists: Account.LiveFollowLists?,
hiddenUsers: Account.LiveHiddenUsers,
): FilterByListParams {
return FilterByListParams(
isGlobal = selectedListName == GLOBAL_FOLLOWS,
isHiddenList = showHiddenKey(selectedListName, userHex),
followLists = followLists,
hiddenLists = hiddenUsers,
)
}
}
}
@@ -36,7 +36,12 @@ class GeoHashFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.noteListCache))
val notes =
LocalCache.notes.filterIntoSet { _, it ->
acceptableEvent(it, tag)
}
return sort(notes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
@@ -44,25 +49,24 @@ class GeoHashFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val myTag = tag ?: return emptySet()
return collection.filterTo(HashSet<Note>()) { acceptableEvent(it, tag) }
}
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()
fun acceptableEvent(
it: Note,
geoTag: String,
): Boolean {
return (
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(geoTag) == true && account.isAcceptable(it)
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
return collection.sortedWith(DefaultFeedOrder)
}
}
@@ -36,7 +36,12 @@ class HashtagFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.noteListCache))
val notes =
LocalCache.notes.filterIntoSet { _, it ->
acceptableEvent(it, tag)
}
return sort(notes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
@@ -44,25 +49,24 @@ class HashtagFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val myTag = tag ?: return emptySet()
return collection.filterTo(HashSet<Note>()) { acceptableEvent(it, tag) }
}
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()
fun acceptableEvent(
it: Note,
hashTag: String,
): Boolean {
return (
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(hashTag) == true && account.isAcceptable(it)
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
return collection.sortedWith(DefaultFeedOrder)
}
}
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.ChannelMessageEvent
@@ -30,7 +29,6 @@ import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.utils.TimeUtils
class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
@@ -38,55 +36,54 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Not
}
override fun showHiddenKey(): Boolean {
return account.defaultHomeFollowList.value ==
PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
account.defaultHomeFollowList.value ==
MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
return account.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
account.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.noteListCache))
val filterParams = buildFilterParams(account)
return sort(
LocalCache.notes.filterIntoSet { _, it ->
acceptableEvent(it, filterParams)
},
)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
fun buildFilterParams(account: Account): FilterByListParams {
return FilterByListParams.create(
userHex = account.userProfile().pubkeyHex,
selectedListName = account.defaultHomeFollowList.value,
followLists = account.liveHomeFollowLists.value,
hiddenUsers = account.flowHiddenUsers.value,
)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val isGlobal = account.defaultHomeFollowList.value == GLOBAL_FOLLOWS
val isHiddenList = showHiddenKey()
val filterParams = buildFilterParams(account)
val followingKeySet = account.liveHomeFollowLists.value?.users ?: emptySet()
val followingTagSet = account.liveHomeFollowLists.value?.hashtags ?: emptySet()
val followingGeohashSet = account.liveHomeFollowLists.value?.geotags ?: emptySet()
return collection.filterTo(HashSet()) {
acceptableEvent(it, filterParams)
}
}
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()
fun acceptableEvent(
it: Note,
filterParams: FilterByListParams,
): Boolean {
return (
it.event is TextNoteEvent ||
it.event is PollNoteEvent ||
it.event is ChannelMessageEvent ||
it.event is LiveActivitiesChatMessageEvent
) && filterParams.match(it.event) && !it.isNewThread()
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
return collection.sortedWith(DefaultFeedOrder)
}
}
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.AudioHeaderEvent
@@ -35,7 +34,6 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.utils.TimeUtils
class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
@@ -43,86 +41,79 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
}
override fun showHiddenKey(): Boolean {
return account.defaultHomeFollowList.value ==
PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
account.defaultHomeFollowList.value ==
MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
return account.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
account.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
}
fun buildFilterParams(account: Account): FilterByListParams {
return FilterByListParams.create(
userHex = account.userProfile().pubkeyHex,
selectedListName = account.defaultHomeFollowList.value,
followLists = account.liveHomeFollowLists.value,
hiddenUsers = account.flowHiddenUsers.value,
)
}
override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.noteListCache, true)
val longFormNotes = innerApplyFilter(LocalCache.addressables.values, false)
val gRelays = account.activeGlobalRelays().toSet()
val filterParams = buildFilterParams(account)
val notes =
LocalCache.notes.filterIntoSet { _, note ->
// Avoids processing addressables twice.
(note.event?.kind() ?: 99999) < 10000 && acceptableEvent(note, gRelays, filterParams)
}
val longFormNotes =
LocalCache.addressables.filterIntoSet { _, note ->
acceptableEvent(note, gRelays, filterParams)
}
return sort(notes + longFormNotes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection, false)
return innerApplyFilter(collection)
}
private fun innerApplyFilter(
collection: Collection<Note>,
ignoreAddressables: Boolean,
): Set<Note> {
val isGlobal = account.defaultHomeFollowList.value == GLOBAL_FOLLOWS
val gRelays = account.activeGlobalRelays()
val isHiddenList = showHiddenKey()
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val gRelays = account.activeGlobalRelays().toSet()
val filterParams = buildFilterParams(account)
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()
return collection.filterTo(HashSet()) {
acceptableEvent(it, gRelays, filterParams)
}
}
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()
fun acceptableEvent(
it: Note,
globalRelays: Set<String>,
filterParams: FilterByListParams,
): Boolean {
val noteEvent = it.event
val isGlobalRelay = it.relays.any { globalRelays.contains(it.url) }
return (
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
) &&
filterParams.match(noteEvent, isGlobalRelay) &&
it.isNewThread()
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
return collection.distinctBy {
if (it.event is RepostEvent || it.event is GenericRepostEvent) {
it.replyTo?.lastOrNull()?.idHex ?: it.idHex // only the most recent repost per feed.
} else {
it.idHex
}
}.sortedWith(DefaultFeedOrder)
}
}
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.encoders.HexKey
@@ -32,6 +31,9 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.GitIssueEvent
import com.vitorpamplona.quartz.events.GitPatchEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.MuteListEvent
@@ -51,8 +53,24 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
}
fun buildFilterParams(account: Account): FilterByListParams {
return FilterByListParams.create(
userHex = account.userProfile().pubkeyHex,
selectedListName = account.defaultNotificationFollowList.value,
followLists = account.liveNotificationFollowLists.value,
hiddenUsers = account.flowHiddenUsers.value,
)
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.noteListCache))
val filterParams = buildFilterParams(account)
val notifications =
LocalCache.notes.filterIntoSet { _, note ->
acceptableEvent(note, filterParams)
}
return sort(notifications)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
@@ -60,32 +78,49 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val isGlobal = account.defaultNotificationFollowList.value == GLOBAL_FOLLOWS
val isHiddenList = showHiddenKey()
val filterParams = buildFilterParams(account)
val followingKeySet = account.liveNotificationFollowLists.value?.users ?: emptySet()
return collection.filterTo(HashSet()) { acceptableEvent(it, filterParams) }
}
val loggedInUser = account.userProfile()
val loggedInUserHex = loggedInUser.pubkeyHex
fun acceptableEvent(
it: Note,
filterParams: FilterByListParams,
): Boolean {
val loggedInUserHex = account.userProfile().pubkeyHex
return collection
.filterTo(HashSet()) {
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)
val noteEvent = it.event
val notifAuthor =
if (noteEvent is LnZapEvent) {
val zapRequest = noteEvent.zapRequest
if (zapRequest != null) {
if (noteEvent.zapRequest?.isPrivateZap() == true) {
zapRequest.cachedPrivateZap()?.pubKey ?: zapRequest.pubKey
} else {
zapRequest.pubKey
}
} else {
noteEvent.pubKey
}
} else {
it.author?.pubkeyHex
}
return 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 || notifAuthor != loggedInUserHex) &&
(filterParams.isGlobal || filterParams.followLists?.users?.contains(notifAuthor) == true) &&
it.event?.isTaggedUser(loggedInUserHex) ?: false &&
(filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) &&
tagsAnEventByUser(it, loggedInUserHex)
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
return collection.sortedWith(DefaultFeedOrder)
}
fun tagsAnEventByUser(
@@ -94,6 +129,14 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
): Boolean {
val event = note.event
if (event is GitIssueEvent || event is GitPatchEvent) {
return true
}
if (event is HighlightEvent) {
return true
}
if (event is BaseTextNoteEvent) {
if (note.replyTo?.any { it.author?.pubkeyHex == authorHex } == true) return true
@@ -31,7 +31,12 @@ class UserProfileAppRecommendationsFeedFilter(val user: User) : AdditiveFeedFilt
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.addressables.values))
val recommendations =
LocalCache.addressables.mapFlattenIntoSet { _, note ->
filterMap(note)
}
return sort(recommendations)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
@@ -39,26 +44,21 @@ class UserProfileAppRecommendationsFeedFilter(val user: User) : AdditiveFeedFilt
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
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 collection.mapNotNull { filterMap(it) }.flatten().toSet()
}
return recommendations
fun filterMap(it: Note): List<Note>? {
val noteEvent = it.event
if (noteEvent is AppRecommendationEvent) {
if (noteEvent.pubKey == user.pubkeyHex) {
return noteEvent.recommendations().map { LocalCache.getOrCreateAddressableNote(it) }
}
}
return null
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
return collection.sortedWith(DefaultFeedOrder)
}
}
@@ -47,7 +47,6 @@ class UserProfileBookmarksFeedFilter(val user: User, val account: Account) : Fee
return (notes + addresses)
.filter { account.isAcceptable(it) }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
.sortedWith(DefaultFeedOrder)
}
}
@@ -36,7 +36,17 @@ class UserProfileConversationsFeedFilter(val user: User, val account: Account) :
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.noteListCache))
val notes =
LocalCache.notes.filterIntoSet { _, it ->
acceptableEvent(it)
}
val longFormNotes =
LocalCache.addressables.filterIntoSet { _, it ->
acceptableEvent(it)
}
return sort(notes + longFormNotes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
@@ -44,23 +54,21 @@ class UserProfileConversationsFeedFilter(val user: User, val account: Account) :
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
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()
return collection.filterTo(HashSet()) { acceptableEvent(it) }
}
fun acceptableEvent(it: Note): Boolean {
return it.author == user &&
(
it.event is TextNoteEvent ||
it.event is PollNoteEvent ||
it.event is ChannelMessageEvent ||
it.event is LiveActivitiesChatMessageEvent
) && !it.isNewThread() && account.isAcceptable(it)
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
return collection.sortedWith(DefaultFeedOrder)
}
override fun limit() = 200
@@ -30,7 +30,9 @@ class UserProfileFollowersFeedFilter(val user: User, val account: Account) : Fee
}
override fun feed(): List<User> {
return LocalCache.userListCache.filter { it.isFollowing(user) && !account.isHidden(it) }
return LocalCache.users.filter { _, it ->
it.isFollowing(user) && !account.isHidden(it)
}
}
override fun limit() = 400
@@ -21,9 +21,11 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
@@ -41,8 +43,15 @@ class UserProfileNewThreadFeedFilter(val user: User, val account: Account) :
}
override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.noteListCache)
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
val notes =
LocalCache.notes.filterIntoSet { _, it ->
it !is AddressableNote && it.event !is AddressableEvent && acceptableEvent(it)
}
val longFormNotes =
LocalCache.addressables.filterIntoSet { _, it ->
acceptableEvent(it)
}
return sort(notes + longFormNotes)
}
@@ -52,28 +61,26 @@ class UserProfileNewThreadFeedFilter(val user: User, val account: Account) :
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
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()
return collection.filterTo(HashSet()) { acceptableEvent(it) }
}
fun acceptableEvent(it: Note): Boolean {
return 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)
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
return collection.sortedWith(DefaultFeedOrder)
}
override fun limit() = 200
@@ -44,7 +44,7 @@ class UserProfileReportsFeedFilter(val user: User) : AdditiveFeedFilter<Note>()
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
return collection.sortedWith(DefaultFeedOrder)
}
override fun limit() = 400
@@ -21,14 +21,12 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.utils.TimeUtils
class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
@@ -36,14 +34,17 @@ class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
}
override fun showHiddenKey(): Boolean {
return account.defaultStoriesFollowList.value ==
PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
account.defaultStoriesFollowList.value ==
MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
return account.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
account.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
}
override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.noteListCache)
val params = buildFilterParams(account)
val notes =
LocalCache.notes.filterIntoSet { _, it ->
acceptableEvent(it, params)
}
return sort(notes)
}
@@ -53,36 +54,32 @@ class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
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 params = buildFilterParams(account)
val followingKeySet = account.liveStoriesFollowLists.value?.users ?: emptySet()
val followingTagSet = account.liveStoriesFollowLists.value?.hashtags ?: emptySet()
val followingGeohashSet = account.liveStoriesFollowLists.value?.geotags ?: emptySet()
return collection.filterTo(HashSet()) { acceptableEvent(it, params) }
}
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()
fun acceptableEvent(
it: Note,
params: FilterByListParams,
): Boolean {
val noteEvent = it.event
return ((noteEvent is FileHeaderEvent && noteEvent.hasUrl() && noteEvent.isImageOrVideo()) || (noteEvent is FileStorageHeaderEvent && noteEvent.isImageOrVideo())) &&
params.match(noteEvent) &&
account.isAcceptable(it)
}
fun buildFilterParams(account: Account): FilterByListParams {
return FilterByListParams.create(
userHex = account.userProfile().pubkeyHex,
selectedListName = account.defaultStoriesFollowList.value,
followLists = account.liveStoriesFollowLists.value,
hiddenUsers = account.flowHiddenUsers.value,
)
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
return collection.sortedWith(DefaultFeedOrder)
}
}
@@ -28,7 +28,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -41,13 +41,12 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.NewItemsBubble
import com.vitorpamplona.amethyst.ui.note.TimeAgo
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.theme.ChatHeadlineBorders
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.StdTopPadding
import com.vitorpamplona.quartz.utils.TimeUtils
@Composable
@@ -73,7 +72,7 @@ fun ChannelNamePreview() {
onClick = {},
)
Divider()
HorizontalDivider(thickness = DividerThickness)
ListItem(
headlineContent = {
@@ -134,10 +133,5 @@ fun ChatHeaderLayout(
}
}
}
Divider(
modifier = StdTopPadding,
thickness = DividerThickness,
)
}
}
@@ -57,10 +57,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
@Preview
fun LeftPictureLayoutPreview() {
ThemeComparisonColumn(
onDark = { LeftPictureLayoutPreviewCard() },
onLight = { LeftPictureLayoutPreviewCard() },
)
ThemeComparisonColumn { LeftPictureLayoutPreviewCard() }
}
@Composable
@@ -61,7 +61,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
@@ -78,7 +77,6 @@ import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -95,7 +93,10 @@ fun AccountSwitchBottomSheet(
Column(modifier = Modifier.verticalScroll(scrollState)) {
Row(
modifier = Modifier.fillMaxWidth().padding(Size10dp),
modifier =
Modifier
.fillMaxWidth()
.padding(Size10dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -103,7 +104,10 @@ fun AccountSwitchBottomSheet(
}
accounts.forEach { acc -> DisplayAccount(acc, accountViewModel, accountStateViewModel) }
Row(
modifier = Modifier.fillMaxWidth().padding(top = Size10dp, bottom = Size55dp),
modifier =
Modifier
.fillMaxWidth()
.padding(top = Size10dp, bottom = Size55dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -167,7 +171,8 @@ fun DisplayAccount(
baseUser?.let {
Row(
modifier =
Modifier.fillMaxWidth()
Modifier
.fillMaxWidth()
.clickable { accountStateViewModel.switchUser(acc) }
.padding(16.dp, 16.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -181,7 +186,10 @@ fun DisplayAccount(
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.width(55.dp).padding(0.dp),
modifier =
Modifier
.width(55.dp)
.padding(0.dp),
) {
val automaticallyShowProfilePicture =
remember {
@@ -241,22 +249,17 @@ private fun AccountName(
acc: AccountInfo,
user: User,
) {
val displayName by user.live().metadata.map { user.bestDisplayName() }.observeAsState()
val info by user.live().userMetadataInfo.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,
)
info?.let {
it.bestName()?.let { name ->
CreateTextWithEmoji(
text = name,
tags = it.tags,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Text(
@@ -27,7 +27,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
@@ -132,7 +132,7 @@ private fun RenderBottomMenu(
nav: (Route, Boolean) -> Unit,
) {
Column(modifier = BottomTopHeight) {
Divider(
HorizontalDivider(
thickness = DividerThickness,
)
NavigationBar(tonalElevation = Size0dp) {
@@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreenByAuthor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DiscoverScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DraftListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.GeoHashScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HiddenUsersScreen
@@ -214,6 +215,7 @@ fun AppNavigation(
composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, nav) })
composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, nav) })
composable(Route.Drafts.route, content = { DraftListScreen(accountViewModel, nav) })
Route.Profile.let { route ->
composable(
@@ -31,7 +31,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
@@ -39,15 +39,14 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Divider
import androidx.compose.material3.DrawerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
@@ -107,12 +106,12 @@ import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.LoadCityName
import com.vitorpamplona.amethyst.ui.note.LoadUser
import com.vitorpamplona.amethyst.ui.note.LongCommunityHeader
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.note.ShortCommunityHeader
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.types.LongCommunityHeader
import com.vitorpamplona.amethyst.ui.note.types.ShortCommunityHeader
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DislayGeoTagHeader
@@ -185,8 +184,9 @@ private fun RenderTopRouteBar(
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)
Route.Settings.base -> TopBarWithBackButton(stringResource(id = R.string.application_preferences), navPopBack)
Route.Bookmarks.base -> TopBarWithBackButton(stringResource(id = R.string.bookmarks), navPopBack)
Route.Drafts.base -> TopBarWithBackButton(stringResource(id = R.string.drafts), navPopBack)
else -> {
if (id != null) {
when (currentRoute) {
@@ -356,7 +356,9 @@ private fun RenderRoomTopBar(
RoomNameOnlyDisplay(
room,
Modifier.padding(start = 10.dp).weight(1f),
Modifier
.padding(start = 10.dp)
.weight(1f),
fontWeight = FontWeight.Normal,
accountViewModel.userProfile(),
)
@@ -394,8 +396,6 @@ private fun ChannelTopBar(
}
}
@Composable fun NoTopBar() {}
@Composable
fun StoriesTopBar(
followLists: FollowListViewModel,
@@ -403,7 +403,7 @@ fun StoriesTopBar(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel ->
GenericMainTopBar(drawerState, accountViewModel, nav) {
val list by accountViewModel.account.defaultStoriesFollowList.collectAsStateWithLifecycle()
FollowListWithRoutes(
@@ -422,7 +422,7 @@ fun HomeTopBar(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel ->
GenericMainTopBar(drawerState, accountViewModel, nav) {
val list by accountViewModel.account.defaultHomeFollowList.collectAsStateWithLifecycle()
FollowListWithRoutes(
@@ -445,7 +445,7 @@ fun NotificationTopBar(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel ->
GenericMainTopBar(drawerState, accountViewModel, nav) {
val list by accountViewModel.account.defaultNotificationFollowList.collectAsStateWithLifecycle()
FollowListWithoutRoutes(
@@ -464,7 +464,7 @@ fun DiscoveryTopBar(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel ->
GenericMainTopBar(drawerState, accountViewModel, nav) {
val list by accountViewModel.account.defaultDiscoveryFollowList.collectAsStateWithLifecycle()
FollowListWithoutRoutes(
@@ -491,28 +491,17 @@ fun GenericMainTopBar(
drawerState: DrawerState,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
content: @Composable (AccountViewModel) -> Unit,
content: @Composable () -> Unit,
) {
Column(modifier = BottomTopHeight) {
TopAppBar(
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
title = {
Column(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box(Modifier) {
Column(
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
content(accountViewModel)
}
}
content()
}
},
navigationIcon = {
@@ -521,7 +510,7 @@ fun GenericMainTopBar(
},
actions = { SearchButton { nav(Route.Search.route) } },
)
Divider(thickness = DividerThickness)
HorizontalDivider(thickness = DividerThickness)
}
}
@@ -542,9 +531,7 @@ private fun LoggedInUserPictureDrawer(
val profilePicture by
accountViewModel.account.userProfile().live().profilePictureChanges.observeAsState()
IconButton(
onClick = onClick,
) {
IconButton(onClick = onClick) {
RobohashFallbackAsyncImage(
robot = accountViewModel.userProfile().pubkeyHex,
model = profilePicture,
@@ -652,15 +639,15 @@ class FollowListViewModel(val account: Account) : ViewModel() {
val newFollowLists =
LocalCache.addressables
.mapNotNull {
val event = (it.value.event as? PeopleListEvent)
.mapNotNull { _, addressableNote ->
val event = (addressableNote.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)
CodeName(event.address().toTag(), PeopleListName(addressableNote), CodeNameType.PEOPLE_LIST)
} else {
null
}
@@ -786,12 +773,14 @@ fun SimpleTextSpinner(
}
Box(
modifier =
Modifier.matchParentSize().clickable(
interactionSource = interactionSource,
indication = null,
) {
optionsShowing = true
},
Modifier
.matchParentSize()
.clickable(
interactionSource = interactionSource,
indication = null,
) {
optionsShowing = true
},
)
}
@@ -889,7 +878,7 @@ fun TopBarWithBackButton(
},
actions = {},
)
Divider(thickness = DividerThickness)
HorizontalDivider(thickness = DividerThickness)
}
}
@@ -907,7 +896,7 @@ fun FlexibleTopBarWithBackButton(
actions = {},
)
Spacer(modifier = HalfVertSpacer)
Divider(thickness = DividerThickness)
HorizontalDivider(thickness = DividerThickness)
}
}
@@ -924,13 +913,7 @@ fun AmethystClickableIcon() {
fun debugState(context: Context) {
Client.allSubscriptions()
.map {
"$it ${
Client.getSubscriptionFilters(it)
.joinToString { it.filter.toJson() }
}"
}
.forEach { Log.d("STATE DUMP", it) }
.forEach { Log.d("STATE DUMP", "${it.key} ${it.value.joinToString { it.filter.toJson() }}") }
NostrAccountDataSource.printCounter()
NostrChannelDataSource.printCounter()
@@ -975,44 +958,44 @@ fun debugState(context: Context) {
Log.d(
"STATE DUMP",
"Notes: " +
LocalCache.noteListCache.filter { it.liveSet != null }.size +
LocalCache.notes.filter { _, it -> it.liveSet != null }.size +
" / " +
LocalCache.noteListCache.filter { it.event != null }.size +
LocalCache.notes.filter { _, it -> it.event != null }.size +
" / " +
LocalCache.noteListCache.size,
LocalCache.notes.size(),
)
Log.d(
"STATE DUMP",
"Addressables: " +
LocalCache.addressables.filter { it.value.liveSet != null }.size +
LocalCache.addressables.filter { _, it -> it.liveSet != null }.size +
" / " +
LocalCache.addressables.filter { it.value.event != null }.size +
LocalCache.addressables.filter { _, it -> it.event != null }.size +
" / " +
LocalCache.addressables.size,
LocalCache.addressables.size(),
)
Log.d(
"STATE DUMP",
"Users: " +
LocalCache.userListCache.filter { it.liveSet != null }.size +
LocalCache.users.filter { _, it -> it.liveSet != null }.size +
" / " +
LocalCache.userListCache.filter { it.info?.latestMetadata != null }.size +
LocalCache.users.filter { _, it -> it.latestMetadata != null }.size +
" / " +
LocalCache.userListCache.size,
LocalCache.users.size(),
)
Log.d(
"STATE DUMP",
"Memory used by Events: " +
LocalCache.noteListCache.sumOf { it.event?.countMemory() ?: 0 } / (1024 * 1024) +
LocalCache.notes.sumOfLong { _, note -> note.event?.countMemory() ?: 0L } / (1024 * 1024) +
" MB",
)
LocalCache.noteListCache
.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
.countByGroup { _, it -> it.event?.kind() }
.forEach { Log.d("STATE DUMP", "Kind ${it.key}: \t${it.value} elements ") }
LocalCache.addressables
.countByGroup { _, it -> it.event?.kind() }
.forEach { Log.d("STATE DUMP", "Kind ${it.key}: \t${it.value} elements ") }
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -1064,8 +1047,6 @@ fun MyExtensibleTopAppBar(
}
}
private val AppBarHeight = 50.dp
// TODO: this should probably be part of the touch target of the start and end icons, clarify this
private val AppBarHorizontalPadding = 4.dp
@@ -45,8 +45,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Divider
import androidx.compose.material3.DrawerState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -72,10 +72,13 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -86,6 +89,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.service.relays.RelayPoolStatus
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.components.ClickableText
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.LoadStatuses
@@ -149,13 +153,16 @@ fun DrawerContent(
FollowingAndFollowerCounts(accountViewModel.account.userProfile(), onClickUser)
Divider(
HorizontalDivider(
thickness = DividerThickness,
modifier = Modifier.padding(top = 20.dp),
)
ListContent(
modifier = Modifier.fillMaxWidth().weight(1f),
modifier =
Modifier
.fillMaxWidth()
.weight(1f),
drawerState,
openSheet,
accountViewModel,
@@ -185,7 +192,7 @@ fun ProfileContent(
profilePubHex = baseAccountUser.pubkeyHex,
profileBanner = userInfo?.banner,
profilePicture = userInfo?.profilePicture(),
bestDisplayName = userInfo?.bestDisplayName(),
bestDisplayName = userInfo?.bestName(),
tags = userInfo?.tags,
modifier = modifier,
accountViewModel = accountViewModel,
@@ -227,7 +234,8 @@ fun ProfileContentTemplate(
model = profilePicture,
contentDescription = stringResource(id = R.string.profile_image),
modifier =
Modifier.width(100.dp)
Modifier
.width(100.dp)
.height(100.dp)
.clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colorScheme.background, CircleShape)
@@ -240,7 +248,10 @@ fun ProfileContentTemplate(
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
modifier = Modifier.padding(top = 7.dp).clickable(onClick = onClick),
modifier =
Modifier
.padding(top = 7.dp)
.clickable(onClick = onClick),
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
maxLines = 1,
@@ -451,7 +462,10 @@ fun ListContent(
val context = LocalContext.current
Column(
modifier = modifier.fillMaxHeight().verticalScroll(rememberScrollState()),
modifier =
modifier
.fillMaxHeight()
.verticalScroll(rememberScrollState()),
) {
NavigationRow(
title = stringResource(R.string.profile),
@@ -471,6 +485,15 @@ fun ListContent(
route = Route.Bookmarks.route,
)
NavigationRow(
title = stringResource(R.string.drafts),
icon = Route.Drafts.icon,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
drawerState = drawerState,
route = Route.Drafts.route,
)
IconRowRelays(
accountViewModel = accountViewModel,
onClick = {
@@ -658,7 +681,8 @@ fun IconRow(
) {
Row(
modifier =
Modifier.fillMaxWidth()
Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
@@ -689,10 +713,16 @@ fun IconRowRelays(
onClick: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().clickable { onClick() },
modifier =
Modifier
.fillMaxWidth()
.clickable { onClick() },
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 15.dp, horizontal = 25.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 15.dp, horizontal = 25.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
@@ -728,37 +758,35 @@ fun BottomContent(
var dialogOpen by remember { mutableStateOf(false) }
Column(modifier = Modifier) {
Divider(
HorizontalDivider(
modifier = Modifier.padding(top = 15.dp),
thickness = DividerThickness,
)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 15.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 15.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
ClickableText(
text =
buildAnnotatedString {
withStyle(
SpanStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
),
) {
append("v" + BuildConfig.VERSION_NAME + "-" + BuildConfig.FLAVOR.uppercase())
}
},
onClick = {
nav("Note/${BuildConfig.RELEASE_NOTES_ID}")
coroutineScope.launch { drawerState.close() }
},
modifier = Modifier.padding(start = 16.dp),
text = "v" + BuildConfig.VERSION_NAME + "-" + BuildConfig.FLAVOR.uppercase(),
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
)
/*
IconButton(
onClick = {
when (AppCompatDelegate.getDefaultNightMode()) {
AppCompatDelegate.MODE_NIGHT_NO -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
AppCompatDelegate.MODE_NIGHT_YES -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
else -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
}
}
) {
Icon(
painter = painterResource(R.drawable.ic_theme),
null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
}*/
Box(modifier = Modifier.weight(1F))
IconButton(
onClick = {
@@ -25,12 +25,14 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ChatroomKeyable
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.IsInPublicChatChannel
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import kotlinx.collections.immutable.persistentSetOf
@@ -40,18 +42,51 @@ fun routeFor(
note: Note,
loggedIn: User,
): String? {
val noteEvent = note.event
val noteEvent = note.event ?: return "Note/${URLEncoder.encode(note.idHex, "utf-8")}"
if (
noteEvent is ChannelMessageEvent ||
noteEvent is ChannelCreateEvent ||
noteEvent is ChannelMetadataEvent
) {
note.channelHex()?.let {
return routeFor(noteEvent, loggedIn)
}
fun routeFor(
noteEvent: EventInterface,
loggedIn: User,
): String? {
if (noteEvent is DraftEvent) {
val innerEvent = noteEvent.preCachedDraft(loggedIn.pubkeyHex)
if (innerEvent is IsInPublicChatChannel) {
innerEvent.channel()?.let {
return "Channel/$it"
}
} else if (innerEvent is LiveActivitiesEvent) {
innerEvent.address().toTag().let {
return "Channel/${URLEncoder.encode(it, "utf-8")}"
}
} else if (innerEvent is LiveActivitiesChatMessageEvent) {
innerEvent.activity()?.toTag()?.let {
return "Channel/${URLEncoder.encode(it, "utf-8")}"
}
} else if (innerEvent is ChatroomKeyable) {
val room = innerEvent.chatroomKey(loggedIn.pubkeyHex)
loggedIn.createChatroom(room)
return "Room/${room.hashCode()}"
} else if (innerEvent is AddressableEvent) {
return "Note/${URLEncoder.encode(noteEvent.address().toTag(), "utf-8")}"
} else {
return "Note/${URLEncoder.encode(noteEvent.id(), "utf-8")}"
}
} else if (noteEvent is IsInPublicChatChannel) {
noteEvent.channel()?.let {
return "Channel/$it"
}
} else if (noteEvent is LiveActivitiesEvent || noteEvent is LiveActivitiesChatMessageEvent) {
note.channelHex()?.let {
} else if (noteEvent is ChannelCreateEvent) {
return "Channel/${noteEvent.id}"
} else if (noteEvent is LiveActivitiesEvent) {
noteEvent.address().toTag().let {
return "Channel/${URLEncoder.encode(it, "utf-8")}"
}
} else if (noteEvent is LiveActivitiesChatMessageEvent) {
noteEvent.activity()?.toTag()?.let {
return "Channel/${URLEncoder.encode(it, "utf-8")}"
}
} else if (noteEvent is ChatroomKeyable) {
@@ -59,9 +94,11 @@ fun routeFor(
loggedIn.createChatroom(room)
return "Room/${room.hashCode()}"
} else if (noteEvent is CommunityDefinitionEvent) {
return "Community/${URLEncoder.encode(note.idHex, "utf-8")}"
return "Community/${URLEncoder.encode(noteEvent.address().toTag(), "utf-8")}"
} else if (noteEvent is AddressableEvent) {
return "Note/${URLEncoder.encode(noteEvent.address().toTag(), "utf-8")}"
} else {
return "Note/${URLEncoder.encode(note.idHex, "utf-8")}"
return "Note/${URLEncoder.encode(noteEvent.id(), "utf-8")}"
}
return null

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