mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30391160da | ||
|
|
3273aa32a5 | ||
|
|
1e358d31d5 | ||
|
|
b57dd98059 | ||
|
|
5b596d2a56 | ||
|
|
62db68c7b2 | ||
|
|
b3bb85cd53 | ||
|
|
fe6fb8470d | ||
|
|
ee97eac00c | ||
|
|
48e3ccc165 | ||
|
|
52c0503b6d | ||
|
|
c052399a16 | ||
|
|
76faf7ae35 | ||
|
|
36ea33a919 | ||
|
|
8aad4d8030 | ||
|
|
a23c8c5c04 | ||
|
|
0434ef6ec6 | ||
|
|
4d85a2f6fb | ||
|
|
e9b235f748 | ||
|
|
30320805da | ||
|
|
8e913ef974 | ||
|
|
eb191406e6 | ||
|
|
ca89b70150 | ||
|
|
6bcd6950ef | ||
|
|
dfa9c77e3e | ||
|
|
af143f2ea1 | ||
|
|
52761b3901 | ||
|
|
f027d23fcf | ||
|
|
2d30f3e9d2 | ||
|
|
a63b383425 | ||
|
|
e04eb733e7 | ||
|
|
0a49298ec2 | ||
|
|
1121cfcaf9 | ||
|
|
66b5fad6c2 | ||
|
|
5d352f1b6c | ||
|
|
9018175f54 | ||
|
|
98c55661e8 | ||
|
|
521407f879 | ||
|
|
fa4257ad7d | ||
|
|
d73dec8f0b | ||
|
|
c6124376df | ||
|
|
87c19f4232 | ||
|
|
a1f116909f | ||
|
|
ca9951f9b8 | ||
|
|
2bedd79e6f | ||
|
|
a9af2d8982 | ||
|
|
655a563f61 | ||
|
|
9777c709a1 | ||
|
|
251f4854fc | ||
|
|
43c32f0b0e | ||
|
|
ec3b07147c | ||
|
|
0e6a2c339e | ||
|
|
844f7cd478 | ||
|
|
a2edc9f3b2 | ||
|
|
35d09aee77 | ||
|
|
a79d1d5bd6 | ||
|
|
ba9315fcd1 | ||
|
|
f5c71b4f90 | ||
|
|
5a40b39952 | ||
|
|
2ac3b42d4d | ||
|
|
7cfad373f2 | ||
|
|
5d061a79e2 | ||
|
|
eb59a9d505 | ||
|
|
784f867a8e | ||
|
|
954a9d66fb | ||
|
|
dfdced3e8c | ||
|
|
0af141c5b7 | ||
|
|
70c4c69bbf | ||
|
|
5597189ea5 | ||
|
|
8884adcefb |
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
"\"${
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -120,7 +120,7 @@ fun JoinUserOrChannelView(searchBarViewModel: SearchBarViewModel, onClose: () ->
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
CloseButton(onPress = {
|
||||
searchBarViewModel.clear()
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
onClose()
|
||||
|
||||
@@ -51,7 +51,7 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
CloseButton(onPress = {
|
||||
postViewModel.clear()
|
||||
onClose()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -129,7 +129,7 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
|
||||
},
|
||||
navigationIcon = {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
CloseButton(onCancel = {
|
||||
CloseButton(onPress = {
|
||||
postViewModel.clear()
|
||||
onClose()
|
||||
})
|
||||
|
||||
@@ -67,7 +67,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
CloseButton(onPress = {
|
||||
postViewModel.clear()
|
||||
onClose()
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@ fun RelayInformationDialog(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
CloseButton(onPress = {
|
||||
onClose()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ fun RelaySelectionDialog(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(
|
||||
onCancel = {
|
||||
onPress = {
|
||||
onClose()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ fun AddBountyAmountDialog(bounty: Note, accountViewModel: AccountViewModel, onCl
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
CloseButton(onPress = {
|
||||
postViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -155,7 +155,7 @@ fun UpdateReactionTypeDialog(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
CloseButton(onPress = {
|
||||
postViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -94,7 +94,7 @@ fun ZapCustomDialog(onClose: () -> Unit, accountViewModel: AccountViewModel, bas
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
CloseButton(onPress = {
|
||||
postViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -91,6 +91,11 @@ class RelayFeedViewModel : ViewModel() {
|
||||
refreshSuspended()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
bundler.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
|
||||
@@ -16,5 +16,5 @@ class RelayPoolViewModel : ViewModel() {
|
||||
|
||||
val isConnected = RelayPool.live.map {
|
||||
it.relays.connectedRelays() > 0
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ fun AccountBackupDialog(account: Account, onClose: () -> Unit) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = onClose)
|
||||
CloseButton(onPress = onClose)
|
||||
}
|
||||
|
||||
Column(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ fun ConnectOrbotDialog(onClose: () -> Unit, onPost: () -> Unit, portNumber: Muta
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
CloseButton(onPress = {
|
||||
onClose()
|
||||
})
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
552
app/src/main/res/values-th/strings.xml
Normal file
552
app/src/main/res/values-th/strings.xml
Normal 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/<user>/<gist></string>
|
||||
<string name="telegram_proof_url_template" translatable="false">https://t.me/<proof post></string>
|
||||
<string name="mastodon_proof_url_template" translatable="false">https://<server>/<user>/<proof post></string>
|
||||
<string name="twitter_proof_url_template" translatable="false">https://twitter.com/<user>/status/<proof post></string>
|
||||
<string name="private_conversation_notification">"<ไม่สามารถถอดรหัสข้อความส่วนตัวได้>\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">(0–100)%</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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) })
|
||||
|
||||
17
build.gradle
17
build.gradle
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user