Compare commits

...

70 Commits

Author SHA1 Message Date
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
Vitor Pamplona
70c4c69bbf v0.74.2 2023-08-19 12:40:26 -04:00
Vitor Pamplona
5597189ea5 Allowing null values as a result of the mapper 2023-08-19 12:38:44 -04:00
Vitor Pamplona
8884adcefb Testing new build to find bug on Account loading: 0.74.1 2023-08-19 11:58:08 -04:00
128 changed files with 2945 additions and 1397 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-315)
- [ ] 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 273
versionName "0.74.0"
versionCode 281
versionName "0.75.2"
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'

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 {
@@ -299,7 +303,8 @@ object LocalPreferences {
val followingCommunities = getStringSet(PrefKeys.FOLLOWING_COMMUNITIES, null) ?: setOf()
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
val localRelays = getString(PrefKeys.RELAYS, "[]")?.let {
Event.mapper.readValue<Set<RelaySetupInfo>>(it)
println("LocalRelays: $it")
Event.mapper.readValue<Set<RelaySetupInfo>?>(it)
} ?: setOf<RelaySetupInfo>()
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
@@ -310,24 +315,24 @@ object LocalPreferences {
val defaultDiscoveryFollowList = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val zapAmountChoices = getString(PrefKeys.ZAP_AMOUNTS, "[]")?.let {
Event.mapper.readValue<List<Long>>(it)
Event.mapper.readValue<List<Long>?>(it)
}?.ifEmpty { listOf(500L, 1000L, 5000L) } ?: listOf(500L, 1000L, 5000L)
val reactionChoices = getString(PrefKeys.REACTION_CHOICES, "[]")?.let {
Event.mapper.readValue<List<String>>(it)
Event.mapper.readValue<List<String>?>(it)
}?.ifEmpty { listOf("+") } ?: listOf("+")
val defaultZapType = getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName ->
LnZapEvent.ZapType.values().first { it.name == serverName }
LnZapEvent.ZapType.values().firstOrNull() { it.name == serverName }
} ?: LnZapEvent.ZapType.PUBLIC
val defaultFileServer = getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName ->
ServersAvailable.values().first { it.name == serverName }
ServersAvailable.values().firstOrNull() { it.name == serverName }
} ?: ServersAvailable.NOSTR_BUILD
val zapPaymentRequestServer = try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
Event.mapper.readValue<Nip47URI>(it)
Event.mapper.readValue<Nip47URI?>(it)
}
} catch (e: Throwable) {
e.printStackTrace()
@@ -345,7 +350,7 @@ object LocalPreferences {
val languagePreferences = try {
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
Event.mapper.readValue<Map<String, String>>(it)
Event.mapper.readValue<Map<String, String>?>(it)
} ?: mapOf()
} catch (e: Throwable) {
e.printStackTrace()
@@ -369,7 +374,7 @@ object LocalPreferences {
val lastReadPerRoute = try {
getString(PrefKeys.LAST_READ_PER_ROUTE, null)?.let {
Event.mapper.readValue<Map<String, Long>>(it)
Event.mapper.readValue<Map<String, Long>?>(it)
} ?: mapOf()
} catch (e: Throwable) {
e.printStackTrace()

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 {
@@ -117,11 +118,15 @@ object ServiceManager {
fun cleanUp() {
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,25 @@ 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 removeEmojiPack(usersEmojiList: Note, emojiList: Note) {
if (!isWriteable()) return
@@ -1610,8 +1663,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

@@ -14,7 +14,10 @@ import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.*
import com.vitorpamplona.quartz.utils.TimeUtils
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 +207,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 +312,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 +336,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 +361,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?.statuses?.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 +383,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 +413,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 +440,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 +540,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 +562,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)
@@ -1301,7 +1112,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 +1120,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 +1149,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 +1188,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 +1212,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 +1228,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 +1276,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 +1290,48 @@ 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 now = TimeUtils.now()
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 +1387,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 +1446,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 +1492,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 +1509,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.distinctUntilChanged
import androidx.lifecycle.map
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
@@ -72,7 +75,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 {
@@ -129,12 +147,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
@@ -236,8 +256,7 @@ open class Note(val idHex: String) {
zaps = zaps.minus(note)
liveSet?.zaps?.invalidateData()
} else if (zaps.containsValue(note)) {
val toRemove = zaps.filterValues { it == note }
zaps = zaps.minus(toRemove.keys)
zaps = zaps.filterValues { it != note }
liveSet?.zaps?.invalidateData()
}
}
@@ -613,6 +632,7 @@ open class Note(val idHex: String) {
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet?.destroy()
liveSet = null
}
}
@@ -634,10 +654,18 @@ open class Note(val idHex: String) {
}
}
@Stable
class NoteLiveSet(u: Note) {
// Observers line up here.
val metadata: NoteLiveData = NoteLiveData(u)
val authorChanges = metadata.map {
it.note.author
}
val hasEvent = metadata.map {
it.note.event != null
}.distinctUntilChanged()
val reactions: NoteLiveData = NoteLiveData(u)
val boosts: NoteLiveData = NoteLiveData(u)
val replies: NoteLiveData = NoteLiveData(u)
@@ -645,6 +673,20 @@ class NoteLiveSet(u: Note) {
val relays: NoteLiveData = NoteLiveData(u)
val zaps: NoteLiveData = NoteLiveData(u)
val hasReactions = zaps.combineWith(boosts, reactions) { zapState, boostState, reactionState ->
zapState?.note?.zaps?.isNotEmpty() ?: false ||
boostState?.note?.boosts?.isNotEmpty() ?: false ||
reactionState?.note?.reactions?.isNotEmpty() ?: false
}.distinctUntilChanged()
val replyCount = replies.map {
it.note.replies.size
}.distinctUntilChanged()
val boostCount = boosts.map {
it.note.boosts.size
}.distinctUntilChanged()
fun isInUse(): Boolean {
return metadata.hasObservers() ||
reactions.hasObservers() ||
@@ -654,12 +696,26 @@ class NoteLiveSet(u: Note) {
relays.hasObservers() ||
zaps.hasObservers()
}
fun destroy() {
metadata.destroy()
reactions.destroy()
boosts.destroy()
replies.destroy()
reports.destroy()
relays.destroy()
zaps.destroy()
}
}
class NoteLiveData(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

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,8 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
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
@@ -372,11 +374,13 @@ class User(val pubkeyHex: String) {
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet?.destroy()
liveSet = null
}
}
}
@Stable
class UserLiveSet(u: User) {
// UI Observers line up here.
val follows: UserLiveData = UserLiveData(u)
@@ -388,6 +392,19 @@ class UserLiveSet(u: User) {
val metadata: UserLiveData = UserLiveData(u)
val zaps: UserLiveData = UserLiveData(u)
val bookmarks: UserLiveData = UserLiveData(u)
val statuses: UserLiveData = UserLiveData(u)
val profilePictureChanges = metadata.map {
it.user.profilePicture()
}.distinctUntilChanged()
val nip05Changes = metadata.map {
it.user.nip05()
}.distinctUntilChanged()
val userMetadataInfo = metadata.map {
it.user.info
}.distinctUntilChanged()
fun isInUse(): Boolean {
return follows.hasObservers() ||
@@ -398,8 +415,22 @@ class UserLiveSet(u: User) {
relayInfo.hasObservers() ||
metadata.hasObservers() ||
zaps.hasObservers() ||
statuses.hasObservers() ||
bookmarks.hasObservers()
}
fun destroy() {
follows.destroy()
followers.destroy()
reports.destroy()
messages.destroy()
relays.destroy()
relayInfo.destroy()
metadata.destroy()
zaps.destroy()
bookmarks.destroy()
statuses.destroy()
}
}
@Immutable
@@ -470,6 +501,10 @@ class UserLiveData(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()

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() {
@@ -116,8 +121,7 @@ abstract class NostrDataSource(val debugName: String) {
}
fun resetFilters() {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
scope.launch(Dispatchers.IO) {
resetFiltersSuspend()
}
}

View File

@@ -7,6 +7,7 @@ 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>()
@@ -26,6 +27,21 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
}
}
fun createUserStatusFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
return usersToWatch.map {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(StatusEvent.kind),
authors = listOf(it.pubkeyHex),
since = it.latestEOSEs
)
)
}
}
fun createUserReportFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
@@ -59,8 +75,8 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
val userChannelOnce = requestNewChannel()
override fun updateChannelFilters() {
userChannel.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null }
userChannelOnce.typedFilters = listOfNotNull(createUserReportFilter()).flatten().ifEmpty { null }
userChannel.typedFilters = listOfNotNull(createUserReportFilter(), createUserStatusFilter()).flatten().ifEmpty { null }
userChannelOnce.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null }
}
fun add(user: User) {

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("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 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

@@ -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,7 +97,9 @@ class MainActivity : AppCompatActivity() {
ServiceManager.start(this@MainActivity)
}
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
GlobalScope.launch(Dispatchers.IO) {
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
}
}
override fun onPause() {

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

@@ -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
}
}
}
}
@@ -887,19 +914,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 +1186,10 @@ private fun MarkAsSensitive(
}
@Composable
fun CloseButton(onCancel: () -> Unit) {
fun CloseButton(onPress: () -> Unit) {
Button(
onClick = {
onCancel()
onPress()
},
shape = ButtonBorder,
colors = ButtonDefaults

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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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,7 @@ 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.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Divider
@@ -27,13 +28,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 +52,15 @@ 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.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 +73,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 +109,7 @@ fun DrawerContent(
.padding(horizontal = 25.dp)
.padding(top = 100.dp),
scaffoldState,
accountViewModel,
nav
)
Divider(
@@ -123,6 +136,7 @@ fun ProfileContent(
baseAccountUser: User,
modifier: Modifier = Modifier,
scaffoldState: ScaffoldState,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val coroutineScope = rememberCoroutineScope()
@@ -215,9 +229,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 +250,132 @@ 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(
capitalization = KeyboardCapitalization.Sentences
),
singleLine = true,
trailingIcon = {
if (hasChanged) {
UserStatusSendButton() {
scope.launch(Dispatchers.IO) {
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(
capitalization = KeyboardCapitalization.Sentences
),
singleLine = true,
trailingIcon = {
if (hasChanged) {
UserStatusSendButton() {
scope.launch(Dispatchers.IO) {
accountViewModel.updateStatus(it, thisStatus.value)
focusManager.clearFocus(true)
}
}
} else {
UserStatusDeleteButton() {
scope.launch(Dispatchers.IO) {
accountViewModel.updateStatus(it, "")
accountViewModel.delete(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 +629,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

@@ -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
@@ -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
)
}
}
}

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,8 +41,6 @@ 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
@@ -54,6 +53,7 @@ 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
@@ -159,6 +159,10 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, showHi
NoteDropDownMenu(baseNote, popupExpanded, accountViewModel)
}
Divider(
thickness = DividerThickness
)
}
}
@@ -573,9 +577,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 +587,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

@@ -5,38 +5,58 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.Size18Modifier
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.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds
@Composable
fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String): MutableState<Boolean?> {
@@ -93,47 +113,193 @@ 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)
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 + 1))
}
}
}
}
@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 = Size18Modifier,
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
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) {
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) {
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 +323,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 +335,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
)

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
@@ -202,7 +205,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 +223,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 +241,18 @@ fun NoteCompose(
accountViewModel = accountViewModel,
nav = nav
)
} else {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
BlankNote(
remember {
modifier.combinedClickable(
onClick = { },
onLongClick = showPopup
)
},
isBoostedNote || isQuotedNote
)
}
}
}
}
@@ -462,45 +462,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 +559,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 +576,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 +596,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 +660,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 +674,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 +697,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 +716,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
@@ -897,7 +941,6 @@ fun ClickableNote(
}
}
@OptIn(ExperimentalTime::class)
@Composable
fun InnerNoteWithReactions(
baseNote: Note,
@@ -1009,7 +1052,7 @@ private fun NoteBody(
)
}
Spacer(modifier = HalfVertSpacer)
Spacer(modifier = Modifier.height(3.dp))
if (!makeItShort) {
ReplyRow(
@@ -1139,6 +1182,14 @@ private fun RenderNoteRow(
)
}
is FileHeaderEvent -> {
FileHeaderDisplay(baseNote, true, accountViewModel)
}
is FileStorageHeaderEvent -> {
FileStorageHeaderDisplay(baseNote, true, accountViewModel)
}
is CommunityPostApprovalEvent -> {
RenderPostApproval(
baseNote,
@@ -2436,7 +2487,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 +2506,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 +3269,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 +3305,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 +3325,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 +3372,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 +3501,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

@@ -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
@@ -57,6 +57,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 +115,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 +141,6 @@ fun ReactionsRow(
Spacer(modifier = HalfDoubleVertSpacer)
}
@OptIn(ExperimentalTime::class)
@Composable
private fun InnerReactionRow(
baseNote: Note,
@@ -415,14 +414,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()
)
@@ -595,9 +587,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 +602,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 +636,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 +687,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 +722,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 +786,7 @@ fun LikeReaction(
if (wantsToReact) {
ReactionChoicePopup(
baseNote,
iconSize,
accountViewModel,
onDismiss = {
wantsToReact = false
@@ -1227,10 +1217,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 +1265,7 @@ private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewMo
@Composable
fun ReactionChoicePopup(
baseNote: Note,
iconSize: Dp,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
onChangeAmount: () -> Unit
@@ -1282,9 +1277,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

@@ -94,7 +94,7 @@ fun ZapCustomDialog(onClose: () -> Unit, accountViewModel: AccountViewModel, bas
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
postViewModel.cancel()
onClose()
})

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

@@ -28,7 +28,6 @@ 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
@@ -76,8 +75,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 +215,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()) {
@@ -353,6 +350,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()
}
}
@@ -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 }
@@ -340,6 +337,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,6 +14,7 @@ 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.Note
import com.vitorpamplona.amethyst.model.User
@@ -378,6 +379,14 @@ class AccountViewModel(val account: Account) : ViewModel() {
}
}
fun createStatus(newStatus: String) {
account.createStatus(newStatus)
}
fun updateStatus(it: AddressableNote, newStatus: String) {
account.updateStatus(it, newStatus)
}
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)
}
}
@@ -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,

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
@@ -505,7 +504,7 @@ fun ChatroomHeader(
Column(modifier = Modifier.padding(start = 10.dp)) {
UsernameDisplay(baseUser)
ObserveDisplayNip05Status(baseUser)
ObserveDisplayNip05Status(baseUser, accountViewModel = accountViewModel, nav = nav)
}
}
}
@@ -550,7 +549,7 @@ fun GroupChatroomHeader(
}
if (expanded.value) {
LongRoomHeader(room, accountViewModel, nav)
LongRoomHeader(room = room, accountViewModel = accountViewModel, nav = nav)
}
}
@@ -617,7 +616,7 @@ fun NewSubjectView(onClose: () -> Unit, accountViewModel: AccountViewModel, room
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
onClose()
})
@@ -687,15 +686,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 +714,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)

View File

@@ -56,7 +56,7 @@ fun HomeScreen(
) {
var wantsToAddNip47 by remember(nip47) { mutableStateOf(nip47) }
val pagerState = rememberForeverPagerState(key = PagerStateKeys.HOME_SCREEN)
val pagerState = rememberForeverPagerState(key = PagerStateKeys.HOME_SCREEN) { 2 }
WatchAccountForHomeScreen(homeFeedViewModel, repliesFeedViewModel, accountViewModel)
@@ -124,7 +124,7 @@ private fun HomePages(
}
}
HorizontalPager(pageCount = 2, state = pagerState) { page ->
HorizontalPager(state = pagerState) { page ->
RefresheableFeedView(
viewModel = tabs[page].viewModel,
routeForLastRead = tabs[page].routeForLastRead,

View File

@@ -25,6 +25,8 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChatroomKeyable
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -75,9 +77,17 @@ fun LoadRedirectScreen(baseNote: Note, accountViewModel: AccountViewModel, nav:
LaunchedEffect(key1 = noteState) {
scope.launch {
val note = noteState?.note
val event = note?.event
val channelHex = note?.channelHex()
val note = noteState?.note ?: return@launch
var event = note.event
val channelHex = note.channelHex()
if (event is GiftWrapEvent) {
event = accountViewModel.unwrap(event)
}
if (event is SealedGossipEvent) {
event = accountViewModel.unseal(event)
}
if (event == null) {
// stay here, loading
@@ -85,7 +95,7 @@ fun LoadRedirectScreen(baseNote: Note, accountViewModel: AccountViewModel, nav:
nav("Channel/${note.idHex}")
} else if (event is ChatroomKeyable) {
note.author?.let {
val withKey = (note.event as ChatroomKeyable)
val withKey = (event as ChatroomKeyable)
.chatroomKey(accountViewModel.userProfile().pubkeyHex)
withContext(Dispatchers.IO) {

View File

@@ -217,7 +217,7 @@ fun MainScreen(
}
},
floatingActionButton = {
FloatingButtons(navState, accountViewModel, accountStateViewModel, nav)
FloatingButtons(navState, accountViewModel, accountStateViewModel, nav, navBottomRow)
},
scaffoldState = scaffoldState
) {
@@ -247,7 +247,8 @@ fun FloatingButtons(
navEntryState: State<NavBackStackEntry?>,
accountViewModel: AccountViewModel,
accountStateViewModel: AccountStateViewModel,
nav: (String) -> Unit
nav: (String) -> Unit,
navScrollToTop: (Route, Boolean) -> Unit
) {
val accountState by accountStateViewModel.accountContent.collectAsState()
@@ -260,7 +261,7 @@ fun FloatingButtons(
// Does nothing.
}
is AccountState.LoggedIn -> {
WritePermissionButtons(navEntryState, accountViewModel, nav)
WritePermissionButtons(navEntryState, accountViewModel, nav, navScrollToTop)
}
}
}
@@ -270,7 +271,8 @@ fun FloatingButtons(
private fun WritePermissionButtons(
navEntryState: State<NavBackStackEntry?>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
nav: (String) -> Unit,
navScrollToTop: (Route, Boolean) -> Unit
) {
val currentRoute by remember(navEntryState.value) {
derivedStateOf {
@@ -281,7 +283,7 @@ private fun WritePermissionButtons(
when (currentRoute) {
Route.Home.base -> NewNoteButton(accountViewModel, nav)
Route.Message.base -> ChannelFabColumn(accountViewModel, nav)
Route.Video.base -> NewImageButton(accountViewModel, nav)
Route.Video.base -> NewImageButton(accountViewModel, nav, navScrollToTop)
Route.Community.base -> {
val communityId by remember(navEntryState.value) {
derivedStateOf {

View File

@@ -113,12 +113,12 @@ var hasAlreadyAskedNotificationPermissions = false
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CheckifItNeedsToRequestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasAlreadyAskedNotificationPermissions) {
val notificationPermissionState = rememberPermissionState(
Manifest.permission.POST_NOTIFICATIONS
)
if (!notificationPermissionState.status.isGranted && !hasAlreadyAskedNotificationPermissions) {
if (!notificationPermissionState.status.isGranted) {
hasAlreadyAskedNotificationPermissions = true
// This will pause the APP, including the connection with relays.

View File

@@ -308,7 +308,6 @@ private fun RenderSurface(
columnSize = it
}
) {
val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope()
val scrollState = rememberScrollState()
@@ -347,7 +346,6 @@ private fun RenderSurface(
) {
RenderScreen(
baseUser,
pagerState,
tabRowModifier,
pagerModifier,
threadsViewModel,
@@ -370,7 +368,6 @@ private fun RenderSurface(
@OptIn(ExperimentalFoundationApi::class)
private fun RenderScreen(
baseUser: User,
pagerState: PagerState,
tabRowModifier: Modifier,
pagerModifier: Modifier,
threadsViewModel: NostrUserProfileNewThreadsFeedViewModel,
@@ -384,6 +381,8 @@ private fun RenderScreen(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val pagerState = rememberPagerState { 9 }
Column() {
ProfileHeader(baseUser, appRecommendations, nav, accountViewModel)
ScrollableTabRow(
@@ -395,7 +394,6 @@ private fun RenderScreen(
CreateAndRenderTabs(baseUser, pagerState)
}
HorizontalPager(
pageCount = 9,
state = pagerState,
modifier = pagerModifier
) { page ->
@@ -685,9 +683,6 @@ private fun ProfileHeader(
) {
MessageButton(baseUser, accountViewModel, nav)
// No need for this button anymore
// NPubCopyButton(baseUser)
ProfileActions(baseUser, accountViewModel)
}
}
@@ -1092,7 +1087,7 @@ private fun DisplayAppRecommendations(
Text(stringResource(id = R.string.recommended_apps))
FlowRow(
verticalAlignment = Alignment.CenterVertically,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(vertical = 5.dp)
) {
state.feed.value.forEach { app ->
@@ -1177,7 +1172,7 @@ private fun RenderBadgeList(
nav: (String) -> Unit
) {
FlowRow(
verticalAlignment = Alignment.CenterVertically,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(vertical = 5.dp)
) {
list.forEach { badgeAwardEvent ->

View File

@@ -195,6 +195,11 @@ class SearchBarViewModel(val account: Account) : ViewModel() {
}
}
override fun onCleared() {
bundler.cancel()
super.onCleared()
}
fun isSearchingFun() = searchValue.isNotBlank()
class Factory(val account: Account) : ViewModelProvider.Factory {

View File

@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
@@ -45,6 +46,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.note.BoostReaction
import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.HiddenNote
@@ -54,15 +56,18 @@ import com.vitorpamplona.amethyst.ui.note.NoteComposeReportState
import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.RenderRelay
import com.vitorpamplona.amethyst.ui.note.ReplyReaction
import com.vitorpamplona.amethyst.ui.note.ViewCountReaction
import com.vitorpamplona.amethyst.ui.note.WatchForReports
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.note.routeFor
import com.vitorpamplona.amethyst.ui.screen.FeedEmpty
import com.vitorpamplona.amethyst.ui.screen.FeedError
import com.vitorpamplona.amethyst.ui.screen.FeedState
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.LoadingFeed
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableView
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
import com.vitorpamplona.amethyst.ui.theme.Size35dp
@@ -131,15 +136,7 @@ private fun SaveableFeedState(
nav: (String) -> Unit,
scrollStateKey: String? = null
) {
val pagerState = if (scrollStateKey != null) {
rememberForeverPagerState(scrollStateKey)
} else {
remember { PagerState() }
}
WatchScrollToTop(videoFeedView, pagerState)
RenderPage(videoFeedView, accountViewModel, pagerState, nav)
RenderPage(videoFeedView, accountViewModel, scrollStateKey, nav)
}
@OptIn(ExperimentalFoundationApi::class)
@@ -163,7 +160,7 @@ public fun WatchScrollToTop(
fun RenderPage(
videoFeedView: NostrVideoFeedViewModel,
accountViewModel: AccountViewModel,
pagerState: PagerState,
pagerStateKey: String?,
nav: (String) -> Unit
) {
val feedState by videoFeedView.feedContent.collectAsState()
@@ -184,12 +181,7 @@ fun RenderPage(
}
is FeedState.Loaded -> {
SlidingCarousel(
state.feed,
pagerState,
accountViewModel,
nav
)
LoadedState(state, pagerStateKey, videoFeedView, accountViewModel, nav)
}
is FeedState.Loading -> {
@@ -201,6 +193,33 @@ fun RenderPage(
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun LoadedState(
state: FeedState.Loaded,
pagerStateKey: String?,
videoFeedView: NostrVideoFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val pagerState = if (pagerStateKey != null) {
rememberForeverPagerState(pagerStateKey) { state.feed.value.size }
} else {
rememberPagerState { state.feed.value.size }
}
WatchScrollToTop(videoFeedView, pagerState)
RefresheableView(viewModel = videoFeedView) {
SlidingCarousel(
state.feed,
pagerState,
accountViewModel,
nav
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SlidingCarousel(
@@ -210,7 +229,6 @@ fun SlidingCarousel(
nav: (String) -> Unit
) {
VerticalPager(
pageCount = feed.value.size,
state = pagerState,
beyondBoundsPageCount = 1,
modifier = Modifier.fillMaxSize(1f),
@@ -356,7 +374,9 @@ private fun RenderAuthorInformation(
Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status(
remember { note.author!! },
remember { Modifier.weight(1f) }
remember { Modifier.weight(1f) },
accountViewModel,
nav = nav
)
}
Row(
@@ -434,13 +454,18 @@ fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, nav: (St
Spacer(modifier = Modifier.height(8.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 75.dp, end = 20.dp)) {
/*
ReplyReaction(baseNote, accountViewModel, iconSize = 40.dp) {
wantsToReplyTo = baseNote
val scope = rememberCoroutineScope()
ReplyReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp) {
scope.launch {
routeFor(
baseNote,
accountViewModel.userProfile()
)?.let { nav(it) }
}
}
BoostReaction(baseNote, accountViewModel, iconSize = 40.dp) {
BoostReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp) {
wantsToQuote = baseNote
}*/
}
LikeReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, nav, iconSize = 40.dp, heartSize = Size35dp, 28.sp)
ZapReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp, animationSize = Size35dp)
ViewCountReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, barChartSize = 39.dp, viewCountColorFilter = MaterialTheme.colors.onBackgroundColorFilter)

View File

@@ -126,7 +126,7 @@ val ChatHeadlineBorders = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.
val VolumeBottomIconSize = Modifier.size(70.dp).padding(10.dp)
val PinBottomIconSize = Modifier.size(70.dp).padding(10.dp)
val NIP05IconSize = Modifier.size(14.dp).padding(top = 1.dp, start = 1.dp, end = 1.dp)
val NIP05IconSize = Modifier.size(13.dp).padding(top = 1.dp, start = 1.dp, end = 1.dp)
val EditFieldModifier = Modifier
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp)

View File

@@ -0,0 +1,552 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name_release" translatable="false">Amethyst</string>
<string name="app_name_debug" translatable="false">Amethyst Debug</string>
<string name="point_to_the_qr_code">สแกน Qr Code</string><!--ไม่รู้ว่าอยู่ตรงไหน-->
<string name="show_qr">โชว์ QR</string>
<string name="profile_image">รูปโปรไฟล์</string>
<string name="scan_qr">แสกน QR</string>
<string name="show_anyway">แสดง</string>
<string name="post_was_flagged_as_inappropriate_by">โพสต์ถูกรายงานว่าไม่เหมาะสมโดย</string>
<string name="post_not_found">ไม่พบโพสต์นี้</string>
<string name="channel_image">รูปของ channel</string><!--ไม่รู้ว่าอยู่ตรงไหน-->
<string name="referenced_event_not_found">ไม่พบ event ที่อ้างอิง</string>
<string name="could_not_decrypt_the_message">ไม่สามารถเข้ารหัสข้อความได้</string>
<string name="group_picture">รูปภาพกลุ่ม</string><!--ไม่รู้ว่าอยู่ตรงไหน-->
<string name="explicit_content">เนื้อหาที่มีความรุนแรง</string>
<string name="spam">สแปม</string>
<string name="impersonation">เลียนแบบ</string><!--ไม่รู้ว่าอยู่ตรงไหน-->
<string name="illegal_behavior">พฤติกรรมที่ผิดกฎหมาย</string><!--ไม่รู้ว่าอยู่ตรงไหน-->
<string name="unknown">ไม่ทราบ</string>
<string name="relay_icon">รีเลย์ไอคอน</string>
<string name="unknown_author">ไม่ทราบผู้เขียน</string>
<string name="copy_text">คัดลอกข่้อความ</string>
<string name="copy_user_pubkey">คัดลอก ID ผู้เขียน</string>
<string name="copy_note_id">คัดลอก ID โน้ต</string>
<string name="broadcast">เพยแพร่</string>
<string name="request_deletion">ส่งคำขอให้ลบ</string>
<string name="block_report">บล๊อก / รายงาน</string>
<string name="block_hide_user"><![CDATA[บล๊อก & ซ่อนผู้ใช้นี้]]></string>
<string name="report_spam_scam">รายงาน: สแปม / หลอกลวง</string>
<string name="report_impersonation">รายงาน: การเลียนแบบ</string>
<string name="report_explicit_content">รายงาน: เนื้อหารุนแรง</string>
<string name="report_illegal_behaviour">รายงาน: พฤติกรรมที่ผิดกฎหมาย</string>
<string name="login_with_a_private_key_to_be_able_to_reply">เข้าสู่ระบบด้วย private key เพื่อตอบกลับ</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">เข้าสู่ระบบด้วย private key เพื่อบูสโพสต์</string>
<string name="login_with_a_private_key_to_like_posts">เข้าสู่ระบบด้วย private key เพื่อถูกใจโพสต์</string>
<string name="no_zap_amount_setup_long_press_to_change">ไม่มีการตั้งค่าจำนวนเพื่อ zap กดค้างเพื่อตั้งค่า</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">เข้าสู่ระบบด้วย private key เพื่อส่ง zaps</string>
<string name="login_with_a_private_key_to_be_able_to_follow">เข้าสู่ระบบด้วย private key เพื่อติดตาม</string>
<string name="login_with_a_private_key_to_be_able_to_unfollow">เข้าสู่ระบบด้วย private key เพื่อเลิกติดตาม</string>
<string name="zaps">Zaps</string>
<string name="view_count">ยอดเข้าชม</string>
<string name="boost">Boost</string>
<string name="boosted">boosted</string>
<string name="quote">โควท</string>
<string name="new_amount_in_sats">ตั้งค่าจำนวนในหน่วย sat</string>
<string name="add">เพิ่ม</string>
<string name="replying_to">"ตอบกลับถึง "</string>
<string name="and">" และ "</string>
<string name="in_channel">" ในช่อง "</string> <!--ไม่รู้ว่าอยู่ตรงไหน-->
<string name="profile_banner">หน้าปกโปรไฟล์</string>
<string name="payment_successful">การชำรพเงินเสร็จสมบูรณ์</string>
<string name="error_parsing_error_message">เกิดข้อผิดพลาดในการวิเคราะห์ข้อความแสดงข้อผิดพลาด</string>
<string name="following">" กำลังติดตาม"</string>
<string name="followers">" ผู้ติดตาม"</string>
<string name="profile">โปรไฟล์</string>
<string name="security_filters">ตัวกรอกความปลอดภัย</string>
<string name="log_out">ออกจากระบบ</string>
<string name="show_more">แสดงเพิ่มเติม</string>
<string name="lightning_invoice">Lightning Invoice</string>
<string name="pay">จ่าย</string>
<string name="lightning_tips">Lightning Tips</string>
<string name="note_to_receiver">โน้ตถึงผู้รับ</string>
<string name="thank_you_so_much">ขอบคุณมาก!</string>
<string name="amount_in_sats">จำนวนในหน่วย sat</string>
<string name="send_sats">ส่ง Sats</string>
<string name="error_parsing_preview_for">"ข้อผิดพลาดในการวิเคราะห์ตัวอย่างสําหรับ %1$s : %2$s"</string>
<string name="preview_card_image_for">"รูปตัวอย่างสำหรับ %1$s"</string>
<string name="new_channel">Channel ใหม่</string>
<string name="channel_name">ชื่อ Channel</string>
<string name="my_awesome_group">กลุ่มที่ยอดเยี่ยมของฉัน!</string><!--ไม่รู้ว่าอยู่ตรงไหน-->
<string name="picture_url">Picture Url</string>
<string name="description">รายละเอียด</string>
<string name="about_us">"เกี่ยวกับเรา.. "</string>
<string name="what_s_on_your_mind">คุณคิดอะไรอยู่ ?</string>
<string name="post">โพสต์</string>
<string name="save">บันทึก</string>
<string name="create">สร้าง</string>
<string name="cancel">ยกเลิก</string>
<string name="failed_to_upload_the_image">ไม่สามารถโหลดรูปภาพได้</string>
<string name="relay_address">Relay Address</string>
<string name="posts">โพสต์</string>
<string name="bytes">Bytes</string>
<string name="errors">ข้อผิดพลาด</string>
<string name="home_feed">ฟีดเริ่มต้น</string>
<string name="private_message_feed">ฟีดข้อความส่วนตัว</string>
<string name="public_chat_feed">ฟีดข้อความ</string>
<string name="global_feed">ฟีดโลก</string>
<string name="search_feed">ฟีดค้นหา</string>
<string name="add_a_relay">เพิ่มรีเลย์</string>
<string name="display_name">ชื่อที่แสดง</string>
<string name="my_display_name">ชื่อที่แสดงของฉัน</string>
<string name="username">ชื่อผู้ใช้</string>
<string name="my_username">ชื่อผู้ใช้ของฉัน</string>
<string name="about_me">เกี่ยวกับฉัน</string>
<string name="avatar_url">Avatar URL</string>
<string name="banner_url">Banner URL</string>
<string name="website_url">Website URL</string>
<string name="ln_address">LN Address</string>
<string name="ln_url_outdated">LN URL (หมดอายุ)</string>
<string name="image_saved_to_the_gallery">บันทึกรูปภาพลงแกลเลอรี</string>
<string name="failed_to_save_the_image">เกิดข้อผิดพลาดในการบันทึกรูปภาพ</string>
<string name="upload_image">อัพโหลดรูปภาพ</string>
<string name="uploading">กำลังอัพโหลด</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">ผู้ใช้นี้ไม่ได้ตั้งค่า lightning address เพื่อรับ sats</string>
<string name="reply_here">"ตอบกลับที่นี่.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">คัดลอกโน้ต ID ลงคลิปบอร์ดเพื่อแชร์ไปยัง Nostr</string>
<string name="copy_channel_id_note_to_the_clipboard">คัดลอก Channel ID (โน้ต) ลงคลิปบอร์ด</string>
<string name="edits_the_channel_metadata">แก้ไข Channel Metadata</string>
<string name="join">เข้าร่วม</string>
<string name="known">รู้</string><!--ไม่รู้ว่าอยู่ตรงไหน-->
<string name="new_requests">ส่งคำขอใหม่</string>
<string name="blocked_users">ผู้ใช้ที่ถูกบล็อก</string>
<string name="new_threads">เธรดใหม่</string>
<string name="conversations">การสนทนา</string>
<string name="notes">โน้ต</string>
<string name="replies">ตอบกลับ</string>
<string name="follows">"ติดตาม"</string>
<string name="reports">"รายงาน"</string>
<string name="more_options">ตัวเลือกเพิ่มเติม</string>
<string name="relays">" รีเลย์"</string>
<string name="website">เว็บไซต์</string>
<string name="lightning_address">Lightning Address</string>
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">คัดลอก Nsec ID (รหัสผ่านของคุณ) ลงคลิปบอร์ดเพื่อสำรองข้อมูล</string>
<string name="copy_private_key_to_the_clipboard">คัดลอก Secret Key ลงคลิปบอร์ด</string>
<string name="copies_the_public_key_to_the_clipboard_for_sharing">คัดลอก public key ลงคลิปบอร์ดเพื่อแชร์</string>
<string name="copy_public_key_npub_to_the_clipboard">คัดลอก Public Key (NPub) ลงคลิปบอร์ด</string>
<string name="send_a_direct_message">ส่งข้อความส่วนตัว</string>
<string name="edits_the_user_s_metadata">แก้ไข the User\'s Metadata</string>
<string name="follow">ติดตาม</string>
<string name="follow_back">ติดตามกลับ</string>
<string name="unblock">เลิกบล๊อก</string>
<string name="copy_user_id">คัดลอก ID ผู้ใช้</string>
<string name="unblock_user">เลิกบล๊อกผู้ใช้</string>
<string name="npub_hex_username">"npub, ชื่อผู้ใช้, ข้อความ"</string>
<string name="clear">เคลียร์</string>
<string name="app_logo">App Logo</string>
<string name="nsec_npub_hex_private_key">nsec.. or npub..</string>
<string name="show_password">แสดงรหัสผ่าน</string>
<string name="hide_password">ซ่อนรหัสผ่าน</string>
<string name="invalid_key">key ไม่ถูกต้อง</string>
<string name="i_accept_the">"ฉันยอมรับ "</string>
<string name="terms_of_use">เงื่อนไขการใช้งาน</string>
<string name="acceptance_of_terms_is_required">จําเป็นต้องยอมรับข้อกําหนด</string>
<string name="key_is_required">ต้องใช้ key</string>
<string name="login">เข้าสู่ระบบ</string>
<string name="generate_a_new_key">สร้าง key ใหม่</string>
<string name="loading_feed">กำลังดาวน์โหลดฟีด</string>
<string name="error_loading_replies">"ไม่สามารถโหลดการตอบกลับ: "</string>
<string name="try_again">ลองอีกครั้ง</string>
<string name="feed_is_empty">ฟีดนี้ว่างเปล่า</string>
<string name="refresh">รีเฟรช</string>
<string name="created">สร้าง</string>
<string name="with_description_of">พร้อมคําอธิบายของ</string>
<string name="and_picture">และ รูปภาพ</string>
<string name="changed_chat_name_to">เปลี่ยนชื่อ chat เป็น</string>
<string name="description_to">คําอธิบายของ</string>
<string name="and_picture_to">และ รูปภาพของ</string>
<string name="leave">ออก</string><!--ไม่รู้ว่าอยู่ตรงไหน-->
<string name="unfollow">เลิกติดตาม</string>
<string name="channel_created">สร้าง Channel</string>
<string name="channel_information_changed_to">"ข้อมูลของ Channel เปลี่ยนเป็น"</string>
<string name="public_chat">แชทสาธรณะ</string>
<string name="posts_received">ได้รับโพสต์แล้ว</string>
<string name="remove">ลบ</string>
<string name="sats" translatable="false">sats</string>
<string name="translations_auto">อัตโนมัติ</string>
<string name="translations_translated_from">แปลจาก</string>
<string name="translations_to">ถึง</string>
<string name="translations_show_in_lang_first">แสดงใน %1$s ก่อน</string>
<string name="translations_always_translate_to_lang">แปลเป็น %1$s เสมอ</string>
<string name="translations_never_translate_from_lang">อย่าแปลจาก %1$s</string>
<string name="nip_05">Nostr Address</string>
<string name="lnurl" translatable="false">LNURL...</string>
<string name="never">ไม่เคย</string>
<string name="now">ตอนนี้</string>
<string name="h">h</string>
<string name="m">m</string>
<string name="d">d</string>
<string name="nudity">ภาพเปลือย/สื่อลามก</string>
<string name="profanity_hateful_speech">คําหยาบคาย / คําพูดที่แสดงความเกลียดชัง</string>
<string name="report_hateful_speech">รายงาน: คําพูดที่แสดงความเกลียดชัง</string>
<string name="report_nudity_porn">รายงาน: ภาพเปลือย/สื่อลามก</string>
<string name="others">อื่น ๆ</string>
<string name="mark_all_known_as_read">ทำเครื่องหมายอ่านแล้วในแชทที่รู้จักทั้งหมด</string>
<string name="mark_all_new_as_read">ทำเครื่องหมายว่าอ่านแล้วทั้งหมดสำหรับข้อความใหม่</string>
<string name="mark_all_as_read">ทพเครื่องหมายว่าอ่านแล้วทั้งหมด</string>
<string name="backup_keys">สพรองข้อมูล Keys</string>
<string name="account_backup_tips_md" tools:ignore="Typos">
## เคล็ดลับการสํารองข้อมูลและความปลอดภัยที่สําคัญ
\n\n บัญชีของคุณมีความปลอดภัยด้วยรหัสลับ key คือสตริงสุ่มยาวที่ขึ้นต้นด้วย **nsec1** ทุกคนที่มีสิทธิ์เข้าถึงรหัสลับของคุณสามารถเผยแพร่เนื้อหาโดยใช้บัญชีของคุณได้
\n\n- **อย่า** ใส่รหัสลับของคุณในเว็บไซต์หรือซอฟต์แวร์ที่คุณไม่เชื่อถือ
\n- นักพัฒนา Amethyst จะ **ไม่เคย** ขอรหัสลับของคุณ
\n- **เก็บ** สําเนาสํารองคีย์ลับของคุณไว้อย่างปลอดภัยสําหรับการกู้คืนบัญชี เราขอแนะนําให้ใช้ password manager.
</string>
<string name="secret_key_copied_to_clipboard">คัดลอก Secret key (nsec) ลงคลิปบอร์ด</string>
<string name="copy_my_secret_key">คัดลอก secret key ของฉัน</string>
<string name="biometric_authentication_failed"> เกิดข้อผิดพลาดในการตรวจสอบ</string>
<string name="biometric_error">ข้อผิดพลาด</string>
<string name="badge_created_by">"สร้างโดย %1$s"</string>
<string name="badge_award_image_for">"ภาพเหรียญตราสําหรับ %1$s"</string>
<string name="new_badge_award_notif">คุณได้รับเหรียญตราใหม่</string>
<string name="award_granted_to">เหรียญตราถูกมอบให้กับ</string>
<string name="copied_note_text_to_clipboard">คัดลอกข่้อความในโน้ตลงคลิปบอร์ด</string>
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">คัดลอก @npub ของผู้เขียนลงคลิปบอร์ด</string>
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">คัดลอกโน้ต ID (@note1)ลงคลิปบอร์ด</string>
<string name="select_text_dialog_top">เลือกข้อความ</string>
<string name="github" translatable="false">Github Gist w/ Proof</string>
<string name="telegram" translatable="false">Telegram</string>
<string name="mastodon" translatable="false">Mastodon Post ID w/ Proof</string>
<string name="twitter" translatable="false">Twitter Status w/ Proof</string>
<string name="github_proof_url_template" translatable="false">https://gist.github.com/&lt;user&gt;/&lt;gist&gt;</string>
<string name="telegram_proof_url_template" translatable="false">https://t.me/&lt;proof post&gt;</string>
<string name="mastodon_proof_url_template" translatable="false">https://&lt;server&gt;/&lt;user&gt;/&lt;proof post&gt;</string>
<string name="twitter_proof_url_template" translatable="false">https://twitter.com/&lt;user&gt;/status/&lt;proof post&gt;</string>
<string name="private_conversation_notification">"&lt;ไม่สามารถถอดรหัสข้อความส่วนตัวได้&gt;\n\nคุณถูกอ้างถึงในการสนทนาส่วนตัว/เข้ารหัสระหว่าง %1$s และ %2$s."</string><!--ไม่รู้ว่าอยู่ตรงไหน-->
<string name="account_switch_add_account_dialog_title">เพิ่มบัญชีใหม่</string>
<string name="drawer_accounts">บัญชี</string>
<string name="account_switch_select_account">เลือกบัญชี</string>
<string name="account_switch_add_account_btn">เพิ่มบัญชีใหม่</string>
<string name="account_switch_active_account">บัญชีที่ใช้งานอยู่</string>
<string name="account_switch_has_private_key">มี private key</string>
<string name="account_switch_pubkey_only">อ่านเท่านั้น, ไม่มี private key</string>
<string name="back">ย้อนกลับ</string>
<string name="quick_action_select">เลือก</string>
<string name="quick_action_share_browser_link">แชร์ Browser Link</string>
<string name="quick_action_share">แชร์</string>
<string name="quick_action_copy_user_id">ID ผู้เขียน</string>
<string name="quick_action_copy_note_id">ID โน้ต</string>
<string name="quick_action_copy_text">คัดลอกข้อความ</string>
<string name="quick_action_delete">ลบ</string>
<string name="quick_action_unfollow">เลิกติดตาม</string>
<string name="quick_action_follow">ติดตาม</string>
<string name="quick_action_request_deletion_alert_title">ส่งคำขอให้ลบ</string>
<string name="quick_action_request_deletion_alert_body">Amethyst จะขอให้ลบโน้ตของคุณออกจากรีเลย์ที่คุณเชื่อมต่ออยู่ ไม่มีการรับประกันว่าโน้ตของคุณจะถูกลบออกอย่างถาวรจากรีเลย์เหล่านั้น หรือ จากรีเลย์อื่น ๆ ที่อาจเก็บไว้</string>
<string name="quick_action_block_dialog_btn">บล๊อก</string>
<string name="quick_action_delete_dialog_btn">ลบ</string>
<string name="quick_action_block">บล๊อก</string>
<string name="quick_action_report">รายงาน</string>
<string name="quick_action_delete_button">ลบ</string>
<string name="quick_action_dont_show_again_button">ไม่แสดงข้อความนี้อีก</string><!--ไม่เจอว่าตรงไหน-->
<string name="report_dialog_spam">สแปม หรือ หลอกลวง</string>
<string name="report_dialog_profanity">คําหยาบคายหรือการแสดงความเกลียดชัง</string>
<string name="report_dialog_impersonation">การแอบอ้างบุคคลอื่นที่เป็นอันตราย</string>
<string name="report_dialog_nudity">เนื้อหาภาพเปลือย</string>
<string name="report_dialog_illegal">พฤติกรรมที่ผิดกฎหมาย</string>
<string name="report_dialog_blocking_a_user">การบล็อกผู้ใช้จะซ่อนเนื้อหาของพวกเขาในแอปของคุณ โน้ตของคุณยังคงดูได้แบบสาธารณะรวมถึงคนที่คุณบล็อกด้วย ผู้ใช้ที่ถูกบล็อกจะแสดงรายการบนหน้าจอตัวกรองความปลอดภัย</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[บล๊อก & ซ่อนผู้ใช้]]></string>
<string name="report_dialog_report_btn">รายงานการล่วงละเมิด</string>
<string name="report_dialog_reminder_public">การรายงานที่โพสต์ทั้งหมดจะปรากฏต่อสาธารณะ</string>
<string name="report_dialog_additional_reason_placeholder">ระบุข้อความเพิ่มเติมเกี่ยวกับรายงานของคุณ (ไม่บังคับ)</string>
<string name="report_dialog_additional_reason_label">ข้อความเพิ่มเติม</string>
<string name="report_dialog_select_reason_label">เหตุผล</string>
<string name="report_dialog_select_reason_placeholder">เลือกเหตุผล…</string>
<string name="report_dialog_post_report_btn">รายงานโพสต์</string>
<string name="report_dialog_title">บล๊อกและรายงาน</string>
<string name="block_only">บล๊อก</string>
<string name="bookmarks">บุ๊คมาร์ค</string>
<string name="private_bookmarks">บุ๊คมาร์คส่วนตัว</string>
<string name="public_bookmarks">บุ๊คมาร์คสาธรณะ</string>
<string name="add_to_private_bookmarks">เพิ่มไปยังบุ๊คมาร์คส่วนตัว</string>
<string name="add_to_public_bookmarks">เพิ่มไปยังบุ๊คมาร์คสาธรณะ</string>
<string name="remove_from_private_bookmarks">ลบออกจากบุ๊คมาร์คส่วนตัว</string>
<string name="remove_from_public_bookmarks">ลบออกจากบุ๊คมาร์คสาธรณะ</string>
<string name="wallet_connect_service">บริการเชื่อมต่อ wallet</string>
<string name="wallet_connect_service_explainer">อนุญาตให้ Nostr Secret จ่าย zaps โดยไม่ต้องออกจากแอป เพื่อรักษาความลับให้ปลอดภัยและใช้รีเลย์ส่วนตัวหากเป็นไปได้</string>
<string name="wallet_connect_service_pubkey">Wallet Connect Pubkey</string>
<string name="wallet_connect_service_relay">Wallet Connect Relay</string>
<string name="wallet_connect_service_secret">Wallet Connect Secret</string>
<string name="wallet_connect_service_show_secret">แสดง secret key</string>
<string name="wallet_connect_service_secret_placeholder">nsec / hex private key</string>
<string name="pledge_amount_in_sats">จำนวนเงินที่นำเข้าในหน่วย sat</string>
<string name="post_poll">สร้างโพลล์</string>
<string name="poll_heading_required">ส่วนที่จำเป็น:</string>
<string name="poll_zap_recipients">ผู้รับ Zap</string>
<string name="poll_primary_description">คำอธิบายโพลล์…</string>
<string name="poll_option_index">ตัวเลือก %s</string>
<string name="poll_option_description">คำอธิบายตัวเลือกในโพลล์</string>
<string name="poll_heading_optional">ตำอธิบายเพิ่มเติม:</string>
<string name="poll_zap_value_min">Zap ขั้นต่ำ</string>
<string name="poll_zap_value_max">Zap สูงสุด</string>
<string name="poll_consensus_threshold">ฉันทามติ</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">ปิดหลังจาก</string>
<string name="poll_closing_time_days">วัน</string>
<string name="poll_is_closed">โพลปิดรับการลงคะแนนเพิ่มเติม</string>
<string name="poll_zap_amount">จำนวน Zap</string>
<string name="one_vote_per_user_on_atomic_votes">อนุญาตให้โหวตได้เพียงครั้งเดียวต่อผู้ใช้หนึ่งคนสำหรับการสำรวจประเภทนี้</string>
<string name="looking_for_event">"กำลังมองหา Event %1$s"</string>
<string name="custom_zaps_add_a_message">เพิ่มในข้อความสาธรณะ</string>
<string name="custom_zaps_add_a_message_private">เพิ่มในข้อความส่วนตัว</string>
<string name="custom_zaps_add_a_message_nonzap">เพิ่มใน invoice message</string>
<string name="custom_zaps_add_a_message_example">ขอบคุณสำหรับทุกงานของคุณ!</string>
<string name="lightning_create_and_add_invoice">สร้างและเพิ่ม</string>
<string name="poll_author_no_vote">ผู้เขียนโพลล์ไม่สามารถลงคะแนนในโพล์ของตนเองได้</string>
<string name="poll_hashtag" translatable="false">#zappoll</string>
<string name="hash_verification_passed">เนื้อหานี้เหมือนเดิมตั้งแต่โพสต์</string>
<string name="hash_verification_failed">เนื้อหามีการเปลี่ยนแปลง ผู้เขียนอาจไม่เห็นหรืออนุมัติการเปลี่ยนแปลง</string>
<string name="content_description_add_image">เพิ่มรูปภาพ</string>
<string name="content_description_add_video">เพิ่มวีดีโอ</string>
<string name="content_description_add_document">เพิ่มเอกสาร</string>
<string name="add_content">เพิ่มไปยังข้อความ</string>
<string name="content_description">คำอธิบายของเนื้อหา</string>
<string name="content_description_example">เรือสีฟ้าในหาดทรายสีขาวยามพระอาทิตย์ตก</string>
<string name="zap_type">ประเภทการ Zap</string>
<string name="zap_type_explainer">ตัวเลือกทั้งหมดสำหรับการ zap</string>
<string name="zap_type_public">สาธรณะ</string>
<string name="zap_type_public_explainer">ทุกคนสามารถเห็นธุรกรรมและการข้อความ</string>
<string name="zap_type_private">ส่วนตัว</string>
<string name="zap_type_private_explainer">ผู้ส่งและผู้รับสามารถเห็นธุรกรรมและข้อความ</string>
<string name="zap_type_anonymous">ไม่ระบุตัวตน</string>
<string name="zap_type_anonymous_explainer">ไม่มีใครรู้ว่าใครเป็นคนทำธุรกรรม</string>
<string name="zap_type_nonzap">Non-Zap</string>
<string name="zap_type_nonzap_explainer">ไม่มีการบันทึกบน Nostr, มีแค่ในระบบ Lightning</string>
<string name="file_server">File Server</string>
<string name="zap_forward_lnAddress">LnAddress or @User</string>
<string name="upload_server_imgur">imgur.com - trusted</string>
<string name="upload_server_imgur_explainer">Imgur สามารถแก้ไขไฟล์ได้</string>
<string name="upload_server_nostrimg">nostrimg.com - trusted</string>
<string name="upload_server_nostrimg_explainer">NostrImg สามารถแก้ไขไฟล์ได้</string>
<string name="upload_server_nostrbuild">nostr.build - trusted</string>
<string name="upload_server_nostrbuild_explainer">Nostr.build สามารถแก้ไขไฟล์ได้</string>
<string name="upload_server_nostrfilesdev">nostrfiles.dev - trusted</string>
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev สามารถแก้ไขไฟล์ได้</string>
<string name="upload_server_nostrcheckme">nostrcheck.me - trusted</string>
<string name="upload_server_nostrcheckme_explainer">nostrcheck.me สามารถแก้ไขไฟล์ได้</string>
<string name="upload_server_imgur_nip94">Verifiable Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">ตวรจสอบว่า Imgur แก้ไขไฟล์ New NIP: ไคลเอนต์อื่น ๆ จะไม่เห็นมัน</string>
<string name="upload_server_nostrimg_nip94">Verifiable NostrImg (NIP-94)</string>
<string name="upload_server_nostrimg_nip94_explainer">ตวรจสอบว่า NostrImg แก้ไขไฟล์ New NIP: ไคลเอนต์อื่น ๆ จะไม่เห็นมัน</string>
<string name="upload_server_nostrbuild_nip94">Verifiable Nostr.build (NIP-94)</string>
<string name="upload_server_nostrbuild_nip94_explainer">ตวรจสอบว่า Nostr.build แก้ไขไฟล์ New NIP: ไคลเอนต์อื่น ๆ จะไม่เห็นมัน</string>
<string name="upload_server_nostrfilesdev_nip94">Verifiable Nostrfiles.dev (NIP-94)</string>
<string name="upload_server_nostrfilesdev_nip94_explainer">ตวรจสอบว่า Nostrfiles.dev แก้ไขไฟล์ New NIP: ไคลเอนต์อื่น ๆ จะไม่เห็นมัน</string>
<string name="upload_server_nostrcheckme_nip94">Verifiable Nostrcheck.me (NIP-94)</string>
<string name="upload_server_nostrcheckme_nip94_explainer">ตวรจสอบว่า Nostrcheck.me แก้ไขไฟล์ New NIP: ไคลเอนต์อื่น ๆ จะไม่เห็นมัน</string>
<string name="upload_server_relays_nip95">รีเลย์ของคุณ (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">ไฟล์อยู่บนรีเลย์ของคุณ New NIP: โปรดตรวจสอบว่ามีการรองรับหรือไม่</string>
<string name="connect_via_tor_short">ตั้งค่า Tor/Orbot</string>
<string name="connect_via_tor">การตั้งค่าการเชื่อมต่อ Orbot ของคุณ</string>
<string name="do_you_really_want_to_disable_tor_title">ตัดการเชื่อมต่อจาก Orbot/Tor ของคุณ?</string>
<string name="do_you_really_want_to_disable_tor_text">ข้อมูลของคุณจะถูกถ่ายโอนในเครือข่ายปกติทันที</string>
<string name="yes">ใช่</string>
<string name="no">ไม่</string>
<string name="follow_list_selection">รายการผู้ติดตาม</string><!--ไม่เจอว่าตรงไหน-->
<string name="follow_list_kind3follows">ติดตามทั้งหมด</string><!--ไม่เจอว่าตรงไหน-->
<string name="follow_list_global">Global</string>
<string name="connect_through_your_orbot_setup_markdown">
## เชื่อมต่อกัย Tor ด้วย Orbot
\n\n1. ติดตั้ง [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
\n2. เริ่มต้นใช้งาน Orbot
\n3. ใน Orbot, ตรวจสอบe Socks port. ค่าเริ่มต้นเป็น 9050
\n4. หากจําเป็นให้เปลี่ยนพอร์ตใน Orbot
\n5. กําหนดค่า Socks port บนหน้าจอนี้
\n6. กดปุ่มเปิดใช้งานเพื่อใช้ Orbot เป็น proxy
</string>
<string name="orbot_socks_port">Orbot Socks Port</string>
<string name="invalid_port_number">เลข port ไม่ถูกต้องr</string>
<string name="use_orbot">ใช้ Orbot</string>
<string name="disconnect_from_your_orbot_setup">ตัดการเชื่อมต่อจาก Tor/Orbot</string>
<string name="app_notification_channel_id" translatable="false">DefaultChannelID</string>
<string name="app_notification_private_message" translatable="false">New notification arrived</string>
<string name="app_notification_dms_channel_id" translatable="false">PrivateMessagesID</string>
<string name="app_notification_dms_channel_name">ข้อความส่วนตัว</string>
<string name="app_notification_dms_channel_description">แจ้งเตือนฉันเมื่อข้อความส่วนตัวมาถึง</string>
<string name="app_notification_zaps_channel_id" translatable="false">ZapsID</string>
<string name="app_notification_zaps_channel_name">ได้รับ Zaps</string>
<string name="app_notification_zaps_channel_description">แจ้งเตือนเมื่อมีคน zaps</string>
<string name="app_notification_zaps_channel_message">%1$s sats</string>
<string name="app_notification_zaps_channel_message_from">จาก %1$s</string>
<string name="app_notification_zaps_channel_message_for">ถึง %1$s</string>
<string name="reply_notify">การแจ้งเตือน: </string>
<string name="channel_list_join_conversation">เข้าร่วมการสนทนา</string>
<string name="channel_list_user_or_group_id">ผู้ใช้ หรือ Group\'s ID</string>
<string name="channel_list_user_or_group_id_demo">npub, nevent or hex</string>
<string name="channel_list_create_channel">สร้าง</string>
<string name="channel_list_join_channel">เข้าร่วม</string>
<string name="today">วันนี้</string>
<string name="content_warning">คําเตือนเนื้อหา</string>
<string name="content_warning_explanation">โพสต์นี้มีเนื้อหาที่ละเอียดอ่อนซึ่งบางคนอาจพบว่าไม่เหมาะสมหรือรบกวน</string>
<string name="content_warning_hide_all_sensitive_content">ซ่อนเนื้อหาที่ละเอียดอ่อนเสมอ</string>
<string name="content_warning_show_all_sensitive_content">แสดงเนื้อหาที่ละเอียดอ่อนเสมอ</string>
<string name="content_warning_see_warnings">แสดงคำเตือนเนื้อหาเสมอ</string>
<string name="recommended_apps">แนะนำ: </string>
<string name="filter_spam_from_strangers">กรองสแปมจากคนแปลกหน้า</string>
<string name="warn_when_posts_have_reports_from_your_follows">เตือนเมื่อมีการรายงานโพสต์จากผู้ติดตามของคุณ</string>
<string name="new_reaction_symbol">สัญลักษณ์ Reaction ใหม่</string>
<string name="no_reaction_type_setup_long_press_to_change">ไม่มีการเลือกประเภทของ reaction กดค้างเพื่อเปลี่ยน</string>
<string name="zapraiser">Zapraiser</string>
<string name="zapraiser_explainer">เพิ่มจํานวนเป้าหมายของ sats ที่จะเพิ่มสําหรับโพสต์นี้ การสนับสนุนไคลเอนต์อาจแสดงสิ่งนี้เป็นแถบความคืบหน้าเพื่อจูงใจการบริจาค</string>
<string name="zapraiser_target_amount_in_sats">จำนวนเป้าหมายในหน่วย Sats</string>
<string name="sats_to_complete">Zapraiser at %1$s. %2$s sats เพื่อไปถึงเป้าหมาย</string>
<string name="read_from_relay">อ่านจาก Relay</string>
<string name="write_to_relay">เขียนบน Relay</string>
<string name="an_error_occurred_trying_to_get_relay_information">เกิดข้อผิดพลาดในการรับข้อมูลจาก %1$s</string>
<string name="owner">เจ้าของ</string>
<string name="version">Version</string>
<string name="software">Software</string>
<string name="contact">ติดต่อ</string>
<string name="supports">รับรอง NIPs</string>
<string name="admission_fees">ค่าใช้จ่ายสำหรับการใช้งาน</string>
<string name="payments_url">url การชำระเงิน</string>
<string name="limitations">ข้อจำกัด</string>
<string name="countries">ประเทศ</string>
<string name="languages">ภาษา</string>
<string name="tags">Tags</string>
<string name="posting_policy">นโยบายการโพสต์</string>
<string name="message_length">ความยาวข้อความ</string>
<string name="subscriptions">การสมัครสมาชิก</string>
<string name="filters">ฟิลเตอร์</string>
<string name="subscription_id_length">ความยาวของรหัสการสมัครสมาชิก</string>
<string name="minimum_prefix">คำนำหน้าขั้นต่ำ</string>
<string name="maximum_event_tags">event tags สูงสุด</string>
<string name="content_length">ความยาวของเนื้อหา</string>
<string name="minimum_pow">PoW ขั้นต่ำ</string>
<string name="auth">Auth</string>
<string name="payment">การชำระเงิน</string>
<string name="cashu">โทเคน Cashu</string>
<string name="cashu_redeem">แลกคืน</string>
<string name="no_lightning_address_set">ไม่ได้ตั้งค่า Lightning Address</string>
<string name="copied_token_to_clipboard">คัดลอกโทเคนลงคลิปบอร์ด</string>
<string name="live_stream_live_tag">LIVE</string>
<string name="live_stream_offline_tag">OFFLINE</string>
<string name="live_stream_ended_tag">ENDED</string>
<string name="live_stream_planned_tag">SCHEDULED</string>
<string name="live_stream_is_offline">Livestream is Offline</string>
<string name="live_stream_has_ended">Livestream Ended</string>
<string name="are_you_sure_you_want_to_log_out">การออกจากระบบจะลบข้อมูลทั้งหมดของคุณ ตรวจสอบให้แน่ใจว่าได้สํารองข้อมูล private key ไว้เพื่อหลีกเลี่ยงการสูญเสียบัญชีของคุณ คุณต้องการดําเนินการต่อหรือไม่?</string>
<string name="followed_tags">Followed Tags</string>
<string name="relay_setup">รีเลย์</string>
<string name="discover_live">Live</string>
<string name="discover_community">ชุมชน</string>
<string name="discover_chat">ช่องสนทนา</string>
<string name="community_approved_posts">อนุมัติโพสต์</string>
<string name="groups_no_descriptor">ชุมชนนี้ไม่มีคำอธิบายหรือกฏ พูดคุยกับเจ้าของเพื่อเพิ่มเติมสิ่งนี้</string>
<string name="community_no_descriptor">ชุมชนนี้ไม่มีคำอธิบาย พูดคุยกับเจ้าของเพื่อเพิ่มเติมสิ่งนี้</string>
<string name="add_sensitive_content_label">เนื้อหาที่ละเอียดอ่อน</string>
<string name="add_sensitive_content_description">เพิ่มการแจ้งเตือนเนื้อหาที่ละเอียดอ่อนก่อนโชว์เนื้อหานี้</string>
<string name="settings">การตั้งค่า</string>
<string name="connectivity_type_always">Aตลอดเวลา</string>
<string name="connectivity_type_wifi_only">Wifi เท่านั้น</string>
<string name="connectivity_type_never">ไม่ต้องแสดง</string>
<string name="system">ระบบ(ค่าพื้นฐาน)</string>
<string name="light">สว่าง</string>
<string name="dark">มืด</string>
<string name="application_preferences">การตั้งค่าแอปพลิเคชัน</string>
<string name="language">ภาษา</string>
<string name="theme">ธีม</string>
<string name="automatically_load_images_gifs">ภาพตัวอย่าง</string>
<string name="automatically_play_videos">การเล่นวิดีโอ</string>
<string name="automatically_show_url_preview">การแสดงตัวอย่าง URL</string>
<string name="load_image">โหลดรูปภาพ</string>
<string name="spamming_users">Spammers</string>
<string name="muted_button">ปิดการมองเห็น คลิกเพื่อปลดออก</string>
<string name="mute_button">เปิดการมองเห็นอยู่ คลิกเพื่อปิด</string>
<string name="search_button">ค้นหาบันทึกการเข้าถึงแบบ local และ remote</string>
<string name="nip05_verified">Nostr address ได้รับการยืนยัน</string>
<string name="nip05_failed">ไม่สามารถยืนยัน Nostr address ได้</string>
<string name="nip05_checking">ตรวจสอบ Nostr address</string>
<string name="select_deselect_all">เลือก/ไม่เลือก ทั้งหมด</string>
<string name="default_relays">ค่าเริ่มต้น</string>
<string name="select_a_relay_to_continue">เลือกรีเลย์เพื่อดำเนินการต่อ</string>
<string name="zap_forward_title">ส่งต่อ Zaps ถึง:</string>
<string name="zap_forward_explainer">ไคลเอนต์ที่สนับสนุนจะส่งต่อ zaps ไปยัง LNAddress หรือโปรไฟล์ผู้ใช้ด้านล่างแทนที่จะเป็นของคุณ</string>
<string name="geohash_title">เปิดเผยตําแหน่งที่ตั้ง </string>
<string name="geohash_explainer">เพิ่ม Geohash ของตําแหน่งของคุณลงในโพสต์ สาธารณชนจะรู้ว่าคุณอยู่ภายใน 5 กม. (3 ไมล์) จากตําแหน่งปัจจุบัน</string>
<string name="add_sensitive_content_explainer">เพิ่มคําเตือนเนื้อหาที่ละเอียดอ่อนก่อนแสดงเนื้อหาของคุณ สิ่งนี้เหมาะสําหรับเนื้อหา NSFW หรือเนื้อหาใด ๆ ที่บางคนอาจพบว่าไม่เหมาะสมหรือรบกวน</string>
<string name="new_feature_nip24_might_not_be_available_title">ฟีเจอร์ใหม่</string>
<string name="new_feature_nip24_might_not_be_available_description">การเปิดใช้งานโหมดนี้ต้องใช้ Amethyst เพื่อส่งข้อความ NIP-24 (GiftWrapped, Sealed Direct และ Group Messages) NIP-24 เป็นของใหม่และไคลเอ็นต์ส่วนใหญ่ยังไม่ได้รองรับ ตรวจสอบให้แน่ใจว่าเครื่องรับใช้ไคลเอ็นต์ที่รองรับ</string>
<string name="new_feature_nip24_activate">เปิดใช้งาน</string>
<string name="messages_create_public_chat">สาธรณะ</string>
<string name="messages_new_message">ส่วนตัว</string>
<string name="messages_new_message_to">ถึง</string>
<string name="messages_new_message_subject">ชื่อเรื่อง</string>
<string name="messages_new_message_subject_caption">หัวข้อการสนทนา</string>
<string name="messages_new_message_to_caption">"@User1, @User2, @User3"</string>
<string name="messages_group_descriptor">ชมาชิกในกลุ่ม</string>
<string name="messages_new_subject_message">คําอธิบายต่อสมาชิก</string>
<string name="messages_new_subject_message_placeholder">เปลี่ยนชื่อสําหรับเป้าหมายใหม่</string>
<string name="language_description">สำหรับ App\'s Interface</string>
<string name="theme_description">ธีม: มืด, สว่าง หรือระบบ</string>
<string name="automatically_load_images_gifs_description">โหลดรูปภาพและ GIFs โดยอัตโนมัติ</string>
<string name="automatically_play_videos_description">เล่นวีดีโอและ GIFs โดยอัตโนมัติ</string>
<string name="automatically_show_url_preview_description">แสดงจัวอย่าง URL</string>
<string name="load_image_description">ควรโหลดรูปภาพเมื่อใด</string>
<string name="copy_url_to_clipboard">คัดลอก URL ลงคลิปบอร์ด</string>
<string name="copy_the_note_id_to_the_clipboard">คัดลอก โน้ต ID ลงคลิปบอร์ดd</string>
<string name="created_at">สร้างโดย</string>
<string name="rules">กฏ</string>
</resources>

View File

@@ -103,7 +103,7 @@
<string name="uploading">Uploading…</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">User does not have a lightning address setup to receive sats</string>
<string name="reply_here">"reply here.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Copies the Note ID to the clipboard for sharing</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Copies the Note ID to the clipboard for sharing in Nostr</string>
<string name="copy_channel_id_note_to_the_clipboard">Copy Channel ID (Note) to the Clipboard</string>
<string name="edits_the_channel_metadata">Edits the Channel Metadata</string>
<string name="join">Join</string>
@@ -544,4 +544,11 @@
<string name="automatically_show_url_preview_description">Show URL previews</string>
<string name="load_image_description">When to load images</string>
<string name="copy_url_to_clipboard">Copy URL to clipboard</string>
<string name="copy_the_note_id_to_the_clipboard">Copy Note ID to clipboard</string>
<string name="created_at">Created at</string>
<string name="rules">Rules</string>
<string name="status_update">Update your status</string>
</resources>

View File

@@ -16,6 +16,7 @@
<locale android:name="ru"/>
<locale android:name="sv-SE"/>
<locale android:name="ta"/>
<locale android:name="th"/>
<locale android:name="tr"/>
<locale android:name="uk"/>
<locale android:name="zh"/>

View File

@@ -8,22 +8,37 @@ import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateDMChannel
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel
import com.vitorpamplona.quartz.events.Event
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
class PushNotificationReceiverService : FirebaseMessagingService() {
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// this is called when a message is received
override fun onMessageReceived(remoteMessage: RemoteMessage) {
remoteMessage.data.let {
val eventStr = remoteMessage.data["event"] ?: return
val event = Event.fromJson(eventStr)
EventNotificationConsumer(applicationContext).consume(event)
scope.launch(Dispatchers.IO) {
remoteMessage.data.let {
val eventStr = remoteMessage.data["event"] ?: return@let
val event = Event.fromJson(eventStr)
EventNotificationConsumer(applicationContext).consume(event)
}
}
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
override fun onNewToken(token: String) {
RegisterAccounts(LocalPreferences.allSavedAccounts()).go(token)
notificationManager().getOrCreateZapChannel(applicationContext)
notificationManager().getOrCreateDMChannel(applicationContext)
scope.launch(Dispatchers.IO) {
RegisterAccounts(LocalPreferences.allSavedAccounts()).go(token)
notificationManager().getOrCreateZapChannel(applicationContext)
notificationManager().getOrCreateDMChannel(applicationContext)
}
}
fun notificationManager(): NotificationManager {

View File

@@ -1,31 +1,18 @@
package com.vitorpamplona.amethyst.service.notifications
import android.util.Log
import com.google.android.gms.tasks.OnCompleteListener
import com.google.firebase.messaging.FirebaseMessaging
import com.vitorpamplona.amethyst.AccountInfo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
class PushNotificationUtils {
fun init(accounts: List<AccountInfo>) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
// get user notification token provided by firebase
FirebaseMessaging.getInstance().token.addOnCompleteListener(
OnCompleteListener { task ->
if (!task.isSuccessful) {
Log.w("FirebaseMsgService", "Fetching FCM registration token failed", task.exception)
return@OnCompleteListener
}
// Get new FCM registration token
val notificationToken = task.result
RegisterAccounts(accounts).go(notificationToken)
}
)
suspend fun init(accounts: List<AccountInfo>) = with(Dispatchers.IO) {
// get user notification token provided by firebase
try {
RegisterAccounts(accounts).go(FirebaseMessaging.getInstance().token.await())
} catch (e: Exception) {
Log.e("Firebase token", "failed to get firebase token", e)
}
}
}

View File

@@ -2,11 +2,13 @@ package com.vitorpamplona.amethyst.service
import android.os.Looper
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.every
import io.mockk.impl.annotations.SpyK
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
@@ -30,7 +32,7 @@ class Nip05VerifierTest {
}
@Test
fun `test with matching case on user name`() {
fun `test with matching case on user name`() = runBlocking {
// Set-up
val userNameToTest = ALL_UPPER_CASE_USER_NAME
val expectedPubKey = "ca29c211f1c72d5b6622268ff43d2288ea2b2cb5b9aa196ff9f1704fc914b71b"
@@ -41,7 +43,7 @@ class Nip05VerifierTest {
" }\n" +
"}"
every { nip05Verifier.fetchNip05Json(any(), any(), any()) } answers {
coEvery { nip05Verifier.fetchNip05Json(any(), any(), any()) } answers {
secondArg<(String) -> Unit>().invoke(nostrJson)
}
@@ -64,7 +66,7 @@ class Nip05VerifierTest {
}
@Test
fun `test with NOT matching case on user name`() {
fun `test with NOT matching case on user name`() = runBlocking {
// Set-up
val expectedPubKey = "ca29c211f1c72d5b6622268ff43d2288ea2b2cb5b9aa196ff9f1704fc914b71b"
@@ -73,7 +75,7 @@ class Nip05VerifierTest {
" \"$ALL_UPPER_CASE_USER_NAME\": \"$expectedPubKey\" \n" +
" }\n" +
"}"
every { nip05Verifier.fetchNip05Json(any(), any(), any()) } answers {
coEvery { nip05Verifier.fetchNip05Json(any(), any(), any()) } answers {
secondArg<(String) -> Unit>().invoke(nostrJson)
}

View File

@@ -38,7 +38,7 @@ class CryptoBenchmark {
val keyPair2 = KeyPair()
benchmarkRule.measureRepeated {
assertNotNull(CryptoUtils.getSharedSecretNIP24(keyPair1.privKey!!, keyPair2.pubKey))
assertNotNull(CryptoUtils.getSharedSecretNIP44(keyPair1.privKey!!, keyPair2.pubKey))
}
}
@@ -58,7 +58,7 @@ class CryptoBenchmark {
val keyPair2 = KeyPair()
benchmarkRule.measureRepeated {
assertNotNull(CryptoUtils.computeSharedSecretNIP24(keyPair1.privKey!!, keyPair2.pubKey))
assertNotNull(CryptoUtils.computeSharedSecretNIP44(keyPair1.privKey!!, keyPair2.pubKey))
}
}

View File

@@ -166,7 +166,7 @@ class GiftWrapReceivingBenchmark {
val toDecrypt = decodeNIP44(wrappedEvent.content) ?: return
benchmarkRule.measureRepeated {
assertNotNull(CryptoUtils.decryptNIP24(toDecrypt, sender.privKey!!, wrappedEvent.pubKey.hexToByteArray()))
assertNotNull(CryptoUtils.decryptNIP44(toDecrypt, sender.privKey!!, wrappedEvent.pubKey.hexToByteArray()))
}
}
@@ -191,7 +191,7 @@ class GiftWrapReceivingBenchmark {
)
val toDecrypt = decodeNIP44(wrappedEvent.content) ?: return
val innerJson = CryptoUtils.decryptNIP24(toDecrypt, sender.privKey!!, wrappedEvent.pubKey.hexToByteArray())
val innerJson = CryptoUtils.decryptNIP44(toDecrypt, sender.privKey!!, wrappedEvent.pubKey.hexToByteArray())
benchmarkRule.measureRepeated {
assertNotNull(innerJson?.let { Event.fromJson(it) })
@@ -236,7 +236,7 @@ class GiftWrapReceivingBenchmark {
val toDecrypt = decodeNIP44(seal.content) ?: return
benchmarkRule.measureRepeated {
assertNotNull(CryptoUtils.decryptNIP24(toDecrypt, sender.privKey!!, seal.pubKey.hexToByteArray()))
assertNotNull(CryptoUtils.decryptNIP44(toDecrypt, sender.privKey!!, seal.pubKey.hexToByteArray()))
}
}
@@ -256,7 +256,7 @@ class GiftWrapReceivingBenchmark {
)
val toDecrypt = decodeNIP44(seal.content) ?: return
val innerJson = CryptoUtils.decryptNIP24(toDecrypt, sender.privKey!!, seal.pubKey.hexToByteArray())
val innerJson = CryptoUtils.decryptNIP44(toDecrypt, sender.privKey!!, seal.pubKey.hexToByteArray())
benchmarkRule.measureRepeated {
assertNotNull(innerJson?.let { Gossip.fromJson(it) })

View File

@@ -4,21 +4,24 @@ buildscript {
ext {
fragment_version = "1.6.1"
lifecycle_version = '2.6.1'
compose_ui_version = '1.4.3'
nav_version = "2.6.0"
compose_ui_version = '1.5.0'
nav_version = "2.7.0"
room_version = "2.4.3"
accompanist_version = '0.30.1'
coil_version = '2.4.0'
vico_version = '1.7.3'
exoplayer_version = '1.1.0'
vico_version = '1.9.2'
exoplayer_version = '1.1.1'
media3_version = '1.1.0'
core_ktx_version = '1.10.1'
}
dependencies {
classpath 'com.google.gms:google-services:4.3.15'
}
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
}
plugins {
id 'com.android.application' version '8.1.0' apply false
id 'com.android.library' version '8.1.0' apply false
id 'com.android.application' version '8.1.1' apply false
id 'com.android.library' version '8.1.1' apply false
id 'org.jetbrains.kotlin.android' version '1.9.0' apply false
id 'org.jetbrains.kotlin.jvm' version '1.9.0' apply false
id 'androidx.benchmark' version '1.1.1' apply false

View File

@@ -32,7 +32,7 @@ android {
}
dependencies {
implementation 'androidx.core:core-ktx:1.9.0'
implementation "androidx.core:core-ktx:$core_ktx_version"
// @Immutable and @Stable
implementation "androidx.compose.runtime:runtime:$compose_ui_version"

View File

@@ -24,7 +24,7 @@ class CryptoUtilsTest {
val privateKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561"
val publicKey = "765cd7cf91d3ad07423d114d5a39c61d52b2cdbc18ba055ddbbeec71fbe2aa2f"
val key = CryptoUtils.getSharedSecretNIP24(privateKey = privateKey.hexToByteArray(), pubKey = publicKey.hexToByteArray())
val key = CryptoUtils.getSharedSecretNIP44(privateKey = privateKey.hexToByteArray(), pubKey = publicKey.hexToByteArray())
assertEquals("577c966f499dddd8e8dcc34e8f352e283cc177e53ae372794947e0b8ede7cfd8", key.toHexKey())
}
@@ -34,8 +34,8 @@ class CryptoUtilsTest {
val sender = KeyPair()
val receiver = KeyPair()
val sharedSecret1 = CryptoUtils.getSharedSecretNIP24(sender.privKey!!, receiver.pubKey)
val sharedSecret2 = CryptoUtils.getSharedSecretNIP24(receiver.privKey!!, sender.pubKey)
val sharedSecret1 = CryptoUtils.getSharedSecretNIP44(sender.privKey!!, receiver.pubKey)
val sharedSecret2 = CryptoUtils.getSharedSecretNIP44(receiver.privKey!!, sender.pubKey)
assertEquals(sharedSecret1.toHexKey(), sharedSecret2.toHexKey())
@@ -73,14 +73,14 @@ class CryptoUtilsTest {
}
@Test
fun encryptDecryptNIP24Test() {
fun encryptDecryptNIP44Test() {
val msg = "Hi"
val privateKey = CryptoUtils.privkeyCreate()
val publicKey = CryptoUtils.pubkeyCreate(privateKey)
val encrypted = CryptoUtils.encryptNIP24(msg, privateKey, publicKey)
val decrypted = CryptoUtils.decryptNIP24(encrypted, privateKey, publicKey)
val encrypted = CryptoUtils.encryptNIP44(msg, privateKey, publicKey)
val decrypted = CryptoUtils.decryptNIP44(encrypted, privateKey, publicKey)
assertEquals(msg, decrypted)
}
@@ -99,15 +99,15 @@ class CryptoUtilsTest {
}
@Test
fun encryptSharedSecretDecryptNIP24Test() {
fun encryptSharedSecretDecryptNIP44Test() {
val msg = "Hi"
val privateKey = CryptoUtils.privkeyCreate()
val publicKey = CryptoUtils.pubkeyCreate(privateKey)
val sharedSecret = CryptoUtils.getSharedSecretNIP24(privateKey, publicKey)
val sharedSecret = CryptoUtils.getSharedSecretNIP44(privateKey, publicKey)
val encrypted = CryptoUtils.encryptNIP24(msg, sharedSecret)
val decrypted = CryptoUtils.decryptNIP24(encrypted, sharedSecret)
val encrypted = CryptoUtils.encryptNIP44(msg, sharedSecret)
val decrypted = CryptoUtils.decryptNIP44(encrypted, sharedSecret)
assertEquals(msg, decrypted)
}

View File

@@ -17,7 +17,7 @@ import javax.crypto.spec.SecretKeySpec
object CryptoUtils {
private val sharedKeyCache04 = LruCache<Int, ByteArray>(200)
private val sharedKeyCache24 = LruCache<Int, ByteArray>(200)
private val sharedKeyCache44 = LruCache<Int, ByteArray>(200)
private val secp256k1 = Secp256k1.get()
private val libSodium = SodiumAndroid()
@@ -26,7 +26,7 @@ object CryptoUtils {
fun clearCache() {
sharedKeyCache04.evictAll()
sharedKeyCache24.evictAll()
sharedKeyCache44.evictAll()
}
fun randomInt(bound: Int): Int {
@@ -115,12 +115,12 @@ object CryptoUtils {
return String(cipher.doFinal(encryptedMsg))
}
fun encryptNIP24(msg: String, privateKey: ByteArray, pubKey: ByteArray): EncryptedInfo {
val sharedSecret = getSharedSecretNIP24(privateKey, pubKey)
return encryptNIP24(msg, sharedSecret)
fun encryptNIP44(msg: String, privateKey: ByteArray, pubKey: ByteArray): EncryptedInfo {
val sharedSecret = getSharedSecretNIP44(privateKey, pubKey)
return encryptNIP44(msg, sharedSecret)
}
fun encryptNIP24(msg: String, sharedSecret: ByteArray): EncryptedInfo {
fun encryptNIP44(msg: String, sharedSecret: ByteArray): EncryptedInfo {
val nonce = ByteArray(24)
random.nextBytes(nonce)
@@ -134,16 +134,16 @@ object CryptoUtils {
return EncryptedInfo(
ciphertext = cipher ?: ByteArray(0),
nonce = nonce,
v = Nip44Version.NIP24.versionCode
v = Nip44Version.NIP44.versionCode
)
}
fun decryptNIP24(encryptedInfo: EncryptedInfo, privateKey: ByteArray, pubKey: ByteArray): String? {
val sharedSecret = getSharedSecretNIP24(privateKey, pubKey)
return decryptNIP24(encryptedInfo, sharedSecret)
fun decryptNIP44(encryptedInfo: EncryptedInfo, privateKey: ByteArray, pubKey: ByteArray): String? {
val sharedSecret = getSharedSecretNIP44(privateKey, pubKey)
return decryptNIP44(encryptedInfo, sharedSecret)
}
fun decryptNIP24(encryptedInfo: EncryptedInfo, sharedSecret: ByteArray): String? {
fun decryptNIP44(encryptedInfo: EncryptedInfo, sharedSecret: ByteArray): String? {
return cryptoStreamXChaCha20Xor(
libSodium = libSodium,
messageBytes = encryptedInfo.ciphertext,
@@ -174,20 +174,20 @@ object CryptoUtils {
/**
* @return 32B shared secret
*/
fun getSharedSecretNIP24(privateKey: ByteArray, pubKey: ByteArray): ByteArray {
fun getSharedSecretNIP44(privateKey: ByteArray, pubKey: ByteArray): ByteArray {
val hash = combinedHashCode(privateKey, pubKey)
val preComputed = sharedKeyCache24[hash]
val preComputed = sharedKeyCache44[hash]
if (preComputed != null) return preComputed
val computed = computeSharedSecretNIP24(privateKey, pubKey)
sharedKeyCache24.put(hash, computed)
val computed = computeSharedSecretNIP44(privateKey, pubKey)
sharedKeyCache44.put(hash, computed)
return computed
}
/**
* @return 32B shared secret
*/
fun computeSharedSecretNIP24(privateKey: ByteArray, pubKey: ByteArray): ByteArray =
fun computeSharedSecretNIP44(privateKey: ByteArray, pubKey: ByteArray): ByteArray =
sha256(secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33))
}
@@ -196,7 +196,7 @@ data class EncryptedInfoString(val ciphertext: String, val nonce: String, val v:
enum class Nip44Version(val versionCode: Int) {
NIP04(0),
NIP24(1)
NIP44(1)
}
@@ -234,10 +234,10 @@ fun decodeByteArray(base64: String): EncryptedInfo? {
fun encodeJackson(info: EncryptedInfo): String {
return Event.mapper.writeValueAsString(
EncryptedInfoString(
v = info.v,
nonce = Base64.getEncoder().encodeToString(info.nonce),
ciphertext = Base64.getEncoder().encodeToString(info.ciphertext)
)
v = info.v,
nonce = Base64.getEncoder().encodeToString(info.nonce),
ciphertext = Base64.getEncoder().encodeToString(info.ciphertext)
)
)
}

View File

@@ -15,9 +15,8 @@ class AdvertisedRelayListEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) {
override fun dTag() = fixedDTag
override fun address() = ATag(kind, pubKey, dTag(), null)
fun relays(): List<AdvertisedRelayInfo> {
return tags.mapNotNull {

View File

@@ -14,10 +14,7 @@ class AppDefinitionEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun appMetaData() = try {
mapper.readValue(
ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)),

View File

@@ -12,16 +12,11 @@ class AppRecommendationEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun recommendations() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
ATag.parse(it[1], it.getOrNull(2))
}
fun forKind() = runCatching { dTag().toInt() }.getOrNull()
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
companion object {
const val kind = 31989
}

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