Compare commits

...

108 Commits

Author SHA1 Message Date
Vitor Pamplona
d116159ed9 v0.75.5 2023-08-27 16:49:31 -04:00
Vitor Pamplona
3c24c011e3 Avoids rebuilding the channel object in DataSource. 2023-08-27 16:45:12 -04:00
Vitor Pamplona
536bdfe43a - When All Follows, the live tab will load any event with a follow in the participant list.
- Removes prefix filters
2023-08-27 16:32:35 -04:00
Vitor Pamplona
0b41110938 Fixes formatting 2023-08-27 15:53:41 -04:00
Vitor Pamplona
da1d4018cf Fixes crash when more than 1 status 2023-08-27 15:52:35 -04:00
Vitor Pamplona
b569c67d3b Fixes Hungarian labels 2023-08-27 15:33:18 -04:00
Vitor Pamplona
50a5a68b71 Merge pull request #554 from anthony-robin/translations
Update french translations
2023-08-27 15:30:42 -04:00
Vitor Pamplona
23a4d80ddb v0.75.4 2023-08-27 12:25:53 -04:00
Vitor Pamplona
6a64ebcc5b migrates to a double layer observer structure for the memory cache 2023-08-27 12:18:11 -04:00
Vitor Pamplona
8e622bfbb1 Updates to debugging logs 2023-08-27 12:16:36 -04:00
Vitor Pamplona
d189dce7f0 Extracts chatroom from user file 2023-08-27 12:16:13 -04:00
Vitor Pamplona
43ac1bf555 Allows cleaning up on App Pause to perform separately from memory pruning functions 2023-08-27 12:10:12 -04:00
Anthony Robin
d2d26e4e57 Update french translations
Update french translations according to newly added english ones.
2023-08-27 07:52:15 +02:00
Vitor Pamplona
de1f13d64c Faster crossfade animation between screens 2023-08-26 13:04:35 -04:00
Vitor Pamplona
2be04bfbe2 Better error handling when Zaps fail 2023-08-26 13:00:53 -04:00
Vitor Pamplona
57c33ae06a Improves User filters by merging lastEOSEs into a single filter. 2023-08-26 12:59:41 -04:00
Vitor Pamplona
99e545f0b8 Adds polls to the hashtag screen 2023-08-26 11:32:37 -04:00
Vitor Pamplona
accb115a20 Making statuses filter just one with the minimum of all observing users. 2023-08-26 10:36:44 -04:00
Vitor Pamplona
9de1cec160 Merge pull request #553 from ZsZolee/main
Additional Hungarian translation
2023-08-26 09:34:36 -04:00
Zoltan
f7774700db Additional Hungarian translation 2023-08-26 13:22:27 +02:00
Vitor Pamplona
ffb81c6246 v0.75.3 2023-08-25 17:51:38 -04:00
Vitor Pamplona
b7c9b14b1f Allowing any BaseTextNote on the reply window 2023-08-25 17:47:01 -04:00
Vitor Pamplona
53b0db61ea Moves isAcceptableNote calculations to viewModel 2023-08-25 17:32:19 -04:00
Vitor Pamplona
6c09e47e4f Moves coroutine of NIP-05 verification to the viewModel 2023-08-25 17:26:47 -04:00
Vitor Pamplona
9d9ad63b4d BugFix deleting status 2023-08-25 17:15:17 -04:00
Vitor Pamplona
7ef46a2662 Continues to move away from starting co-routines directly in composables 2023-08-25 17:14:33 -04:00
Vitor Pamplona
a7d8c87c47 1. Triggers a Toast when no wallet is found to pay the invoice
2. Starts the wallet in a separate task to allow people to come back to Amethyst switching apps instead of the back button.
2023-08-25 15:18:01 -04:00
Vitor Pamplona
1bb2459277 Activates min/max values for polls 2023-08-25 15:08:10 -04:00
Vitor Pamplona
6b39562925 Allows the keyboard action to be used as commit in the status field. 2023-08-25 13:55:41 -04:00
Vitor Pamplona
3d34e1fc4e Reduces the icon to the music and adds space before the status 2023-08-25 13:41:17 -04:00
Vitor Pamplona
469e1241d5 Updates some dependencies 2023-08-25 13:28:17 -04:00
Vitor Pamplona
86c0ca975b Moves the User DataSource invalidation to the right channel 2023-08-25 13:27:57 -04:00
Vitor Pamplona
53eeea3225 Removes unused time 2023-08-25 13:26:56 -04:00
Vitor Pamplona
e9ff9d3b37 Adds a space between the status and the clickable icon 2023-08-25 13:24:47 -04:00
Vitor Pamplona
e2c011ec60 Avoids lists with the same hexId because the app was pruning events while loading them 2023-08-25 13:01:49 -04:00
Vitor Pamplona
5328740bff Improves blinking when loading a thread. 2023-08-25 13:01:08 -04:00
Vitor Pamplona
017df9845d Fixes r filter in tags 2023-08-25 12:59:21 -04:00
Vitor Pamplona
ed70658be9 Removes unecessary @ExperimentalTime tag 2023-08-25 11:52:44 -04:00
Vitor Pamplona
dc74c3014f updates cached content when note changes 2023-08-25 11:51:41 -04:00
Vitor Pamplona
53d5c5f354 BugFix for serialization filter 2023-08-25 11:49:41 -04:00
Vitor Pamplona
63ab2a9fbe Moves NIP315 to NIP38 2023-08-25 10:31:51 -04:00
Vitor Pamplona
30391160da v0.75.2 2023-08-24 18:56:39 -04:00
Vitor Pamplona
3273aa32a5 Removes the debug r tag used to test. 2023-08-24 18:54:48 -04:00
Vitor Pamplona
1e358d31d5 v0.75.1 2023-08-24 17:59:26 -04:00
Vitor Pamplona
b57dd98059 Displays links in the Status if they are present. 2023-08-24 17:58:34 -04:00
Vitor Pamplona
5b596d2a56 - Updating Status on the Drawer
- Reducing font size for statuses and nip05s
2023-08-24 17:34:02 -04:00
Vitor Pamplona
62db68c7b2 Correctly display status 2023-08-24 16:17:20 -04:00
Vitor Pamplona
b3bb85cd53 Display statuses if NIP05 succeeds. 2023-08-24 15:57:18 -04:00
Vitor Pamplona
fe6fb8470d Adds isExpired method in events. 2023-08-24 15:56:43 -04:00
Vitor Pamplona
ee97eac00c Downloads Statuses from Relays 2023-08-24 15:56:08 -04:00
Vitor Pamplona
48e3ccc165 Allow changing message status 2023-08-24 15:55:32 -04:00
Vitor Pamplona
52c0503b6d Support for pruning expired events. 2023-08-24 13:13:30 -04:00
Vitor Pamplona
c052399a16 Adds support to consume Status Events 2023-08-24 13:12:10 -04:00
Vitor Pamplona
76faf7ae35 Moving livedata creation to long-lived objects 2023-08-24 12:53:07 -04:00
Vitor Pamplona
36ea33a919 Adds StatusEvent support 2023-08-24 08:56:51 -04:00
Vitor Pamplona
8aad4d8030 Moves Manifest to target API 34 2023-08-24 08:56:37 -04:00
Vitor Pamplona
a23c8c5c04 v0.75.0 2023-08-23 18:22:38 -04:00
Vitor Pamplona
0434ef6ec6 Fixing quotes and boosts for NIP94 and NIP95 content 2023-08-23 17:31:11 -04:00
Vitor Pamplona
4d85a2f6fb Correct imePadding of the NewPostView 2023-08-23 17:08:46 -04:00
Vitor Pamplona
e9b235f748 Don't show zaps on search. 2023-08-23 16:27:04 -04:00
Vitor Pamplona
30320805da Adds Thai translation 2023-08-23 10:19:00 -04:00
Vitor Pamplona
8e913ef974 Adds replies and boosts to Stories 2023-08-22 17:49:44 -04:00
Vitor Pamplona
eb191406e6 Vertical Arrangement not working as intended. 2023-08-22 17:32:59 -04:00
Vitor Pamplona
ca89b70150 Activates pruning of old replaceable versions 2023-08-22 17:07:59 -04:00
Vitor Pamplona
6bcd6950ef Refactors replaceable consumers into a single function
Activates calendar consumers
2023-08-22 16:59:58 -04:00
Vitor Pamplona
dfa9c77e3e Migrates Addressable Events to a Base class 2023-08-22 16:39:23 -04:00
Vitor Pamplona
af143f2ea1 Base support for Calendar events 2023-08-22 16:19:59 -04:00
Vitor Pamplona
52761b3901 Activates pull to refresh on the Stories feed. 2023-08-22 16:19:37 -04:00
Vitor Pamplona
f027d23fcf Small refactoring to create the PrivateZap Event 2023-08-22 16:10:32 -04:00
Vitor Pamplona
2d30f3e9d2 Finishing the prune clean up to make sure we don't delete post from other accounts logged in. 2023-08-22 16:01:59 -04:00
Vitor Pamplona
a63b383425 BugFix Force Relay Reconnection when writing a new events. 2023-08-22 14:06:02 -04:00
Vitor Pamplona
e04eb733e7 - Protecting contact lists of all accounts in the device.
- Pruning events that are not from or cite an account.
2023-08-22 14:04:49 -04:00
Vitor Pamplona
0a49298ec2 Faster ReactionRow compositions 2023-08-22 12:23:45 -04:00
Vitor Pamplona
1121cfcaf9 BugFix on bundled update dispatchers. 2023-08-22 12:23:26 -04:00
Vitor Pamplona
66b5fad6c2 Saves some loading time by moving the check if notification was already asked forward. 2023-08-22 11:23:11 -04:00
Vitor Pamplona
5d352f1b6c Updates compose navigation, exoplayer and vico dependencies to the latest. 2023-08-22 11:16:31 -04:00
Vitor Pamplona
9018175f54 Removes unecessary import. 2023-08-22 11:06:10 -04:00
Vitor Pamplona
98c55661e8 Migrates to Compose 1.5.0 2023-08-22 11:05:17 -04:00
Vitor Pamplona
521407f879 Updates Gradle plugin 2023-08-22 10:07:40 -04:00
Vitor Pamplona
fa4257ad7d Separating NIP24 into the encryption NIP 44 and the messaging NIP 24 2023-08-22 09:41:40 -04:00
Vitor Pamplona
d73dec8f0b v0.74.5 2023-08-21 18:31:00 -04:00
Vitor Pamplona
c6124376df Merge pull request #552 from greenart7c3/fix-crash
fix crash when getting firebase token without play services
2023-08-21 18:30:02 -04:00
Vitor Pamplona
87c19f4232 BugFix for ClassCastException when the event changes 2023-08-21 18:21:28 -04:00
greenart7c3
a1f116909f fix crash when not using play services 2023-08-21 19:20:14 -03:00
Vitor Pamplona
ca9951f9b8 Fixes the loading of GiftWraps as intents in the app. 2023-08-21 18:19:10 -04:00
Vitor Pamplona
2bedd79e6f Fixing search for root to go backwards in the etag-stack. 2023-08-21 18:17:21 -04:00
Vitor Pamplona
a9af2d8982 v0.74.4 2023-08-21 17:30:37 -04:00
Vitor Pamplona
655a563f61 Moves community description and rules to a single Translateable text 2023-08-21 16:09:22 -04:00
Vitor Pamplona
9777c709a1 - Fixes some of the TopBar padding issues.
- Adds more info tap on single-user chat rooms
- Moves creation date to a new line.
2023-08-21 15:42:45 -04:00
Vitor Pamplona
251f4854fc Copies to Clipboard the GiftWrap ID and not the inner message ID 2023-08-21 14:23:36 -04:00
Vitor Pamplona
43c32f0b0e Moves last scope to a class-bound variable. 2023-08-21 13:51:03 -04:00
Vitor Pamplona
ec3b07147c Avoids creating new corotine scopes 2023-08-21 13:34:18 -04:00
Vitor Pamplona
0e6a2c339e Fixes the update of the feed after adding an image on stories. 2023-08-20 21:24:57 -04:00
Vitor Pamplona
844f7cd478 Reports on the number of observers to the cache. 2023-08-20 21:24:23 -04:00
Vitor Pamplona
a2edc9f3b2 Fixes the divider presence in Live Activities and Notifications 2023-08-20 17:31:36 -04:00
Vitor Pamplona
35d09aee77 - Filter follows in the list of participants
- Uses KIND3 follows for the notification dot in the bottom nav bar
2023-08-20 17:08:24 -04:00
Vitor Pamplona
a79d1d5bd6 Fixes issue with unfollowing hashtag written in a different case 2023-08-20 16:46:44 -04:00
Vitor Pamplona
ba9315fcd1 Moves coroutines of BundledUpdate and BundledInsert to a managed model. 2023-08-20 16:37:49 -04:00
Vitor Pamplona
f5c71b4f90 Broadcasts a note when a person replies to it. 2023-08-20 16:35:34 -04:00
Vitor Pamplona
5a40b39952 v0.74.3 2023-08-20 11:36:00 -04:00
Vitor Pamplona
2ac3b42d4d Correctly gets the Global Relays being used. 2023-08-20 11:33:56 -04:00
Vitor Pamplona
7cfad373f2 Adds Copy URL and Copy Note ID popups for images and videos 2023-08-20 11:18:31 -04:00
Vitor Pamplona
5d061a79e2 Explanation on why the Global and All Follows code have spaces 2023-08-20 10:29:35 -04:00
Vitor Pamplona
eb59a9d505 Keeps track of a message's GiftWrap host in order to broadcast later. 2023-08-20 10:24:11 -04:00
Vitor Pamplona
784f867a8e Updating media controls to the next version 2023-08-20 01:16:08 -04:00
Vitor Pamplona
954a9d66fb Fixes the crash when opening a malformed thread with a wrong root marker 2023-08-20 01:15:46 -04:00
Vitor Pamplona
dfdced3e8c Includes the zaps from the user's own key in the Summary Chart 2023-08-19 14:57:24 -04:00
Vitor Pamplona
0af141c5b7 Fixes generateForegroundServiceDidNotStartInTimeException on Samsung phones 2023-08-19 14:44:39 -04:00
154 changed files with 3932 additions and 1971 deletions

View File

@@ -82,13 +82,14 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Private Messages and Small Groups (NIP-24)
- [x] Gift Wraps & Seals (NIP-59)
- [x] Versioned Encrypted Payloads (NIP-44)
- [x] Expiration Support (NIP-40)
- [x] Status Event (NIP-38)
- [ ] Marketplace (NIP-15)
- [ ] Image/Video Capture in the app
- [ ] Local Database
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
- [ ] Workspaces
- [ ] Expiration Support (NIP-40)
- [ ] Infinity Scroll
- [ ] Relay List Metadata (NIP-65)
- [ ] Signing Requests (NIP-46)

View File

@@ -13,14 +13,14 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
versionCode 275
versionName "0.74.2"
versionCode 284
versionName "0.75.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW', 'ja']
resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'ja', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'th', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW']
}
buildTypes {
@@ -88,7 +88,7 @@ android {
dependencies {
implementation project(path: ':quartz')
implementation 'androidx.core:core-ktx:1.10.1'
implementation "androidx.core:core-ktx:$core_ktx_version"
implementation 'androidx.activity:activity-compose:1.7.2'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
@@ -111,7 +111,8 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
implementation "net.engawapg.lib:zoomable:1.4.3"
// Zoomable images
implementation "net.engawapg.lib:zoomable:1.5.0"
// Biometrics
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
@@ -119,7 +120,7 @@ dependencies {
// Websockets API
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
// link preview
// HTML Parsing for Link Preview
implementation 'org.jsoup:jsoup:1.16.1'
// Encrypted Key Storage
@@ -159,9 +160,9 @@ dependencies {
// Language picker and Theme chooser
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.media3:media3-exoplayer-hls:1.1.0'
implementation 'androidx.media3:media3-ui:1.1.0'
implementation 'androidx.media3:media3-session:1.1.0'
implementation "androidx.media3:media3-exoplayer-hls:$media3_version"
implementation "androidx.media3:media3-ui:$media3_version"
implementation "androidx.media3:media3-session:$media3_version"
// Local model for language identification
playImplementation 'com.google.mlkit:language-id:17.0.4'
@@ -170,7 +171,7 @@ dependencies {
playImplementation 'com.google.mlkit:translate:17.0.1'
// PushNotifications
playImplementation platform('com.google.firebase:firebase-bom:32.2.2')
playImplementation platform('com.google.firebase:firebase-bom:32.2.3')
playImplementation 'com.google.firebase:firebase-messaging-ktx'
// Charts

View File

@@ -21,7 +21,7 @@ class ImageUploadTesting {
val image = "R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw=="
fun testBase(server: FileServer) {
private fun testBase(server: FileServer) {
val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
@@ -39,7 +39,7 @@ class ImageUploadTesting {
"image/gif",
server,
onSuccess = { newUrl, contentType ->
println("Uploaded to $url")
println("Uploaded $contentType to $url")
url = newUrl
countDownLatch.countDown()
},

View File

@@ -4128,7 +4128,7 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
}
private fun printStateForDebug(state: RichTextViewerState) {
state.paragraphs.forEachIndexed { index, paragraph ->
state.paragraphs.forEach { paragraph ->
paragraph.words.forEach { seg ->
println(
"\"${

View File

@@ -60,7 +60,7 @@
android:usesCleartextTraffic="true"
android:hardwareAccelerated="true"
android:localeConfig="@xml/locales_config"
tools:targetApi="33">
tools:targetApi="34">
<activity
android:name=".ui.MainActivity"
android:exported="true"

View File

@@ -205,6 +205,10 @@ object LocalPreferences {
}
}
fun allLocalAccountNPubs(): Set<String> {
return savedAccounts().toSet()
}
fun saveToEncryptedStorage(account: Account) {
val prefs = encryptedPreferences(account.userProfile().pubkeyNpub())
prefs.edit().apply {

View File

@@ -26,15 +26,35 @@ class PlaybackService : MediaSessionService() {
return ProgressiveMediaSource.Factory(VideoCache.get(Amethyst.instance, HttpClient.getHttpClient()))
}
fun lazyHlsDS(): MultiPlayerPlaybackManager {
managerHls?.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
return newInstance
}
// Create your Player and MediaSession in the onCreate lifecycle event
@OptIn(UnstableApi::class)
override fun onCreate() {
super.onCreate()
managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
managerProgressive = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
managerLocal = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache)
// Stop all videos and recreates all managers when the proxy changes.
HttpClient.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated)
}
@@ -62,11 +82,11 @@ class PlaybackService : MediaSessionService() {
fun getAppropriateMediaSessionManager(fileName: String): MultiPlayerPlaybackManager? {
return if (fileName.startsWith("file")) {
managerLocal
lazyLocalDS()
} else if (fileName.endsWith("m3u8")) {
managerHls
lazyHlsDS()
} else {
managerProgressive
lazyProgressiveDS()
}
}

View File

@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
import java.io.File
object ServiceManager {
@@ -114,14 +115,22 @@ object ServiceManager {
Client.disconnect()
}
fun cleanUp() {
fun cleanObservers() {
LocalCache.cleanObservers()
}
fun trimMemory() {
LocalCache.cleanObservers()
val accounts = LocalPreferences.allLocalAccountNPubs().mapNotNull { decodePublicKeyAsHexOrNull(it) }.toSet()
account?.let {
LocalCache.pruneOldAndHiddenMessages(it)
LocalCache.pruneHiddenMessages(it)
LocalCache.pruneContactLists(it)
LocalCache.pruneRepliesAndReactions(it)
LocalCache.pruneContactLists(accounts)
LocalCache.pruneRepliesAndReactions(accounts)
LocalCache.prunePastVersionsOfReplaceables()
LocalCache.pruneExpiredEvents()
}
}
}

View File

@@ -49,8 +49,8 @@ fun getLanguagesSpokenByUser(): Set<String> {
return codedList
}
val GLOBAL_FOLLOWS = " Global "
val KIND3_FOLLOWS = " All Follows "
val GLOBAL_FOLLOWS = " Global " // This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val KIND3_FOLLOWS = " All Follows " // This has spaces to avoid mixing with a potential NIP-51 list with the same name.
@OptIn(DelicateCoroutinesApi::class)
@Stable
@@ -452,7 +452,13 @@ class Account(
fun broadcast(note: Note) {
note.event?.let {
Client.send(it)
if (it is WrappedEvent && it.host != null) {
it.host?.let { hostEvent ->
Client.send(hostEvent)
}
} else {
Client.send(it)
}
}
}
@@ -784,6 +790,23 @@ class Account(
Client.send(signedEvent, relayList = relayList)
LocalCache.consume(signedEvent)
// broadcast replied notes
replyingTo?.let {
LocalCache.getNoteIfExists(replyingTo)?.event?.let {
Client.send(it, relayList = relayList)
}
}
replyTo?.forEach {
it.event?.let {
Client.send(it, relayList = relayList)
}
}
addresses?.forEach {
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
Client.send(it, relayList = relayList)
}
}
}
fun sendPoll(
@@ -826,6 +849,17 @@ class Account(
// println("Sending new PollNoteEvent: %s".format(signedEvent.toJson()))
Client.send(signedEvent, relayList = relayList)
LocalCache.consume(signedEvent)
replyTo?.forEach {
it.event?.let {
Client.send(it, relayList = relayList)
}
}
addresses?.forEach {
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
Client.send(it, relayList = relayList)
}
}
}
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) {
@@ -974,6 +1008,40 @@ class Account(
}
}
fun updateStatus(oldStatus: AddressableNote, newStatus: String) {
if (!isWriteable()) return
val oldEvent = oldStatus.event as? StatusEvent ?: return
val event = StatusEvent.update(oldEvent, newStatus, keyPair.privKey!!)
Client.send(event)
LocalCache.consume(event, null)
}
fun createStatus(newStatus: String) {
if (!isWriteable()) return
val event = StatusEvent.create(newStatus, "general", expiration = null, keyPair.privKey!!)
Client.send(event)
LocalCache.consume(event, null)
}
fun deleteStatus(oldStatus: AddressableNote) {
if (!isWriteable()) return
val oldEvent = oldStatus.event as? StatusEvent ?: return
val event = StatusEvent.clear(oldEvent, keyPair.privKey!!)
Client.send(event)
LocalCache.consume(event, null)
val event2 = DeletionEvent.create(listOf(event.id), keyPair.privKey!!)
Client.send(event2)
LocalCache.consume(event2)
}
fun removeEmojiPack(usersEmojiList: Note, emojiList: Note) {
if (!isWriteable()) return
@@ -1610,8 +1678,8 @@ class Account(
}.toTypedArray()
}
fun convertGlobalRelays(): Array<String> {
return localRelays.filter { it.feedTypes.contains(FeedType.GLOBAL) }
fun activeGlobalRelays(): Array<String> {
return (activeRelays() ?: convertLocalRelays()).filter { it.activeTypes.contains(FeedType.GLOBAL) }
.map { it.url }
.toTypedArray()
}

View File

@@ -164,20 +164,12 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
override fun onActive() {
super.onActive()
if (channel is PublicChatChannel) {
NostrSingleChannelDataSource.add(channel.idHex)
} else {
NostrSingleChannelDataSource.add(channel.idHex)
}
NostrSingleChannelDataSource.add(channel)
}
override fun onInactive() {
super.onInactive()
if (channel is PublicChatChannel) {
NostrSingleChannelDataSource.remove(channel.idHex)
} else {
NostrSingleChannelDataSource.remove(channel.idHex)
}
NostrSingleChannelDataSource.remove(channel)
}
}

View File

@@ -0,0 +1,63 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.utils.TimeUtils
@Stable
class Chatroom() {
var roomMessages: Set<Note> = setOf()
var subject: String? = null
var subjectCreatedAt: Long? = null
@Synchronized
fun addMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
val newSubject = msg.event?.subject()
if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) {
subject = newSubject
subjectCreatedAt = msg.createdAt()
}
}
}
@Synchronized
fun removeMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
roomMessages.filter { it.event?.subject() != null }.sortedBy { it.createdAt() }.lastOrNull()?.let {
subject = it.event?.subject()
subjectCreatedAt = it.createdAt()
}
}
}
fun senderIntersects(keySet: Set<HexKey>): Boolean {
return roomMessages.any { it.author?.pubkeyHex in keySet }
}
fun pruneMessagesToTheLatestOnly(): Set<Note> {
val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
val toKeep = if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) {
// Recent messages, keep last 100
sorted.take(100).toSet()
} else {
// Old messages, keep the last one.
sorted.take(1).toSet()
} + sorted.filter { it.liveSet?.isInUse() ?: false }
val toRemove = roomMessages.minus(toKeep)
roomMessages = toKeep
return toRemove
}
}

View File

@@ -14,7 +14,9 @@ import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.*
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -204,46 +206,6 @@ object LocalCache {
}
}
fun consume(event: PeopleListEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: AdvertisedRelayListEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
fun formattedDateTime(timestamp: Long): String {
return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
@@ -349,25 +311,6 @@ object LocalCache {
refreshObservers(note)
}
private fun consume(event: CommunityDefinitionEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: LiveActivitiesEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
@@ -392,102 +335,16 @@ object LocalCache {
}
}
fun consume(event: EmojiPackSelectionEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: EmojiPackEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: ClassifiedsEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: PinListEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: RelaySetEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: AudioTrackEvent) {
fun consume(event: PeopleListEvent) { consumeBaseReplaceable(event) }
private fun consume(event: AdvertisedRelayListEvent) { consumeBaseReplaceable(event) }
private fun consume(event: CommunityDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event) }
fun consume(event: EmojiPackSelectionEvent) { consumeBaseReplaceable(event) }
private fun consume(event: EmojiPackEvent) { consumeBaseReplaceable(event) }
private fun consume(event: ClassifiedsEvent) { consumeBaseReplaceable(event) }
private fun consume(event: PinListEvent) { consumeBaseReplaceable(event) }
private fun consume(event: RelaySetEvent) { consumeBaseReplaceable(event) }
private fun consume(event: AudioTrackEvent) { consumeBaseReplaceable(event) }
fun consume(event: StatusEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
@@ -503,30 +360,14 @@ object LocalCache {
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
fun consume(event: BadgeDefinitionEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList<Note>())
author.liveSet?.innerStatuses?.invalidateData()
refreshObservers(note)
}
}
fun consume(event: BadgeDefinitionEvent) { consumeBaseReplaceable(event) }
fun consume(event: BadgeProfilesEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
@@ -541,7 +382,7 @@ object LocalCache {
if (note.event?.id() == event.id()) return
val replyTo = event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } +
event.badgeAwardDefinitions().mapNotNull { getOrCreateAddressableNote(it) }
event.badgeAwardDefinitions().map { getOrCreateAddressableNote(it) }
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, replyTo)
@@ -571,7 +412,14 @@ object LocalCache {
refreshObservers(note)
}
private fun comsume(event: NNSEvent) {
private fun comsume(event: NNSEvent) { consumeBaseReplaceable(event) }
fun consume(event: AppDefinitionEvent) { consumeBaseReplaceable(event) }
private fun consume(event: CalendarEvent) { consumeBaseReplaceable(event) }
private fun consume(event: CalendarDateSlotEvent) { consumeBaseReplaceable(event) }
private fun consume(event: CalendarTimeSlotEvent) { consumeBaseReplaceable(event) }
private fun consume(event: CalendarRSVPEvent) { consumeBaseReplaceable(event) }
private fun consumeBaseReplaceable(event: BaseAddressableEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
@@ -591,45 +439,7 @@ object LocalCache {
}
}
fun consume(event: AppDefinitionEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
fun consume(event: AppRecommendationEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
fun consume(event: AppRecommendationEvent) { consumeBaseReplaceable(event) }
@Suppress("UNUSED_PARAMETER")
fun consume(event: RecommendRelayEvent) {
@@ -729,7 +539,7 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey)
val repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, repliesTo)
@@ -751,7 +561,7 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey)
val repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, repliesTo)
@@ -840,7 +650,7 @@ object LocalCache {
mentions.forEach {
// doesn't add to reports, but triggers recounts
it.liveSet?.reports?.invalidateData()
it.liveSet?.innerReports?.invalidateData()
}
}
@@ -1301,7 +1111,7 @@ object LocalCache {
}
return notes.values.filter {
(it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent) &&
(it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent && it.event !is LnZapEvent && it.event !is LnZapRequestEvent) &&
(
it.event?.content()?.contains(text, true) ?: false ||
it.event?.matchTag1With(text) ?: false ||
@@ -1309,7 +1119,7 @@ object LocalCache {
it.idNote().startsWith(text, true)
)
} + addressables.values.filter {
(it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent) &&
(it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent && it.event !is LnZapEvent && it.event !is LnZapRequestEvent) &&
(
it.event?.content()?.contains(text, true) ?: false ||
it.event?.matchTag1With(text) ?: false ||
@@ -1338,6 +1148,18 @@ object LocalCache {
}
}
suspend fun findStatusesForUser(user: User): ImmutableList<AddressableNote> {
checkNotInMainThread()
return addressables.filter {
val noteEvent = it.value.event
(noteEvent is StatusEvent && noteEvent.pubKey == user.pubkeyHex && !noteEvent.isExpired() && noteEvent.content.isNotBlank())
}.values
.sortedWith(compareBy({ it.event?.expiration() ?: it.event?.createdAt() }, { it.idHex }))
.reversed()
.toImmutableList()
}
fun cleanObservers() {
notes.forEach {
it.value.clearLive()
@@ -1365,8 +1187,8 @@ object LocalCache {
// Doesn't need to clean up the replies and mentions.. Too small to matter.
// Counts the replies
it.replyTo?.forEach { _ ->
it.removeReply(it)
it.replyTo?.forEach { parent ->
parent.removeReply(it)
}
childrenToBeRemoved.addAll(it.removeAllChildNotes())
@@ -1389,8 +1211,8 @@ object LocalCache {
notes.remove(it.idHex)
// Counts the replies
it.replyTo?.forEach { _ ->
it.removeReply(it)
it.replyTo?.forEach { parent ->
parent.removeReply(it)
}
childrenToBeRemoved.addAll(it.removeAllChildNotes())
@@ -1405,9 +1227,46 @@ object LocalCache {
}
}
fun pruneRepliesAndReactions(account: Account) {
fun prunePastVersionsOfReplaceables() {
val toBeRemoved = notes.filter {
val noteEvent = it.value.event
if (noteEvent is AddressableEvent) {
noteEvent.createdAt() < (addressables[noteEvent.address().toTag()]?.event?.createdAt() ?: 0)
} else {
false
}
}.values
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
notes.remove(it.idHex)
val newerVersion = addressables[(it.event as? AddressableEvent)?.address()?.toTag()]
if (newerVersion != null) {
it.moveAllReferencesTo(newerVersion)
}
it.replyTo?.forEach { masterNote ->
masterNote.removeReply(it)
masterNote.removeBoost(it)
masterNote.removeReaction(it)
masterNote.removeZap(it)
masterNote.clearEOSE() // allows reloading of these events
}
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeChildrenOf(childrenToBeRemoved)
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} old version of addressables removed.")
}
}
fun pruneRepliesAndReactions(accounts: Set<HexKey>) {
checkNotInMainThread()
val user = account.userProfile()
val toBeRemoved = notes.filter {
(
@@ -1416,8 +1275,8 @@ object LocalCache {
it.value.event is ReportEvent || it.value.event is GenericRepostEvent
) &&
it.value.liveSet?.isInUse() != true && // don't delete if observing.
it.value.author != user && // don't delete if it is the logged in account
it.value.event?.isTaggedUser(user.pubkeyHex) != true // don't delete if it's a notification to the logged in user
it.value.author?.pubkeyHex !in accounts && // don't delete if it is the logged in account
it.value.event?.isTaggedUsers(accounts) != true // don't delete if it's a notification to the logged in user
}.values
val childrenToBeRemoved = mutableListOf<Note>()
@@ -1430,12 +1289,46 @@ object LocalCache {
masterNote.removeBoost(it)
masterNote.removeReaction(it)
masterNote.removeZap(it)
masterNote.removeReport(it)
masterNote.clearEOSE() // allows reloading of these events
}
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeChildrenOf(childrenToBeRemoved)
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} thread replies removed.")
}
}
fun pruneExpiredEvents() {
checkNotInMainThread()
val toBeRemoved = notes.filter {
it.value.event?.isExpired() == true
}.values
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
notes.remove(it.idHex)
it.replyTo?.forEach { masterNote ->
masterNote.removeReply(it)
masterNote.removeBoost(it)
masterNote.removeReaction(it)
masterNote.removeZap(it)
masterNote.removeReport(it)
masterNote.clearEOSE() // allows reloading of these events
}
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeChildrenOf(childrenToBeRemoved)
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} thread replies removed.")
}
@@ -1491,12 +1384,15 @@ object LocalCache {
}
}
fun pruneContactLists(userAccount: Account) {
fun pruneContactLists(loggedIn: Set<HexKey>) {
checkNotInMainThread()
var removingContactList = 0
users.values.forEach {
if (it != userAccount.userProfile() && (it.liveSet == null || it.liveSet?.isInUse() == false) && it.latestContactList != null) {
if (it.pubkeyHex !in loggedIn &&
(it.liveSet == null || it.liveSet?.isInUse() == false) &&
it.latestContactList != null
) {
it.latestContactList = null
removingContactList++
}
@@ -1547,6 +1443,10 @@ object LocalCache {
is BadgeDefinitionEvent -> consume(event)
is BadgeProfilesEvent -> consume(event)
is BookmarkListEvent -> consume(event)
is CalendarEvent -> consume(event)
is CalendarDateSlotEvent -> consume(event)
is CalendarTimeSlotEvent -> consume(event)
is CalendarRSVPEvent -> consume(event)
is ChannelCreateEvent -> consume(event)
is ChannelHideMessageEvent -> consume(event)
is ChannelMessageEvent -> consume(event, relay)
@@ -1589,6 +1489,7 @@ object LocalCache {
is PrivateDmEvent -> consume(event, relay)
is PinListEvent -> consume(event)
is PeopleListEvent -> consume(event)
is PollNoteEvent -> consume(event, relay)
is ReactionEvent -> consume(event)
is RecommendRelayEvent -> consume(event)
is RelaySetEvent -> consume(event)
@@ -1605,8 +1506,9 @@ object LocalCache {
}
consume(event)
}
is StatusEvent -> consume(event, relay)
is TextNoteEvent -> consume(event, relay)
is PollNoteEvent -> consume(event, relay)
else -> {
Log.w("Event Not Supported", event.toJson())
}

View File

@@ -3,6 +3,8 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.distinctUntilChanged
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
@@ -10,6 +12,7 @@ import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.actions.updated
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.combineWith
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
@@ -19,6 +22,7 @@ import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.toNote
import com.vitorpamplona.quartz.events.*
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import java.math.BigDecimal
import java.time.Instant
@@ -72,7 +76,22 @@ open class Note(val idHex: String) {
open fun idNote() = id().toNote()
open fun toNEvent(): String {
return Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
val myEvent = event
return if (myEvent is WrappedEvent) {
val host = myEvent.host
if (host != null) {
Nip19.createNEvent(
host.id,
host.pubKey,
host.kind(),
relays.firstOrNull()
)
} else {
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
}
} else {
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
}
}
fun toNostrUri(): String {
@@ -108,7 +127,7 @@ open class Note(val idHex: String) {
this.author = author
this.replyTo = replyTo
liveSet?.metadata?.invalidateData()
liveSet?.innerMetadata?.invalidateData()
}
}
@@ -129,12 +148,14 @@ open class Note(val idHex: String) {
return "/" + formattedDateTime(createdAt() ?: 0) + ";"
}
val mySignature = replyTo
.filter { it in eventsToConsider } // This forces the signature to be based on a branch, avoiding two roots
.map {
cachedSignatures[it] ?: it.replyLevelSignature(eventsToConsider, cachedSignatures).apply { cachedSignatures.put(it, this) }
}
.maxBy { it.length }.removeSuffix(";") + "/" + formattedDateTime(createdAt() ?: 0) + ";"
val mySignature = (
replyTo
.filter { it in eventsToConsider } // This forces the signature to be based on a branch, avoiding two roots
.map {
cachedSignatures[it] ?: it.replyLevelSignature(eventsToConsider, cachedSignatures).apply { cachedSignatures.put(it, this) }
}
.maxByOrNull { it.length }?.removeSuffix(";") ?: ""
) + "/" + formattedDateTime(createdAt() ?: 0) + ";"
cachedSignatures[this] = mySignature
return mySignature
@@ -154,21 +175,21 @@ open class Note(val idHex: String) {
fun addReply(note: Note) {
if (note !in replies) {
replies = replies + note
liveSet?.replies?.invalidateData()
liveSet?.innerReplies?.invalidateData()
}
}
fun removeReply(note: Note) {
if (note in replies) {
replies = replies - note
liveSet?.replies?.invalidateData()
liveSet?.innerReplies?.invalidateData()
}
}
fun removeBoost(note: Note) {
if (note in boosts) {
boosts = boosts - note
liveSet?.boosts?.invalidateData()
liveSet?.innerBoosts?.invalidateData()
}
}
@@ -191,11 +212,11 @@ open class Note(val idHex: String) {
relays = listOf<String>()
lastReactionsDownloadTime = emptyMap()
liveSet?.replies?.invalidateData()
liveSet?.reactions?.invalidateData()
liveSet?.boosts?.invalidateData()
liveSet?.reports?.invalidateData()
liveSet?.zaps?.invalidateData()
liveSet?.innerReplies?.invalidateData()
liveSet?.innerReactions?.invalidateData()
liveSet?.innerBoosts?.invalidateData()
liveSet?.innerReports?.invalidateData()
liveSet?.innerZaps?.invalidateData()
return toBeRemoved
}
@@ -214,7 +235,7 @@ open class Note(val idHex: String) {
reactions = reactions + Pair(reaction, newList)
}
liveSet?.reactions?.invalidateData()
liveSet?.innerReactions?.invalidateData()
}
}
}
@@ -226,7 +247,7 @@ open class Note(val idHex: String) {
if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
reports[author]?.let {
reports = reports + Pair(author, it.minus(deleteNote))
liveSet?.reports?.invalidateData()
liveSet?.innerReports?.invalidateData()
}
}
}
@@ -234,29 +255,28 @@ open class Note(val idHex: String) {
fun removeZap(note: Note) {
if (zaps[note] != null) {
zaps = zaps.minus(note)
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
} else if (zaps.containsValue(note)) {
val toRemove = zaps.filterValues { it == note }
zaps = zaps.minus(toRemove.keys)
liveSet?.zaps?.invalidateData()
zaps = zaps.filterValues { it != note }
liveSet?.innerZaps?.invalidateData()
}
}
fun removeZapPayment(note: Note) {
if (zapPayments[note] != null) {
zapPayments = zapPayments.minus(note)
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
} else if (zapPayments.containsValue(note)) {
val toRemove = zapPayments.filterValues { it == note }
zapPayments = zapPayments.minus(toRemove.keys)
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
}
}
fun addBoost(note: Note) {
if (note !in boosts) {
boosts = boosts + note
liveSet?.boosts?.invalidateData()
liveSet?.innerBoosts?.invalidateData()
}
}
@@ -278,12 +298,12 @@ open class Note(val idHex: String) {
if (zapRequest !in zaps.keys) {
val inserted = innerAddZap(zapRequest, zap)
if (inserted) {
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
}
} else if (zaps[zapRequest] == null) {
val inserted = innerAddZap(zapRequest, zap)
if (inserted) {
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
}
}
}
@@ -306,12 +326,12 @@ open class Note(val idHex: String) {
if (zapPaymentRequest !in zapPayments.keys) {
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
if (inserted) {
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
}
} else if (zapPayments[zapPaymentRequest] == null) {
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
if (inserted) {
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
}
}
}
@@ -322,10 +342,10 @@ open class Note(val idHex: String) {
if (reaction !in reactions.keys) {
reactions = reactions + Pair(reaction, listOf(note))
liveSet?.reactions?.invalidateData()
liveSet?.innerReactions?.invalidateData()
} else if (reactions[reaction]?.contains(note) == false) {
reactions = reactions + Pair(reaction, (reactions[reaction] ?: emptySet()) + note)
liveSet?.reactions?.invalidateData()
liveSet?.innerReactions?.invalidateData()
}
}
@@ -334,17 +354,17 @@ open class Note(val idHex: String) {
if (author !in reports.keys) {
reports = reports + Pair(author, listOf(note))
liveSet?.reports?.invalidateData()
liveSet?.innerReports?.invalidateData()
} else if (reports[author]?.contains(note) == false) {
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
liveSet?.reports?.invalidateData()
liveSet?.innerReports?.invalidateData()
}
}
fun addRelay(relay: Relay) {
if (relay.url !in relays) {
relays = relays + relay.url
liveSet?.relays?.invalidateData()
liveSet?.innerRelays?.invalidateData()
}
}
@@ -444,10 +464,6 @@ open class Note(val idHex: String) {
}.flatten()
}
fun countReactions(): Int {
return reactions.values.sumOf { it.size }
}
fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
// Regular Zap Receipts
val completedZaps = zaps.asSequence()
@@ -613,6 +629,7 @@ open class Note(val idHex: String) {
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet?.destroy()
liveSet = null
}
}
@@ -634,16 +651,54 @@ open class Note(val idHex: String) {
}
}
@Stable
class NoteLiveSet(u: Note) {
// Observers line up here.
val metadata: NoteLiveData = NoteLiveData(u)
val innerMetadata = NoteBundledRefresherLiveData(u)
val innerReactions = NoteBundledRefresherLiveData(u)
val innerBoosts = NoteBundledRefresherLiveData(u)
val innerReplies = NoteBundledRefresherLiveData(u)
val innerReports = NoteBundledRefresherLiveData(u)
val innerRelays = NoteBundledRefresherLiveData(u)
val innerZaps = NoteBundledRefresherLiveData(u)
val reactions: NoteLiveData = NoteLiveData(u)
val boosts: NoteLiveData = NoteLiveData(u)
val replies: NoteLiveData = NoteLiveData(u)
val reports: NoteLiveData = NoteLiveData(u)
val relays: NoteLiveData = NoteLiveData(u)
val zaps: NoteLiveData = NoteLiveData(u)
val metadata = innerMetadata.map { it }
val reactions = innerReactions.map { it }
val boosts = innerBoosts.map { it }
val replies = innerReplies.map { it }
val reports = innerReports.map { it }
val relays = innerRelays.map { it }
val zaps = innerZaps.map { it }
val authorChanges = innerMetadata.map {
it.note.author
}
val hasEvent = innerMetadata.map {
it.note.event != null
}.distinctUntilChanged()
val hasReactions = innerZaps.combineWith(innerBoosts, innerReactions) { zapState, boostState, reactionState ->
zapState?.note?.zaps?.isNotEmpty() ?: false ||
boostState?.note?.boosts?.isNotEmpty() ?: false ||
reactionState?.note?.reactions?.isNotEmpty() ?: false
}.distinctUntilChanged()
val replyCount = innerReplies.map {
it.note.replies.size
}.distinctUntilChanged()
val reactionCount = innerReactions.map {
it.note.reactions.values.sumOf { it.size }
}.distinctUntilChanged()
val boostCount = innerBoosts.map {
it.note.boosts.size
}.distinctUntilChanged()
val boostList = innerBoosts.map {
it.note.boosts.toImmutableList()
}.distinctUntilChanged()
fun isInUse(): Boolean {
return metadata.hasObservers() ||
@@ -652,14 +707,35 @@ class NoteLiveSet(u: Note) {
replies.hasObservers() ||
reports.hasObservers() ||
relays.hasObservers() ||
zaps.hasObservers()
zaps.hasObservers() ||
authorChanges.hasObservers() ||
hasEvent.hasObservers() ||
hasReactions.hasObservers() ||
replyCount.hasObservers() ||
reactionCount.hasObservers() ||
boostCount.hasObservers() ||
boostList.hasObservers()
}
fun destroy() {
innerMetadata.destroy()
innerReactions.destroy()
innerBoosts.destroy()
innerReplies.destroy()
innerReports.destroy()
innerRelays.destroy()
innerZaps.destroy()
}
}
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
class NoteBundledRefresherLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
fun destroy() {
bundler.cancel()
}
fun invalidateData() {
if (!hasObservers()) return
@@ -674,6 +750,17 @@ class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
}
}
fun <Y> map(
transform: (NoteState) -> Y
): NoteLoadingLiveData<Y> {
val initialValue = this.value?.let { transform(it) }
val result = NoteLoadingLiveData(note, initialValue)
result.addSource(this) { x -> result.value = transform(x) }
return result
}
}
class NoteLoadingLiveData<Y>(val note: Note, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
override fun onActive() {
super.onActive()
if (note is AddressableNote) {

View File

@@ -15,9 +15,14 @@ class ThreadAssembler {
testedNotes.add(note)
val markedAsRoot = note.event?.tags()?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
if (markedAsRoot != null) return LocalCache.checkGetOrCreateNote(markedAsRoot)
if (markedAsRoot != null) {
// Check to ssee if there is an error in the tag and the root has replies
if (LocalCache.getNoteIfExists(markedAsRoot)?.replyTo?.isEmpty() == true) {
return LocalCache.checkGetOrCreateNote(markedAsRoot)
}
}
val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true }
val hasNoReplyTo = note.replyTo?.reversed()?.firstOrNull { it.replyTo?.isEmpty() == true }
if (hasNoReplyTo != null) return hasNoReplyTo
// recursive

View File

@@ -3,6 +3,9 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.relays.EOSETime
@@ -21,7 +24,6 @@ import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.UserMetadata
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers
import java.math.BigDecimal
@@ -99,7 +101,7 @@ class User(val pubkeyHex: String) {
if (event.id == latestBookmarkList?.id) return
latestBookmarkList = event
liveSet?.bookmarks?.invalidateData()
liveSet?.innerBookmarks?.invalidateData()
}
fun updateContactList(event: ContactListEvent) {
@@ -109,18 +111,18 @@ class User(val pubkeyHex: String) {
latestContactList = event
// Update following of the current user
liveSet?.follows?.invalidateData()
liveSet?.innerFollows?.invalidateData()
// Update Followers of the past user list
// Update Followers of the new contact list
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.followers?.invalidateData()
LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData()
}
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.followers?.invalidateData()
LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData()
}
liveSet?.relays?.invalidateData()
liveSet?.innerRelays?.invalidateData()
}
fun addReport(note: Note) {
@@ -128,10 +130,10 @@ class User(val pubkeyHex: String) {
if (author !in reports.keys) {
reports = reports + Pair(author, setOf(note))
liveSet?.reports?.invalidateData()
liveSet?.innerReports?.invalidateData()
} else if (reports[author]?.contains(note) == false) {
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
liveSet?.reports?.invalidateData()
liveSet?.innerReports?.invalidateData()
}
}
@@ -141,7 +143,7 @@ class User(val pubkeyHex: String) {
if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
reports[author]?.let {
reports = reports + Pair(author, it.minus(deleteNote))
liveSet?.reports?.invalidateData()
liveSet?.innerReports?.invalidateData()
}
}
}
@@ -149,20 +151,20 @@ class User(val pubkeyHex: String) {
fun addZap(zapRequest: Note, zap: Note?) {
if (zapRequest !in zaps.keys) {
zaps = zaps + Pair(zapRequest, zap)
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
zaps = zaps + Pair(zapRequest, zap)
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
}
}
fun removeZap(zapRequestOrZapEvent: Note) {
if (zapRequestOrZapEvent in zaps.keys) {
zaps = zaps.minus(zapRequestOrZapEvent)
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
} else if (zapRequestOrZapEvent in zaps.values) {
zaps = zaps.filter { it.value != zapRequestOrZapEvent }
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
}
}
@@ -216,7 +218,7 @@ class User(val pubkeyHex: String) {
val privateChatroom = getOrCreatePrivateChatroom(room)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.addMessageSync(msg)
liveSet?.messages?.invalidateData()
liveSet?.innerMessages?.invalidateData()
}
}
@@ -224,7 +226,7 @@ class User(val pubkeyHex: String) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.addMessageSync(msg)
liveSet?.messages?.invalidateData()
liveSet?.innerMessages?.invalidateData()
}
}
@@ -238,7 +240,7 @@ class User(val pubkeyHex: String) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg in privateChatroom.roomMessages) {
privateChatroom.removeMessageSync(msg)
liveSet?.messages?.invalidateData()
liveSet?.innerMessages?.invalidateData()
}
}
@@ -253,7 +255,7 @@ class User(val pubkeyHex: String) {
here.counter++
}
liveSet?.relayInfo?.invalidateData()
liveSet?.innerRelayInfo?.invalidateData()
}
fun updateUserInfo(newUserInfo: UserMetadata, latestMetadata: MetadataEvent) {
@@ -278,7 +280,7 @@ class User(val pubkeyHex: String) {
}
}
liveSet?.metadata?.invalidateData()
liveSet?.innerMetadata?.invalidateData()
}
fun isFollowing(user: User): Boolean {
@@ -372,33 +374,78 @@ class User(val pubkeyHex: String) {
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet?.destroy()
liveSet = null
}
}
}
@Stable
class UserLiveSet(u: User) {
val innerMetadata = UserBundledRefresherLiveData(u)
// UI Observers line up here.
val follows: UserLiveData = UserLiveData(u)
val followers: UserLiveData = UserLiveData(u)
val reports: UserLiveData = UserLiveData(u)
val messages: UserLiveData = UserLiveData(u)
val relays: UserLiveData = UserLiveData(u)
val relayInfo: UserLiveData = UserLiveData(u)
val metadata: UserLiveData = UserLiveData(u)
val zaps: UserLiveData = UserLiveData(u)
val bookmarks: UserLiveData = UserLiveData(u)
val innerFollows = UserBundledRefresherLiveData(u)
val innerFollowers = UserBundledRefresherLiveData(u)
val innerReports = UserBundledRefresherLiveData(u)
val innerMessages = UserBundledRefresherLiveData(u)
val innerRelays = UserBundledRefresherLiveData(u)
val innerRelayInfo = UserBundledRefresherLiveData(u)
val innerZaps = UserBundledRefresherLiveData(u)
val innerBookmarks = UserBundledRefresherLiveData(u)
val innerStatuses = UserBundledRefresherLiveData(u)
// UI Observers line up here.
val metadata = innerMetadata.map { it }
val follows = innerFollows.map { it }
val followers = innerFollowers.map { it }
val reports = innerReports.map { it }
val messages = innerMessages.map { it }
val relays = innerRelays.map { it }
val relayInfo = innerRelayInfo.map { it }
val zaps = innerZaps.map { it }
val bookmarks = innerBookmarks.map { it }
val statuses = innerStatuses.map { it }
val profilePictureChanges = innerMetadata.map {
it.user.profilePicture()
}.distinctUntilChanged()
val nip05Changes = innerMetadata.map {
it.user.nip05()
}.distinctUntilChanged()
val userMetadataInfo = innerMetadata.map {
it.user.info
}.distinctUntilChanged()
fun isInUse(): Boolean {
return follows.hasObservers() ||
return metadata.hasObservers() ||
follows.hasObservers() ||
followers.hasObservers() ||
reports.hasObservers() ||
messages.hasObservers() ||
relays.hasObservers() ||
relayInfo.hasObservers() ||
metadata.hasObservers() ||
zaps.hasObservers() ||
bookmarks.hasObservers()
bookmarks.hasObservers() ||
statuses.hasObservers() ||
profilePictureChanges.hasObservers() ||
nip05Changes.hasObservers() ||
userMetadataInfo.hasObservers()
}
fun destroy() {
innerMetadata.destroy()
innerFollows.destroy()
innerFollowers.destroy()
innerReports.destroy()
innerMessages.destroy()
innerRelays.destroy()
innerRelayInfo.destroy()
innerZaps.destroy()
innerBookmarks.destroy()
innerStatuses.destroy()
}
}
@@ -409,67 +456,14 @@ data class RelayInfo(
var counter: Long
)
@Stable
class Chatroom() {
var roomMessages: Set<Note> = setOf()
var subject: String? = null
var subjectCreatedAt: Long? = null
@Synchronized
fun addMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
val newSubject = msg.event?.subject()
if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) {
subject = newSubject
subjectCreatedAt = msg.createdAt()
}
}
}
@Synchronized
fun removeMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
roomMessages.filter { it.event?.subject() != null }.sortedBy { it.createdAt() }.lastOrNull()?.let {
subject = it.event?.subject()
subjectCreatedAt = it.createdAt()
}
}
}
fun senderIntersects(keySet: Set<HexKey>): Boolean {
return roomMessages.any { it.author?.pubkeyHex in keySet }
}
fun pruneMessagesToTheLatestOnly(): Set<Note> {
val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
val toKeep = if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) {
// Recent messages, keep last 100
sorted.take(100).toSet()
} else {
// Old messages, keep the last one.
sorted.take(1).toSet()
} + sorted.filter { it.liveSet?.isInUse() ?: false }
val toRemove = roomMessages.minus(toKeep)
roomMessages = toKeep
return toRemove
}
}
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
class UserBundledRefresherLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
fun destroy() {
bundler.cancel()
}
fun invalidateData() {
if (!hasObservers()) return
checkNotInMainThread()
@@ -483,6 +477,17 @@ class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
}
}
fun <Y> map(
transform: (UserState) -> Y
): UserLoadingLiveData<Y> {
val initialValue = this.value?.let { transform(it) }
val result = UserLoadingLiveData(user, initialValue)
result.addSource(this) { x -> result.value = transform(x) }
return result
}
}
class UserLoadingLiveData<Y>(val user: User, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
override fun onActive() {
super.onActive()
NostrSingleUserDataSource.add(user)

View File

@@ -45,7 +45,7 @@ class CashuProcessor {
}
}
fun melt(token: CashuToken, lud16: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
suspend fun melt(token: CashuToken, lud16: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
checkNotInMainThread()
runCatching {

View File

@@ -2,10 +2,7 @@ package com.vitorpamplona.amethyst.service
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
@@ -26,55 +23,46 @@ class Nip05Verifier() {
return null
}
fun fetchNip05Json(nip05address: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchNip05JsonSuspend(nip05address, onSuccess, onError)
}
}
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
suspend fun fetchNip05Json(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
checkNotInMainThread()
val url = assembleUrl(nip05)
if (url == null) {
onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup")
return
return@withContext
}
withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
try {
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
HttpClient.getHttpClient().newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
checkNotInMainThread()
HttpClient.getHttpClient().newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
checkNotInMainThread()
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not resolve $nip05. Error: ${it.code}. Check if the server up and if the address $nip05 is correct")
}
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not resolve $nip05. Error: ${it.code}. Check if the server up and if the address $nip05 is correct")
}
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve $url. Check if the server up and if the address $nip05 is correct")
e.printStackTrace()
}
})
} catch (e: java.lang.Exception) {
onError("Could not resolve '$url': ${e.message}")
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve $url. Check if the server up and if the address $nip05 is correct")
e.printStackTrace()
}
})
} catch (e: java.lang.Exception) {
onError("Could not resolve '$url': ${e.message}")
}
}
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
suspend fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
// check fails on tests
checkNotInMainThread()

View File

@@ -52,9 +52,9 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(AdvertisedRelayListEvent.kind),
kinds = listOf(AdvertisedRelayListEvent.kind, StatusEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
limit = 5
)
)
}

View File

@@ -10,12 +10,15 @@ import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.UUID
import kotlin.Error
abstract class NostrDataSource(val debugName: String) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var subscriptions = mapOf<String, Subscription>()
data class Counter(var counter: Int)
@@ -76,6 +79,8 @@ abstract class NostrDataSource(val debugName: String) {
fun destroy() {
stop()
Client.unsubscribe(clientListener)
scope.cancel()
bundler.cancel()
}
open fun start() {
@@ -106,18 +111,19 @@ abstract class NostrDataSource(val debugName: String) {
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateFilters() {
bundler.invalidate() {
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
scope.launch(Dispatchers.IO) {
bundler.invalidate() {
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
resetFiltersSuspend()
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
resetFiltersSuspend()
}
}
}
fun resetFilters() {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
scope.launch(Dispatchers.IO) {
resetFiltersSuspend()
}
}

View File

@@ -18,53 +18,66 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
val latestEOSEs = EOSEAccount()
fun createLiveStreamFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
fun createLiveStreamFilter(): List<TypedFilter> {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
val followKeys = follows?.map {
it.substring(0, 8)
}
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = followKeys,
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = follows,
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
),
follows?.let {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
tags = mapOf("p" to it),
kinds = listOf(LiveActivitiesEvent.kind),
limit = 100,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
)
}
)
}
fun createPublicChatFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
fun createPublicChatFilter(): List<TypedFilter> {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
val followChats = account.selectedChatsFollowList().toList()
val followKeys = follows?.map {
it.substring(0, 8)
}
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
authors = followKeys,
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
return listOf(
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
authors = follows,
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
),
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
ids = followChats,
kinds = listOf(ChannelCreateEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
)
)
}
fun createCommunitiesFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val followKeys = follows?.map {
it.substring(0, 8)
}
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = followKeys,
authors = follows,
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
@@ -197,16 +210,16 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
}
override fun updateChannelFilters() {
discoveryFeedChannel.typedFilters = listOfNotNull(
createLiveStreamFilter(),
createPublicChatFilter(),
createCommunitiesFilter(),
createLiveStreamTagsFilter(),
createPublicChatsTagsFilter(),
createCommunitiesTagsFilter(),
createCommunitiesGeohashesFilter(),
createPublicChatsGeohashesFilter(),
createLiveStreamGeohashesFilter()
discoveryFeedChannel.typedFilters = createLiveStreamFilter().plus(createPublicChatFilter()).plus(
listOfNotNull(
createLiveStreamTagsFilter(),
createLiveStreamGeohashesFilter(),
createCommunitiesFilter(),
createPublicChatsTagsFilter(),
createCommunitiesTagsFilter(),
createCommunitiesGeohashesFilter(),
createPublicChatsGeohashesFilter()
)
).ifEmpty { null }
}
}

View File

@@ -54,13 +54,8 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
}
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList)
val followKeys = follows?.map {
it.substring(0, 8)
}
val followSet = followKeys?.plus(account.userProfile().pubkeyHex.substring(0, 8))
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followSet = follows.plus(account.userProfile().pubkeyHex).toList()
return TypedFilter(
types = setOf(FeedType.FOLLOWS),

View File

@@ -1,7 +1,7 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.FeedType
@@ -11,10 +11,10 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
private var channelsToWatch = setOf<String>()
private var channelsToWatch = setOf<Channel>()
private fun createRepliesAndReactionsFilter(): TypedFilter? {
val reactionsToWatch = channelsToWatch.map { it }
private fun createMetadataChangeFilter(): TypedFilter? {
val reactionsToWatch = channelsToWatch.filter { it is PublicChatChannel }.map { it.idHex }
if (reactionsToWatch.isEmpty()) {
return null
@@ -32,7 +32,6 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val directEventsToLoad = channelsToWatch
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
.filter { it.notes.isEmpty() && it is PublicChatChannel }
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
@@ -53,7 +52,6 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
fun createLoadStreamingIfNotLoadedFilter(): List<TypedFilter>? {
val directEventsToLoad = channelsToWatch
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
.filterIsInstance<LiveActivitiesChannel>()
.filter { it.info == null }
@@ -81,7 +79,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
val singleChannelChannel = requestNewChannel()
override fun updateChannelFilters() {
val reactions = createRepliesAndReactionsFilter()
val reactions = createMetadataChangeFilter()
val missing = createLoadEventsIfNotLoadedFilter()
val missingStreaming = createLoadStreamingIfNotLoadedFilter()
@@ -90,13 +88,17 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
).ifEmpty { null }
}
fun add(eventId: String) {
channelsToWatch = channelsToWatch.plus(eventId)
invalidateFilters()
fun add(eventId: Channel) {
if (eventId !in channelsToWatch) {
channelsToWatch = channelsToWatch.plus(eventId)
invalidateFilters()
}
}
fun remove(eventId: String) {
channelsToWatch = channelsToWatch.minus(eventId)
invalidateFilters()
fun remove(eventId: Channel) {
if (eventId in channelsToWatch) {
channelsToWatch = channelsToWatch.minus(eventId)
invalidateFilters()
}
}
}

View File

@@ -7,38 +7,65 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.StatusEvent
object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
var usersToWatch = setOf<User>()
fun createUserFilter(): List<TypedFilter>? {
fun createUserMetadataFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
return usersToWatch.filter { it.info?.latestMetadata == null }.map {
val firstTimers = usersToWatch.filter { it.info?.latestMetadata == null }.map { it.pubkeyHex }
return listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 1
kinds = listOf(MetadataEvent.kind, StatusEvent.kind),
authors = firstTimers,
limit = 10 * firstTimers.size
)
)
}
)
}
fun createUserReportFilter(): List<TypedFilter>? {
fun createUserMetadataFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
if (usersToWatch.isEmpty()) return null
return usersToWatch.map {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to listOf(it.pubkeyHex)),
since = it.latestEOSEs
)
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(StatusEvent.kind),
authors = usersToWatch.map { it.pubkeyHex },
since = minLatestEOSEs
)
}
)
}
fun createUserStatusFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
if (usersToWatch.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(StatusEvent.kind),
authors = usersToWatch.map { it.pubkeyHex },
since = minLatestEOSEs
)
)
}
fun createUserReportFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
if (usersToWatch.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to usersToWatch.map { it.pubkeyHex }),
since = minLatestEOSEs
)
)
}
val userChannel = requestNewChannel() { time, relayUrl ->
@@ -50,17 +77,40 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
eose.time = time
}
}
}
val userChannelFirstTimers = requestNewChannel() { time, relayUrl ->
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
}
val userChannelOnce = requestNewChannel()
override fun updateChannelFilters() {
userChannel.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null }
userChannelOnce.typedFilters = listOfNotNull(createUserReportFilter()).flatten().ifEmpty { null }
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
val neverGottenAnEOSE = mutableSetOf<String>()
usersToWatch.forEach {
if (it.latestEOSEs.isEmpty()) { // first time
neverGottenAnEOSE.add(it.pubkeyHex)
} else {
it.latestEOSEs.forEach {
val minEose = minLatestEOSEs[it.key]
if (minEose == null) {
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
} else if (it.value.time < minEose.time) {
minEose.time = it.value.time
}
}
}
}
userChannel.typedFilters = listOfNotNull(
createUserMetadataFilter(minLatestEOSEs),
createUserStatusFilter(minLatestEOSEs),
createUserReportFilter(minLatestEOSEs)
).ifEmpty { null }
userChannelFirstTimers.typedFilters = listOfNotNull(
createUserMetadataFilter()
).flatten().ifEmpty { null }
}
fun add(user: User) {

View File

@@ -36,8 +36,10 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
}
fun loadThread(noteId: String?) {
eventToWatch = noteId
if (eventToWatch != noteId) {
eventToWatch = noteId
invalidateFilters()
invalidateFilters()
}
}
}

View File

@@ -14,16 +14,12 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
val latestEOSEs = EOSEAccount()
fun createContextualFilter(): TypedFilter? {
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)
val followKeys = follows?.map {
it.substring(0, 6)
}
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)?.toList()
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = followKeys,
authors = follows,
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
limit = 200,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList

View File

@@ -7,10 +7,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.Bech32
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.toLnUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
@@ -40,96 +37,70 @@ class LightningAddressResolver() {
return null
}
fun fetchLightningAddressJson(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchLightningAddressJsonSuspend(lnaddress, onSuccess, onError)
}
}
private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
private suspend fun fetchLightningAddressJson(lnaddress: String, onSuccess: suspend (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
checkNotInMainThread()
val url = assembleUrl(lnaddress)
if (url == null) {
onError("Could not assemble LNUrl from Lightning Address \"${lnaddress}\". Check the user's setup")
return
return@withContext
}
try {
withContext(Dispatchers.IO) {
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not resolve $lnaddress. Error: ${it.code}. Check if the server up and if the lightning address $lnaddress is correct")
}
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
e.printStackTrace()
}
})
}
} catch (e: Exception) {
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
}
}
fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchLightningInvoiceSuspend(lnCallback, milliSats, message, nostrRequest, onSuccess, onError)
}
}
private suspend fun fetchLightningInvoiceSuspend(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
withContext(Dispatchers.IO) {
val encodedMessage = URLEncoder.encode(message, "utf-8")
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
if (nostrRequest != null) {
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
url += "&nostr=$encodedNostrRequest"
}
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.isSuccessful) {
onSuccess(response.body.string())
} else {
onError("Could not fetch invoice from $lnCallback")
}
}
client.newCall(request).execute().use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("The receiver's lightning service at $url is not available. It was calculated from the lightning address \"${lnaddress}\". Error: ${it.code}. Check if the server up and if the lightning address is correct")
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not fetch an invoice from $lnCallback. Message ${e.message}")
e.printStackTrace()
}
})
}
} catch (e: Exception) {
e.printStackTrace()
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
}
}
fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
suspend fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
val encodedMessage = URLEncoder.encode(message, "utf-8")
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
if (nostrRequest != null) {
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
url += "&nostr=$encodedNostrRequest"
}
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.isSuccessful) {
onSuccess(response.body.string())
} else {
onError("Could not fetch invoice from $lnCallback")
}
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not fetch an invoice from $lnCallback. Message ${e.message}")
e.printStackTrace()
}
})
}
suspend fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
fetchLightningAddressJson(
lnaddress,
onSuccess = {
@@ -139,7 +110,15 @@ class LightningAddressResolver() {
)
}
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) {
suspend fun lnAddressInvoice(
lnaddress: String,
milliSats: Long,
message: String,
nostrRequest: String? = null,
onSuccess: (String) -> Unit,
onError: (String) -> Unit,
onProgress: (percent: Float) -> Unit
) {
val mapper = jacksonObjectMapper()
fetchLightningAddressJson(

View File

@@ -20,26 +20,20 @@ import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
class EventNotificationConsumer(private val applicationContext: Context) {
fun consume(event: Event) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
if (LocalCache.notes[event.id] == null) {
if (LocalCache.justVerify(event)) {
LocalCache.justConsume(event, null)
val manager = notificationManager()
if (manager.areNotificationsEnabled()) {
when (event) {
is PrivateDmEvent -> notify(event)
is LnZapEvent -> notify(event)
is GiftWrapEvent -> unwrapAndNotify(event)
}
fun consume(event: Event) {
if (LocalCache.notes[event.id] == null) {
if (LocalCache.justVerify(event)) {
LocalCache.justConsume(event, null)
val manager = notificationManager()
if (manager.areNotificationsEnabled()) {
when (event) {
is PrivateDmEvent -> notify(event)
is LnZapEvent -> notify(event)
is GiftWrapEvent -> unwrapAndNotify(event)
}
}
}

View File

@@ -6,10 +6,8 @@ import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.quartz.events.RelayAuthEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
@@ -64,12 +62,9 @@ class RegisterAccounts(
}
}
fun go(notificationToken: String) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
postRegistrationEvent(
signEventsToProveControlOfAccounts(accounts, notificationToken)
)
}
suspend fun go(notificationToken: String) = withContext(Dispatchers.IO) {
postRegistrationEvent(
signEventsToProveControlOfAccounts(accounts, notificationToken)
)
}
}

View File

@@ -60,7 +60,7 @@ class JsonFilter(
} else {
val jsonObjectSince = factory.objectNode()
entries.forEach { sincePairs ->
put(sincePairs.key, "${sincePairs.value}")
jsonObjectSince.put(sincePairs.key, "${sincePairs.value}")
}
put("since", jsonObjectSince)
}

View File

@@ -284,16 +284,36 @@ class Relay(
checkNotInMainThread()
if (signedEvent is RelayAuthEvent) {
// specific protocol for this event.
val event = """["AUTH",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
}
} else {
if (write) {
if (isConnected()) {
if (isReady) {
val event = """["EVENT",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
}
} else {
// waits 60 seconds to reconnect after disconnected.
if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) {
// sends all filters after connection is successful.
connectAndRun {
checkNotInMainThread()
if (write) {
if (signedEvent !is RelayAuthEvent) {
val event = """["EVENT",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
val event = """["EVENT",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
// Sends everything.
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
}
}
}
}
}
}
}

View File

@@ -4,18 +4,11 @@ import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
/**
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
*/
object RelayPool : Relay.Listener {
val scope = CoroutineScope(Job() + Dispatchers.IO)
private var relays = listOf<Relay>()
private var listeners = setOf<Listener>()
@@ -136,9 +129,7 @@ object RelayPool : Relay.Listener {
val live: RelayPoolLiveData = RelayPoolLiveData(this)
private fun refreshObservers() {
scope.launch {
live.refresh()
}
live.refresh()
}
}

View File

@@ -97,10 +97,13 @@ class MainActivity : AppCompatActivity() {
ServiceManager.start(this@MainActivity)
}
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
GlobalScope.launch(Dispatchers.IO) {
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
}
}
override fun onPause() {
ServiceManager.cleanObservers()
// if (BuildConfig.DEBUG) {
debugState(this)
// }
@@ -125,7 +128,7 @@ class MainActivity : AppCompatActivity() {
super.onTrimMemory(level)
println("Trim Memory $level")
GlobalScope.launch(Dispatchers.Default) {
ServiceManager.cleanUp()
ServiceManager.trimMemory()
}
}

View File

@@ -120,7 +120,7 @@ fun JoinUserOrChannelView(searchBarViewModel: SearchBarViewModel, onClose: () ->
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
searchBarViewModel.clear()
NostrSearchEventOrUserDataSource.clear()
onClose()

View File

@@ -51,7 +51,7 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
postViewModel.clear()
onClose()
})

View File

@@ -123,7 +123,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
postViewModel.cancel()
onClose()
})

View File

@@ -1,9 +1,11 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
@@ -11,8 +13,6 @@ import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -21,51 +21,11 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
var textMax by rememberSaveable { mutableStateOf("") }
var textMin by rememberSaveable { mutableStateOf("") }
// check for zapMax amounts < 1
pollViewModel.isValidvalueMaximum.value = true
if (textMax.isNotEmpty()) {
try {
val int = textMax.toInt()
if (int < 1) {
pollViewModel.isValidvalueMaximum.value = false
} else { pollViewModel.valueMaximum = int }
} catch (e: Exception) { pollViewModel.isValidvalueMaximum.value = false }
}
// check for minZap amounts < 1
pollViewModel.isValidvalueMinimum.value = true
if (textMin.isNotEmpty()) {
try {
val int = textMin.toInt()
if (int < 1) {
pollViewModel.isValidvalueMinimum.value = false
} else { pollViewModel.valueMinimum = int }
} catch (e: Exception) { pollViewModel.isValidvalueMinimum.value = false }
}
// check for zapMin > zapMax
if (textMin.isNotEmpty() && textMax.isNotEmpty()) {
try {
val intMin = textMin.toInt()
val intMax = textMax.toInt()
if (intMin > intMax) {
pollViewModel.isValidvalueMinimum.value = false
pollViewModel.isValidvalueMaximum.value = false
}
} catch (e: Exception) {
pollViewModel.isValidvalueMinimum.value = false
pollViewModel.isValidvalueMaximum.value = false
}
}
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.error,
unfocusedBorderColor = Color.Red
@@ -80,10 +40,10 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
horizontalArrangement = Arrangement.Center
) {
OutlinedTextField(
value = textMin,
onValueChange = { textMin = it },
value = pollViewModel.valueMinimum?.toString() ?: "",
onValueChange = { pollViewModel.updateMinZapAmountForPoll(it) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(150.dp),
modifier = Modifier.weight(1f),
colors = if (pollViewModel.isValidvalueMinimum.value) colorValid else colorInValid,
label = {
Text(
@@ -98,11 +58,14 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
)
}
)
Spacer(modifier = DoubleHorzSpacer)
OutlinedTextField(
value = textMax,
onValueChange = { textMax = it },
value = pollViewModel.valueMaximum?.toString() ?: "",
onValueChange = { pollViewModel.updateMaxZapAmountForPoll(it) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(150.dp),
modifier = Modifier.weight(1f),
colors = if (pollViewModel.isValidvalueMaximum.value) colorValid else colorInValid,
label = {
Text(
@@ -118,10 +81,25 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
}
)
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.poll_zap_value_min_max_explainer),
color = MaterialTheme.colors.placeholderText,
modifier = Modifier.padding(vertical = 10.dp)
)
}
}
@Preview
@Composable
fun NewPollVoteValueRangePreview() {
NewPollVoteValueRange(NewPostViewModel())
Column(
modifier = Modifier.fillMaxWidth()
) {
NewPollVoteValueRange(NewPostViewModel())
}
}

View File

@@ -42,6 +42,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -97,6 +98,7 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
@@ -110,7 +112,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewPostView(
onClose: () -> Unit,
@@ -166,314 +167,340 @@ fun NewPostView(
decorFitsSystemWindows = false
)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
if (showRelaysDialog) {
RelaySelectionDialog(
list = relayList,
onClose = {
showRelaysDialog = false
},
onPost = {
relayList = it
},
accountViewModel = accountViewModel,
nav = nav
)
}
if (showRelaysDialog) {
RelaySelectionDialog(
list = relayList,
onClose = {
showRelaysDialog = false
},
onPost = {
relayList = it
},
accountViewModel = accountViewModel,
nav = nav
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
Column(
modifier = Modifier
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
.imePadding()
.weight(1f)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
Scaffold(
topBar = {
TopAppBar(
title = {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(end = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = StdHorzSpacer)
Box {
IconButton(
modifier = Modifier.align(Alignment.Center),
onClick = {
showRelaysDialog = true
}
) {
Icon(
painter = painterResource(R.drawable.relays),
contentDescription = null,
modifier = Modifier.height(25.dp),
tint = MaterialTheme.colors.onBackground
)
}
}
PostButton(
onPost = {
scope.launch(Dispatchers.IO) {
postViewModel.sendPost(relayList = relayList)
onClose()
}
},
isActive = postViewModel.canPost()
)
}
},
navigationIcon = {
Spacer(modifier = StdHorzSpacer)
CloseButton(onPress = {
postViewModel.cancel()
onClose()
})
Box {
IconButton(
modifier = Modifier.align(Alignment.Center),
onClick = {
showRelaysDialog = true
}
) {
Icon(
painter = painterResource(R.drawable.relays),
contentDescription = null,
modifier = Modifier.height(25.dp),
tint = MaterialTheme.colors.onBackground
)
}
}
PostButton(
onPost = {
scope.launch(Dispatchers.IO) {
postViewModel.sendPost(relayList = relayList)
onClose()
}
},
isActive = postViewModel.canPost()
)
}
Row(
},
backgroundColor = MaterialTheme.colors.surface,
elevation = 0.dp
)
}
) { pad ->
Surface(
modifier = Modifier
.padding(
start = Size10dp,
top = pad.calculateTopPadding(),
end = Size10dp,
bottom = pad.calculateBottomPadding()
)
.fillMaxSize()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp)
.imePadding()
.weight(1f)
) {
Column(
Row(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState)
.weight(1f)
) {
postViewModel.originalNote?.let {
NoteCompose(
baseNote = it,
makeItShort = true,
unPackReply = false,
isQuotedNote = true,
modifier = MaterialTheme.colors.replyModifier,
accountViewModel = accountViewModel,
nav = nav
)
}
Notifying(postViewModel.mentions?.toImmutableList()) {
postViewModel.removeFromReplyList(it)
}
if (enableMessageInterface) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
) {
SendDirectMessageTo(postViewModel = postViewModel)
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState)
) {
postViewModel.originalNote?.let {
Row(Modifier.heightIn(max = 200.dp)) {
NoteCompose(
baseNote = it,
makeItShort = true,
unPackReply = false,
isQuotedNote = true,
modifier = MaterialTheme.colors.replyModifier,
accountViewModel = accountViewModel,
nav = nav
)
Spacer(modifier = StdVertSpacer)
}
}
}
MessageField(postViewModel)
if (postViewModel.wantsPoll) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
) {
PollField(postViewModel)
Row() {
Notifying(postViewModel.mentions?.toImmutableList()) {
postViewModel.removeFromReplyList(it)
}
}
}
if (postViewModel.wantsToMarkAsSensitive) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = Size5dp, horizontal = Size10dp)
) {
ContentSensitivityExplainer(postViewModel)
if (enableMessageInterface) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
) {
SendDirectMessageTo(postViewModel = postViewModel)
}
}
}
if (postViewModel.wantsToAddGeoHash) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = Size5dp, horizontal = Size10dp)
) {
LocationAsHash(postViewModel)
MessageField(postViewModel)
if (postViewModel.wantsPoll) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
) {
PollField(postViewModel)
}
}
}
if (postViewModel.wantsForwardZapTo) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
) {
FowardZapTo(postViewModel)
if (postViewModel.wantsToMarkAsSensitive) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = Size5dp, horizontal = Size10dp)
) {
ContentSensitivityExplainer(postViewModel)
}
}
}
val url = postViewModel.contentToAddUrl
if (url != null) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
ImageVideoDescription(
url,
account.defaultFileServer,
onAdd = { description, server, sensitiveContent ->
postViewModel.upload(url, description, sensitiveContent, server, context, relayList)
account.changeDefaultFileServer(server)
},
onCancel = {
postViewModel.contentToAddUrl = null
},
onError = {
scope.launch {
postViewModel.imageUploadingError.emit(it)
}
},
accountViewModel = accountViewModel
)
if (postViewModel.wantsToAddGeoHash) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = Size5dp, horizontal = Size10dp)
) {
LocationAsHash(postViewModel)
}
}
}
val user = postViewModel.account?.userProfile()
val lud16 = user?.info?.lnAddress()
if (postViewModel.wantsForwardZapTo) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
) {
FowardZapTo(postViewModel)
}
}
if (lud16 != null && postViewModel.wantsInvoice) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
Column(Modifier.fillMaxWidth()) {
InvoiceRequest(
lud16,
user.pubkeyHex,
account,
stringResource(id = R.string.lightning_invoice),
stringResource(id = R.string.lightning_create_and_add_invoice),
onSuccess = {
postViewModel.message = TextFieldValue(postViewModel.message.text + "\n\n" + it)
postViewModel.wantsInvoice = false
val url = postViewModel.contentToAddUrl
if (url != null) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
ImageVideoDescription(
url,
account.defaultFileServer,
onAdd = { description, server, sensitiveContent ->
postViewModel.upload(url, description, sensitiveContent, server, context, relayList)
account.changeDefaultFileServer(server)
},
onClose = {
postViewModel.wantsInvoice = false
}
onCancel = {
postViewModel.contentToAddUrl = null
},
onError = {
scope.launch {
postViewModel.imageUploadingError.emit(it)
}
},
accountViewModel = accountViewModel
)
}
}
}
if (lud16 != null && postViewModel.wantsZapraiser) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
ZapRaiserRequest(
stringResource(id = R.string.zapraiser),
postViewModel
)
}
}
val user = postViewModel.account?.userProfile()
val lud16 = user?.info?.lnAddress()
val myUrlPreview = postViewModel.urlPreview
if (myUrlPreview != null) {
Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
if (isValidURL(myUrlPreview)) {
val removedParamsFromUrl =
myUrlPreview.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
AsyncImage(
model = myUrlPreview,
contentDescription = myUrlPreview,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.subtleBorder,
QuoteBorder
)
if (lud16 != null && postViewModel.wantsInvoice) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
Column(Modifier.fillMaxWidth()) {
InvoiceRequest(
lud16,
user.pubkeyHex,
account,
stringResource(id = R.string.lightning_invoice),
stringResource(id = R.string.lightning_create_and_add_invoice),
onSuccess = {
postViewModel.message = TextFieldValue(postViewModel.message.text + "\n\n" + it)
postViewModel.wantsInvoice = false
},
onClose = {
postViewModel.wantsInvoice = false
}
)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
VideoView(myUrlPreview, roundedCorner = true, accountViewModel = accountViewModel)
} else {
UrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
}
} else if (startsWithNIP19Scheme(myUrlPreview)) {
val bgColor = MaterialTheme.colors.background
val backgroundColor = remember {
mutableStateOf(bgColor)
}
}
}
BechLink(
myUrlPreview,
true,
backgroundColor,
accountViewModel,
nav
if (lud16 != null && postViewModel.wantsZapraiser) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
ZapRaiserRequest(
stringResource(id = R.string.zapraiser),
postViewModel
)
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
UrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
}
}
val myUrlPreview = postViewModel.urlPreview
if (myUrlPreview != null) {
Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
if (isValidURL(myUrlPreview)) {
val removedParamsFromUrl =
myUrlPreview.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
AsyncImage(
model = myUrlPreview,
contentDescription = myUrlPreview,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.subtleBorder,
QuoteBorder
)
)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
VideoView(myUrlPreview, roundedCorner = true, accountViewModel = accountViewModel)
} else {
UrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
}
} else if (startsWithNIP19Scheme(myUrlPreview)) {
val bgColor = MaterialTheme.colors.background
val backgroundColor = remember {
mutableStateOf(bgColor)
}
BechLink(
myUrlPreview,
true,
backgroundColor,
accountViewModel,
nav
)
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
UrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
}
}
}
}
}
}
val userSuggestions = postViewModel.userSuggestions
if (userSuggestions.isNotEmpty()) {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp
),
modifier = Modifier.heightIn(0.dp, 300.dp)
) {
itemsIndexed(
userSuggestions,
key = { _, item -> item.pubkeyHex }
) { _, item ->
UserLine(item, accountViewModel) {
postViewModel.autocompleteWithUser(item)
val userSuggestions = postViewModel.userSuggestions
if (userSuggestions.isNotEmpty()) {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp
),
modifier = Modifier.heightIn(0.dp, 300.dp)
) {
itemsIndexed(
userSuggestions,
key = { _, item -> item.pubkeyHex }
) { _, item ->
UserLine(item, accountViewModel) {
postViewModel.autocompleteWithUser(item)
}
}
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
verticalAlignment = Alignment.CenterVertically
) {
UploadFromGallery(
isUploading = postViewModel.isUploadingImage,
tint = MaterialTheme.colors.onBackground,
Row(
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
verticalAlignment = Alignment.CenterVertically
) {
postViewModel.selectImage(it)
}
if (postViewModel.canUsePoll) {
// These should be hashtag recommendations the user selects in the future.
// val hashtag = stringResource(R.string.poll_hashtag)
// postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
AddPollButton(postViewModel.wantsPoll) {
postViewModel.wantsPoll = !postViewModel.wantsPoll
UploadFromGallery(
isUploading = postViewModel.isUploadingImage,
tint = MaterialTheme.colors.onBackground,
modifier = Modifier
) {
postViewModel.selectImage(it)
}
}
if (postViewModel.canAddInvoice) {
AddLnInvoiceButton(postViewModel.wantsInvoice) {
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
if (postViewModel.canUsePoll) {
// These should be hashtag recommendations the user selects in the future.
// val hashtag = stringResource(R.string.poll_hashtag)
// postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
AddPollButton(postViewModel.wantsPoll) {
postViewModel.wantsPoll = !postViewModel.wantsPoll
}
}
}
if (postViewModel.canAddZapRaiser) {
AddZapraiserButton(postViewModel.wantsZapraiser) {
postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser
if (postViewModel.canAddInvoice) {
AddLnInvoiceButton(postViewModel.wantsInvoice) {
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
}
}
}
MarkAsSensitive(postViewModel) {
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
}
if (postViewModel.canAddZapRaiser) {
AddZapraiserButton(postViewModel.wantsZapraiser) {
postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser
}
}
AddGeoHash(postViewModel) {
postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
}
MarkAsSensitive(postViewModel) {
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
}
ForwardZapTo(postViewModel) {
postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
AddGeoHash(postViewModel) {
postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
}
ForwardZapTo(postViewModel) {
postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
}
}
}
}
@@ -491,6 +518,8 @@ private fun PollField(postViewModel: NewPostViewModel) {
NewPollOption(postViewModel, index)
}
NewPollVoteValueRange(postViewModel)
Button(
onClick = {
postViewModel.pollOptions[postViewModel.pollOptions.size] =
@@ -887,19 +916,18 @@ fun DisplayLocationInTitle(geohash: String) {
fun Notifying(baseMentions: ImmutableList<User>?, onClick: (User) -> Unit) {
val mentions = baseMentions?.toSet()
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 10.dp)) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
if (!mentions.isNullOrEmpty()) {
Text(
stringResource(R.string.reply_notify),
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colors.placeholderText,
modifier = Modifier.align(CenterVertically)
)
mentions.forEachIndexed { idx, user ->
val innerUserState by user.live().metadata.observeAsState()
innerUserState?.user?.let { myUser ->
Spacer(modifier = Modifier.width(5.dp))
val tags = remember(innerUserState) {
myUser.info?.latestMetadata?.tags?.toImmutableListOfLists()
}
@@ -1160,10 +1188,10 @@ private fun MarkAsSensitive(
}
@Composable
fun CloseButton(onCancel: () -> Unit) {
fun CloseButton(onPress: () -> Unit) {
Button(
onClick = {
onCancel()
onPress()
},
shape = ButtonBorder,
colors = ButtonDefaults

View File

@@ -73,8 +73,8 @@ open class NewPostViewModel() : ViewModel() {
var wantsPoll by mutableStateOf(false)
var zapRecipients = mutableStateListOf<HexKey>()
var pollOptions = newStateMapPollOptions()
var valueMaximum: Int? = null
var valueMinimum: Int? = null
var valueMaximum by mutableStateOf<Int?>(null)
var valueMinimum by mutableStateOf<Int?>(null)
var consensusThreshold: Int? = null
var closedAt: Int? = null
@@ -494,7 +494,7 @@ open class NewPostViewModel() : ViewModel() {
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount != null) &&
(!wantsDirectMessage || !toUsers.text.isNullOrBlank()) &&
(!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) &&
(!wantsPoll || (pollOptions.values.all { it.isNotEmpty() } && isValidvalueMinimum.value && isValidvalueMaximum.value)) &&
contentToAddUrl == null
}
@@ -612,6 +612,50 @@ open class NewPostViewModel() : ViewModel() {
nip24 = !nip24
}
}
fun updateMinZapAmountForPoll(textMin: String) {
if (textMin.isNotEmpty()) {
try {
val int = textMin.toInt()
if (int < 1) {
valueMinimum = null
} else {
valueMinimum = int
}
} catch (e: Exception) {}
} else {
valueMinimum = null
}
checkMinMax()
}
fun updateMaxZapAmountForPoll(textMax: String) {
if (textMax.isNotEmpty()) {
try {
val int = textMax.toInt()
if (int < 1) {
valueMaximum = null
} else {
valueMaximum = int
}
} catch (e: Exception) {}
} else {
valueMaximum = null
}
checkMinMax()
}
fun checkMinMax() {
if ((valueMinimum ?: 0) > (valueMaximum ?: Int.MAX_VALUE)) {
isValidvalueMinimum.value = false
isValidvalueMaximum.value = false
} else {
isValidvalueMinimum.value = true
isValidvalueMaximum.value = true
}
}
}
enum class GeohashPrecision(val digits: Int) {

View File

@@ -129,7 +129,7 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
},
navigationIcon = {
Spacer(modifier = StdHorzSpacer)
CloseButton(onCancel = {
CloseButton(onPress = {
postViewModel.clear()
onClose()
})

View File

@@ -67,7 +67,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
postViewModel.clear()
onClose()
})

View File

@@ -74,7 +74,7 @@ fun RelayInformationDialog(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
onClose()
})
}

View File

@@ -110,7 +110,7 @@ fun RelaySelectionDialog(
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(
onCancel = {
onPress = {
onClose()
}
)

View File

@@ -48,7 +48,7 @@ import kotlinx.coroutines.withContext
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun NewImageButton(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun NewImageButton(accountViewModel: AccountViewModel, nav: (String) -> Unit, navScrollToTop: (Route, Boolean) -> Unit) {
var wantsToPost by remember {
mutableStateOf(false)
}
@@ -62,11 +62,9 @@ fun NewImageButton(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val postViewModel: NewMediaModel = viewModel()
postViewModel.onceUploaded {
scope.launch(Dispatchers.Default) {
// awaits an refresh on the list
delay(250)
delay(500)
withContext(Dispatchers.Main) {
val route = Route.Video.route.replace("{scrollToTop}", "true")
nav(route)
navScrollToTop(Route.Video, true)
}
}
}

View File

@@ -5,8 +5,9 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -21,6 +22,8 @@ class BundledUpdate(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.Default
) {
val scope = CoroutineScope(dispatcher + SupervisorJob())
private var onlyOneInBlock = AtomicBoolean()
private var invalidatesAgain = false
@@ -32,8 +35,7 @@ class BundledUpdate(
return
}
val scope = CoroutineScope(Job() + dispatcher)
scope.launch {
scope.launch(dispatcher) {
try {
onUpdate()
delay(delay)
@@ -48,6 +50,10 @@ class BundledUpdate(
}
}
}
fun cancel() {
scope.cancel()
}
}
/**
@@ -58,6 +64,8 @@ class BundledInsert<T>(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.Default
) {
val scope = CoroutineScope(dispatcher + SupervisorJob())
private var onlyOneInBlock = AtomicBoolean()
private var queue = LinkedBlockingQueue<T>()
@@ -69,8 +77,7 @@ class BundledInsert<T>(
return
}
val scope = CoroutineScope(Job() + dispatcher)
scope.launch(Dispatchers.IO) {
scope.launch(dispatcher) {
try {
val mySet = mutableSetOf<T>()
queue.drainTo(mySet)
@@ -88,4 +95,8 @@ class BundledInsert<T>(
}
}
}
fun cancel() {
scope.cancel()
}
}

View File

@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
@@ -12,6 +13,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextDirection
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.quartz.encoders.LnWithdrawalUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -41,10 +43,7 @@ fun MayBeWithdrawal(lnurlWord: String) {
@Composable
fun ClickableWithdrawal(withdrawalString: String) {
val context = LocalContext.current
val uri = remember(withdrawalString) {
Uri.parse("lightning:$withdrawalString")
}
val scope = rememberCoroutineScope()
val withdraw = remember(withdrawalString) {
AnnotatedString("$withdrawalString ")
@@ -53,9 +52,18 @@ fun ClickableWithdrawal(withdrawalString: String) {
ClickableText(
text = withdraw,
onClick = {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, uri)
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$withdrawalString"))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
ContextCompat.startActivity(context, intent, null)
} catch (e: Exception) {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.lightning_wallets_not_found),
Toast.LENGTH_LONG
).show()
}
}
},
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)

View File

@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
@@ -66,6 +67,7 @@ fun MayBeInvoicePreview(lnbcWord: String) {
@Composable
fun InvoicePreview(lnInvoice: String, amount: String?) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
Column(
modifier = Modifier
@@ -118,9 +120,18 @@ fun InvoicePreview(lnInvoice: String, amount: String?) {
.fillMaxWidth()
.padding(vertical = 10.dp),
onClick = {
runCatching {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$lnInvoice"))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(context, intent, null)
} catch (e: Exception) {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.lightning_wallets_not_found),
Toast.LENGTH_LONG
).show()
}
}
},
shape = QuoteBorder,

View File

@@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
@@ -152,23 +153,25 @@ fun InvoiceRequest(
Button(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
onClick = {
val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message, account.defaultZapType)
scope.launch(Dispatchers.IO) {
val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message, account.defaultZapType)
LightningAddressResolver().lnAddressInvoice(
lud16,
amount * 1000,
message,
zapRequest?.toJson(),
onSuccess = onSuccess,
onError = {
scope.launch {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
onClose()
LightningAddressResolver().lnAddressInvoice(
lud16,
amount * 1000,
message,
zapRequest?.toJson(),
onSuccess = onSuccess,
onError = {
scope.launch {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
onClose()
}
},
onProgress = {
}
},
onProgress = {
}
)
)
}
},
shape = QuoteBorder,
colors = ButtonDefaults.buttonColors(

View File

@@ -30,8 +30,6 @@ import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.em
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.markdown.MarkdownParseOptions
import com.halilibo.richtext.ui.material.MaterialRichText
@@ -421,9 +419,7 @@ private fun ObserveNIP19Event(
@Composable
fun ObserveNote(note: Note, onRefresh: () -> Unit) {
val loadedNoteId by note.live().metadata.map {
it.note.event?.id()
}.distinctUntilChanged().observeAsState(note.event?.id())
val loadedNoteId by note.live().metadata.observeAsState()
LaunchedEffect(key1 = loadedNoteId) {
if (loadedNoteId != null) {
@@ -460,9 +456,7 @@ private fun ObserveNIP19User(
@Composable
private fun ObserveUser(user: User, onRefresh: () -> Unit) {
val loadedUserMetaId by user.live().metadata.map {
it.user.info?.latestMetadata?.id
}.distinctUntilChanged().observeAsState(user.info?.latestMetadata?.id)
val loadedUserMetaId by user.live().metadata.observeAsState()
LaunchedEffect(key1 = loadedUserMetaId) {
if (loadedUserMetaId != null) {
@@ -854,9 +848,7 @@ private fun DisplayUserFromTag(
val route = remember { "User/${baseUser.pubkeyHex}" }
val hex = remember { baseUser.pubkeyDisplayHex() }
val meta by baseUser.live().metadata.map {
it.user.info
}.distinctUntilChanged().observeAsState(baseUser.info)
val meta by baseUser.live().userMetadataInfo.observeAsState(baseUser.info)
Crossfade(targetState = meta) {
Row() {

View File

@@ -18,7 +18,6 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -30,9 +29,8 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SlidingCarousel(
pagerState: PagerState,
modifier: Modifier = Modifier,
pagerState: PagerState = remember { PagerState() },
itemsCount: Int,
itemContent: @Composable (index: Int) -> Unit
) {
val isDragged by pagerState.interactionSource.collectIsDraggedAsState()
@@ -40,7 +38,7 @@ fun SlidingCarousel(
Box(
modifier = modifier.fillMaxWidth()
) {
HorizontalPager(pageCount = itemsCount, state = pagerState) { page ->
HorizontalPager(state = pagerState) { page ->
itemContent(page)
}
@@ -55,7 +53,7 @@ fun SlidingCarousel(
) {
DotsIndicator(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
totalDots = itemsCount,
totalDots = pagerState.pageCount,
selectedIndex = if (isDragged) pagerState.currentPage else pagerState.targetPage,
dotSize = 8.dp
)

View File

@@ -12,8 +12,6 @@ import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun UrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel) {
@@ -37,10 +35,8 @@ fun UrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel)
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
if (urlPreviewState == UrlPreviewState.Loading) {
LaunchedEffect(url) {
launch(Dispatchers.IO) {
UrlCachedPreviewer.previewInfo(url) {
urlPreviewState = it
}
accountViewModel.urlPreview(url) {
urlPreviewState = it
}
}
}

View File

@@ -29,11 +29,18 @@ import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
@@ -46,10 +53,12 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
@@ -77,11 +86,15 @@ import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
import com.vitorpamplona.amethyst.ui.note.HashCheckFailedIcon
import com.vitorpamplona.amethyst.ui.note.HashCheckIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Font17SP
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size24dp
import com.vitorpamplona.amethyst.ui.theme.Size30dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import kotlinx.collections.immutable.ImmutableList
@@ -184,13 +197,20 @@ fun ZoomableContentView(
roundedCorner: Boolean,
accountViewModel: AccountViewModel
) {
val clipboardManager = LocalClipboardManager.current
// store the dialog open or close state
var dialogOpen by remember {
mutableStateOf(false)
}
// store the dialog open or close state
val shareOpen = remember {
mutableStateOf(false)
}
if (shareOpen.value) {
ShareImageAction(shareOpen, content) { shareOpen.value = false }
}
var mainImageModifier = if (roundedCorner) {
MaterialTheme.colors.imageModifier
} else {
@@ -200,12 +220,12 @@ fun ZoomableContentView(
if (content is ZoomableUrlContent) {
mainImageModifier = mainImageModifier.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri ?: content.url)) }
onLongClick = { shareOpen.value = true }
)
} else if (content is ZoomablePreloadedContent) {
mainImageModifier = mainImageModifier.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) }
onLongClick = { shareOpen.value = true }
)
} else {
mainImageModifier = mainImageModifier.clickable {
@@ -617,7 +637,7 @@ fun ZoomableImageDialog(
) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
val pagerState: PagerState = rememberPagerState()
val pagerState: PagerState = rememberPagerState() { allImages.size }
LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
val page = allImages.indexOf(imageUrl)
@@ -628,12 +648,10 @@ fun ZoomableImageDialog(
if (allImages.size > 1) {
SlidingCarousel(
pagerState = pagerState,
itemsCount = allImages.size,
itemContent = { index ->
RenderImageOrVideo(allImages[index], false, accountViewModel)
}
)
pagerState = pagerState
) { index ->
RenderImageOrVideo(allImages[index], false, accountViewModel)
}
} else {
RenderImageOrVideo(imageUrl, false, accountViewModel)
}
@@ -645,11 +663,14 @@ fun ZoomableImageDialog(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = onDismiss)
CloseButton(onPress = onDismiss)
val myContent = allImages[pagerState.currentPage]
if (myContent is ZoomableUrlContent) {
SaveToGallery(url = myContent.url)
Row() {
CopyToClipboard(content = myContent)
SaveToGallery(url = myContent.url)
}
} else if (myContent is ZoomableLocalImage && myContent.localFile != null) {
SaveToGallery(
localFile = myContent.localFile,
@@ -662,6 +683,64 @@ fun ZoomableImageDialog(
}
}
@Composable
private fun CopyToClipboard(
content: ZoomableContent
) {
val popupExpanded = remember { mutableStateOf(false) }
Button(
modifier = Modifier.padding(horizontal = Size5dp),
onClick = { popupExpanded.value = true },
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.placeholderText
)
) {
Icon(
tint = Color.White,
imageVector = Icons.Default.Share,
modifier = Size20Modifier,
contentDescription = stringResource(R.string.copy_url_to_clipboard)
)
ShareImageAction(popupExpanded, content) { popupExpanded.value = false }
}
}
@Composable
private fun ShareImageAction(
popupExpanded:
MutableState<Boolean>,
content: ZoomableContent,
onDismiss: () -> Unit
) {
DropdownMenu(
expanded = popupExpanded.value,
onDismissRequest = onDismiss
) {
val clipboardManager = LocalClipboardManager.current
if (content is ZoomableUrlContent) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(content.url)); onDismiss() }) {
Text(stringResource(R.string.copy_url_to_clipboard))
}
if (content.uri != null) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(content.uri)); onDismiss() }) {
Text(stringResource(R.string.copy_the_note_id_to_the_clipboard))
}
}
}
if (content is ZoomablePreloadedContent) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(content.uri)); onDismiss() }) {
Text(stringResource(R.string.copy_the_note_id_to_the_clipboard))
}
}
}
}
@Composable
private fun RenderImageOrVideo(content: ZoomableContent, roundedCorner: Boolean, accountViewModel: AccountViewModel) {
val mainModifier = Modifier

View File

@@ -7,7 +7,6 @@ import com.vitorpamplona.amethyst.ui.actions.updated
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ChatroomKeyable
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
@@ -50,7 +49,6 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
.reversed()
}
@OptIn(ExperimentalTime::class)
override fun updateListWith(oldList: List<Note>, newItems: Set<Note>): List<Note> {
val (feed, elapsed) = measureTimedValue {
val me = account.userProfile()

View File

@@ -6,7 +6,6 @@ import com.vitorpamplona.amethyst.ui.actions.updated
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ChatroomKeyable
import com.vitorpamplona.quartz.events.PrivateDmEvent
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
@@ -38,7 +37,6 @@ class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>
.reversed()
}
@OptIn(ExperimentalTime::class)
override fun updateListWith(oldList: List<Note>, newItems: Set<Note>): List<Note> {
val (feed, elapsed) = measureTimedValue {
val me = account.userProfile()

View File

@@ -11,13 +11,19 @@ import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_PLANNED
import com.vitorpamplona.quartz.utils.TimeUtils
open class DiscoverLiveFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
open class DiscoverLiveFeedFilter(
val account: Account
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList
return account.userProfile().pubkeyHex + "-" + followList()
}
open fun followList(): String {
return account.defaultDiscoveryFollowList
}
override fun showHiddenKey(): Boolean {
return account.defaultDiscoveryFollowList == PeopleListEvent.blockList
return followList() == PeopleListEvent.blockList
}
override fun feed(): List<Note> {
@@ -39,15 +45,15 @@ open class DiscoverLiveFeedFilter(val account: Account) : AdditiveFeedFilter<Not
val isGlobal = account.defaultDiscoveryFollowList == GLOBAL_FOLLOWS
val isHiddenList = showHiddenKey()
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val followingGeohashSet = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val followingKeySet = account.selectedUsersFollowList(followList()) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(followList()) ?: emptySet()
val followingGeohashSet = account.selectedGeohashesFollowList(followList()) ?: emptySet()
val activities = collection
.asSequence()
.filter { it.event is LiveActivitiesEvent }
.filter {
isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(
isGlobal || (it.event as LiveActivitiesEvent).participantsIntersect(followingKeySet) || it.event?.isTaggedHashes(
followingTagSet
) == true || it.event?.isTaggedGeoHashes(
followingGeohashSet
@@ -61,7 +67,7 @@ open class DiscoverLiveFeedFilter(val account: Account) : AdditiveFeedFilter<Not
}
override fun sort(collection: Set<Note>): List<Note> {
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val followingKeySet = account.selectedUsersFollowList(followList())
val counter = ParticipantListBuilder()
val participantCounts = collection.associate {

View File

@@ -1,12 +1,26 @@
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.Note
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.quartz.events.*
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
class DiscoverLiveNowFeedFilter(account: Account) : DiscoverLiveFeedFilter(account) {
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
}
}
override fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val allItems = super.innerApplyFilter(collection)

View File

@@ -2,11 +2,9 @@ package com.vitorpamplona.amethyst.ui.dal
import android.util.Log
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
abstract class FeedFilter<T> {
@OptIn(ExperimentalTime::class)
fun loadTop(): List<T> {
checkNotInMainThread()
@@ -33,7 +31,6 @@ abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
abstract fun applyFilter(collection: Set<T>): Set<T>
abstract fun sort(collection: Set<T>): List<T>
@OptIn(ExperimentalTime::class)
open fun updateListWith(oldList: List<T>, newItems: Set<T>): List<T> {
checkNotInMainThread()

View File

@@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
@@ -34,6 +35,7 @@ class GeoHashFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
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

View File

@@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
@@ -34,6 +35,7 @@ class HashtagFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
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

View File

@@ -38,7 +38,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
private fun innerApplyFilter(collection: Collection<Note>, ignoreAddressables: Boolean): Set<Note> {
val isGlobal = account.defaultHomeFollowList == GLOBAL_FOLLOWS
val gRelays = account.convertGlobalRelays()
val gRelays = account.activeGlobalRelays()
val isHiddenList = showHiddenKey()
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()

View File

@@ -3,6 +3,9 @@ package com.vitorpamplona.amethyst.ui.navigation
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -80,7 +83,12 @@ fun AppNavigation(
}
}
NavHost(navController, startDestination = Route.Home.route) {
NavHost(
navController,
startDestination = Route.Home.route,
enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) }
) {
Route.Home.let { route ->
composable(route.route, route.arguments, content = { it ->
val nip47 = it.arguments?.getString("nip47")
@@ -215,6 +223,7 @@ fun AppNavigation(
composable(route.route, route.arguments, content = {
ChatroomScreen(
roomId = it.arguments?.getString("id"),
draftMessage = it.arguments?.getString("message"),
accountViewModel = accountViewModel,
nav = nav
)

View File

@@ -15,6 +15,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AppBarDefaults
import androidx.compose.material.ContentAlpha
import androidx.compose.material.Divider
@@ -91,6 +93,7 @@ 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.screen.equalImmutableLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -107,8 +110,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowVideoStreaming
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
@@ -118,7 +121,6 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -232,7 +234,9 @@ private fun CommunityTopBar(
ShortCommunityHeader(baseNote, fontWeight = FontWeight.Medium, accountViewModel, nav)
},
extendableRow = {
LongCommunityHeader(baseNote, accountViewModel, nav)
Column(Modifier.verticalScroll(rememberScrollState())) {
LongCommunityHeader(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav)
}
},
popBack = navPopBack
)
@@ -298,6 +302,17 @@ private fun RenderRoomTopBar(
}
}
},
extendableRow = {
LoadUser(baseUserHex = room.users.first()) {
if (it != null) {
UserCompose(
baseUser = it,
accountViewModel = accountViewModel,
nav = nav
)
}
}
},
popBack = navPopBack
)
} else {
@@ -309,10 +324,17 @@ private fun RenderRoomTopBar(
size = Size34dp
)
RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp).weight(1f), fontWeight = FontWeight.Medium, accountViewModel.userProfile())
RoomNameOnlyDisplay(
room,
Modifier
.padding(start = 10.dp)
.weight(1f),
fontWeight = FontWeight.Medium,
accountViewModel.userProfile()
)
},
extendableRow = {
LongRoomHeader(room, accountViewModel, nav)
LongRoomHeader(room = room, accountViewModel = accountViewModel, nav = nav)
},
popBack = navPopBack
)
@@ -343,7 +365,7 @@ private fun ChannelTopBar(
)
},
extendableRow = {
LongChannelHeader(baseChannel, accountViewModel, nav)
LongChannelHeader(baseChannel = baseChannel, accountViewModel = accountViewModel, nav = nav)
},
popBack = navPopBack
)
@@ -536,8 +558,7 @@ class FollowListViewModel(val account: Account) : ViewModel() {
val followLists = _followLists.asStateFlow()
fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
viewModelScope.launch(Dispatchers.Default) {
refreshFollows()
}
}
@@ -682,6 +703,7 @@ fun FlexibleTopBarWithBackButton(
},
actions = {}
)
Spacer(modifier = HalfVertSpacer)
Divider(thickness = 0.25.dp)
}
}
@@ -732,9 +754,9 @@ fun debugState(context: Context) {
Log.d("STATE DUMP", "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB")
Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB")
Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size)
Log.d("STATE DUMP", "Addressables: " + LocalCache.addressables.filter { it.value.event != null }.size + "/" + LocalCache.addressables.size)
Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size)
Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.liveSet != null }.size + " / " + LocalCache.notes.filter { it.value.event != null }.size + " / " + LocalCache.notes.size)
Log.d("STATE DUMP", "Addressables: " + LocalCache.addressables.filter { it.value.liveSet != null }.size + " / " + LocalCache.addressables.filter { it.value.event != null }.size + " / " + LocalCache.addressables.size)
Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.liveSet != null }.size + " / " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + " / " + LocalCache.users.size)
Log.d("STATE DUMP", "Memory used by Events: " + LocalCache.notes.values.sumOf { it.event?.countMemory() ?: 0 } / (1024 * 1024) + " MB")
@@ -863,9 +885,7 @@ fun MyExtensibleTopAppBar(
if (expanded.value && extendableRow != null) {
Row(
Modifier
.fillMaxWidth()
.padding(bottom = Size10dp),
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {

View File

@@ -19,6 +19,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Divider
@@ -27,13 +29,18 @@ import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.ScaffoldState
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Send
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -46,13 +53,16 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
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.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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.BuildConfig
@@ -65,12 +75,16 @@ import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.note.LoadStatuses
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.coroutines.CoroutineScope
@@ -97,6 +111,7 @@ fun DrawerContent(
.padding(horizontal = 25.dp)
.padding(top = 100.dp),
scaffoldState,
accountViewModel,
nav
)
Divider(
@@ -123,6 +138,7 @@ fun ProfileContent(
baseAccountUser: User,
modifier: Modifier = Modifier,
scaffoldState: ScaffoldState,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val coroutineScope = rememberCoroutineScope()
@@ -215,9 +231,14 @@ fun ProfileContent(
)
)
}
Row(Modifier.padding(top = Size10dp)) {
EditStatusBox(baseAccountUser, accountViewModel)
}
Row(
modifier = Modifier
.padding(top = 15.dp)
.padding(top = Size10dp)
.clickable(onClick = {
nav(route)
coroutineScope.launch {
@@ -231,6 +252,139 @@ fun ProfileContent(
}
}
@Composable
private fun EditStatusBox(baseAccountUser: User, accountViewModel: AccountViewModel) {
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
LoadStatuses(user = baseAccountUser) { statuses ->
if (statuses.isEmpty()) {
val currentStatus = remember {
mutableStateOf("")
}
val hasChanged by remember {
derivedStateOf {
currentStatus.value != ""
}
}
OutlinedTextField(
value = currentStatus.value,
onValueChange = { currentStatus.value = it },
label = { Text(text = stringResource(R.string.status_update)) },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.status_update),
color = MaterialTheme.colors.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Send,
capitalization = KeyboardCapitalization.Sentences
),
keyboardActions = KeyboardActions(
onSend = {
accountViewModel.createStatus(currentStatus.value)
focusManager.clearFocus(true)
}
),
singleLine = true,
trailingIcon = {
if (hasChanged) {
UserStatusSendButton() {
accountViewModel.createStatus(currentStatus.value)
focusManager.clearFocus(true)
}
}
}
)
} else {
statuses.forEach {
val originalStatus by it.live().metadata.map {
it.note.event?.content() ?: ""
}.observeAsState(it.event?.content() ?: "")
val thisStatus = remember {
mutableStateOf(originalStatus)
}
val hasChanged by remember {
derivedStateOf {
thisStatus.value != originalStatus
}
}
OutlinedTextField(
value = thisStatus.value,
onValueChange = { thisStatus.value = it },
label = { Text(text = stringResource(R.string.status_update)) },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.status_update),
color = MaterialTheme.colors.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Send,
capitalization = KeyboardCapitalization.Sentences
),
keyboardActions = KeyboardActions(
onSend = {
accountViewModel.updateStatus(it, thisStatus.value)
focusManager.clearFocus(true)
}
),
singleLine = true,
trailingIcon = {
if (hasChanged) {
UserStatusSendButton() {
accountViewModel.updateStatus(it, thisStatus.value)
focusManager.clearFocus(true)
}
} else {
UserStatusDeleteButton() {
accountViewModel.deleteStatus(it)
focusManager.clearFocus(true)
}
}
}
)
}
}
}
}
@Composable
fun UserStatusSendButton(onClick: () -> Unit) {
IconButton(
modifier = Size26Modifier,
onClick = onClick
) {
Icon(
imageVector = Icons.Default.Send,
null,
modifier = Size20Modifier,
tint = MaterialTheme.colors.placeholderText
)
}
}
@Composable
fun UserStatusDeleteButton(onClick: () -> Unit) {
IconButton(
modifier = Size26Modifier,
onClick = onClick
) {
Icon(
imageVector = Icons.Default.Delete,
null,
modifier = Size20Modifier,
tint = MaterialTheme.colors.placeholderText
)
}
}
@Composable
private fun FollowingAndFollowerCounts(baseAccountUser: User) {
var followingCount by remember { mutableStateOf("--") }
@@ -484,7 +638,7 @@ private fun RelayStatus(
relayViewModel: RelayPoolViewModel
) {
val connectedRelaysText by relayViewModel.connectionStatus.observeAsState("--/--")
val isConnected by relayViewModel.isConnected.distinctUntilChanged().observeAsState(false)
val isConnected by relayViewModel.isConnected.observeAsState(false)
RenderRelayStatus(connectedRelaysText, isConnected)
}

View File

@@ -120,9 +120,12 @@ sealed class Route(
)
object Room : Route(
route = "Room/{id}",
route = "Room/{id}?message={message}",
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
arguments = listOf(
navArgument("id") { type = NavType.StringType },
navArgument("message") { type = NavType.StringType; nullable = true; defaultValue = null }
).toImmutableList()
)
object RoomByAuthor : Route(

View File

@@ -95,7 +95,7 @@ fun AddBountyAmountDialog(bounty: Note, accountViewModel: AccountViewModel, onCl
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
CloseButton(onCancel = {
CloseButton(onPress = {
postViewModel.cancel()
onClose()
})

View File

@@ -15,6 +15,7 @@ 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.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@@ -58,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.EndedFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.OfflineFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
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.Size35dp
@@ -91,24 +93,10 @@ fun ChannelCardCompose(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val isBlank by baseNote.live().metadata.map {
it.note.event == null
}.distinctUntilChanged().observeAsState(baseNote.event == null)
val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null)
Crossfade(targetState = isBlank) {
Crossfade(targetState = hasEvent) {
if (it) {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
BlankNote(
remember {
modifier.combinedClickable(
onClick = { },
onLongClick = showPopup
)
},
false
)
}
} else {
if (forceEventKind == null || baseNote.event?.kind() == forceEventKind) {
CheckHiddenChannelCardCompose(
baseNote,
@@ -120,6 +108,18 @@ fun ChannelCardCompose(
nav
)
}
} else {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
BlankNote(
remember {
modifier.combinedClickable(
onClick = { },
onLongClick = showPopup
)
},
false
)
}
}
}
}
@@ -361,6 +361,10 @@ fun InnerChannelCardWithReactions(
)
}
}
Divider(
thickness = DividerThickness
)
}
@Composable
@@ -621,7 +625,7 @@ fun RenderCommunitiesThumb(baseNote: Note, accountViewModel: AccountViewModel, n
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
}
description?.let {
@@ -735,7 +739,7 @@ fun RenderChannelThumb(baseNote: Note, channel: Channel, accountViewModel: Accou
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
}
description?.let {
@@ -764,7 +768,7 @@ fun RenderChannelThumb(baseNote: Note, channel: Channel, accountViewModel: Accou
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun Gallery(users: List<User>, accountViewModel: AccountViewModel) {
FlowRow(verticalAlignment = CenterVertically) {
FlowRow(verticalArrangement = Arrangement.Center) {
users.take(6).forEach {
ClickableUserPicture(it, Size35dp, accountViewModel)
}

View File

@@ -77,14 +77,12 @@ fun ChatroomHeaderCompose(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val isBlank by baseNote.live().metadata.map {
it.note.event == null
}.observeAsState(baseNote.event == null)
val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null)
if (isBlank) {
BlankNote(Modifier)
} else {
if (hasEvent) {
ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav)
} else {
BlankNote(Modifier)
}
}

View File

@@ -85,12 +85,20 @@ fun ChatroomMessageCompose(
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit
) {
val isBlank by baseNote.live().metadata.map {
it.note.event == null
}.observeAsState(baseNote.event == null)
val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null)
Crossfade(targetState = isBlank) {
Crossfade(targetState = hasEvent) {
if (it) {
CheckHiddenChatMessage(
baseNote,
routeForLastRead,
innerQuote,
parentBackgroundColor,
accountViewModel,
nav,
onWantsToReply
)
} else {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
BlankNote(
remember {
@@ -101,16 +109,6 @@ fun ChatroomMessageCompose(
}
)
}
} else {
CheckHiddenChatMessage(
baseNote,
routeForLastRead,
innerQuote,
parentBackgroundColor,
accountViewModel,
nav,
onWantsToReply
)
}
}
}
@@ -568,7 +566,7 @@ private fun StatusRow(
Row(verticalAlignment = Alignment.CenterVertically, modifier = ReactionRowHeightChat) {
LikeReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav = nav)
Spacer(modifier = DoubleHorzSpacer)
ReplyReaction(
baseNote = baseNote,

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -25,6 +26,7 @@ import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.screen.MessageSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -118,5 +120,9 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String,
NoteDropDownMenu(baseNote, popupExpanded, accountViewModel)
}
}
Divider(
thickness = DividerThickness
)
}
}

View File

@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@@ -40,10 +41,7 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.ImageUrlType
@@ -53,7 +51,7 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifierSmaller
import com.vitorpamplona.amethyst.ui.theme.Size10dp
@@ -70,8 +68,6 @@ import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.overPictureBackground
import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
@@ -159,6 +155,10 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, showHi
NoteDropDownMenu(baseNote, popupExpanded, accountViewModel)
}
Divider(
thickness = DividerThickness
)
}
}
@@ -348,8 +348,7 @@ private fun ParseAuthorCommentAndAmount(
}
LaunchedEffect(key1 = zapRequest.idHex, key2 = zapEvent?.idHex) {
launch(Dispatchers.IO) {
val newState = loadAmountState(zapRequest, zapEvent, accountViewModel)
accountViewModel.decryptAmountMessage(zapRequest, zapEvent) { newState ->
if (newState != null) {
content.value = newState
}
@@ -359,34 +358,6 @@ private fun ParseAuthorCommentAndAmount(
onReady(content)
}
private suspend fun loadAmountState(
zapRequest: Note,
zapEvent: Note?,
accountViewModel: AccountViewModel
): ZapAmountCommentNotification? {
(zapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = accountViewModel.decryptZap(zapRequest)
val amount = (zapEvent?.event as? LnZapEvent)?.amount
if (decryptedContent != null) {
val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
return ZapAmountCommentNotification(
newAuthor,
decryptedContent.content.ifBlank { null },
showAmountAxis(amount)
)
} else {
if (!zapRequest.event?.content().isNullOrBlank() || amount != null) {
return ZapAmountCommentNotification(
zapRequest.author,
zapRequest.event?.content()?.ifBlank { null },
showAmountAxis(amount)
)
}
}
}
return null
}
@Composable
private fun RenderState(
content: MutableState<ZapAmountCommentNotification>,
@@ -573,9 +544,7 @@ private fun WatchNoteAuthor(
baseNote: Note,
onContent: @Composable (User?) -> Unit
) {
val author by baseNote.live().metadata.map {
it.note.author
}.observeAsState(baseNote.author)
val author by baseNote.live().authorChanges.observeAsState(baseNote.author)
onContent(author)
}
@@ -585,9 +554,7 @@ private fun WatchUserMetadata(
author: User,
onNewMetadata: @Composable (String?) -> Unit
) {
val userProfile by author.live().metadata.map {
it.user.profilePicture()
}.distinctUntilChanged().observeAsState(author.profilePicture())
val userProfile by author.live().profilePictureChanges.observeAsState(author.profilePicture())
onNewMetadata(userProfile)
}

View File

@@ -3,43 +3,62 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.map
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.Nip05Verifier
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.LoadStatuses
import com.vitorpamplona.amethyst.ui.note.NIP05CheckingIcon
import com.vitorpamplona.amethyst.ui.note.NIP05FailedVerification
import com.vitorpamplona.amethyst.ui.note.NIP05VerifiedIcon
import com.vitorpamplona.amethyst.ui.note.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.nip05
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.UserMetadata
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds
@Composable
fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String): MutableState<Boolean?> {
fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String, accountViewModel: AccountViewModel): MutableState<Boolean?> {
val nip05Verified = remember(userMetadata.nip05) {
// starts with null if must verify or already filled in if verified in the last hour
val default = if ((userMetadata.nip05LastVerificationTime ?: 0) > TimeUtils.oneHourAgo()) {
@@ -53,37 +72,9 @@ fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String): Mu
if (nip05Verified.value == null) {
LaunchedEffect(key1 = userMetadata.nip05) {
launch(Dispatchers.IO) {
userMetadata.nip05?.ifBlank { null }?.let { nip05 ->
Nip05Verifier().verifyNip05(
nip05,
onSuccess = {
// Marks user as verified
if (it == pubkeyHex) {
userMetadata.nip05Verified = true
userMetadata.nip05LastVerificationTime = TimeUtils.now()
if (nip05Verified.value != true) {
nip05Verified.value = true
}
} else {
userMetadata.nip05Verified = false
userMetadata.nip05LastVerificationTime = 0
if (nip05Verified.value != false) {
nip05Verified.value = false
}
}
},
onError = {
userMetadata.nip05LastVerificationTime = 0
userMetadata.nip05Verified = false
if (nip05Verified.value != false) {
nip05Verified.value = false
}
}
)
accountViewModel.verifyNip05(userMetadata, pubkeyHex) { newVerificationStatus ->
if (nip05Verified.value != newVerificationStatus) {
nip05Verified.value = newVerificationStatus
}
}
}
@@ -93,47 +84,196 @@ fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String): Mu
}
@Composable
fun ObserveDisplayNip05Status(baseNote: Note, columnModifier: Modifier = Modifier) {
val noteState by baseNote.live().metadata.observeAsState()
val author by remember(noteState) {
derivedStateOf {
noteState?.note?.author
}
}
fun ObserveDisplayNip05Status(
baseNote: Note,
columnModifier: Modifier = Modifier,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val author by baseNote.live().authorChanges.observeAsState()
author?.let {
ObserveDisplayNip05Status(it, columnModifier)
ObserveDisplayNip05Status(it, columnModifier, accountViewModel, nav)
}
}
@Composable
fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifier) {
val nip05 by baseUser.live().metadata.map {
it.user.nip05()
}.observeAsState(baseUser.nip05())
fun ObserveDisplayNip05Status(
baseUser: User,
columnModifier: Modifier = Modifier,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val nip05 by baseUser.live().nip05Changes.observeAsState(baseUser.nip05())
Crossfade(targetState = nip05, modifier = columnModifier) {
if (it != null) {
DisplayNIP05Line(it, baseUser, columnModifier)
} else {
Text(
text = baseUser.pubkeyDisplayHex(),
color = MaterialTheme.colors.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = columnModifier
)
LoadStatuses(baseUser) { statuses ->
Crossfade(targetState = nip05, modifier = columnModifier, label = "ObserveDisplayNip05StatusCrossfade") {
VerifyAndDisplayNIP05OrStatusLine(it, statuses, baseUser, columnModifier, accountViewModel, nav)
}
}
}
@Composable
private fun DisplayNIP05Line(nip05: String, baseUser: User, columnModifier: Modifier = Modifier) {
private fun VerifyAndDisplayNIP05OrStatusLine(
nip05: String?,
statuses: ImmutableList<AddressableNote>,
baseUser: User,
columnModifier: Modifier = Modifier,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Column(modifier = columnModifier) {
val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex)
Crossfade(targetState = nip05Verified) {
Row(verticalAlignment = Alignment.CenterVertically) {
DisplayNIP05(nip05, it)
Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05 != null) {
val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex, accountViewModel)
if (nip05Verified.value != true) {
DisplayNIP05(nip05, nip05Verified)
} else if (!statuses.isEmpty()) {
RotateStatuses(statuses, accountViewModel, nav)
} else {
DisplayNIP05(nip05, nip05Verified)
}
} else {
if (!statuses.isEmpty()) {
RotateStatuses(statuses, accountViewModel, nav)
} else {
DisplayUsersNpub(baseUser.pubkeyDisplayHex())
}
}
}
}
}
@Composable
fun RotateStatuses(
statuses: ImmutableList<AddressableNote>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var indexToDisplay by remember {
mutableIntStateOf(0)
}
DisplayStatus(statuses[indexToDisplay], accountViewModel, nav)
if (statuses.size > 1) {
LaunchedEffect(Unit) {
while (true) {
delay(10.seconds)
indexToDisplay = (indexToDisplay + 1) % statuses.size
}
}
}
}
@Composable
fun DisplayUsersNpub(npub: String) {
Text(
text = npub,
fontSize = 14.sp,
color = MaterialTheme.colors.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
@Composable
fun DisplayStatus(
addressableNote: AddressableNote,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val noteState by addressableNote.live().metadata.observeAsState()
val content = remember(noteState) { addressableNote.event?.content() ?: "" }
val type = remember(noteState) {
(addressableNote.event as? AddressableEvent)?.dTag() ?: ""
}
val url = remember(noteState) {
addressableNote.event?.firstTaggedUrl()?.ifBlank { null }
}
val nostrATag = remember(noteState) {
addressableNote.event?.firstTaggedAddress()
}
val nostrHexID = remember(noteState) {
addressableNote.event?.firstTaggedEvent()?.ifBlank { null }
}
when (type) {
"music" -> Icon(
painter = painterResource(id = R.drawable.tunestr),
null,
modifier = Size15Modifier.padding(end = Size5dp),
tint = MaterialTheme.colors.placeholderText
)
else -> {}
}
Text(
text = content,
fontSize = Font14SP,
color = MaterialTheme.colors.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (url != null) {
val uri = LocalUriHandler.current
Spacer(modifier = StdHorzSpacer)
IconButton(
modifier = Size15Modifier,
onClick = { runCatching { uri.openUri(url.trim()) } }
) {
Icon(
imageVector = Icons.Default.OpenInNew,
null,
modifier = Size15Modifier,
tint = MaterialTheme.colors.lessImportantLink
)
}
} else if (nostrATag != null) {
LoadAddressableNote(nostrATag) { note ->
if (note != null) {
Spacer(modifier = StdHorzSpacer)
IconButton(
modifier = Size15Modifier,
onClick = {
routeFor(
note,
accountViewModel.userProfile()
)?.let { nav(it) }
}
) {
Icon(
imageVector = Icons.Default.OpenInNew,
null,
modifier = Size15Modifier,
tint = MaterialTheme.colors.lessImportantLink
)
}
}
}
} else if (nostrHexID != null) {
LoadNote(baseNoteHex = nostrHexID) {
if (it != null) {
Spacer(modifier = StdHorzSpacer)
IconButton(
modifier = Size15Modifier,
onClick = {
routeFor(
it,
accountViewModel.userProfile()
)?.let { nav(it) }
}
) {
Icon(
imageVector = Icons.Default.OpenInNew,
null,
modifier = Size15Modifier,
tint = MaterialTheme.colors.lessImportantLink
)
}
}
}
}
@@ -157,6 +297,7 @@ private fun DisplayNIP05(
if (user != "_") {
Text(
text = remember(nip05) { AnnotatedString(user) },
fontSize = Font14SP,
color = MaterialTheme.colors.nip05,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -168,7 +309,7 @@ private fun DisplayNIP05(
ClickableText(
text = remember(nip05) { AnnotatedString(domain) },
onClick = { runCatching { uri.openUri("https://$domain") } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.nip05),
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.nip05, fontSize = Font14SP),
maxLines = 1,
overflow = TextOverflow.Visible
)
@@ -186,12 +327,12 @@ private fun NIP05VerifiedSymbol(nip05Verified: MutableState<Boolean?>, modifier:
}
@Composable
fun DisplayNip05ProfileStatus(user: User) {
fun DisplayNip05ProfileStatus(user: User, accountViewModel: AccountViewModel) {
val uri = LocalUriHandler.current
user.nip05()?.let { nip05 ->
if (nip05.split("@").size <= 2) {
val nip05Verified = nip05VerificationAsAState(user.info!!, user.pubkeyHex)
val nip05Verified = nip05VerificationAsAState(user.info!!, user.pubkeyHex, accountViewModel)
Row(verticalAlignment = Alignment.CenterVertically) {
NIP05VerifiedSymbol(nip05Verified, Size16Modifier)
var domainPadStart = 5.dp

View File

@@ -23,9 +23,11 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CutCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
@@ -113,6 +115,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.JoinCommunityButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LeaveCommunityButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NormalTimeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -121,9 +124,9 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
@@ -162,6 +165,7 @@ import com.vitorpamplona.quartz.events.BaseTextNoteEvent
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.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
@@ -202,7 +206,6 @@ import java.io.File
import java.math.BigDecimal
import java.net.URL
import java.util.Locale
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@OptIn(ExperimentalFoundationApi::class)
@@ -221,24 +224,10 @@ fun NoteCompose(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val isBlank by baseNote.live().metadata.map {
it.note.event == null
}.distinctUntilChanged().observeAsState(baseNote.event == null)
val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null)
Crossfade(targetState = isBlank) {
Crossfade(targetState = hasEvent) {
if (it) {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
BlankNote(
remember {
modifier.combinedClickable(
onClick = { },
onLongClick = showPopup
)
},
isBoostedNote || isQuotedNote
)
}
} else {
CheckHiddenNoteCompose(
note = baseNote,
routeForLastRead = routeForLastRead,
@@ -253,6 +242,18 @@ fun NoteCompose(
accountViewModel = accountViewModel,
nav = nav
)
} else {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
BlankNote(
remember {
modifier.combinedClickable(
onClick = { },
onLongClick = showPopup
)
},
isBoostedNote || isQuotedNote
)
}
}
}
}
@@ -441,9 +442,7 @@ fun WatchForReports(
val noteReportsState by note.live().reports.observeAsState()
LaunchedEffect(key1 = noteReportsState, key2 = userFollowsState) {
launch(Dispatchers.Default) {
accountViewModel.isNoteAcceptable(note, onChange)
}
accountViewModel.isNoteAcceptable(note, onChange)
}
}
@@ -462,45 +461,86 @@ fun NormalNote(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
when (baseNote.event) {
is ChannelCreateEvent, is ChannelMetadataEvent -> ChannelHeader(
channelNote = baseNote,
showVideo = !makeItShort,
showBottomDiviser = true,
sendToChannel = true,
accountViewModel = accountViewModel,
nav = nav
)
is CommunityDefinitionEvent -> (baseNote as? AddressableNote)?.let {
CommunityHeader(
baseNote = it,
if (isQuotedNote || isBoostedNote) {
when (baseNote.event) {
is ChannelCreateEvent, is ChannelMetadataEvent -> ChannelHeader(
channelNote = baseNote,
showVideo = !makeItShort,
showBottomDiviser = true,
sendToCommunity = true,
sendToChannel = true,
accountViewModel = accountViewModel,
nav = nav
)
}
is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote)
is FileHeaderEvent -> FileHeaderDisplay(baseNote, isQuotedNote, accountViewModel)
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, isQuotedNote, accountViewModel)
else ->
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
CheckNewAndRenderNote(
baseNote,
routeForLastRead,
modifier,
isBoostedNote,
isQuotedNote,
unPackReply,
makeItShort,
addMarginTop,
canPreview,
parentBackgroundColor,
accountViewModel,
showPopup,
nav
is CommunityDefinitionEvent -> (baseNote as? AddressableNote)?.let {
CommunityHeader(
baseNote = it,
showBottomDiviser = true,
sendToCommunity = true,
accountViewModel = accountViewModel,
nav = nav
)
}
is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote)
else ->
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
CheckNewAndRenderNote(
baseNote,
routeForLastRead,
modifier,
isBoostedNote,
isQuotedNote,
unPackReply,
makeItShort,
addMarginTop,
canPreview,
parentBackgroundColor,
accountViewModel,
showPopup,
nav
)
}
}
} else {
when (baseNote.event) {
is ChannelCreateEvent, is ChannelMetadataEvent -> ChannelHeader(
channelNote = baseNote,
showVideo = !makeItShort,
showBottomDiviser = true,
sendToChannel = true,
accountViewModel = accountViewModel,
nav = nav
)
is CommunityDefinitionEvent -> (baseNote as? AddressableNote)?.let {
CommunityHeader(
baseNote = it,
showBottomDiviser = true,
sendToCommunity = true,
accountViewModel = accountViewModel,
nav = nav
)
}
is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote)
is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, accountViewModel)
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, accountViewModel)
else ->
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
CheckNewAndRenderNote(
baseNote,
routeForLastRead,
modifier,
isBoostedNote,
isQuotedNote,
unPackReply,
makeItShort,
addMarginTop,
canPreview,
parentBackgroundColor,
accountViewModel,
showPopup,
nav
)
}
}
}
}
@@ -518,7 +558,7 @@ fun CommunityHeader(
Column(Modifier.fillMaxWidth()) {
Column(
verticalArrangement = Arrangement.Center,
modifier = modifier.clickable {
modifier = Modifier.clickable {
if (sendToCommunity) {
routeFor(baseNote, accountViewModel.userProfile())?.let {
nav(it)
@@ -535,7 +575,14 @@ fun CommunityHeader(
)
if (expanded.value) {
LongCommunityHeader(baseNote, accountViewModel, nav)
Column(Modifier.verticalScroll(rememberScrollState())) {
LongCommunityHeader(
baseNote = baseNote,
lineModifier = modifier,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
@@ -548,23 +595,41 @@ fun CommunityHeader(
}
@Composable
fun LongCommunityHeader(baseNote: AddressableNote, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun LongCommunityHeader(
baseNote: AddressableNote,
lineModifier: Modifier = Modifier.padding(horizontal = Size10dp, vertical = Size5dp),
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val noteState by baseNote.live().metadata.observeAsState()
val noteEvent = remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return
Row(
Modifier
.fillMaxWidth()
.padding(top = 10.dp)
lineModifier
) {
val rulesLabel = stringResource(id = R.string.rules)
val summary = remember(noteState) {
noteEvent.description()?.ifBlank { null }
val subject = noteEvent.subject()?.ifEmpty { null }
val body = noteEvent.description()?.ifBlank { null }
val rules = noteEvent.rules()?.ifBlank { null }
if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) {
if (rules == null) {
"## $subject\n$body"
} else {
"## $subject\n$body\n\n## $rulesLabel\n\n$rules"
}
} else {
if (rules == null) {
body
} else {
"$body\n\n$rulesLabel\n$rules"
}
}
}
Column(
Modifier
.weight(1f)
.padding(start = 10.dp)
Modifier.weight(1f)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
val defaultBackground = MaterialTheme.colors.background
@@ -594,40 +659,8 @@ fun LongCommunityHeader(baseNote: AddressableNote, accountViewModel: AccountView
}
}
val rules = remember(noteState) {
noteEvent.rules()?.ifBlank { null }
}
rules?.let {
Spacer(DoubleVertSpacer)
Row(
Modifier
.fillMaxWidth()
.padding(start = 10.dp)
) {
val defaultBackground = MaterialTheme.colors.background
val background = remember {
mutableStateOf(defaultBackground)
}
val tags = remember(noteEvent) { noteEvent?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
TranslatableRichTextViewer(
content = it,
canPreview = false,
tags = tags,
backgroundColor = background,
accountViewModel = accountViewModel,
nav = nav
)
}
}
Spacer(DoubleVertSpacer)
Row(
Modifier
.fillMaxWidth()
.padding(start = 10.dp),
lineModifier,
verticalAlignment = Alignment.CenterVertically
) {
Text(
@@ -640,8 +673,6 @@ fun LongCommunityHeader(baseNote: AddressableNote, accountViewModel: AccountView
NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp)
Spacer(DoubleHorzSpacer)
NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) })
TimeAgo(baseNote)
MoreOptionsButton(baseNote, accountViewModel)
}
var participantUsers by remember(baseNote) {
@@ -665,12 +696,9 @@ fun LongCommunityHeader(baseNote: AddressableNote, accountViewModel: AccountView
participantUsers.forEach {
Row(
Modifier
.fillMaxWidth()
.padding(start = 10.dp, top = 10.dp)
.clickable {
nav("User/${it.second.pubkeyHex}")
},
lineModifier.clickable {
nav("User/${it.second.pubkeyHex}")
},
verticalAlignment = Alignment.CenterVertically
) {
it.first.role?.let { it1 ->
@@ -687,6 +715,21 @@ fun LongCommunityHeader(baseNote: AddressableNote, accountViewModel: AccountView
UsernameDisplay(it.second, remember { Modifier.weight(1f) })
}
}
Row(
lineModifier,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(id = R.string.created_at),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(75.dp)
)
Spacer(DoubleHorzSpacer)
NormalTimeAgo(baseNote = baseNote, Modifier.weight(1f))
MoreOptionsButton(baseNote, accountViewModel)
}
}
@Composable
@@ -740,9 +783,9 @@ private fun ShortCommunityActionOptions(
nav: (String) -> Unit
) {
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
LikeReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
ZapReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
WatchAddressableNoteFollows(note, accountViewModel) { isFollowing ->
if (!isFollowing) {
@@ -897,7 +940,6 @@ fun ClickableNote(
}
}
@OptIn(ExperimentalTime::class)
@Composable
fun InnerNoteWithReactions(
baseNote: Note,
@@ -1009,7 +1051,7 @@ private fun NoteBody(
)
}
Spacer(modifier = HalfVertSpacer)
Spacer(modifier = Modifier.height(3.dp))
if (!makeItShort) {
ReplyRow(
@@ -1139,6 +1181,14 @@ private fun RenderNoteRow(
)
}
is FileHeaderEvent -> {
FileHeaderDisplay(baseNote, true, accountViewModel)
}
is FileStorageHeaderEvent -> {
FileStorageHeaderDisplay(baseNote, true, accountViewModel)
}
is CommunityPostApprovalEvent -> {
RenderPostApproval(
baseNote,
@@ -1187,6 +1237,16 @@ fun routeFor(note: Note, loggedIn: User): String? {
return null
}
fun routeToMessage(user: User, draftMessage: String?, accountViewModel: AccountViewModel): String {
val withKey = ChatroomKey(persistentSetOf(user.pubkeyHex))
accountViewModel.account.userProfile().createChatroom(withKey)
return if (draftMessage != null) {
"Room/${withKey.hashCode()}?message=$draftMessage"
} else {
"Room/${withKey.hashCode()}"
}
}
fun routeFor(note: Channel): String {
return "Channel/${note.idHex}"
}
@@ -1264,7 +1324,7 @@ fun RenderPoll(
nav: (String) -> Unit
) {
val noteEvent = note.event as? PollNoteEvent ?: return
val eventContent = remember { noteEvent.content() }
val eventContent = remember(note) { noteEvent.content() }
if (makeItShort && accountViewModel.isLoggedUser(note.author)) {
Text(
@@ -2348,9 +2408,9 @@ private fun ReplyRow(
) {
val noteEvent = note.event
val showReply by remember {
val showReply by remember(note) {
derivedStateOf {
noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())
noteEvent is BaseTextNoteEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())
}
}
@@ -2436,7 +2496,7 @@ fun SecondUserInfoRow(
val noteAuthor = remember { note.author } ?: return
Row(verticalAlignment = CenterVertically, modifier = UserNameMaxRowHeight) {
ObserveDisplayNip05Status(noteAuthor, remember { Modifier.weight(1f) })
ObserveDisplayNip05Status(noteAuthor, remember { Modifier.weight(1f) }, accountViewModel, nav)
val geo = remember { noteEvent.getGeoHash() }
if (geo != null) {
@@ -2455,6 +2515,30 @@ fun SecondUserInfoRow(
}
}
@Composable
fun LoadStatuses(
user: User,
content: @Composable (ImmutableList<AddressableNote>) -> Unit
) {
var statuses: ImmutableList<AddressableNote> by remember {
mutableStateOf(persistentListOf())
}
val userStatus by user.live().statuses.observeAsState()
LaunchedEffect(key1 = userStatus) {
launch(Dispatchers.IO) {
val myUser = userStatus?.user ?: return@launch
val newStatuses = LocalCache.findStatusesForUser(myUser)
if (!equalImmutableLists(statuses, newStatuses)) {
statuses = newStatuses
}
}
}
content(statuses)
}
@Composable
fun DisplayLocation(geohash: String, nav: (String) -> Unit) {
val context = LocalContext.current
@@ -3194,7 +3278,7 @@ private fun RenderBadge(
}
@Composable
fun FileHeaderDisplay(note: Note, isQuotedNote: Boolean, accountViewModel: AccountViewModel) {
fun FileHeaderDisplay(note: Note, roundedCorner: Boolean, accountViewModel: AccountViewModel) {
val event = (note.event as? FileHeaderEvent) ?: return
val fullUrl = event.url() ?: return
@@ -3230,18 +3314,18 @@ fun FileHeaderDisplay(note: Note, isQuotedNote: Boolean, accountViewModel: Accou
}
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
ZoomableContentView(content = content, roundedCorner = isQuotedNote, accountViewModel = accountViewModel)
ZoomableContentView(content = content, roundedCorner = roundedCorner, accountViewModel = accountViewModel)
}
}
@Composable
fun FileStorageHeaderDisplay(baseNote: Note, isQuotedNote: Boolean, accountViewModel: AccountViewModel) {
fun FileStorageHeaderDisplay(baseNote: Note, roundedCorner: Boolean, accountViewModel: AccountViewModel) {
val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return
val dataEventId = eventHeader.dataEventId() ?: return
LoadNote(baseNoteHex = dataEventId) { contentNote ->
if (contentNote != null) {
ObserverAndRenderNIP95(baseNote, contentNote, isQuotedNote, accountViewModel)
ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, accountViewModel)
}
}
}
@@ -3250,7 +3334,7 @@ fun FileStorageHeaderDisplay(baseNote: Note, isQuotedNote: Boolean, accountViewM
private fun ObserverAndRenderNIP95(
header: Note,
content: Note,
isQuotedNote: Boolean,
roundedCorner: Boolean,
accountViewModel: AccountViewModel
) {
val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return
@@ -3297,7 +3381,7 @@ private fun ObserverAndRenderNIP95(
Crossfade(targetState = content) {
if (it != null) {
SensitivityWarning(note = header, accountViewModel = accountViewModel) {
ZoomableContentView(content = it, roundedCorner = isQuotedNote, accountViewModel = accountViewModel)
ZoomableContentView(content = it, roundedCorner = roundedCorner, accountViewModel = accountViewModel)
}
}
}
@@ -3426,7 +3510,12 @@ fun AudioHeader(noteEvent: AudioHeaderEvent, note: Note, accountViewModel: Accou
}
content?.let {
Row(verticalAlignment = CenterVertically, modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(top = 5.dp)
) {
TranslatableRichTextViewer(
content = it,
canPreview = true,

View File

@@ -98,9 +98,7 @@ private fun WatchZapsAndUpdateTallies(
val zapsState by baseNote.live().zaps.observeAsState()
LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) {
pollViewModel.refreshTallies()
}
pollViewModel.refreshTallies()
}
}
@@ -132,8 +130,7 @@ private fun OptionNote(
ZapVote(
baseNote,
poolOption,
accountViewModel,
pollViewModel,
pollViewModel = pollViewModel,
nonClickablePrepend = {
RenderOptionAfterVote(
poolOption.descriptor,
@@ -147,18 +144,21 @@ private fun OptionNote(
)
},
clickablePrepend = {
}
},
accountViewModel = accountViewModel,
nav = nav
)
} else {
ZapVote(
baseNote,
poolOption,
accountViewModel,
pollViewModel,
pollViewModel = pollViewModel,
nonClickablePrepend = {},
clickablePrepend = {
RenderOptionBeforeVote(poolOption.descriptor, canPreview, tags, backgroundColor, accountViewModel, nav)
}
},
accountViewModel = accountViewModel,
nav = nav
)
}
}
@@ -269,11 +269,12 @@ private fun RenderOptionBeforeVote(
fun ZapVote(
baseNote: Note,
poolOption: PollOption,
accountViewModel: AccountViewModel,
pollViewModel: PollNoteViewModel,
modifier: Modifier = Modifier,
pollViewModel: PollNoteViewModel,
nonClickablePrepend: @Composable () -> Unit,
clickablePrepend: @Composable () -> Unit
clickablePrepend: @Composable () -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val isLoggedUser by remember {
derivedStateOf {
@@ -283,6 +284,7 @@ fun ZapVote(
var wantsToZap by remember { mutableStateOf(false) }
var zappingProgress by remember { mutableStateOf(0f) }
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -384,7 +386,7 @@ fun ZapVote(
onError = {
scope.launch {
zappingProgress = 0f
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
showErrorMessageDialog = it
}
},
onProgress = {
@@ -395,6 +397,19 @@ fun ZapVote(
)
}
if (showErrorMessageDialog != null) {
ErrorMessageDialog(
title = stringResource(id = R.string.error_dialog_zap_error),
textContent = showErrorMessageDialog ?: "",
onClickStartMessage = {
baseNote.author?.let {
nav(routeToMessage(it, showErrorMessageDialog, accountViewModel))
}
},
onDismiss = { showErrorMessageDialog = null }
)
}
clickablePrepend()
if (poolOption.zappedByLoggedIn) {

View File

@@ -3,13 +3,16 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.events.*
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.math.RoundingMode
@@ -52,12 +55,12 @@ class PollNoteViewModel : ViewModel() {
closedAt = pollEvent?.getTagInt(CLOSED_AT)
}
suspend fun refreshTallies() {
totalZapped = totalZapped()
wasZappedByLoggedInAccount = pollNote?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false
fun refreshTallies() {
viewModelScope.launch(Dispatchers.Default) {
totalZapped = totalZapped()
wasZappedByLoggedInAccount = pollNote?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false
_tallies.emit(
pollOptions?.keys?.map {
val newOptions = pollOptions?.keys?.map {
val zappedInOption = zappedPollOptionAmount(it)
val myTally = if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
@@ -71,8 +74,12 @@ class PollNoteViewModel : ViewModel() {
val consensus = consensusThreshold != null && myTally >= consensusThreshold!!
PollOption(it, pollOptions?.get(it) ?: "", zappedInOption, myTally, consensus, zappedByLoggedIn)
} ?: emptyList()
)
}
_tallies.emit(
newOptions ?: emptyList()
)
}
}
fun canZap(): Boolean {

View File

@@ -4,7 +4,7 @@ import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalAnimationApi
@@ -40,6 +40,7 @@ import androidx.compose.material.ProgressIndicatorDefaults
import androidx.compose.material.Text
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
@@ -57,6 +58,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
@@ -114,7 +116,6 @@ import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
import kotlin.math.roundToInt
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@Composable
@@ -141,7 +142,6 @@ fun ReactionsRow(
Spacer(modifier = HalfDoubleVertSpacer)
}
@OptIn(ExperimentalTime::class)
@Composable
private fun InnerReactionRow(
baseNote: Note,
@@ -211,7 +211,7 @@ private fun InnerReactionRow(
) {
val (value, elapsed) = measureTimedValue {
Row(verticalAlignment = CenterVertically) {
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav = nav)
}
}
Log.d("Rendering Metrics", "Reaction Zaps: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
@@ -242,7 +242,7 @@ private fun LoadAndDisplayZapraiser(
wantsToSeeReactions: MutableState<Boolean>,
accountViewModel: AccountViewModel
) {
val zapraiserAmount by remember {
val zapraiserAmount by remember(baseNote) {
derivedStateOf {
baseNote.event?.zapraiserAmount() ?: 0
}
@@ -262,42 +262,26 @@ private fun LoadAndDisplayZapraiser(
}
}
@Immutable
data class ZapraiserStatus(val progress: Float, val left: String)
@Composable
fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, accountViewModel: AccountViewModel) {
val zapsState by baseNote.live().zaps.observeAsState()
var zapraiserProgress by remember { mutableStateOf(0F) }
var zapraiserLeft by remember { mutableStateOf("$zapraiserAmount") }
var zapraiserStatus by remember { mutableStateOf(ZapraiserStatus(0F, "$zapraiserAmount")) }
LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) {
zapsState?.note?.let {
val newZapAmount = accountViewModel.calculateZapAmount(it)
var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat()
if (percentage > 1) {
percentage = 1f
}
if (Math.abs(zapraiserProgress - percentage) > 0.001) {
val newZapraiserProgress = percentage
val newZapraiserLeft = if (percentage > 0.99) {
"0"
} else {
showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal())
}
if (zapraiserLeft != newZapraiserLeft) {
zapraiserLeft = newZapraiserLeft
}
if (zapraiserProgress != newZapraiserProgress) {
zapraiserProgress = newZapraiserProgress
}
zapsState?.note?.let {
accountViewModel.calculateZapraiser(baseNote) { newStatus ->
if (zapraiserStatus != newStatus) {
zapraiserStatus = newStatus
}
}
}
}
val color = if (zapraiserProgress > 0.99) {
val color = if (zapraiserStatus.progress > 0.99) {
DarkerGreen
} else {
MaterialTheme.colors.mediumImportanceLink
@@ -308,7 +292,7 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc
.fillMaxWidth()
.height(if (details) 24.dp else 4.dp),
color = color,
progress = zapraiserProgress
progress = zapraiserStatus.progress
)
if (details) {
@@ -316,14 +300,14 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc
contentAlignment = Center,
modifier = TinyBorders
) {
val totalPercentage by remember(zapraiserProgress) {
val totalPercentage by remember(zapraiserStatus) {
derivedStateOf {
"${(zapraiserProgress * 100).roundToInt()}%"
"${(zapraiserStatus.progress * 100).roundToInt()}%"
}
}
Text(
text = stringResource(id = R.string.sats_to_complete, totalPercentage, zapraiserLeft),
text = stringResource(id = R.string.sats_to_complete, totalPercentage, zapraiserStatus.left),
modifier = NoSoTinyBorders,
color = MaterialTheme.colors.placeholderText,
fontSize = Font14SP,
@@ -335,15 +319,7 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc
@Composable
private fun WatchReactionsZapsBoostsAndDisplayIfExists(baseNote: Note, content: @Composable () -> Unit) {
val hasReactions by baseNote.live().zaps.combineWith(
liveData1 = baseNote.live().boosts,
liveData2 = baseNote.live().reactions,
block = { zapsState, boostsState, reactionsState ->
zapsState?.note?.zaps?.isNotEmpty() == true ||
boostsState?.note?.boosts?.isNotEmpty() == true ||
reactionsState?.note?.reactions?.isNotEmpty() == true
}
).observeAsState(
val hasReactions by baseNote.live().hasReactions.observeAsState(
baseNote.zaps.isNotEmpty() ||
baseNote.boosts.isNotEmpty() ||
baseNote.reactions.isNotEmpty()
@@ -415,14 +391,7 @@ private fun ReactionDetailGallery(
val defaultBackgroundColor = MaterialTheme.colors.background
val backgroundColor = remember { mutableStateOf<Color>(defaultBackgroundColor) }
val hasReactions by baseNote.live().zaps.combineWith(
baseNote.live().boosts,
baseNote.live().reactions
) { zapState, boostState, reactionState ->
zapState?.note?.zaps?.isNotEmpty() ?: false ||
boostState?.note?.boosts?.isNotEmpty() ?: false ||
reactionState?.note?.reactions?.isNotEmpty() ?: false
}.distinctUntilChanged().observeAsState(
val hasReactions by baseNote.live().hasReactions.observeAsState(
baseNote.zaps.isNotEmpty() || baseNote.boosts.isNotEmpty() || baseNote.reactions.isNotEmpty()
)
@@ -443,17 +412,16 @@ private fun WatchBoostsAndRenderGallery(
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
val boostsState by baseNote.live().boosts.observeAsState()
val boostsEvents by remember(boostsState) {
derivedStateOf { baseNote.boosts.toImmutableList() }
}
val boostsEvents by baseNote.live().boostList.observeAsState()
if (boostsEvents.isNotEmpty()) {
RenderBoostGallery(
boostsEvents,
nav,
accountViewModel
)
boostsEvents?.let {
if (it.isNotEmpty()) {
RenderBoostGallery(
it,
nav,
accountViewModel
)
}
}
}
@@ -595,9 +563,7 @@ fun ReplyReaction(
@Composable
fun ReplyCounter(baseNote: Note, textColor: Color) {
val repliesState by baseNote.live().replies.map {
it.note.replies.size
}.observeAsState(baseNote.replies.size)
val repliesState by baseNote.live().replyCount.observeAsState(baseNote.replies.size)
SlidingAnimationCount(repliesState, textColor)
}
@@ -612,14 +578,14 @@ private fun SlidingAnimationCount(baseCount: MutableState<Int>, textColor: Color
private fun SlidingAnimationCount(baseCount: Int, textColor: Color) {
AnimatedContent<Int>(
targetState = baseCount,
transitionSpec = AnimatedContentScope<Int>::transitionSpec
transitionSpec = AnimatedContentTransitionScope<Int>::transitionSpec
) { count ->
TextCount(count, textColor)
}
}
@OptIn(ExperimentalAnimationApi::class)
private fun <S> AnimatedContentScope<S>.transitionSpec(): ContentTransform {
private fun <S> AnimatedContentTransitionScope<S>.transitionSpec(): ContentTransform {
return slideAnimation
}
@@ -646,7 +612,7 @@ private fun TextCount(count: Int, textColor: Color) {
private fun SlidingAnimationAmount(amount: MutableState<String>, textColor: Color) {
AnimatedContent(
targetState = amount.value,
transitionSpec = AnimatedContentScope<String>::transitionSpec
transitionSpec = AnimatedContentTransitionScope<String>::transitionSpec
) { count ->
Text(
text = count,
@@ -697,20 +663,21 @@ fun BoostReaction(
}
) {
BoostIcon(baseNote, iconSize, grayTint, accountViewModel)
}
if (wantsToBoost) {
BoostTypeChoicePopup(
baseNote,
accountViewModel,
onDismiss = {
wantsToBoost = false
},
onQuote = {
wantsToBoost = false
onQuotePress()
}
)
if (wantsToBoost) {
BoostTypeChoicePopup(
baseNote,
iconSize,
accountViewModel,
onDismiss = {
wantsToBoost = false
},
onQuote = {
wantsToBoost = false
onQuotePress()
}
)
}
}
BoostText(baseNote, grayTint)
@@ -731,9 +698,7 @@ fun BoostIcon(baseNote: Note, iconSize: Dp = Size20dp, grayTint: Color, accountV
@Composable
fun BoostText(baseNote: Note, grayTint: Color) {
val boostState by baseNote.live().boosts.map {
it.note.boosts.size
}.distinctUntilChanged().observeAsState(baseNote.boosts.size)
val boostState by baseNote.live().boostCount.observeAsState(baseNote.boosts.size)
SlidingAnimationCount(boostState, grayTint)
}
@@ -797,6 +762,7 @@ fun LikeReaction(
if (wantsToReact) {
ReactionChoicePopup(
baseNote,
iconSize,
accountViewModel,
onDismiss = {
wantsToReact = false
@@ -846,10 +812,7 @@ private fun WatchReactionTypeForNote(baseNote: Note, accountViewModel: AccountVi
val reactionsState by baseNote.live().reactions.observeAsState()
LaunchedEffect(key1 = reactionsState) {
launch(Dispatchers.Default) {
val reactionNote = reactionsState?.note?.getReactionBy(accountViewModel.userProfile())
onNewReactionType(reactionNote)
}
accountViewModel.loadReactionTo(reactionsState?.note, onNewReactionType)
}
}
@@ -884,32 +847,9 @@ private fun RenderReactionType(
@Composable
fun LikeText(baseNote: Note, grayTint: Color) {
val reactionsCount = remember(baseNote) {
mutableStateOf(baseNote.reactions.size)
}
val reactionCount by baseNote.live().reactionCount.observeAsState(0)
val scope = rememberCoroutineScope()
WatchReactionCountForNote(baseNote) { newReactionsCount ->
if (reactionsCount.value != newReactionsCount) {
scope.launch(Dispatchers.Main) {
reactionsCount.value = newReactionsCount
}
}
}
SlidingAnimationCount(reactionsCount, grayTint)
}
@Composable
private fun WatchReactionCountForNote(baseNote: Note, onNewReactionCount: (Int) -> Unit) {
val reactionsState by baseNote.live().reactions.observeAsState()
LaunchedEffect(key1 = reactionsState) {
launch(Dispatchers.Default) {
onNewReactionCount(reactionsState?.note?.countReactions() ?: 0)
}
}
SlidingAnimationCount(reactionCount, grayTint)
}
private fun likeClick(
@@ -960,11 +900,13 @@ fun ZapReaction(
grayTint: Color,
accountViewModel: AccountViewModel,
iconSize: Dp = 20.dp,
animationSize: Dp = 14.dp
animationSize: Dp = 14.dp,
nav: (String) -> Unit
) {
var wantsToZap by remember { mutableStateOf(false) }
var wantsToChangeZapAmount by remember { mutableStateOf(false) }
var wantsToSetCustomZap by remember { mutableStateOf(false) }
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -1016,7 +958,7 @@ fun ZapReaction(
onError = {
scope.launch {
zappingProgress = 0f
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
showErrorMessageDialog = it
}
},
onProgress = {
@@ -1027,6 +969,19 @@ fun ZapReaction(
)
}
if (showErrorMessageDialog != null) {
ErrorMessageDialog(
title = stringResource(id = R.string.error_dialog_zap_error),
textContent = showErrorMessageDialog ?: "",
onClickStartMessage = {
baseNote.author?.let {
nav(routeToMessage(it, showErrorMessageDialog, accountViewModel))
}
},
onDismiss = { showErrorMessageDialog = null }
)
}
if (wantsToChangeZapAmount) {
UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, accountViewModel = accountViewModel)
}
@@ -1149,9 +1104,7 @@ private fun WatchZapsForNote(baseNote: Note, accountViewModel: AccountViewModel,
val zapsState by baseNote.live().zaps.observeAsState()
LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) {
onWasZapped(accountViewModel.calculateIfNoteWasZappedByAccount(baseNote))
}
accountViewModel.calculateIfNoteWasZappedByAccount(baseNote, onWasZapped)
}
}
@@ -1181,9 +1134,7 @@ fun WatchZapAmountsForNote(baseNote: Note, accountViewModel: AccountViewModel, o
val zapsState by baseNote.live().zaps.observeAsState()
LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) {
onZapAmount(showAmount(accountViewModel.calculateZapAmount(baseNote)))
}
accountViewModel.calculateZapAmount(baseNote, onZapAmount)
}
}
@@ -1227,10 +1178,14 @@ private fun DrawViewCount(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onQuote: () -> Unit) {
private fun BoostTypeChoicePopup(baseNote: Note, iconSize: Dp, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onQuote: () -> Unit) {
val iconSizePx = with(LocalDensity.current) {
-iconSize.toPx().toInt()
}
Popup(
alignment = Alignment.BottomCenter,
offset = IntOffset(0, -50),
offset = IntOffset(0, iconSizePx),
onDismissRequest = { onDismiss() }
) {
FlowRow {
@@ -1271,6 +1226,7 @@ private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewMo
@Composable
fun ReactionChoicePopup(
baseNote: Note,
iconSize: Dp,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
onChangeAmount: () -> Unit
@@ -1282,9 +1238,13 @@ fun ReactionChoicePopup(
baseNote.reactedBy(account.userProfile()).toSet()
}
val iconSizePx = with(LocalDensity.current) {
-iconSize.toPx().toInt()
}
Popup(
alignment = Alignment.BottomCenter,
offset = IntOffset(0, -50),
offset = IntOffset(0, iconSizePx),
onDismissRequest = { onDismiss() }
) {
FlowRow(horizontalArrangement = Arrangement.Center) {

View File

@@ -155,7 +155,7 @@ fun UpdateReactionTypeDialog(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
postViewModel.cancel()
onClose()
})

View File

@@ -250,7 +250,7 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, nip47uri: String? = null, account
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
postViewModel.cancel()
onClose()
})

View File

@@ -13,18 +13,12 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
@Composable
fun UserCompose(
baseUser: User,
overallModifier: Modifier = remember {
Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
)
},
overallModifier: Modifier = StdPadding,
showDiviser: Boolean = true,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -56,7 +50,6 @@ fun UserCompose(
if (showDiviser) {
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
}

View File

@@ -57,6 +57,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.time.Instant
@@ -109,26 +111,58 @@ fun UserReactionsRow(
@Composable
private fun UserZapModel(model: UserReactionsViewModel) {
val zaps by model.zaps.collectAsState()
UserZapReaction(showAmountAxis(zaps[model.today()]))
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Size24Modifier,
tint = BitcoinOrange
)
Spacer(modifier = Modifier.width(8.dp))
UserZapReaction(model)
}
@Composable
private fun UserReactionModel(model: UserReactionsViewModel) {
val reactions by model.reactions.collectAsState()
UserLikeReaction(reactions[model.today()])
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = Size20Modifier,
tint = Color.Unspecified
)
Spacer(modifier = StdHorzSpacer)
UserLikeReaction(model)
}
@Composable
private fun UserBoostModel(model: UserReactionsViewModel) {
val boosts by model.boosts.collectAsState()
UserBoostReaction(boosts[model.today()])
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = Size24Modifier,
tint = Color.Unspecified
)
Spacer(modifier = StdHorzSpacer)
UserBoostReaction(model)
}
@Composable
private fun UserReplyModel(model: UserReactionsViewModel) {
val replies by model.replies.collectAsState()
UserReplyReaction(replies[model.today()])
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Size20Modifier,
tint = RoyalBlue
)
Spacer(modifier = StdHorzSpacer)
UserReplyReaction(model)
}
@Stable
@@ -154,6 +188,11 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
private var takenIntoAccount = setOf<HexKey>()
private val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat()
val todaysReplyCount = _replies.map { showCount(it[today()]) }.distinctUntilChanged()
val todaysBoostCount = _boosts.map { showCount(it[today()]) }.distinctUntilChanged()
val todaysReactionCount = _reactions.map { showCount(it[today()]) }.distinctUntilChanged()
val todaysZapAmount = _zaps.map { showAmountAxis(it[today()]) }.distinctUntilChanged()
fun formatDate(createAt: Long): String {
return sdf.format(
Instant.ofEpochSecond(createAt)
@@ -191,7 +230,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
takenIntoAccount.add(noteEvent.id())
}
} else if (noteEvent is LnZapEvent) {
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
if (noteEvent.isTaggedUser(currentUser)) { // the user might be sending his own receipts noteEvent.pubKey != currentUser
val netDate = formatDate(noteEvent.createdAt)
zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO)
takenIntoAccount.add(noteEvent.id())
@@ -245,7 +284,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
hasNewElements = true
}
} else if (noteEvent is LnZapEvent) {
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
if (noteEvent.isTaggedUser(currentUser)) { // && noteEvent.pubKey != currentUser User might be sending his own receipts
val netDate = formatDate(noteEvent.createdAt)
zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO)
takenIntoAccount.add(noteEvent.id())
@@ -326,6 +365,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
override fun onCleared() {
collectorJob?.cancel()
bundlerInsert.cancel()
super.onCleared()
}
@@ -338,18 +378,9 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
@Composable
fun UserReplyReaction(
replyCount: Int?
model: UserReactionsViewModel
) {
val showCounts = remember(replyCount) { showCount(replyCount) }
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Size20Modifier,
tint = RoyalBlue
)
Spacer(modifier = StdHorzSpacer)
val showCounts by model.todaysReplyCount.collectAsState("")
Text(
showCounts,
@@ -360,21 +391,12 @@ fun UserReplyReaction(
@Composable
fun UserBoostReaction(
boostCount: Int?
model: UserReactionsViewModel
) {
val showCounts = remember(boostCount) { showCount(boostCount) }
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = Size24Modifier,
tint = Color.Unspecified
)
Spacer(modifier = StdHorzSpacer)
val boosts by model.todaysBoostCount.collectAsState("")
Text(
showCounts,
boosts,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
@@ -382,21 +404,12 @@ fun UserBoostReaction(
@Composable
fun UserLikeReaction(
likeCount: Int?
model: UserReactionsViewModel
) {
val showCounts = remember(likeCount) { showCount(likeCount) }
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = Size20Modifier,
tint = Color.Unspecified
)
Spacer(modifier = StdHorzSpacer)
val reactions by model.todaysReactionCount.collectAsState("")
Text(
text = showCounts,
text = reactions,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
@@ -404,17 +417,9 @@ fun UserLikeReaction(
@Composable
fun UserZapReaction(
amount: String
model: UserReactionsViewModel
) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Size24Modifier,
tint = BitcoinOrange
)
Spacer(modifier = Modifier.width(8.dp))
val amount by model.todaysZapAmount.collectAsState("")
Text(
amount,
fontWeight = FontWeight.Bold,

View File

@@ -4,11 +4,14 @@ import android.widget.Toast
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Done
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
@@ -94,7 +97,7 @@ fun ZapCustomDialog(onClose: () -> Unit, accountViewModel: AccountViewModel, bas
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
postViewModel.cancel()
onClose()
})
@@ -111,8 +114,7 @@ fun ZapCustomDialog(onClose: () -> Unit, accountViewModel: AccountViewModel, bas
onError = {
zappingProgress = 0f
scope.launch {
Toast
.makeText(context, it, Toast.LENGTH_SHORT).show()
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
},
onProgress = {
@@ -225,3 +227,51 @@ fun ZapButton(isActive: Boolean, onPost: () -> Unit) {
Text(text = "⚡Zap ", color = Color.White)
}
}
@Composable
fun ErrorMessageDialog(
title: String,
textContent: String,
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
onClickStartMessage: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(title)
},
text = {
Text(textContent)
},
buttons = {
Row(
modifier = Modifier
.padding(all = 8.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = onClickStartMessage) {
Icon(
painter = painterResource(R.drawable.ic_dm),
contentDescription = null
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.error_dialog_talk_to_user))
}
Button(onClick = onDismiss, colors = buttonColors) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.Done,
contentDescription = null
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.error_dialog_button_ok))
}
}
}
}
)
}

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -22,6 +23,7 @@ import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.screen.ZapUserSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
@@ -89,5 +91,9 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false,
UserCompose(baseUser = zapSetCard.user, accountViewModel = accountViewModel, nav = nav)
}
}
Divider(
thickness = DividerThickness
)
}
}

View File

@@ -61,7 +61,7 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = onClose)
CloseButton(onPress = onClose)
}
Column(

View File

@@ -13,11 +13,9 @@ import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.bechToBytes
import com.vitorpamplona.quartz.encoders.hexToByteArray
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
@@ -89,8 +87,7 @@ class AccountStateViewModel(val context: Context) : ViewModel() {
} else {
_accountContent.update { AccountState.LoggedInViewOnly(account) }
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
GlobalScope.launch(Dispatchers.IO) {
ServiceManager.start(account, context)
}
GlobalScope.launch(Dispatchers.Main) {

View File

@@ -35,7 +35,6 @@ import com.vitorpamplona.amethyst.ui.note.MultiSetCompose
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class)
@Composable
@@ -102,11 +101,9 @@ private fun WatchScrollToTop(
val scrollToTop by viewModel.scrollToTop.collectAsState()
LaunchedEffect(scrollToTop) {
launch {
if (scrollToTop > 0 && viewModel.scrolltoTopPending) {
listState.scrollToItem(index = 0)
viewModel.sentToTop()
}
if (scrollToTop > 0 && viewModel.scrolltoTopPending) {
listState.scrollToItem(index = 0)
viewModel.sentToTop()
}
}
}

View File

@@ -28,14 +28,12 @@ import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.RepostEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@Stable
@@ -76,8 +74,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private var lastNotes: Set<Note>? = null
fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
viewModelScope.launch(Dispatchers.Default) {
refreshSuspended()
}
}
@@ -217,8 +214,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
private fun updateFeed(notes: ImmutableList<Card>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
viewModelScope.launch(Dispatchers.Main) {
val currentState = _feedContent.value
if (notes.isEmpty()) {
@@ -273,7 +269,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private val bundler = BundledUpdate(1000, Dispatchers.IO)
private val bundlerInsert = BundledInsert<Set<Note>>(1000, Dispatchers.IO)
@OptIn(ExperimentalTime::class)
fun invalidateData(ignoreIfDoing: Boolean = false) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
@@ -285,7 +280,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
@OptIn(ExperimentalTime::class)
fun invalidateDataAndSendToTop() {
clear()
bundler.invalidate(false) {
@@ -299,7 +293,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
@OptIn(ExperimentalTime::class)
fun checkKeysInvalidateDataAndSendToTop() {
if (lastFeedKey != localFilter.feedKey()) {
clear()
@@ -315,7 +308,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
@OptIn(ExperimentalTime::class)
fun invalidateInsertData(newItems: Set<Note>) {
bundlerInsert.invalidateList(newItems) {
val newObjects = it.flatten().toSet()
@@ -353,6 +345,8 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
override fun onCleared() {
clear()
bundlerInsert.cancel()
bundler.cancel()
collectorJob?.cancel()
super.onCleared()
}

View File

@@ -41,7 +41,6 @@ import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
import com.vitorpamplona.quartz.events.ChatroomKey
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -228,8 +227,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
}
private fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
viewModelScope.launch(Dispatchers.Default) {
refreshSuspended()
}
}
@@ -238,7 +236,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
checkNotInMainThread()
lastFeedKey = localFilter.feedKey()
val notes = localFilter.loadTop().toImmutableList()
val notes = localFilter.loadTop().distinctBy { it.idHex }.toImmutableList()
val oldNotesState = _feedContent.value
if (oldNotesState is FeedState.Loaded) {
@@ -251,8 +249,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
}
private fun updateFeed(notes: ImmutableList<Note>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
viewModelScope.launch(Dispatchers.Main) {
val currentState = _feedContent.value
if (notes.isEmpty()) {
_feedContent.update { FeedState.Empty }
@@ -304,11 +301,13 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
fun checkKeysInvalidateDataAndSendToTop() {
if (lastFeedKey != localFilter.feedKey()) {
bundler.invalidate(false) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
sendToTop()
viewModelScope.launch(Dispatchers.IO) {
bundler.invalidate(false) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
sendToTop()
}
}
}
}
@@ -340,6 +339,8 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
}
override fun onCleared() {
bundlerInsert.cancel()
bundler.cancel()
collectorJob?.cancel()
super.onCleared()
}

View File

@@ -14,7 +14,6 @@ import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -36,8 +35,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<ZapReqResponse>) : View
val feedContent = _feedContent.asStateFlow()
private fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
viewModelScope.launch(Dispatchers.Default) {
refreshSuspended()
}
}
@@ -58,8 +56,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<ZapReqResponse>) : View
}
private fun updateFeed(notes: ImmutableList<ZapReqResponse>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
viewModelScope.launch(Dispatchers.Main) {
val currentState = _feedContent.value
if (notes.isEmpty()) {
_feedContent.update { LnZapFeedState.Empty }
@@ -96,6 +93,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<ZapReqResponse>) : View
}
override fun onCleared() {
bundler.cancel()
collectorJob?.cancel()
super.onCleared()
}

View File

@@ -91,6 +91,11 @@ class RelayFeedViewModel : ViewModel() {
refreshSuspended()
}
}
override fun onCleared() {
bundler.cancel()
super.onCleared()
}
}
@OptIn(ExperimentalMaterialApi::class)

View File

@@ -16,5 +16,5 @@ class RelayPoolViewModel : ViewModel() {
val isConnected = RelayPool.live.map {
it.relays.connectedRelays() > 0
}
}.distinctUntilChanged()
}

View File

@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.saveable.rememberSaveable
@@ -61,17 +62,19 @@ fun rememberForeverLazyListState(
fun rememberForeverPagerState(
key: String,
initialFirstVisibleItemIndex: Int = 0,
initialFirstVisibleItemScrollOffset: Float = 0.0f
initialFirstVisibleItemScrollOffset: Float = 0.0f,
pageCount: () -> Int
): PagerState {
val scrollState = rememberSaveable(saver = PagerState.Saver) {
val savedValue = savedScrollStates[key]
val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset
PagerState(
savedIndex,
savedOffset
)
}
val savedValue = savedScrollStates[key]
val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset
val scrollState = rememberPagerState(
savedIndex,
savedOffset,
pageCount
)
DisposableEffect(scrollState) {
onDispose {
val lastIndex = scrollState.currentPage
@@ -79,5 +82,6 @@ fun rememberForeverPagerState(
savedScrollStates[key] = ScrollState(lastIndex, lastOffset)
}
}
return scrollState
}

View File

@@ -328,7 +328,7 @@ fun NoteMaster(
}
Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status(baseNote, remember { Modifier.weight(1f) })
ObserveDisplayNip05Status(baseNote, remember { Modifier.weight(1f) }, accountViewModel, nav)
val geo = remember { noteEvent.getGeoHash() }
if (geo != null) {

View File

@@ -18,7 +18,6 @@ import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowsFeedFilter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -64,8 +63,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel(), In
val feedContent = _feedContent.asStateFlow()
private fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
viewModelScope.launch(Dispatchers.Default) {
refreshSuspended()
}
}
@@ -87,8 +85,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel(), In
}
private fun updateFeed(notes: ImmutableList<User>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
viewModelScope.launch(Dispatchers.Main) {
val currentState = _feedContent.value
if (notes.isEmpty()) {
_feedContent.update { UserFeedState.Empty }
@@ -125,6 +122,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel(), In
}
override fun onCleared() {
bundler.cancel()
collectorJob?.cancel()
super.onCleared()
}

View File

@@ -70,7 +70,7 @@ fun AccountBackupDialog(account: Account, onClose: () -> Unit) {
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = onClose)
CloseButton(onPress = onClose)
}
Column(

View File

@@ -14,18 +14,30 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.Nip05Verifier
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.events.UserMetadata
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableSet
@@ -120,21 +132,86 @@ class AccountViewModel(val account: Account) : ViewModel() {
account.delete(account.boostsTo(note))
}
fun calculateIfNoteWasZappedByAccount(zappedNote: Note): Boolean {
return account.calculateIfNoteWasZappedByAccount(zappedNote)
fun calculateIfNoteWasZappedByAccount(zappedNote: Note, onWasZapped: (Boolean) -> Unit) {
viewModelScope.launch(Dispatchers.Default) {
onWasZapped(account.calculateIfNoteWasZappedByAccount(zappedNote))
}
}
fun calculateZapAmount(zappedNote: Note): BigDecimal {
suspend fun calculateZapAmount(zappedNote: Note): BigDecimal {
return account.calculateZappedAmount(zappedNote)
}
fun calculateZapAmount(zappedNote: Note, onZapAmount: (String) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
onZapAmount(showAmount(account.calculateZappedAmount(zappedNote)))
}
}
fun calculateZapraiser(zappedNote: Note, onZapraiserStatus: (ZapraiserStatus) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
val zapraiserAmount = zappedNote.event?.zapraiserAmount() ?: 0
val newZapAmount = calculateZapAmount(zappedNote)
var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat()
if (percentage > 1) {
percentage = 1f
}
val newZapraiserProgress = percentage
val newZapraiserLeft = if (percentage > 0.99) {
"0"
} else {
showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal())
}
onZapraiserStatus(ZapraiserStatus(newZapraiserProgress, newZapraiserLeft))
}
}
fun decryptAmountMessage(
zapRequest: Note,
zapEvent: Note?,
onNewState: (ZapAmountCommentNotification?) -> Unit
) {
viewModelScope.launch(Dispatchers.IO) {
onNewState(innerDecryptAmountMessage(zapRequest, zapEvent))
}
}
private suspend fun innerDecryptAmountMessage(
zapRequest: Note,
zapEvent: Note?
): ZapAmountCommentNotification? {
(zapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = decryptZap(zapRequest)
val amount = (zapEvent?.event as? LnZapEvent)?.amount
if (decryptedContent != null) {
val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
return ZapAmountCommentNotification(
newAuthor,
decryptedContent.content.ifBlank { null },
showAmountAxis(amount)
)
} else {
if (!zapRequest.event?.content().isNullOrBlank() || amount != null) {
return ZapAmountCommentNotification(
zapRequest.author,
zapRequest.event?.content()?.ifBlank { null },
showAmountAxis(amount)
)
}
}
}
return null
}
fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) {
viewModelScope.launch(Dispatchers.IO) {
innerZap(note, amount, pollOption, message, context, onError, onProgress, zapType)
}
}
suspend fun innerZap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) {
private suspend fun innerZap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) {
val lud16 = note.event?.zapAddress() ?: note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
if (lud16.isNullOrBlank()) {
@@ -185,9 +262,12 @@ class AccountViewModel(val account: Account) : ViewModel() {
onProgress(0f)
}
} else {
runCatching {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
ContextCompat.startActivity(context, intent, null)
} catch (e: Exception) {
onError(context.getString(R.string.lightning_wallets_not_found))
}
onProgress(0f)
}
@@ -331,24 +411,26 @@ class AccountViewModel(val account: Account) : ViewModel() {
}
fun isNoteAcceptable(note: Note, onReady: (Boolean, Boolean, ImmutableSet<Note>) -> Unit) {
val isFromLoggedIn = note.author?.pubkeyHex == userProfile().pubkeyHex
val isFromLoggedInFollow = note.author?.let { userProfile().isFollowingCached(it) } ?: true
viewModelScope.launch {
val isFromLoggedIn = note.author?.pubkeyHex == userProfile().pubkeyHex
val isFromLoggedInFollow = note.author?.let { userProfile().isFollowingCached(it) } ?: true
if (isFromLoggedIn || isFromLoggedInFollow) {
// No need to process if from trusted people
onReady(true, true, persistentSetOf())
} else {
val newCanPreview = !note.hasAnyReports()
val newIsAcceptable = account.isAcceptable(note)
if (newCanPreview && newIsAcceptable) {
// No need to process reports if nothing is wrong
if (isFromLoggedIn || isFromLoggedInFollow) {
// No need to process if from trusted people
onReady(true, true, persistentSetOf())
} else {
val newRelevantReports = account.getRelevantReports(note)
val newCanPreview = !note.hasAnyReports()
onReady(newIsAcceptable, newCanPreview, newRelevantReports.toImmutableSet())
val newIsAcceptable = account.isAcceptable(note)
if (newCanPreview && newIsAcceptable) {
// No need to process reports if nothing is wrong
onReady(true, true, persistentSetOf())
} else {
val newRelevantReports = account.getRelevantReports(note)
onReady(newIsAcceptable, newCanPreview, newRelevantReports.toImmutableSet())
}
}
}
}
@@ -378,6 +460,75 @@ class AccountViewModel(val account: Account) : ViewModel() {
}
}
fun createStatus(newStatus: String) {
viewModelScope.launch(Dispatchers.IO) {
account.createStatus(newStatus)
}
}
fun updateStatus(it: AddressableNote, newStatus: String) {
viewModelScope.launch(Dispatchers.IO) {
account.updateStatus(it, newStatus)
}
}
fun deleteStatus(it: AddressableNote) {
viewModelScope.launch(Dispatchers.IO) {
account.deleteStatus(it)
}
}
fun checkIfOnline(url: String, onResult: (Boolean) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
val isOnline = OnlineChecker.isOnline(url)
onResult(isOnline)
}
}
fun urlPreview(url: String, onResult: suspend (UrlPreviewState) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
UrlCachedPreviewer.previewInfo(url, onResult)
}
}
fun loadReactionTo(note: Note?, onNewReactionType: (String?) -> Unit) {
if (note == null) return
viewModelScope.launch(Dispatchers.Default) {
onNewReactionType(note.getReactionBy(userProfile()))
}
}
fun verifyNip05(userMetadata: UserMetadata, pubkeyHex: String, onResult: (Boolean) -> Unit) {
val nip05 = userMetadata.nip05?.ifBlank { null } ?: return
viewModelScope.launch(Dispatchers.IO) {
Nip05Verifier().verifyNip05(
nip05,
onSuccess = {
// Marks user as verified
if (it == pubkeyHex) {
userMetadata.nip05Verified = true
userMetadata.nip05LastVerificationTime = TimeUtils.now()
onResult(userMetadata.nip05Verified)
} else {
userMetadata.nip05Verified = false
userMetadata.nip05LastVerificationTime = 0
onResult(userMetadata.nip05Verified)
}
},
onError = {
userMetadata.nip05LastVerificationTime = 0
userMetadata.nip05Verified = false
onResult(userMetadata.nip05Verified)
}
)
}
}
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): AccountViewModel {
return AccountViewModel(account) as AccountViewModel

View File

@@ -50,7 +50,7 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit
Column(Modifier.fillMaxHeight()) {
Column(modifier = Modifier.padding(start = 10.dp, end = 10.dp)) {
val pagerState = rememberPagerState()
val pagerState = rememberPagerState() { 2 }
val coroutineScope = rememberCoroutineScope()
TabRow(
@@ -73,7 +73,7 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit
}
)
}
HorizontalPager(pageCount = 2, state = pagerState) { page ->
HorizontalPager(state = pagerState) { page ->
when (page) {
0 -> RefresheableFeedView(
privateFeedViewModel,

View File

@@ -105,12 +105,12 @@ import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.TimeAgo
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.note.routeFor
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
@@ -580,7 +580,7 @@ fun ChannelHeader(
)
if (expanded.value) {
LongChannelHeader(baseChannel, accountViewModel, nav)
LongChannelHeader(baseChannel = baseChannel, accountViewModel = accountViewModel, nav = nav)
}
}
@@ -623,7 +623,7 @@ fun ShowVideoStreaming(
}
url?.let {
CheckIfUrlIsOnline(url) {
CheckIfUrlIsOnline(url, accountViewModel) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = remember { Modifier.heightIn(max = 300.dp) }
@@ -722,6 +722,7 @@ fun ShortChannelHeader(
@Composable
fun LongChannelHeader(
baseChannel: Channel,
lineModifier: Modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -731,18 +732,14 @@ fun LongChannelHeader(
} ?: return
Row(
Modifier
.fillMaxWidth()
.padding(top = 10.dp)
lineModifier
) {
val summary = remember(channelState) {
channel.summary()?.ifBlank { null }
}
Column(
Modifier
.weight(1f)
.padding(start = 10.dp)
Modifier.weight(1f)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
val defaultBackground = MaterialTheme.colors.background
@@ -769,7 +766,9 @@ fun LongChannelHeader(
}
if (baseChannel is LiveActivitiesChannel) {
val hashtags = remember(baseChannel.info) { baseChannel.info?.hashtags()?.toImmutableList() ?: persistentListOf() }
val hashtags = remember(baseChannel.info) {
baseChannel.info?.hashtags()?.toImmutableList() ?: persistentListOf()
}
DisplayUncitedHashtags(hashtags, summary ?: "", nav)
}
}
@@ -784,28 +783,37 @@ fun LongChannelHeader(
}
}
Spacer(DoubleVertSpacer)
Row(
Modifier
.fillMaxWidth()
.padding(start = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
LoadNote(baseNoteHex = channel.idHex) {
it?.let {
LoadNote(baseNoteHex = channel.idHex) { loadingNote ->
loadingNote?.let { note ->
Row(
lineModifier,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(id = R.string.owner),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(55.dp)
modifier = Modifier.width(75.dp)
)
Spacer(DoubleHorzSpacer)
NoteAuthorPicture(it, nav, accountViewModel, Size25dp)
NoteAuthorPicture(note, nav, accountViewModel, Size25dp)
Spacer(DoubleHorzSpacer)
NoteUsernameDisplay(it, remember { Modifier.weight(1f) })
TimeAgo(it)
MoreOptionsButton(it, accountViewModel)
NoteUsernameDisplay(note, remember { Modifier.weight(1f) })
}
Row(
lineModifier,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(id = R.string.created_at),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(75.dp)
)
Spacer(DoubleHorzSpacer)
NormalTimeAgo(note, remember { Modifier.weight(1f) })
MoreOptionsButton(note, accountViewModel)
}
}
}
@@ -831,9 +839,7 @@ fun LongChannelHeader(
participantUsers.forEach {
Row(
Modifier
.fillMaxWidth()
.padding(start = 10.dp, top = 10.dp)
lineModifier
.clickable {
nav("User/${it.second.pubkeyHex}")
},
@@ -856,6 +862,24 @@ fun LongChannelHeader(
}
}
@Composable
fun NormalTimeAgo(baseNote: Note, modifier: Modifier) {
val nowStr = stringResource(id = R.string.now)
val time by remember(baseNote) {
derivedStateOf {
timeAgoShort(baseNote.createdAt() ?: 0, nowStr)
}
}
Text(
text = time,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
}
@Composable
private fun ShortChannelActionOptions(
channel: PublicChatChannel,
@@ -869,7 +893,7 @@ private fun ShortChannelActionOptions(
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
Spacer(modifier = StdHorzSpacer)
}
}
@@ -944,7 +968,7 @@ private fun LiveChannelActionOptions(
LikeReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
}
}

View File

@@ -57,7 +57,7 @@ fun ChatroomListScreen(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val pagerState = rememberPagerState()
val pagerState = rememberPagerState() { 2 }
val coroutineScope = rememberCoroutineScope()
var moreActionsExpanded by remember { mutableStateOf(false) }
@@ -136,7 +136,6 @@ fun ChatroomListScreen(
}
HorizontalPager(
pageCount = 2,
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->

View File

@@ -5,7 +5,6 @@ import androidx.compose.animation.Crossfade
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
@@ -102,6 +101,7 @@ import kotlinx.coroutines.withContext
@Composable
fun ChatroomScreen(
roomId: String?,
draftMessage: String? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -111,6 +111,7 @@ fun ChatroomScreen(
it?.let {
PrepareChatroomViewModels(
room = it,
draftMessage = draftMessage,
accountViewModel = accountViewModel,
nav = nav
)
@@ -121,6 +122,7 @@ fun ChatroomScreen(
@Composable
fun ChatroomScreenByAuthor(
authorPubKeyHex: String?,
draftMessage: String? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -130,6 +132,7 @@ fun ChatroomScreenByAuthor(
it?.let {
PrepareChatroomViewModels(
room = it,
draftMessage = draftMessage,
accountViewModel = accountViewModel,
nav = nav
)
@@ -167,7 +170,12 @@ fun LoadRoomByAuthor(authorPubKeyHex: String, accountViewModel: AccountViewModel
}
@Composable
fun PrepareChatroomViewModels(room: ChatroomKey, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun PrepareChatroomViewModels(
room: ChatroomKey,
draftMessage: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val feedViewModel: NostrChatroomFeedViewModel = viewModel(
key = room.hashCode().toString() + "ChatroomViewModels",
factory = NostrChatroomFeedViewModel.Factory(
@@ -194,6 +202,12 @@ fun PrepareChatroomViewModels(room: ChatroomKey, accountViewModel: AccountViewMo
}
}
if (draftMessage != null) {
LaunchedEffect(key1 = draftMessage) {
newPostModel.message = TextFieldValue(draftMessage)
}
}
ChatroomScreen(
room = room,
feedViewModel = feedViewModel,
@@ -505,7 +519,7 @@ fun ChatroomHeader(
Column(modifier = Modifier.padding(start = 10.dp)) {
UsernameDisplay(baseUser)
ObserveDisplayNip05Status(baseUser)
ObserveDisplayNip05Status(baseUser, accountViewModel = accountViewModel, nav = nav)
}
}
}
@@ -550,7 +564,7 @@ fun GroupChatroomHeader(
}
if (expanded.value) {
LongRoomHeader(room, accountViewModel, nav)
LongRoomHeader(room = room, accountViewModel = accountViewModel, nav = nav)
}
}
@@ -617,7 +631,7 @@ fun NewSubjectView(onClose: () -> Unit, accountViewModel: AccountViewModel, room
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
onClose()
})
@@ -687,15 +701,18 @@ fun NewSubjectView(onClose: () -> Unit, accountViewModel: AccountViewModel, room
}
@Composable
fun LongRoomHeader(room: ChatroomKey, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun LongRoomHeader(
room: ChatroomKey,
lineModifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val list = remember(room) {
room.users.toPersistentList()
}
Row(
modifier = Modifier
.padding(top = 10.dp)
.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
@@ -712,16 +729,18 @@ fun LongRoomHeader(room: ChatroomKey, accountViewModel: AccountViewModel, nav: (
}
LazyColumn(
modifier = Modifier.fillMaxHeight(),
contentPadding = PaddingValues(
bottom = 10.dp
),
modifier = Modifier,
state = rememberLazyListState()
) {
itemsIndexed(list, key = { _, item -> item }) { _, item ->
LoadUser(baseUserHex = item) {
if (it != null) {
UserCompose(baseUser = it, accountViewModel = accountViewModel, nav = nav)
UserCompose(
baseUser = it,
overallModifier = lineModifier,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}

View File

@@ -56,7 +56,7 @@ fun ConnectOrbotDialog(onClose: () -> Unit, onPost: () -> Unit, portNumber: Muta
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
CloseButton(onCancel = {
CloseButton(onPress = {
onClose()
})

View File

@@ -69,7 +69,17 @@ fun DiscoverScreen(
) {
val lifeCycleOwner = LocalLifecycleOwner.current
val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN)
val tabs by remember(discoveryLiveFeedViewModel, discoveryCommunityFeedViewModel, discoveryChatFeedViewModel) {
mutableStateOf(
listOf(
TabItem(R.string.discover_live, discoveryLiveFeedViewModel, Route.Discover.base + "Live", ScrollStateKeys.DISCOVER_LIVE, LiveActivitiesEvent.kind),
TabItem(R.string.discover_community, discoveryCommunityFeedViewModel, Route.Discover.base + "Community", ScrollStateKeys.DISCOVER_COMMUNITY, CommunityDefinitionEvent.kind),
TabItem(R.string.discover_chat, discoveryChatFeedViewModel, Route.Discover.base + "Chats", ScrollStateKeys.DISCOVER_CHATS, ChannelCreateEvent.kind)
).toImmutableList()
)
}
val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN) { tabs.size }
WatchAccountForDiscoveryScreen(
discoveryLiveFeedViewModel = discoveryLiveFeedViewModel,
@@ -92,16 +102,6 @@ fun DiscoverScreen(
}
}
val tabs by remember(discoveryLiveFeedViewModel, discoveryCommunityFeedViewModel, discoveryChatFeedViewModel) {
mutableStateOf(
listOf(
TabItem(R.string.discover_live, discoveryLiveFeedViewModel, Route.Discover.base + "Live", ScrollStateKeys.DISCOVER_LIVE, LiveActivitiesEvent.kind),
TabItem(R.string.discover_community, discoveryCommunityFeedViewModel, Route.Discover.base + "Community", ScrollStateKeys.DISCOVER_COMMUNITY, CommunityDefinitionEvent.kind),
TabItem(R.string.discover_chat, discoveryChatFeedViewModel, Route.Discover.base + "Chats", ScrollStateKeys.DISCOVER_CHATS, ChannelCreateEvent.kind)
).toImmutableList()
)
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
@@ -139,7 +139,7 @@ private fun DiscoverPages(
}
}
HorizontalPager(pageCount = 3, state = pagerState) { page ->
HorizontalPager(state = pagerState) { page ->
RefresheableView(tabs[page].viewModel, true) {
SaveableFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { listState ->
RenderDiscoverFeed(

View File

@@ -78,7 +78,7 @@ fun HiddenUsersScreen(
Column(Modifier.fillMaxHeight()) {
Column(modifier = Modifier.padding(start = 10.dp, end = 10.dp)) {
val pagerState = rememberPagerState()
val pagerState = rememberPagerState() { 2 }
val coroutineScope = rememberCoroutineScope()
var warnAboutReports by remember { mutableStateOf(accountViewModel.account.warnAboutPostsWithReports) }
var filterSpam by remember { mutableStateOf(accountViewModel.account.filterSpamFromStrangers) }
@@ -129,7 +129,7 @@ fun HiddenUsersScreen(
}
)
}
HorizontalPager(pageCount = 2, state = pagerState) { page ->
HorizontalPager(state = pagerState) { page ->
when (page) {
0 -> RefreshingUserFeedView(hiddenFeedViewModel, accountViewModel, nav)
1 -> RefreshingUserFeedView(spammerFeedViewModel, accountViewModel, nav)

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