Compare commits

..
55 Commits
Author SHA1 Message Date
Vitor Pamplona 70890def82 v0.43.2 2023-05-08 09:43:31 -04:00
Vitor Pamplona d0eadcf30d Formatting 2023-05-08 09:39:54 -04:00
Vitor Pamplona ae268cc197 Fixing NostrFiles dev downloader 2023-05-08 09:39:44 -04:00
Vitor Pamplona d6d273e34c v0.43.1 2023-05-07 21:46:01 -04:00
Vitor Pamplona 462c73341f Fixes cutting text in the wrong language 2023-05-07 21:45:38 -04:00
Vitor Pamplona aa07fbfc94 Adjusting NostrFilesDev to the latest API 2023-05-07 18:47:39 -04:00
Vitor PamplonaandGitHub 59d7f7cc70 Merge pull request #396 from believethehype/nostrfilesdev
Added Nostrfiles.dev Image hoster Backend Code
2023-05-07 18:42:39 -04:00
Vitor PamplonaandGitHub 950eefa4e3 Merge branch 'main' into nostrfilesdev 2023-05-07 18:42:09 -04:00
Vitor Pamplona f2bbdd44a6 Removing file transform function 2023-05-07 18:40:24 -04:00
Vitor Pamplona cb09a26ef5 Refactoring the nostr.build uploader 2023-05-07 18:37:33 -04:00
Vitor Pamplona eba14cf9f1 v0.43.0 2023-05-07 16:14:18 -04:00
Vitor Pamplona 13de5973ca Improving performance of the RichText Viewer 2023-05-07 16:12:28 -04:00
Vitor Pamplona 936f750826 Moving private messages and channel loading to a thread. 2023-05-07 16:11:40 -04:00
Vitor Pamplona f44549de41 Loading Users and Notes in Launched Effects only because of @Synchronized in the getOrCreate methods in LocalCache 2023-05-07 12:51:12 -04:00
Vitor Pamplona 1c0fcc4ab8 Solves NPE when loading an invalid npub in a citation 2023-05-07 12:50:17 -04:00
Vitor Pamplona fcc6793244 Limiting AuthorGalleries to 50 people 2023-05-07 08:53:52 -04:00
Vitor Pamplona dea35552d5 Calculating ZapAmount and converting to the screen before exiting the IO thread 2023-05-06 22:27:00 -04:00
Vitor Pamplona b093873eb0 Avoids trying to decrypt a zap that was not sent by the author. 2023-05-06 22:17:43 -04:00
Vitor Pamplona e5de2c45de Moving withContext to scope.launch to avoid waiting the results. 2023-05-06 21:58:25 -04:00
Vitor Pamplona 70d8d9e2e0 formatting 2023-05-06 20:59:24 -04:00
Vitor Pamplona dc0fe75b3d Removing crossfades that might reduce the loading speed 2023-05-06 20:59:09 -04:00
Vitor Pamplona 25a34dd2e8 formatting 2023-05-06 20:03:04 -04:00
Vitor Pamplona 90227e1cdc Refactoring NIP05 verification 2023-05-06 20:02:54 -04:00
Vitor Pamplona bba45dd0a7 Moving clickable to Row and not AsyncImage because it's faster 2023-05-06 19:19:57 -04:00
Vitor Pamplona fdca0af97b Improving performance of the UserNameDisplay by making variables immutable 2023-05-06 19:18:15 -04:00
Vitor Pamplona 795120e932 Improving performance of Badge events 2023-05-06 19:17:39 -04:00
Vitor Pamplona 79da9cee01 NoteCompose refactoring 2023-05-06 11:49:53 -04:00
Vitor Pamplona fbbb9b0d72 Increasing sensitivity for Language identification options 2023-05-06 09:53:44 -04:00
Vitor Pamplona 97d56d61d1 Caching Private Zap decryption 2023-05-06 09:46:25 -04:00
Vitor Pamplona f3316f53ea Renames Hex.toByteArray() to avoid confusing with String.toByteArray() 2023-05-06 09:24:39 -04:00
Vitor Pamplona e3ccf71a84 v0.42.5 2023-05-05 18:50:19 -04:00
Vitor Pamplona 635279d59a Testing removing the gradient of quoted posts. 2023-05-05 18:49:32 -04:00
Vitor Pamplona 0c673e91c6 BugFix for displaying other people's list after loading them from their profile 2023-05-05 18:49:13 -04:00
Vitor Pamplona 3519e20a98 BugFix for the empty selection 2023-05-05 18:48:37 -04:00
Vitor Pamplona 7167d57d22 v0.42.4 2023-05-05 13:49:41 -04:00
Vitor Pamplona 86adac95c9 Adds protections for calculating zap amounts without a private key 2023-05-05 13:49:18 -04:00
Vitor Pamplona cffcc158f5 0.42.3 2023-05-05 13:29:20 -04:00
Vitor Pamplona 49a8778975 BugFix for NPE on null ZappedNote 2023-05-05 13:26:55 -04:00
Believethehype dd62d2bae4 activated nostrfiles_dev for stories feed. 2023-05-05 19:25:31 +02:00
Believethehype 3c3b6c97c0 deactivate nostrfiles_dev selection for now (because of space) 2023-05-05 19:21:25 +02:00
Believethehype dabeea43ea added hoster nostrfiles.dev 2023-05-05 18:59:24 +02:00
Vitor Pamplona 5b6b6f19e5 Reverting background refactoring due to color being stuck in recompositions. 2023-05-05 10:01:37 -04:00
Vitor PamplonaandGitHub 28370dbc01 Merge pull request #395 from ZsZolee/main
Follow list Hungarian translation
2023-05-05 09:11:03 -04:00
ZoltanandGitHub ab841c6490 Follow list Hungarian translation 2023-05-05 10:38:53 +02:00
Vitor Pamplona 12ff434a07 v0.42.2 2023-05-04 22:35:08 -04:00
Vitor Pamplona 51432c8c50 Fixing the toByteArray call 2023-05-04 22:34:09 -04:00
Vitor Pamplona 483e7d673c Refactoring part of NoteCompose 2023-05-04 21:57:19 -04:00
Vitor Pamplona 3ce6ce9951 v0.42.1 2023-05-04 21:14:55 -04:00
Vitor Pamplona 84f51dc473 Removing animation from blurhash 2023-05-04 21:14:42 -04:00
Vitor Pamplona 5f4886ce52 Fixes migration of follow lists from old accounts 2023-05-04 21:10:09 -04:00
Vitor Pamplona dea6f0ca21 v0.42.0 2023-05-04 20:10:01 -04:00
Vitor Pamplona 01bdc41286 Merge remote-tracking branch 'origin/HEAD' 2023-05-04 20:08:50 -04:00
Vitor Pamplona 8c2e89197e Adds support for Private and Public Follow Lists 2023-05-04 20:08:36 -04:00
Vitor PamplonaandGitHub 712950c2a1 Merge pull request #393 from believethehype/hashtag_fixes
Update HashtagIcon.kt
2023-05-04 11:49:38 -04:00
Believethehype 95cc2ee589 Update HashtagIcon.kt 2023-05-04 13:29:00 +02:00
91 changed files with 2602 additions and 1452 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 141
versionName "0.41.0"
versionCode 150
versionName "0.43.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -4,12 +4,12 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
import com.vitorpamplona.amethyst.ui.actions.ImgurServer
import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer
import com.vitorpamplona.amethyst.ui.actions.NostrFilesDevServer
import com.vitorpamplona.amethyst.ui.actions.NostrImgServer
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.fail
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import java.util.Base64
@@ -21,12 +21,12 @@ class ImageUploadTesting {
@Test()
fun testImgurUpload() = runBlocking {
val inputStream = Base64.getDecoder().decode(image).inputStream()
println("Uploading")
val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
ImageUploader.uploadImage(
inputStream,
bytes.size.toLong(),
"image/gif",
ImgurServer(),
onSuccess = { url, contentType ->
@@ -39,18 +39,17 @@ class ImageUploadTesting {
}
)
delay(1000)
delay(5000)
}
@Test()
@Ignore
fun testNostrBuildUpload() = runBlocking {
val inputStream = Base64.getDecoder().decode(image).inputStream()
println("Uploading")
val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
ImageUploader.uploadImage(
inputStream,
bytes.size.toLong(),
"image/gif",
NostrBuildServer(),
onSuccess = { url, contentType ->
@@ -68,12 +67,12 @@ class ImageUploadTesting {
@Test()
fun testNostrImgUpload() = runBlocking {
val inputStream = Base64.getDecoder().decode(image).inputStream()
println("Uploading")
val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
ImageUploader.uploadImage(
inputStream,
bytes.size.toLong(),
"image/gif",
NostrImgServer(),
onSuccess = { url, contentType ->
@@ -88,4 +87,27 @@ class ImageUploadTesting {
delay(1000)
}
@Test()
fun testNostrFilesDevUpload() = runBlocking {
val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
ImageUploader.uploadImage(
inputStream,
bytes.size.toLong(),
"image/gif",
NostrFilesDevServer(),
onSuccess = { url, contentType ->
println("Uploaded to $url")
assertNotNull(url)
},
onError = {
println("Failed to Upload")
fail("${it.message}")
}
)
delay(5000)
}
}
@@ -78,7 +78,7 @@ class PrivateZapTests {
if (recepientPK != null && recepientPost != null) {
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
val decodedPrivateZap =
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
privateZapRequest.getPrivateZapEvent(privateKey, recepientPK)
println(decodedPrivateZap?.toJson())
assertNotNull(decodedPrivateZap)
@@ -127,8 +127,7 @@ class PrivateZapTests {
if (recepientPK != null && recepientPost != null) {
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
val decodedPrivateZap =
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
val decodedPrivateZap = privateZapRequest.getPrivateZapEvent(privateKey, recepientPK)
println(decodedPrivateZap?.toJson())
assertNotNull(decodedPrivateZap)
@@ -6,8 +6,10 @@ import android.content.SharedPreferences
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
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.RelaySetupInfo
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
@@ -46,6 +48,8 @@ private object PrefKeys {
const val ZAP_AMOUNTS = "zapAmounts"
const val DEFAULT_ZAPTYPE = "defaultZapType"
const val DEFAULT_FILE_SERVER = "defaultFileServer"
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
@@ -197,6 +201,8 @@ object LocalPreferences {
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType))
putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer))
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList)
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
@@ -217,6 +223,8 @@ object LocalPreferences {
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val zapAmountChoices = gson.fromJson(
getString(PrefKeys.ZAP_AMOUNTS, "[]"),
@@ -268,7 +276,7 @@ object LocalPreferences {
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
val a = Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
followingChannels,
hiddenUsers,
localRelays,
@@ -278,6 +286,8 @@ object LocalPreferences {
zapAmountChoices,
defaultZapType,
defaultFileServer,
defaultHomeFollowList,
defaultStoriesFollowList,
zapPaymentRequestServer,
hideDeleteRequestDialog,
hideBlockAlertDialog,
@@ -12,6 +12,7 @@ import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Constants
@@ -33,6 +34,7 @@ object ServiceManager {
NostrAccountDataSource.account = myAccount
NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrVideoDataSource.account = myAccount
// Notification Elements
NostrHomeDataSource.start()
@@ -61,6 +63,7 @@ object ServiceManager {
NostrSingleUserDataSource.stop()
NostrThreadDataSource.stop()
NostrUserProfileDataSource.stop()
NostrVideoDataSource.stop()
Client.disconnect()
}
@@ -38,6 +38,9 @@ fun getLanguagesSpokenByUser(): Set<String> {
return codedList
}
val GLOBAL_FOLLOWS = " Global "
val KIND3_FOLLOWS = " All Follows "
@OptIn(DelicateCoroutinesApi::class)
class Account(
val loggedIn: Persona,
@@ -50,6 +53,8 @@ class Account(
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE,
var defaultFileServer: ServersAvailable = ServersAvailable.IMGUR,
var defaultHomeFollowList: String = KIND3_FOLLOWS,
var defaultStoriesFollowList: String = GLOBAL_FOLLOWS,
var zapPaymentRequest: Nip47URI? = null,
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
@@ -173,44 +178,49 @@ class Account(
}
fun isNIP47Author(pubkeyHex: String?): Boolean {
val privKey = zapPaymentRequest?.secret?.toByteArray() ?: loggedIn.privKey!!
val privKey = zapPaymentRequest?.secret?.hexToByteArray() ?: loggedIn.privKey
if (privKey == null) return false
val pubKey = Utils.pubkeyCreate(privKey).toHexKey()
return (pubKey == pubkeyHex)
}
fun decryptZapPaymentResponseEvent(zapResponseEvent: LnZapPaymentResponseEvent): Response? {
val myNip47 = zapPaymentRequest ?: return null
return zapResponseEvent.response(
myNip47.secret?.toByteArray() ?: loggedIn.privKey!!,
myNip47.pubKeyHex.toByteArray()
)
val privKey = myNip47.secret?.hexToByteArray() ?: loggedIn.privKey
val pubKey = myNip47.pubKeyHex.hexToByteArray()
if (privKey == null) return null
return zapResponseEvent.response(privKey, pubKey)
}
fun calculateIfNoteWasZappedByAccount(zappedNote: Note): Boolean {
return zappedNote.isZappedBy(userProfile(), this) == true
fun calculateIfNoteWasZappedByAccount(zappedNote: Note?): Boolean {
return zappedNote?.isZappedBy(userProfile(), this) == true
}
fun calculateZappedAmount(zappedNote: Note?): BigDecimal {
return zappedNote?.zappedAmount(
zapPaymentRequest?.secret?.toByteArray() ?: loggedIn.privKey!!,
zapPaymentRequest?.pubKeyHex?.toByteArray()
) ?: BigDecimal.ZERO
val privKey = zapPaymentRequest?.secret?.hexToByteArray() ?: loggedIn.privKey
val pubKey = zapPaymentRequest?.pubKeyHex?.hexToByteArray()
return zappedNote?.zappedAmount(privKey, pubKey) ?: BigDecimal.ZERO
}
fun sendZapPaymentRequestFor(bolt11: String, zappedNote: Note?, onResponse: (Response?) -> Unit) {
if (!isWriteable()) return
zapPaymentRequest?.let { nip47 ->
val event = LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, nip47.secret?.toByteArray() ?: loggedIn.privKey!!)
val event = LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, nip47.secret?.hexToByteArray() ?: loggedIn.privKey!!)
val wcListener = NostrLnZapPaymentResponseDataSource(nip47.pubKeyHex, event.pubKey, event.id)
wcListener.start()
LocalCache.consume(event, zappedNote) {
// After the response is received.
val privKey = nip47.secret?.toByteArray()
val privKey = nip47.secret?.hexToByteArray()
if (privKey != null) {
onResponse(it.response(privKey, nip47.pubKeyHex.toByteArray()))
onResponse(it.response(privKey, nip47.pubKeyHex.hexToByteArray()))
}
}
@@ -403,12 +413,12 @@ class Account(
}
}
fun createNip95(data: ByteArray, headerInfo: FileHeader): Pair<FileStorageEvent, FileStorageHeaderEvent>? {
fun createNip95(byteArray: ByteArray, headerInfo: FileHeader): Pair<FileStorageEvent, FileStorageHeaderEvent>? {
if (!isWriteable()) return null
val data = FileStorageEvent.create(
mimeType = headerInfo.mimeType ?: "",
data = data,
data = byteArray,
privateKey = loggedIn.privKey!!
)
@@ -729,6 +739,18 @@ class Account(
saveable.invalidateData()
}
fun changeDefaultHomeFollowList(name: String) {
defaultHomeFollowList = name
live.invalidateData()
saveable.invalidateData()
}
fun changeDefaultStoriesFollowList(name: String) {
defaultStoriesFollowList = name
live.invalidateData()
saveable.invalidateData()
}
fun changeZapAmounts(newAmounts: List<Long>) {
zapAmountChoices = newAmounts
live.invalidateData()
@@ -741,6 +763,37 @@ class Account(
saveable.invalidateData()
}
fun selectedUsersFollowList(listName: String?): Set<String>? {
if (listName == GLOBAL_FOLLOWS) return null
if (listName == KIND3_FOLLOWS) return userProfile().cachedFollowingKeySet()
val privKey = loggedIn.privKey
return if (listName != null) {
val aTag = ATag(PeopleListEvent.kind, userProfile().pubkeyHex, listName, null).toTag()
val list = LocalCache.addressables[aTag]
if (list != null) {
val publicHexList = (list.event as? PeopleListEvent)?.bookmarkedPeople() ?: emptySet()
val privateHexList = privKey?.let {
(list.event as? PeopleListEvent)?.privateTaggedUsers(it)
} ?: emptySet()
(publicHexList + privateHexList).toSet()
} else {
emptySet()
}
} else {
emptySet()
}
}
fun selectedTagsFollowList(listName: String?): Set<String>? {
if (listName == GLOBAL_FOLLOWS) return null
if (listName == KIND3_FOLLOWS) return userProfile().cachedFollowingTagSet()
return emptySet()
}
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
if (!isWriteable()) return
@@ -767,13 +820,13 @@ class Account(
return if (event is PrivateDmEvent && loggedIn.privKey != null) {
var pubkeyToUse = event.pubKey
val recepientPK = event.recipientPubKey()
val recepientPK = event.verifiedRecipientPubKey()
if (note.author == userProfile() && recepientPK != null) {
pubkeyToUse = recepientPK
}
event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray())
event.plainContent(loggedIn.privKey!!, pubkeyToUse.hexToByteArray())
} else if (event is LnZapRequestEvent && loggedIn.privKey != null) {
decryptZapContentAuthor(note)?.content()
} else {
@@ -794,7 +847,7 @@ class Account(
val privateKeyToUse = loggedInPrivateKey
val pubkeyToUse = event.pubKey
LnZapRequestEvent.checkForPrivateZap(event, privateKeyToUse, pubkeyToUse)
event.getPrivateZapEvent(privateKeyToUse, pubkeyToUse)
} else {
// if the sender is logged in, these are the params
val altPubkeyToUse = recipientPK
@@ -815,11 +868,21 @@ class Account(
}
if (altPrivateKeyToUse != null && altPubkeyToUse != null) {
val result = LnZapRequestEvent.checkForPrivateZap(event, altPrivateKeyToUse, altPubkeyToUse)
if (result == null) {
Log.w("Private ZAP Decrypt", "Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}")
val altPubKeyFromPrivate = Utils.pubkeyCreate(altPrivateKeyToUse).toHexKey()
if (altPubKeyFromPrivate == event.pubKey) {
val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse)
if (result == null) {
Log.w(
"Private ZAP Decrypt",
"Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}"
)
}
result
} else {
null
}
result
} else {
null
}
@@ -14,7 +14,7 @@ fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp))
"coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
"grownostr", "#permaculture", "#gardening", "#garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
"grownostr", "gardening", "garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
"footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
else -> null
}
@@ -29,7 +29,7 @@ fun ByteArray.toHexKey(): HexKey {
return toHex()
}
fun HexKey.toByteArray(): ByteArray {
fun HexKey.hexToByteArray(): ByteArray {
return Hex.decode(this)
}
@@ -39,7 +39,7 @@ fun HexKey.toDisplayHexKey(): String {
fun decodePublicKey(key: String): ByteArray {
val parsed = Nip19.uriToRoute(key)
val pubKeyParsed = parsed?.hex?.toByteArray()
val pubKeyParsed = parsed?.hex?.hexToByteArray()
return if (key.startsWith("nsec")) {
Persona(privKey = key.bechToBytes()).pubKey
@@ -159,6 +159,20 @@ object LocalCache {
}
}
fun consume(event: PeopleListEvent) {
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
// 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"))
@@ -345,7 +359,7 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
val recipient = event.recipientPubKey()?.let { getOrCreateUser(it) }
val recipient = event.verifiedRecipientPubKey()?.let { getOrCreateUser(it) }
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
@@ -393,7 +407,7 @@ object LocalCache {
if (deleteNote.event is PrivateDmEvent) {
val author = deleteNote.author
val recipient = (deleteNote.event as? PrivateDmEvent)?.recipientPubKey()?.let { checkGetOrCreateUser(it) }
val recipient = (deleteNote.event as? PrivateDmEvent)?.verifiedRecipientPubKey()?.let { checkGetOrCreateUser(it) }
if (recipient != null && author != null) {
author.removeMessage(recipient, deleteNote)
@@ -6,7 +6,7 @@ import kotlin.time.measureTimedValue
class ThreadAssembler {
fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
private fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
testedNotes.add(note)
@@ -181,7 +181,7 @@ class User(val pubkeyHex: String) {
}
@Synchronized
fun getOrCreatePrivateChatroom(user: User): Chatroom {
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
return privateChatrooms[user] ?: run {
val privateChatroom = Chatroom(setOf<Note>())
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
@@ -59,7 +59,6 @@ object BlurHashRequester {
.Builder(context)
.data("bluehash:$encodedMessage")
.fetcherFactory(BlurHashFetcher.Factory)
.crossfade(100)
.build()
}
}
@@ -4,8 +4,8 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import io.trbl.blurhash.BlurHash
import java.net.URL
import java.security.MessageDigest
import kotlin.math.roundToInt
@@ -19,11 +19,15 @@ class FileHeader(
val description: String? = null
) {
companion object {
fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
suspend fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
try {
val imageData = URL(fileUrl).readBytes()
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
if (imageData != null) {
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
} else {
onError()
}
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
onError()
@@ -62,9 +62,9 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BookmarkListEvent.kind),
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
limit = 100
)
)
}
@@ -90,6 +90,7 @@ abstract class NostrDataSource(val debugName: String) {
is LongTextNoteEvent -> LocalCache.consume(event, relay)
is MetadataEvent -> LocalCache.consume(event)
is PrivateDmEvent -> LocalCache.consume(event, relay)
is PeopleListEvent -> LocalCache.consume(event)
is ReactionEvent -> LocalCache.consume(event)
is RecommendRelayEvent -> LocalCache.consume(event)
is ReportEvent -> LocalCache.consume(event, relay)
@@ -45,7 +45,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
}
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.followingKeySet()
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followKeys = follows.map {
it.substring(0, 6)
@@ -65,7 +65,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.followingTagSet()
val hashToLoad = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
if (hashToLoad.isEmpty()) return null
@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
@@ -10,13 +9,8 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
var user: User? = null
fun loadUserProfile(userId: String?) {
if (userId != null) {
user = LocalCache.getOrCreateUser(userId)
} else {
user = null
}
fun loadUserProfile(user: User?) {
this.user = user
resetFilters()
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
@@ -7,17 +8,47 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
limit = 200
lateinit var account: Account
fun createContextualFilter(): TypedFilter? {
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)
val followKeys = follows?.map {
it.substring(0, 6)
}
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = followKeys,
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
limit = 200
)
)
)
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultStoriesFollowList)
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 100
)
)
}
val videoFeedChannel = requestNewChannel()
override fun updateChannelFilters() {
videoFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
videoFeedChannel.typedFilters = listOfNotNull(createContextualFilter(), createFollowTagsFilter()).ifEmpty { null }
}
}
@@ -1,7 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.nip19.Tlv
import fr.acinq.secp256k1.Hex
@@ -14,7 +14,7 @@ data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String, val rela
fun toNAddr(): String {
val kind = kind.toByteArray()
val author = pubKeyHex.toByteArray()
val author = pubKeyHex.hexToByteArray()
val dTag = dTag.toByteArray(Charsets.UTF_8)
val relay = relay?.toByteArray(Charsets.UTF_8)
@@ -10,13 +10,9 @@ class BadgeAwardEvent(
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun awardees() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun awardDefinition() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
fun awardees() = taggedUsers()
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
fun awardDefinition() = taggedAddresses()
companion object {
const val kind = 8
@@ -10,13 +10,13 @@ class BadgeDefinitionEvent(
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
fun name() = tags.filter { it.firstOrNull() == "name" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
fun thumb() = tags.filter { it.firstOrNull() == "thumb" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
fun image() = tags.filter { it.firstOrNull() == "image" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
fun description() = tags.filter { it.firstOrNull() == "description" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1)
fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == "thumb" }?.get(1)
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
companion object {
const val kind = 30009
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.tagSearch
import com.vitorpamplona.amethyst.service.nip19.Nip19
@@ -41,17 +42,18 @@ open class BaseTextNoteEvent(
val key = matcher2.group(3) // bech32
val additionalChars = matcher2.group(4) // additional chars
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
try {
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
if (parsed != null) {
try {
if (parsed != null) {
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
if (tag != null && tag[0] == "p") {
returningList.add(tag[1])
}
} catch (e: Exception) {
}
} catch (e: Exception) {
Log.w("Unable to parse cited users that matched a NIP19 regex", e)
}
}
@@ -1,9 +1,6 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@@ -15,50 +12,7 @@ class BookmarkListEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
fun category() = dTag()
fun bookmarkedPosts() = tags.filter { it[0] == "e" }.mapNotNull { it.getOrNull(1) }
fun plainContent(privKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
return Utils.decrypt(content, sharedSecret)
} catch (e: Exception) {
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
null
}
}
@Transient
private var privateTagsCache: List<List<String>>? = null
fun privateTags(privKey: ByteArray): List<List<String>>? {
if (privateTagsCache != null) {
return privateTagsCache
}
privateTagsCache = try {
gson.fromJson(plainContent(privKey), object : TypeToken<List<List<String>>>() {}.type)
} catch (e: Throwable) {
Log.w("BookmarkList", "Error parsing the JSON ${e.message}")
null
}
return privateTagsCache
}
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "e" }?.mapNotNull { it.getOrNull(1) }
fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
companion object {
const val kind = 30001
@@ -77,24 +31,7 @@ class BookmarkListEvent(
createdAt: Long = Date().time / 1000
): BookmarkListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val privTags = mutableListOf<List<String>>()
privEvents?.forEach {
privTags.add(listOf("e", it))
}
privUsers?.forEach {
privTags.add(listOf("p", it))
}
privAddresses?.forEach {
privTags.add(listOf("a", it.toTag()))
}
val msg = gson.toJson(privTags)
val content = Utils.encrypt(
msg,
privateKey,
pubKey
)
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
val tags = mutableListOf<List<String>>()
tags.add(listOf("d", name))
@@ -38,6 +38,8 @@ open class Event(
override fun toJson(): String = gson.toJson(this)
fun hasAnyTaggedUser() = tags.any { it.size > 1 && it[0] == "p" }
fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
@@ -83,7 +85,7 @@ open class Event(
override fun getReward(): BigDecimal? {
return try {
tags.filter { it.firstOrNull() == "reward" }.mapNotNull { it.getOrNull(1)?.let { BigDecimal(it) } }.firstOrNull()
tags.firstOrNull { it.size > 1 && it[0] == "reward" }?.get(1)?.let { BigDecimal(it) }
} catch (e: Exception) {
null
}
@@ -238,6 +240,7 @@ open class Event(
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
PeopleListEvent.kind -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig)
PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,90 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.hexToByteArray
import nostr.postr.Utils
abstract class GeneralListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
fun category() = dTag()
fun bookmarkedPosts() = taggedEvents()
fun bookmarkedPeople() = taggedUsers()
fun plainContent(privKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.hexToByteArray())
return Utils.decrypt(content, sharedSecret)
} catch (e: Exception) {
Log.w("GeneralList", "Error decrypting the message ${e.message}")
null
}
}
@Transient
private var privateTagsCache: List<List<String>>? = null
fun privateTags(privKey: ByteArray): List<List<String>>? {
if (privateTagsCache != null) {
return privateTagsCache
}
privateTagsCache = try {
gson.fromJson(plainContent(privKey), object : TypeToken<List<List<String>>>() {}.type)
} catch (e: Throwable) {
Log.w("GeneralList", "Error parsing the JSON ${e.message}")
null
}
return privateTagsCache
}
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "p" }?.map { it[1] }
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "e" }?.map { it[1] }
fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
companion object {
fun createPrivateTags(
privEvents: List<String>? = null,
privUsers: List<String>? = null,
privAddresses: List<ATag>? = null,
privateKey: ByteArray,
pubKey: ByteArray
): String {
val privTags = mutableListOf<List<String>>()
privEvents?.forEach {
privTags.add(listOf("e", it))
}
privUsers?.forEach {
privTags.add(listOf("p", it))
}
privAddresses?.forEach {
privTags.add(listOf("a", it.toTag()))
}
val msg = gson.toJson(privTags)
return Utils.encrypt(
msg,
privateKey,
pubKey
)
}
}
}
@@ -6,7 +6,7 @@ import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.lang.reflect.Type
@@ -21,9 +21,17 @@ class LnZapPaymentRequestEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
// Once one of an app user decrypts the payment, all users else can see it.
@Transient
private var lnInvoice: String? = null
fun walletServicePubKey() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1)
fun lnInvoice(privKey: ByteArray, pubkey: ByteArray): String? {
if (lnInvoice != null) {
return lnInvoice
}
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubkey)
@@ -31,7 +39,9 @@ class LnZapPaymentRequestEvent(
val payInvoiceMethod = gson.fromJson(jsonText, Request::class.java)
return (payInvoiceMethod as? PayInvoiceMethod)?.params?.invoice
lnInvoice = (payInvoiceMethod as? PayInvoiceMethod)?.params?.invoice
return lnInvoice
} catch (e: Exception) {
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
null
@@ -53,7 +63,7 @@ class LnZapPaymentRequestEvent(
val content = Utils.encrypt(
serializedRequest,
privateKey,
walletServicePubkey.toByteArray()
walletServicePubkey.hexToByteArray()
)
val tags = mutableListOf<List<String>>()
@@ -19,10 +19,14 @@ class LnZapPaymentResponseEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
// Once one of an app user decrypts the payment, all users else can see it.
@Transient
private var response: Response? = null
fun requestAuthor() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1)
fun requestId() = tags.firstOrNull() { it.size > 1 && it[0] == "e" }?.get(1)
fun decrypt(privKey: ByteArray, pubKey: ByteArray): String? {
private fun decrypt(privKey: ByteArray, pubKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey)
@@ -39,16 +43,21 @@ class LnZapPaymentResponseEvent(
}
}
fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try {
if (content.isNotEmpty()) {
val decrypted = decrypt(privKey, pubKey)
gson.fromJson(decrypted, Response::class.java)
} else {
fun response(privKey: ByteArray, pubKey: ByteArray): Response? {
if (response != null) response
return try {
if (content.isNotEmpty()) {
val decrypted = decrypt(privKey, pubKey)
response = gson.fromJson(decrypted, Response::class.java)
response
} else {
null
}
} catch (e: Exception) {
Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e)
null
}
} catch (e: Exception) {
Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e)
null
}
companion object {
@@ -20,12 +20,37 @@ class LnZapRequestEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient
private var privateZapEvent: Event? = null
fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() }
fun getPrivateZapEvent(loggedInUserPrivKey: ByteArray, pubKey: HexKey): Event? {
if (privateZapEvent != null) return privateZapEvent
val anonTag = tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" }
if (anonTag != null) {
val encnote = anonTag[1]
if (encnote.isNotBlank()) {
try {
val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.hexToByteArray())
val decryptedEvent = fromJson(note)
if (decryptedEvent.kind == 9733) {
privateZapEvent = decryptedEvent
return privateZapEvent
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
return null
}
companion object {
const val kind = 9734
@@ -59,7 +84,7 @@ class LnZapRequestEvent(
} else if (zapType == LnZapEvent.ZapType.PRIVATE) {
var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), originalNote.id(), createdAt)
var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson()
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, originalNote.pubKey().toByteArray())
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, originalNote.pubKey().hexToByteArray())
tags = tags + listOf(listOf("anon", encryptedContent))
content = "" // make sure public content is empty, as the content is encrypted
privkey = encryptionPrivateKey // sign event with generated privkey
@@ -92,7 +117,7 @@ class LnZapRequestEvent(
} else if (zapType == LnZapEvent.ZapType.PRIVATE) {
var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), userHex, createdAt)
var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson()
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, userHex.toByteArray())
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, userHex.hexToByteArray())
tags = tags + listOf(listOf("anon", encryptedContent))
content = ""
privkey = encryptionPrivateKey
@@ -109,7 +134,7 @@ class LnZapRequestEvent(
return sha256.digest(strbyte)
}
fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
private fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
var sharedSecret = Utils.getSharedSecret(privkey, pubkey)
val iv = ByteArray(16)
SecureRandom().nextBytes(iv)
@@ -128,7 +153,7 @@ class LnZapRequestEvent(
return encryptedMsgBech32 + "_" + ivBech32
}
fun decryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
private fun decryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
var sharedSecret = Utils.getSharedSecret(privkey, pubkey)
if (sharedSecret.size != 16 && sharedSecret.size != 32) {
throw IllegalArgumentException("Invalid shared secret size")
@@ -150,61 +175,5 @@ class LnZapRequestEvent(
throw IllegalArgumentException("Bad padding: ${ex.message}")
}
}
fun checkForPrivateZap(zapRequest: LnZapRequestEvent, loggedInUserPrivKey: ByteArray, pubKey: HexKey): Event? {
val anonTag = zapRequest.tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" }
if (anonTag != null) {
val encnote = anonTag[1]
if (encnote.isNotBlank()) {
try {
val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.toByteArray())
val decryptedEvent = fromJson(note)
if (decryptedEvent.kind == 9733) {
return decryptedEvent
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
return null
}
}
}
/*
{
"pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245",
"content": "",
"id": "d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d",
"created_at": 1674164539,
"sig": "77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d",
"kind": 9734,
"tags": [
[
"e",
"3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8"
],
[
"p",
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"
],
[
"relays",
"wss://relay.damus.io",
"wss://nostr-relay.wlvs.space",
"wss://nostr.fmt.wiz.biz",
"wss://relay.nostr.bg",
"wss://nostr.oxtr.dev",
"wss://nostr.v0l.io",
"wss://brb.io",
"wss://nostr.bitcoiner.social",
"ws://monad.jb55.com:8080",
"wss://relay.snort.social"
],
[
"poll_option", "n"
]
],
"ots": <base64-encoded OTS file data> // TODO
}
*/
@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@@ -21,7 +21,7 @@ class MuteListEvent(
fun plainContent(privKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.hexToByteArray())
return Utils.decrypt(content, sharedSecret)
} catch (e: Exception) {
@@ -0,0 +1,54 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class PeopleListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
companion object {
const val kind = 30000
fun create(
name: String = "",
events: List<String>? = null,
users: List<String>? = null,
addresses: List<ATag>? = null,
privEvents: List<String>? = null,
privUsers: List<String>? = null,
privAddresses: List<ATag>? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): PeopleListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
val tags = mutableListOf<List<String>>()
tags.add(listOf("d", name))
events?.forEach {
tags.add(listOf("e", it))
}
users?.forEach {
tags.add(listOf("p", it))
}
addresses?.forEach {
tags.add(listOf("a", it.toTag()))
}
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return PeopleListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -21,7 +21,11 @@ class PrivateDmEvent(
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
* for initial messages.
*/
fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one
fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }
fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this[1]) }?.getOrNull()
fun verifiedRecipientPubKey() = recipientPubKey()?.runCatching { Hex.decode(this[1]).toHexKey() }?.getOrNull() // makes sure its a valid one
/**
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
@@ -31,6 +35,11 @@ class PrivateDmEvent(
*/
fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
fun with(pubkeyHex: String): Boolean {
return pubkeyHex == pubKey ||
tags.firstOrNull { it.size > 1 && it[0] == "p" }?.getOrNull(1) == pubkeyHex
}
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey)
@@ -1,7 +1,7 @@
package com.vitorpamplona.amethyst.service.nip19
import android.util.Log
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Bech32
import nostr.postr.bechToBytes
@@ -147,8 +147,8 @@ object Nip19 {
public fun createNEvent(idHex: String, author: String?, kind: Int?, relay: String?): String {
val kind = kind?.toByteArray()
val author = author?.toByteArray()
val idHex = idHex.toByteArray()
val author = author?.hexToByteArray()
val idHex = idHex.hexToByteArray()
val relay = relay?.toByteArray(Charsets.UTF_8)
var fullArray = byteArrayOf(Tlv.Type.SPECIAL.id, idHex.size.toByte()) + idHex
@@ -0,0 +1,47 @@
package com.vitorpamplona.amethyst.ui.actions
import kotlinx.coroutines.delay
import java.net.HttpURLConnection
import java.net.URL
class ImageDownloader {
suspend fun waitAndGetImage(imageUrl: String): ByteArray? {
var imageData: ByteArray? = null
var tentatives = 0
// Servers are usually not ready.. so tries to download it for 15 times/seconds.
while (imageData == null && tentatives < 15) {
imageData = try {
HttpURLConnection.setFollowRedirects(true)
var url = URL(imageUrl)
var huc = url.openConnection() as HttpURLConnection
huc.instanceFollowRedirects = true
var responseCode = huc.responseCode
if (responseCode in 300..400) {
val newUrl: String = huc.getHeaderField("Location")
// open the new connnection again
url = URL(newUrl)
huc = url.openConnection() as HttpURLConnection
responseCode = huc.responseCode
}
if (responseCode in 200..300) {
huc.inputStream.use { it.readBytes() }
} else {
tentatives++
delay(1000)
null
}
} catch (e: Exception) {
tentatives++
delay(1000)
null
}
}
return imageData
}
}
@@ -1,20 +1,15 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
import android.webkit.MimeTypeMap
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.asRequestBody
import okio.BufferedSink
import okio.source
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
@@ -27,43 +22,51 @@ object ImageUploader {
fun uploadImage(
uri: Uri,
server: ServersAvailable,
context: Context,
contentResolver: ContentResolver,
onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit
) {
val contentType = contentResolver.getType(uri)
val imageInputStream = contentResolver.openInputStream(uri)
val length = contentResolver.query(uri, null, null, null, null)?.use {
it.moveToFirst()
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
it.getLong(sizeIndex)
} ?: 0
checkNotNull(imageInputStream) {
"Can't open the image input stream"
}
val myServer = if (server == ServersAvailable.IMGUR) {
ImgurServer()
} else if (server == ServersAvailable.NOSTRIMG) {
NostrImgServer()
} else if (server == ServersAvailable.NOSTR_BUILD) {
NostrBuildServer()
} else {
ImgurServer()
val myServer = when (server) {
ServersAvailable.IMGUR, ServersAvailable.IMGUR_NIP_94 -> {
ImgurServer()
}
ServersAvailable.NOSTRIMG, ServersAvailable.NOSTRIMG_NIP_94 -> {
NostrImgServer()
}
ServersAvailable.NOSTR_BUILD, ServersAvailable.NOSTR_BUILD_NIP_94 -> {
NostrBuildServer()
}
ServersAvailable.NOSTRFILES_DEV, ServersAvailable.NOSTRFILES_DEV_NIP_94 -> {
NostrFilesDevServer()
}
else -> {
ImgurServer()
}
}
val file = getRealPathFromURI(uri, context)?.let { File(it) } // create path from uri
if (file != null) {
uploadImage(file, imageInputStream, contentType, myServer, server, onSuccess, onError)
}
uploadImage(imageInputStream, length, contentType, myServer, onSuccess, onError)
}
fun uploadImage(
file: File,
inputStream: InputStream,
length: Long,
contentType: String?,
server: FileServer,
serverType: ServersAvailable,
onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit
) {
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
val fileName = randomChars()
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
@@ -71,35 +74,23 @@ object ImageUploader {
val requestBody: RequestBody
val requestBuilder = Request.Builder()
if (serverType == ServersAvailable.NOSTR_BUILD) {
requestBuilder.addHeader("Content-Type", "multipart/form-data")
requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"fileToUpload",
"$fileName.$extension",
file.asRequestBody(contentType?.toMediaType())
requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
server.inputParameterName(contentType),
"$fileName.$extension",
)
.build()
} else {
requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
category,
"$fileName.$extension",
object : RequestBody() {
override fun contentType() = contentType?.toMediaType()
object : RequestBody() {
override fun contentType(): MediaType? =
contentType?.toMediaType()
override fun contentLength() = length
override fun writeTo(sink: BufferedSink) {
inputStream.source().use(sink::writeAll)
}
override fun writeTo(sink: BufferedSink) {
inputStream.source().use(sink::writeAll)
}
)
.build()
}
}
)
.build()
server.clientID()?.let {
requestBuilder.addHeader("Authorization", it)
@@ -116,7 +107,7 @@ object ImageUploader {
try {
check(response.isSuccessful)
response.body.use { body ->
val url = server.parseUrlFromSucess(body.string())
val url = server.parseUrlFromSuccess(body.string())
checkNotNull(url) {
"There must be an uploaded image URL in the response"
}
@@ -137,41 +128,10 @@ object ImageUploader {
}
}
fun getRealPathFromURI(uri: Uri, context: Context): String? {
val returnCursor = context.contentResolver.query(uri, null, null, null, null)
val nameIndex = returnCursor!!.getColumnIndex(OpenableColumns.DISPLAY_NAME)
returnCursor.moveToFirst()
val name = returnCursor.getString(nameIndex)
val file = File(context.filesDir, name)
try {
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
val outputStream = FileOutputStream(file)
var read = 0
val maxBufferSize = 1 * 1024 * 1024
val bytesAvailable: Int = inputStream?.available() ?: 0
val bufferSize = Math.min(bytesAvailable, maxBufferSize)
val buffers = ByteArray(bufferSize)
while (inputStream?.read(buffers).also {
if (it != null) {
read = it
}
} != -1
) {
outputStream.write(buffers, 0, read)
}
Log.e("File Size", "Size " + file.length())
inputStream?.close()
outputStream.close()
Log.e("File Path", "Path " + file.path)
} catch (e: java.lang.Exception) {
Log.e("Exception", e.message!!)
}
return file.path
}
abstract class FileServer {
abstract fun postUrl(contentType: String?): String
abstract fun parseUrlFromSucess(body: String): String?
abstract fun parseUrlFromSuccess(body: String): String?
abstract fun inputParameterName(contentType: String?): String
open fun clientID(): String? = null
}
@@ -179,12 +139,16 @@ abstract class FileServer {
class NostrImgServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload"
override fun parseUrlFromSucess(body: String): String? {
override fun parseUrlFromSuccess(body: String): String? {
val tree = jacksonObjectMapper().readTree(body)
val url = tree?.get("data")?.get("link")?.asText()
return url
}
override fun inputParameterName(contentType: String?): String {
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
}
override fun clientID() = null
}
@@ -194,21 +158,43 @@ class ImgurServer : FileServer() {
return if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
}
override fun parseUrlFromSucess(body: String): String? {
override fun parseUrlFromSuccess(body: String): String? {
val tree = jacksonObjectMapper().readTree(body)
val url = tree?.get("data")?.get("link")?.asText()
return url
}
override fun inputParameterName(contentType: String?): String {
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
}
override fun clientID() = "Client-ID e6aea87296f3f96"
}
class NostrBuildServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/android.php"
override fun parseUrlFromSucess(body: String): String? {
override fun parseUrlFromSuccess(body: String): String? {
val url = jacksonObjectMapper().readTree(body) // return url.toString()
return url.toString().replace("\"", "")
}
override fun inputParameterName(contentType: String?): String {
return "fileToUpload"
}
override fun clientID() = null
}
class NostrFilesDevServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostrfiles.dev/upload_image"
override fun parseUrlFromSuccess(body: String): String? {
val tree = jacksonObjectMapper().readTree(body)
return tree?.get("url")?.asText()
}
override fun inputParameterName(contentType: String?): String {
return "file"
}
override fun clientID() = null
}
@@ -11,10 +11,8 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.FileHeader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import java.net.URL
open class NewMediaModel : ViewModel() {
var account: Account? = null
@@ -46,6 +44,8 @@ open class NewMediaModel : ViewModel() {
selectedServer = ServersAvailable.NOSTRIMG_NIP_94
} else if (selectedServer == ServersAvailable.NOSTR_BUILD) {
selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94
} else if (selectedServer == ServersAvailable.NOSTRFILES_DEV) {
selectedServer = ServersAvailable.NOSTRFILES_DEV_NIP_94
}
}
@@ -77,7 +77,6 @@ open class NewMediaModel : ViewModel() {
ImageUploader.uploadImage(
uri = uri,
server = serverToUse,
context = context,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
createNIP94Record(imageUrl, mimeType, description)
@@ -115,18 +114,12 @@ open class NewMediaModel : ViewModel() {
uploadingDescription.value = "Server Processing"
// Images don't seem to be ready immediately after upload
if (mimeType?.startsWith("image/") == true) {
delay(2000)
} else {
delay(15000)
}
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl)
uploadingDescription.value = "Downloading"
uploadingPercentage.value = 0.60f
try {
val imageData = URL(imageUrl).readBytes()
if (imageData != null) {
uploadingPercentage.value = 0.80f
uploadingDescription.value = "Hashing"
@@ -138,7 +131,7 @@ open class NewMediaModel : ViewModel() {
onReady = {
uploadingPercentage.value = 0.90f
uploadingDescription.value = "Sending"
val note = account?.sendHeader(it)
account?.sendHeader(it)
uploadingPercentage.value = 1.00f
isUploadingImage = false
onceUploaded()
@@ -154,8 +147,8 @@ open class NewMediaModel : ViewModel() {
}
}
)
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
} else {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
@@ -109,6 +109,13 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
}
}
fun isNIP94Server(selectedServer: ServersAvailable?): Boolean {
return selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
selectedServer == ServersAvailable.IMGUR_NIP_94 ||
selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
selectedServer == ServersAvailable.NOSTRFILES_DEV_NIP_94
}
@Composable
fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
val scope = rememberCoroutineScope()
@@ -117,6 +124,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
)
@@ -191,9 +199,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
)
}
if (postViewModel.selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
postViewModel.selectedServer == ServersAvailable.IMGUR_NIP_94 ||
postViewModel.selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
if (isNIP94Server(postViewModel.selectedServer) ||
postViewModel.selectedServer == ServersAvailable.NIP95
) {
Row(
@@ -588,9 +588,11 @@ enum class ServersAvailable {
IMGUR,
NOSTR_BUILD,
NOSTRIMG,
NOSTRFILES_DEV,
IMGUR_NIP_94,
NOSTRIMG_NIP_94,
NOSTR_BUILD_NIP_94,
NOSTRFILES_DEV_NIP_94,
NIP95
}
@@ -612,9 +614,11 @@ fun ImageVideoDescription(
Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)),
Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)),
Triple(ServersAvailable.NOSTR_BUILD, stringResource(id = R.string.upload_server_nostrbuild), stringResource(id = R.string.upload_server_nostrbuild_explainer)),
Triple(ServersAvailable.NOSTRFILES_DEV, stringResource(id = R.string.upload_server_nostrfilesdev), stringResource(id = R.string.upload_server_nostrfilesdev_explainer)),
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
)
@@ -742,9 +746,7 @@ fun ImageVideoDescription(
)
}
if (selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
selectedServer == ServersAvailable.IMGUR_NIP_94 ||
selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
if (isNIP94Server(selectedServer) ||
selectedServer == ServersAvailable.NIP95
) {
Row(
@@ -19,7 +19,6 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
@@ -145,10 +144,9 @@ open class NewPostViewModel : ViewModel() {
ImageUploader.uploadImage(
uri = it,
server = server,
context = context,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
if (server == ServersAvailable.IMGUR_NIP_94 || server == ServersAvailable.NOSTRIMG_NIP_94 || server == ServersAvailable.NOSTR_BUILD_NIP_94) {
if (isNIP94Server(server)) {
createNIP94Record(imageUrl, mimeType, description)
} else {
isUploadingImage = false
@@ -288,13 +286,6 @@ open class NewPostViewModel : ViewModel() {
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
viewModelScope.launch(Dispatchers.IO) {
// Images don't seem to be ready immediately after upload
if (mimeType?.startsWith("image/") == true) {
delay(2000)
} else {
delay(5000)
}
FileHeader.prepare(
imageUrl,
mimeType,
@@ -171,7 +171,6 @@ class NewUserMetadataViewModel : ViewModel() {
ImageUploader.uploadImage(
uri = it,
server = account.defaultFileServer,
context = context,
contentResolver = context.contentResolver,
onSuccess = { imageUrl, mimeType ->
onUploading(false)
@@ -5,15 +5,23 @@ import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun ClickableRoute(
@@ -21,56 +29,190 @@ fun ClickableRoute(
navController: NavController
) {
if (nip19.type == Nip19.Type.USER) {
val userBase = LocalCache.getOrCreateUser(nip19.hex)
val userState by userBase.live().metadata.observeAsState()
val user = userState?.user ?: return
CreateClickableText(user.toBestDisplayName(), nip19.additionalChars, "User/${nip19.hex}", navController)
DisplayUser(nip19, navController)
} else if (nip19.type == Nip19.Type.ADDRESS) {
val noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
DisplayAddress(nip19, navController)
} else if (nip19.type == Nip19.Type.NOTE) {
DisplayNote(nip19, navController)
} else if (nip19.type == Nip19.Type.EVENT) {
DisplayEvent(nip19, navController)
} else {
Text(
"@${nip19.hex}${nip19.additionalChars} "
)
}
}
if (noteBase == null) {
Text(
"@${nip19.hex}${nip19.additionalChars} "
@Composable
private fun DisplayEvent(
nip19: Nip19.Return,
navController: NavController
) {
var noteBase by remember { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = nip19.hex) {
withContext(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateNote(nip19.hex)
}
}
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = noteState?.note ?: return
val channel = note.channel()
if (note.event is ChannelCreateEvent) {
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Channel/${nip19.hex}",
navController
)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Room/${note.author?.pubkeyHex}",
navController
)
} else if (channel != null) {
CreateClickableText(
channel.toBestDisplayName(),
nip19.additionalChars,
"Channel/${note.channel()?.idHex}",
navController
)
} else {
val noteState by noteBase.live().metadata.observeAsState()
val note = noteState?.note ?: return
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Note/${nip19.hex}", navController)
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Event/${nip19.hex}",
navController
)
}
} else if (nip19.type == Nip19.Type.NOTE) {
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
val noteState by noteBase.live().metadata.observeAsState()
}
if (noteBase == null) {
Text(
"@${nip19.hex}${nip19.additionalChars} "
)
}
}
@Composable
private fun DisplayNote(
nip19: Nip19.Return,
navController: NavController
) {
var noteBase by remember { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = nip19.hex) {
withContext(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateNote(nip19.hex)
}
}
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = noteState?.note ?: return
val channel = note.channel()
if (note.event is ChannelCreateEvent) {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Channel/${nip19.hex}", navController)
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Channel/${nip19.hex}",
navController
)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Room/${note.author?.pubkeyHex}", navController)
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Room/${note.author?.pubkeyHex}",
navController
)
} else if (channel != null) {
CreateClickableText(channel.toBestDisplayName(), nip19.additionalChars, "Channel/${note.channel()?.idHex}", navController)
CreateClickableText(
channel.toBestDisplayName(),
nip19.additionalChars,
"Channel/${note.channel()?.idHex}",
navController
)
} else {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Note/${nip19.hex}", navController)
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Note/${nip19.hex}",
navController
)
}
} else if (nip19.type == Nip19.Type.EVENT) {
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
val noteState by noteBase.live().metadata.observeAsState()
val note = noteState?.note ?: return
val channel = note.channel()
}
if (note.event is ChannelCreateEvent) {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Channel/${nip19.hex}", navController)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Room/${note.author?.pubkeyHex}", navController)
} else if (channel != null) {
CreateClickableText(channel.toBestDisplayName(), nip19.additionalChars, "Channel/${note.channel()?.idHex}", navController)
} else {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Event/${nip19.hex}", navController)
if (noteBase == null) {
Text(
"@${nip19.hex}${nip19.additionalChars} "
)
}
}
@Composable
private fun DisplayAddress(
nip19: Nip19.Return,
navController: NavController
) {
var noteBase by remember { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = nip19.hex) {
withContext(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
}
} else {
}
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = noteState?.note ?: return
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Note/${nip19.hex}",
navController
)
}
if (noteBase == null) {
Text(
"@${nip19.hex}${nip19.additionalChars} "
)
}
}
@Composable
private fun DisplayUser(
nip19: Nip19.Return,
navController: NavController
) {
var userBase by remember { mutableStateOf<User?>(null) }
LaunchedEffect(key1 = nip19.hex) {
withContext(Dispatchers.IO) {
userBase = LocalCache.checkGetOrCreateUser(nip19.hex)
}
}
userBase?.let {
val userState by it.live().metadata.observeAsState()
val user = userState?.user ?: return
CreateClickableText(
user.toBestDisplayName(),
nip19.additionalChars,
"User/${nip19.hex}",
navController
)
}
if (userBase == null) {
Text(
"@${nip19.hex}${nip19.additionalChars} "
)
@@ -42,11 +42,13 @@ fun ExpandableRichTextViewer(
) {
var showFullText by remember { mutableStateOf(false) }
// Cuts the text in the first space after 350
val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
val firstNewLineAfterCut = content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
val whereToCut = remember(content) {
// Cuts the text in the first space after 350
val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
val firstNewLineAfterCut = content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
val whereToCut = minOf(firstSpaceAfterCut, firstNewLineAfterCut)
minOf(firstSpaceAfterCut, firstNewLineAfterCut)
}
val text = if (showFullText) {
content
@@ -75,6 +75,14 @@ fun isValidURL(url: String?): Boolean {
val richTextDefaults = RichTextStyle().resolveDefaults()
fun isMarkdown(content: String): Boolean {
return content.startsWith("> ") ||
content.startsWith("# ") ||
content.contains("##") ||
content.contains("__") ||
content.contains("```")
}
@Composable
fun RichTextViewer(
content: String,
@@ -85,58 +93,39 @@ fun RichTextViewer(
accountViewModel: AccountViewModel,
navController: NavController
) {
val isMarkdown = remember { isMarkdown(content) }
Column(modifier = modifier) {
if (content.startsWith("> ") ||
content.startsWith("# ") ||
content.contains("##") ||
content.contains("__") ||
content.contains("```")
) {
val myMarkDownStyle = richTextDefaults.copy(
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
textStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp
),
modifier = Modifier
.padding(0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.background(
MaterialTheme.colors.onSurface
.copy(alpha = 0.05f)
.compositeOver(backgroundColor)
)
),
stringStyle = richTextDefaults.stringStyle?.copy(
linkStyle = SpanStyle(
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary
),
codeStyle = SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f).compositeOver(backgroundColor)
)
)
)
val markdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
MaterialRichText(
style = myMarkDownStyle
) {
Markdown(
content = markdownWithSpecialContent,
markdownParseOptions = MarkdownParseOptions.Default
)
}
if (isMarkdown) {
RenderContentAsMarkdown(content, backgroundColor)
} else {
RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, navController)
}
}
}
class RichTextViewerState(
val content: String,
val urlSet: LinkedHashSet<String>,
val imagesForPager: Map<String, ZoomableUrlContent>,
val imageList: List<ZoomableUrlContent>
)
@Composable
private fun RenderRegular(
content: String,
tags: List<List<String>>?,
canPreview: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) {
var processedState by remember {
mutableStateOf<RichTextViewerState?>(null)
}
LaunchedEffect(key1 = content) {
withContext(Dispatchers.IO) {
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
val imagesForPager = urlSet.mapNotNull { fullUrl ->
@@ -151,67 +140,71 @@ fun RichTextViewer(
}.associateBy { it.url }
val imageList = imagesForPager.values.toList()
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->
FlowRow() {
val s = if (isArabic(paragraph)) paragraph.trim().split(' ').reversed() else paragraph.trim().split(' ')
s.forEach { word: String ->
if (canPreview) {
// Explicit URL
val img = imagesForPager[word]
if (img != null) {
ZoomableContentView(img, imageList)
} else if (urlSet.contains(word)) {
UrlPreview(word, "$word ")
} else if (word.startsWith("lnbc", true)) {
MayBeInvoicePreview(word)
} else if (word.startsWith("lnurl", true)) {
MayBeWithdrawal(word)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(
processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList)
}
}
// FlowRow doesn't work well with paragraphs. So we need to split them
processedState?.let { state ->
content.split('\n').forEach { paragraph ->
FlowRow() {
val s = if (isArabic(paragraph)) {
paragraph.trim().split(' ')
.reversed()
} else {
paragraph.trim().split(' ')
}
s.forEach { word: String ->
if (canPreview) {
// Explicit URL
val img = state.imagesForPager[word]
if (img != null) {
ZoomableContentView(img, state.imageList)
} else if (state.urlSet.contains(word)) {
UrlPreview(word, "$word ")
} else if (word.startsWith("lnbc", true)) {
MayBeInvoicePreview(word)
} else if (word.startsWith("lnurl", true)) {
MayBeWithdrawal(word)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
@@ -219,69 +212,74 @@ fun RichTextViewer(
)
}
} else {
if (urlSet.contains(word)) {
ClickableUrl("$word ", word)
} else if (word.startsWith("lnurl", true)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
if (lnWithdrawal != null) {
ClickableWithdrawal(withdrawalString = lnWithdrawal)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else {
if (state.urlSet.contains(word)) {
ClickableUrl("$word ", word)
} else if (word.startsWith("lnurl", true)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
if (lnWithdrawal != null) {
ClickableWithdrawal(withdrawalString = lnWithdrawal)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
}
}
@@ -291,21 +289,137 @@ fun RichTextViewer(
}
@Composable
private fun getDisplayNameFromUserNip19(parsedNip19: Nip19.Return): String? {
if (parsedNip19.type == Nip19.Type.USER) {
val userHex = parsedNip19.hex
val userBase = LocalCache.getOrCreateUser(userHex)
private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
val myMarkDownStyle = richTextDefaults.copy(
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
textStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp
),
modifier = Modifier
.padding(0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.background(
MaterialTheme.colors.onSurface
.copy(alpha = 0.05f)
.compositeOver(backgroundColor)
)
),
stringStyle = richTextDefaults.stringStyle?.copy(
linkStyle = SpanStyle(
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary
),
codeStyle = SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f)
.compositeOver(backgroundColor)
)
)
)
val userState by userBase.live().metadata.observeAsState()
val displayName = userState?.user?.bestDisplayName()
if (displayName !== null) {
return displayName
var markdownWithSpecialContent by remember { mutableStateOf<String?>(null) }
var nip19References by remember { mutableStateOf<List<Nip19.Return>>(emptyList()) }
var refresh by remember { mutableStateOf(0) }
LaunchedEffect(key1 = content) {
withContext(Dispatchers.IO) {
nip19References = returnNIP19References(content)
markdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
}
}
LaunchedEffect(key1 = refresh) {
withContext(Dispatchers.IO) {
val newMarkdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
if (markdownWithSpecialContent != newMarkdownWithSpecialContent) {
markdownWithSpecialContent = newMarkdownWithSpecialContent
}
}
}
nip19References.forEach {
var baseUser by remember { mutableStateOf<User?>(null) }
var baseNote by remember { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = it.hex) {
withContext(Dispatchers.IO) {
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
baseNote = note
}
}
if (it.type == Nip19.Type.USER) {
LocalCache.checkGetOrCreateUser(it.hex)?.let { user ->
baseUser = user
}
}
}
}
baseNote?.let {
val noteState by it.live().metadata.observeAsState()
if (noteState?.note?.event != null) {
refresh++
}
}
baseUser?.let {
val userState by it.live().metadata.observeAsState()
if (userState?.user?.info != null) {
refresh++
}
}
}
markdownWithSpecialContent?.let {
MaterialRichText(
style = myMarkDownStyle
) {
Markdown(
content = it,
markdownParseOptions = MarkdownParseOptions.Default
)
}
}
return null
}
@Composable
private fun getDisplayNameFromNip19(nip19: Nip19.Return): String? {
if (nip19.type == Nip19.Type.USER) {
return LocalCache.users[nip19.hex]?.bestDisplayName()
} else if (nip19.type == Nip19.Type.NOTE) {
return LocalCache.notes[nip19.hex]?.idDisplayNote()
} else if (nip19.type == Nip19.Type.ADDRESS) {
return LocalCache.addressables[nip19.hex]?.idDisplayNote()
} else if (nip19.type == Nip19.Type.EVENT) {
return LocalCache.notes[nip19.hex]?.idDisplayNote() ?: LocalCache.addressables[nip19.hex]?.idDisplayNote()
} else {
return null
}
}
private fun returnNIP19References(content: String): List<Nip19.Return> {
val listOfReferences = mutableListOf<Nip19.Return>()
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
if (isBechLink(word)) {
val parsedNip19 = Nip19.uriToRoute(word)
parsedNip19?.let {
listOfReferences.add(it)
}
}
}
}
return listOfReferences
}
private fun returnMarkdownWithSpecialContent(content: String): String {
var returnContent = ""
content.split('\n').forEach { paragraph ->
@@ -319,7 +433,7 @@ private fun returnMarkdownWithSpecialContent(content: String): String {
} else if (isBechLink(word)) {
val parsedNip19 = Nip19.uriToRoute(word)
returnContent += if (parsedNip19 !== null) {
val displayName = getDisplayNameFromUserNip19(parsedNip19)
val displayName = getDisplayNameFromNip19(parsedNip19)
if (displayName != null) {
"[@$displayName](nostr://$word) "
} else {
@@ -359,9 +473,9 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
baseNotePair = Pair(note, it.additionalChars)
}
} else {
nip19Route = it
}
nip19Route = it
}
}
}
@@ -380,8 +494,7 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
),
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
.compositeOver(backgroundColor),
parentBackgroundColor = backgroundColor,
isQuotedNote = true,
navController = navController
)
@@ -530,8 +643,7 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
),
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
.compositeOver(backgroundColor),
parentBackgroundColor = backgroundColor,
isQuotedNote = true,
navController = navController
)
@@ -92,7 +92,6 @@ object Robohash {
.data("robohash:$message")
.fetcherFactory(HashImageFetcher.Factory)
.size(robotSize)
.crossfade(100)
.build()
}
}
@@ -33,7 +33,14 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
@Composable
fun TextSpinner(label: String, placeholder: String, options: List<String>, explainers: List<String>? = null, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) {
fun TextSpinner(
label: String,
placeholder: String,
options: List<String>,
explainers: List<String>? = null,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier
) {
val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
var optionsShowing by remember { mutableStateOf(false) }
@@ -7,6 +7,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.baha.url.preview.IUrlPreviewCallback
@@ -14,7 +15,7 @@ import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
@Composable
fun UrlPreview(url: String, urlText: String) {
@@ -28,11 +29,12 @@ fun UrlPreview(url: String, urlText: String) {
val context = LocalContext.current
var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(default) }
val scope = rememberCoroutineScope()
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
LaunchedEffect(url) {
if (urlPreviewState == UrlPreviewState.Loading) {
withContext(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
UrlCachedPreviewer.previewInfo(
url,
object : IUrlPreviewCallback {
@@ -60,8 +60,10 @@ import java.io.File
public var muted = mutableStateOf(true)
@Composable
fun VideoView(localFile: File, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
VideoView(localFile.toUri(), description, onDialog)
fun VideoView(localFile: File?, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
if (localFile != null) {
VideoView(localFile.toUri(), description, onDialog)
}
}
@Composable
@@ -3,9 +3,6 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -78,7 +75,6 @@ import com.vitorpamplona.amethyst.ui.theme.Nip05
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
import java.io.File
@@ -115,7 +111,7 @@ class ZoomableUrlVideo(
) : ZoomableUrlContent(url, description, hash, dim, uri)
abstract class ZoomablePreloadedContent(
val localFile: File,
val localFile: File?,
description: String? = null,
val mimeType: String? = null,
val isVerified: Boolean? = null,
@@ -124,7 +120,7 @@ abstract class ZoomablePreloadedContent(
) : ZoomableContent(description, dim)
class ZoomableLocalImage(
localFile: File,
localFile: File?,
mimeType: String? = null,
description: String? = null,
val blurhash: String? = null,
@@ -134,7 +130,7 @@ class ZoomableLocalImage(
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
class ZoomableLocalVideo(
localFile: File,
localFile: File?,
mimeType: String? = null,
description: String? = null,
dim: String? = null,
@@ -213,7 +209,9 @@ private fun LocalImageView(
mutableStateOf<AsyncImagePainter.State?>(null)
}
val ratio = aspectRatio(content.dim)
val ratio = remember {
aspectRatio(content.dim)
}
BoxWithConstraints(contentAlignment = Alignment.Center) {
val myModifier = mainImageModifier.also {
@@ -223,7 +221,7 @@ private fun LocalImageView(
}
val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
if (content.localFile.exists()) {
if (content.localFile != null && content.localFile.exists()) {
AsyncImage(
model = content.localFile,
contentDescription = content.description,
@@ -240,12 +238,7 @@ private fun LocalImageView(
if (imageState is AsyncImagePainter.State.Success) {
HashVerificationSymbol(content.isVerified, Modifier.align(Alignment.TopEnd))
}
AnimatedVisibility(
visible = imageState !is AsyncImagePainter.State.Success,
exit = fadeOut(animationSpec = tween(200))
) {
} else {
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
} else {
@@ -255,7 +248,7 @@ private fun LocalImageView(
}
}
if (imageState is AsyncImagePainter.State.Error || !content.localFile.exists()) {
if (imageState is AsyncImagePainter.State.Error || content.localFile == null || !content.localFile.exists()) {
BlankNote()
}
}
@@ -278,7 +271,9 @@ private fun UrlImageView(
mutableStateOf<Boolean?>(null)
}
val ratio = aspectRatio(content.dim)
val ratio = remember {
aspectRatio(content.dim)
}
LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
@@ -311,12 +306,7 @@ private fun UrlImageView(
if (imageState is AsyncImagePainter.State.Success) {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
AnimatedVisibility(
visible = imageState !is AsyncImagePainter.State.Success,
exit = fadeOut(animationSpec = tween(200))
) {
} else {
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
} else {
@@ -350,9 +340,10 @@ private fun aspectRatio(dim: String?): Float? {
@Composable
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
var cnt by remember { mutableStateOf<ZoomableContent?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
delay(200)
cnt = content
}
@@ -1,21 +1,23 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
lateinit var channel: Channel
var channelId: String? = null
fun loadMessagesBetween(accountLoggedIn: Account, channelId: String) {
account = accountLoggedIn
channel = LocalCache.getOrCreateChannel(channelId)
fun loadMessagesBetween(accountLoggedIn: Account, channelId: String?) {
this.account = accountLoggedIn
this.channelId = channelId
}
// returns the last Note of each user.
override fun feed(): List<Note> {
val processingChannel = channelId ?: return emptyList()
val channel = LocalCache.getOrCreateChannel(processingChannel)
return channel.notes
.values
.filter { account.isAcceptable(it) }
@@ -24,6 +26,9 @@ object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
val processingChannel = channelId ?: return emptySet()
val channel = LocalCache.getOrCreateChannel(processingChannel)
return collection
.filter { it.idHex in channel.notes.keys && account.isAcceptable(it) }
.toSet()
@@ -3,21 +3,22 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
object ChatroomFeedFilter : AdditiveFeedFilter<Note>() {
var account: Account? = null
var withUser: User? = null
var withUser: String? = null
fun loadMessagesBetween(accountIn: Account, userId: String) {
account = accountIn
withUser = LocalCache.checkGetOrCreateUser(userId)
withUser = userId
}
// returns the last Note of each user.
override fun feed(): List<Note> {
val processingUser = withUser ?: return emptyList()
val myAccount = account
val myUser = withUser
val myUser = LocalCache.checkGetOrCreateUser(processingUser)
if (myAccount == null || myUser == null) return emptyList()
@@ -32,8 +33,10 @@ object ChatroomFeedFilter : AdditiveFeedFilter<Note>() {
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
val processingUser = withUser ?: return emptySet()
val myAccount = account
val myUser = withUser
val myUser = LocalCache.checkGetOrCreateUser(processingUser)
if (myAccount == null || myUser == null) return emptySet()
@@ -18,9 +18,8 @@ object HomeConversationsFeedFilter : AdditiveFeedFilter<Note>() {
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val user = account.userProfile()
val followingKeySet = user.cachedFollowingKeySet()
val followingTagSet = user.cachedFollowingTagSet()
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
return collection
.asSequence()
@@ -24,9 +24,8 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter<Note>() {
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val user = account.userProfile()
val followingKeySet = user.cachedFollowingKeySet()
val followingTagSet = user.cachedFollowingTagSet()
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
return collection
.asSequence()
@@ -0,0 +1,25 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
object PeopleListFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
val privKey = account.loggedIn.privKey ?: return emptyList()
val lists = LocalCache.addressables.values
.asSequence()
.filter {
(it.event is PeopleListEvent)
}
.toSet()
return lists
.sortedBy { it.createdAt() }
.reversed()
}
}
@@ -9,9 +9,9 @@ object UserProfileBookmarksFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
var user: User? = null
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
account = accountLoggedIn
user = LocalCache.users[userId]
this.user = user
}
override fun feed(): List<Note> {
@@ -1,7 +1,6 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -9,9 +8,9 @@ object UserProfileConversationsFeedFilter : FeedFilter<Note>() {
var account: Account? = null
var user: User? = null
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
account = accountLoggedIn
user = LocalCache.checkGetOrCreateUser(userId)
this.user = user
}
override fun feed(): List<Note> {
@@ -8,9 +8,9 @@ object UserProfileFollowersFeedFilter : FeedFilter<User>() {
lateinit var account: Account
var user: User? = null
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
account = accountLoggedIn
user = LocalCache.users[userId]
this.user = user
}
override fun feed(): List<User> {
@@ -8,9 +8,9 @@ object UserProfileFollowsFeedFilter : FeedFilter<User>() {
lateinit var account: Account
var user: User? = null
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
account = accountLoggedIn
user = LocalCache.users[userId]
this.user = user
}
override fun feed(): List<User> {
@@ -9,9 +9,9 @@ object UserProfileNewThreadFeedFilter : FeedFilter<Note>() {
var account: Account? = null
var user: User? = null
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
fun loadUserProfile(accountLoggedIn: Account, user: User) {
account = accountLoggedIn
user = LocalCache.checkGetOrCreateUser(userId)
this.user = user
}
override fun feed(): List<Note> {
@@ -1,14 +1,13 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
object UserProfileReportsFeedFilter : FeedFilter<Note>() {
var user: User? = null
fun loadUserProfile(userId: String) {
user = LocalCache.checkGetOrCreateUser(userId)
fun loadUserProfile(user: User?) {
this.user = user
}
override fun feed(): List<Note> {
@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.zaps.UserZaps
@@ -8,8 +7,8 @@ import com.vitorpamplona.amethyst.service.model.zaps.UserZaps
object UserProfileZapsFeedFilter : FeedFilter<Pair<Note, Note>>() {
var user: User? = null
fun loadUserProfile(userId: String) {
user = LocalCache.checkGetOrCreateUser(userId)
fun loadUserProfile(user: User?) {
this.user = user
}
override fun feed(): List<Pair<Note, Note>> {
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.*
@@ -20,17 +21,17 @@ object VideoFeedFilter : AdditiveFeedFilter<Note>() {
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val now = System.currentTimeMillis() / 1000
val isGlobal = account.defaultStoriesFollowList == GLOBAL_FOLLOWS
val followingKeySet = account.selectedUsersFollowList(account.defaultStoriesFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultStoriesFollowList) ?: emptySet()
return collection
.asSequence()
.filter {
it.event is FileHeaderEvent || it.event is FileStorageHeaderEvent
}
.filter { it.event is FileHeaderEvent || it.event is FileStorageHeaderEvent }
.filter { isGlobal || it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false) }
.filter { account.isAcceptable(it) }
.filter {
// Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating.
it.createdAt()!! <= now
}
.filter { it.createdAt()!! <= now }
.toSet()
}
@@ -41,7 +41,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.ExperimentalTime
val bottomNavigationItems = listOf(
@@ -159,15 +158,16 @@ private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: Ac
val notif = notifState.value ?: return
var hasNewItems by remember { mutableStateOf<Boolean>(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = notif) {
withContext(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
hasNewItems = route.hasNewItems(account, notif.cache, emptySet())
}
}
LaunchedEffect(key1 = db) {
withContext(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
hasNewItems = route.hasNewItems(account, notif.cache, db)
}
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.navigation
import android.util.Log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -23,6 +24,8 @@ import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -41,7 +44,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import coil.Coil
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
@@ -55,6 +62,7 @@ import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
@@ -62,33 +70,58 @@ import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
when (currentRoute(navController)) {
when (currentRoute(navController)?.substringBefore("?")) {
// Route.Profile.route -> TopBarWithBackButton(navController)
Route.Home.base -> HomeTopBar(scaffoldState, accountViewModel)
Route.Video.base -> StoriesTopBar(scaffoldState, accountViewModel)
else -> MainTopBar(scaffoldState, accountViewModel)
}
}
@Composable
fun StoriesTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { account ->
FollowList(account.defaultStoriesFollowList, account.userProfile(), true) { listName ->
account.changeDefaultStoriesFollowList(listName)
}
}
}
@Composable
fun HomeTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { account ->
FollowList(account.defaultHomeFollowList, account.userProfile(), false) { listName ->
account.changeDefaultHomeFollowList(listName)
}
}
}
@Composable
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) {
AmethystIcon()
}
}
@OptIn(coil.annotation.ExperimentalCoilApi::class)
@Composable
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, content: @Composable (Account) -> Unit) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().live().metadata.observeAsState()
val accountUser = accountUserState?.user ?: return
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
val connectedRelaysLiveData by relayViewModel.connectedRelaysLiveData.observeAsState()
val availableRelaysLiveData by relayViewModel.availableRelaysLiveData.observeAsState()
val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
var wantsToEditRelays by remember {
mutableStateOf(false)
}
@@ -115,48 +148,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
IconButton(
onClick = {
Client.allSubscriptions().map {
"$it ${
Client.getSubscriptionFilters(it)
.joinToString { it.filter.toJson() }
}"
}.forEach {
Log.d("STATE DUMP", it)
}
NostrAccountDataSource.printCounter()
NostrChannelDataSource.printCounter()
NostrChatroomDataSource.printCounter()
NostrChatroomListDataSource.printCounter()
NostrGlobalDataSource.printCounter()
NostrHashtagDataSource.printCounter()
NostrHomeDataSource.printCounter()
NostrSearchEventOrUserDataSource.printCounter()
NostrSingleChannelDataSource.printCounter()
NostrSingleEventDataSource.printCounter()
NostrSingleUserDataSource.printCounter()
NostrThreadDataSource.printCounter()
NostrUserProfileDataSource.printCounter()
Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays())
val imageLoader = Coil.imageLoader(context)
Log.d("STATE DUMP", "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB")
Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB")
Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size)
Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size)
}
) {
Icon(
painter = painterResource(R.drawable.amethyst),
null,
modifier = Modifier.size(40.dp),
tint = Color.Unspecified
)
}
content(account)
}
Column(
@@ -186,23 +178,10 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
}
},
navigationIcon = {
IconButton(
onClick = {
coroutineScope.launch {
scaffoldState.drawerState.open()
}
},
modifier = Modifier
) {
RobohashAsyncImageProxy(
robot = accountUser.pubkeyHex,
model = ResizeImage(accountUser.profilePicture(), 34.dp),
contentDescription = stringResource(id = R.string.profile_image),
modifier = Modifier
.width(34.dp)
.height(34.dp)
.clip(shape = CircleShape)
)
LoggedInUserPictureDrawer(account) {
coroutineScope.launch {
scaffoldState.drawerState.open()
}
}
},
actions = {
@@ -223,6 +202,107 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
}
}
@Composable
private fun LoggedInUserPictureDrawer(
account: Account,
onClick: () -> Unit
) {
val accountUserState by account.userProfile().live().metadata.observeAsState()
val accountUser = accountUserState?.user ?: return
IconButton(
onClick = onClick,
modifier = Modifier
) {
RobohashAsyncImageProxy(
robot = accountUser.pubkeyHex,
model = ResizeImage(accountUser.profilePicture(), 34.dp),
contentDescription = stringResource(id = R.string.profile_image),
modifier = Modifier
.width(34.dp)
.height(34.dp)
.clip(shape = CircleShape)
)
}
}
@Composable
fun FollowList(listName: String, loggedIn: User, withGlobal: Boolean, onChange: (String) -> Unit) {
// Notification
val dbState = LocalCache.live.observeAsState()
val db = dbState.value ?: return
val kind3Follow = Pair(KIND3_FOLLOWS, stringResource(id = R.string.follow_list_kind3follows))
val globalFollow = Pair(GLOBAL_FOLLOWS, stringResource(id = R.string.follow_list_global))
val defaultOptions = if (withGlobal) listOf(kind3Follow, globalFollow) else listOf(kind3Follow)
var followLists by remember { mutableStateOf(defaultOptions) }
val followNames = remember { derivedStateOf { followLists.map { it.second } } }
LaunchedEffect(key1 = db) {
withContext(Dispatchers.IO) {
followLists = defaultOptions + LocalCache.addressables.mapNotNull {
val event = (it.value.event as? PeopleListEvent)
// Has to have an list
if (event != null && event.pubKey == loggedIn.pubkeyHex && (event.tags.size > 1 || event.content.length > 50)) {
Pair(event.dTag(), event.dTag())
} else {
null
}
}.sortedBy { it.second }
}
}
SimpleTextSpinner(
placeholder = followLists.firstOrNull { it.first == listName }?.second ?: "Select an Option",
options = followNames.value,
onSelect = {
onChange(followLists.getOrNull(it)?.first ?: KIND3_FOLLOWS)
}
)
}
@Composable
fun SimpleTextSpinner(
placeholder: String,
options: List<String>,
explainers: List<String>? = null,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
var optionsShowing by remember { mutableStateOf(false) }
var currentText by remember { mutableStateOf(placeholder) }
Box(
modifier = modifier,
contentAlignment = Alignment.Center
) {
Text(currentText)
Box(
modifier = Modifier
.matchParentSize()
.clickable(
interactionSource = interactionSource,
indication = null
) {
optionsShowing = true
}
)
}
if (optionsShowing) {
options.isNotEmpty().also {
SpinnerSelectionDialog(options = options, explainers = explainers, onDismiss = { optionsShowing = false }) {
currentText = options[it]
optionsShowing = false
onSelect(it)
}
}
}
}
@Composable
fun TopBarWithBackButton(navController: NavHostController) {
Column() {
@@ -250,3 +330,51 @@ fun TopBarWithBackButton(navController: NavHostController) {
Divider(thickness = 0.25.dp)
}
}
@Composable
fun AmethystIcon() {
val context = LocalContext.current
IconButton(
onClick = {
Client.allSubscriptions().map {
"$it ${
Client.getSubscriptionFilters(it)
.joinToString { it.filter.toJson() }
}"
}.forEach {
Log.d("STATE DUMP", it)
}
NostrAccountDataSource.printCounter()
NostrChannelDataSource.printCounter()
NostrChatroomDataSource.printCounter()
NostrChatroomListDataSource.printCounter()
NostrGlobalDataSource.printCounter()
NostrHashtagDataSource.printCounter()
NostrHomeDataSource.printCounter()
NostrSearchEventOrUserDataSource.printCounter()
NostrSingleChannelDataSource.printCounter()
NostrSingleEventDataSource.printCounter()
NostrSingleUserDataSource.printCounter()
NostrThreadDataSource.printCounter()
NostrUserProfileDataSource.printCounter()
Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays())
val imageLoader = Coil.imageLoader(context)
Log.d("STATE DUMP", "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB")
Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB")
Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size)
Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size)
}
) {
Icon(
painter = painterResource(R.drawable.amethyst),
null,
modifier = Modifier.size(40.dp),
tint = Color.Unspecified
)
}
}
@@ -23,6 +23,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -34,11 +35,10 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.ui.screen.BadgeCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -48,8 +48,8 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
val context = LocalContext.current.applicationContext
val noteEvent = note?.event
var popupExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (note == null) {
BlankNote(Modifier, isInnerNote)
@@ -57,7 +57,7 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = likeSetCard) {
withContext(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
isNew = likeSetCard.createdAt() > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt())
@@ -71,20 +71,17 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
}
Column(
modifier = Modifier.background(backgroundColor).combinedClickable(
onClick = {
if (noteEvent !is ChannelMessageEvent) {
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
} else {
note.channel()?.let {
navController.navigate("Channel/${it.idHex}")
}
}
},
onLongClick = { popupExpanded = true }
)
modifier = Modifier
.background(backgroundColor)
.combinedClickable(
onClick = {
routeFor(
note,
accountViewModel.userProfile()
)?.let { navController.navigate(it) }
},
onLongClick = { popupExpanded = true }
)
) {
Row(
modifier = Modifier
@@ -104,7 +101,9 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
Icon(
imageVector = Icons.Default.MilitaryTech,
null,
modifier = Modifier.size(25.dp).align(Alignment.TopEnd),
modifier = Modifier
.size(25.dp)
.align(Alignment.TopEnd),
tint = MaterialTheme.colors.primary
)
}
@@ -115,7 +114,9 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
Text(
stringResource(R.string.new_badge_award_notif),
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 5.dp).weight(1f)
modifier = Modifier
.padding(bottom = 5.dp)
.weight(1f)
)
Text(
@@ -74,7 +74,7 @@ fun HiddenNote(reports: Set<Note>, loggedIn: User, modifier: Modifier = Modifier
FlowRow(modifier = Modifier.padding(top = 10.dp)) {
reports.forEach {
NoteAuthorPicture(
note = it,
baseNote = it,
navController = navController,
userAccount = loggedIn,
size = 35.dp
@@ -28,7 +28,6 @@ import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.ui.screen.BoostSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
@@ -68,15 +67,7 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro
Column(
modifier = Modifier.background(backgroundColor).combinedClickable(
onClick = {
if (noteEvent !is ChannelMessageEvent) {
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
} else {
note.channel()?.let {
navController.navigate("Channel/${it.idHex}")
}
}
routeFor(note, account.userProfile())?.let { navController.navigate(it) }
},
onLongClick = { popupExpanded = true }
)
@@ -109,7 +100,7 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro
FlowRow() {
boostSetCard.boostEvents.forEach {
NoteAuthorPicture(
note = it,
baseNote = it,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
@@ -22,6 +22,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -50,7 +51,7 @@ import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
@Composable
fun ChatroomCompose(
@@ -64,6 +65,8 @@ fun ChatroomCompose(
val notificationCacheState = NotificationCache.live.observeAsState()
val notificationCache = notificationCacheState.value ?: return
val scope = rememberCoroutineScope()
if (note?.event == null) {
BlankNote(Modifier)
} else if (note.channel() != null) {
@@ -86,7 +89,7 @@ fun ChatroomCompose(
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = notificationCache, key2 = note) {
withContext(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
note.createdAt()?.let { timestamp ->
hasNewMessages =
timestamp > notificationCache.cache.load("Channel/${chan.idHex}")
@@ -131,7 +134,7 @@ fun ChatroomCompose(
} else {
val replyAuthorBase =
(note.event as? PrivateDmEvent)
?.recipientPubKey()
?.verifiedRecipientPubKey()
?.let { LocalCache.getOrCreateUser(it) }
var userToComposeOn = note.author!!
@@ -148,7 +151,7 @@ fun ChatroomCompose(
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = notificationCache, key2 = note) {
withContext(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
noteEvent?.let {
hasNewMessages = it.createdAt() > notificationCache.cache.load(
"Room/${userToComposeOn.pubkeyHex}"
@@ -31,6 +31,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -63,7 +64,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp)
val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
@@ -94,6 +95,7 @@ fun ChatroomMessageCompose(
var showHiddenNote by remember { mutableStateOf(false) }
val context = LocalContext.current.applicationContext
val scope = rememberCoroutineScope()
if (note?.event == null) {
BlankNote(Modifier)
@@ -135,7 +137,7 @@ fun ChatroomMessageCompose(
LaunchedEffect(key1 = routeForLastRead) {
routeForLastRead?.let {
withContext(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
val lastTime = NotificationCache.load(it)
val createdAt = note.createdAt()
@@ -28,7 +28,6 @@ import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.ui.screen.LikeSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
@@ -68,15 +67,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, route
Column(
modifier = Modifier.background(backgroundColor).combinedClickable(
onClick = {
if (noteEvent !is ChannelMessageEvent) {
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
} else {
note.channel()?.let {
navController.navigate("Channel/${it.idHex}")
}
}
routeFor(note, account.userProfile())?.let { navController.navigate(it) }
},
onLongClick = { popupExpanded = true }
)
@@ -109,7 +100,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, route
FlowRow() {
likeSetCard.likeEvents.forEach {
NoteAuthorPicture(
note = it,
baseNote = it,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
@@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -26,13 +27,10 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.screen.MessageSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -40,20 +38,25 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
val noteState by messageSetCard.note.live().metadata.observeAsState()
val note = noteState?.note
val noteEvent = note?.event
var popupExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (note == null) {
BlankNote(Modifier, isInnerNote)
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = messageSetCard.createdAt()) {
withContext(Dispatchers.IO) {
isNew =
scope.launch(Dispatchers.IO) {
val newIsNew =
messageSetCard.createdAt() > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, messageSetCard.createdAt())
if (newIsNew != isNew) {
isNew = newIsNew
}
}
}
@@ -66,30 +69,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
Column(
modifier = Modifier.background(backgroundColor).combinedClickable(
onClick = {
if (noteEvent is ChannelMessageEvent) {
note.channel()?.let {
navController.navigate("Channel/${it.idHex}")
}
} else if (noteEvent is PrivateDmEvent) {
val replyAuthorBase =
(note.event as? PrivateDmEvent)
?.recipientPubKey()
?.let { LocalCache.getOrCreateUser(it) }
var userToComposeOn = note.author!!
if (replyAuthorBase != null) {
if (note.author == accountViewModel.userProfile()) {
userToComposeOn = replyAuthorBase
}
}
navController.navigate("Room/${userToComposeOn.pubkeyHex}")
} else {
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
}
routeFor(note, accountViewModel.userProfile())?.let { navController.navigate(it) }
},
onLongClick = { popupExpanded = true }
)
@@ -120,7 +100,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
NoteCompose(
baseNote = note,
baseNote = messageSetCard.note,
routeForLastRead = null,
isBoostedNote = true,
addMarginTop = false,
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.runtime.Composable
@@ -21,6 +23,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -37,15 +40,13 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -56,19 +57,24 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val noteEvent = note?.event
var popupExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (note == null) {
BlankNote(Modifier, false)
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = multiSetCard.createdAt()) {
withContext(Dispatchers.IO) {
isNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead)
scope.launch(Dispatchers.IO) {
val newIsNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, multiSetCard.createdAt)
if (newIsNew != isNew) {
isNew = newIsNew
}
}
}
@@ -83,32 +89,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
.background(backgroundColor)
.combinedClickable(
onClick = {
if (noteEvent is ChannelMessageEvent) {
note
.channel()
?.let {
navController.navigate("Channel/${it.idHex}")
}
} else if (noteEvent is PrivateDmEvent) {
val replyAuthorBase =
(note.event as? PrivateDmEvent)
?.recipientPubKey()
?.let { LocalCache.getOrCreateUser(it) }
var userToComposeOn = note.author!!
if (replyAuthorBase != null) {
if (note.author == accountViewModel.userProfile()) {
userToComposeOn = replyAuthorBase
}
}
navController.navigate("Room/${userToComposeOn.pubkeyHex}")
} else {
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
}
routeFor(note, account.userProfile())?.let { navController.navigate(it) }
},
onLongClick = { popupExpanded = true }
)
@@ -187,15 +168,10 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
}
Row(Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.width(65.dp)
.padding(0.dp)
) {
}
Spacer(modifier = Modifier.width(65.dp))
NoteCompose(
baseNote = note,
baseNote = multiSetCard.note,
routeForLastRead = null,
modifier = Modifier.padding(top = 5.dp),
isBoostedNote = true,
@@ -225,7 +201,7 @@ fun AuthorGalleryZaps(
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
authorNotes.forEach {
AuthorPictureAndComment(it.key, it.value, navController, accountUser, accountViewModel)
AuthorPictureAndComment(it.key, navController, accountUser, accountViewModel)
}
}
}
@@ -234,7 +210,6 @@ fun AuthorGalleryZaps(
@Composable
private fun AuthorPictureAndComment(
zapRequest: Note,
zapEvent: Note,
navController: NavController,
accountUser: User,
accountViewModel: AccountViewModel
@@ -242,16 +217,19 @@ private fun AuthorPictureAndComment(
val author = zapRequest.author ?: return
var content by remember { mutableStateOf<Pair<User, String?>>(Pair(author, null)) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = zapRequest.idHex) {
(zapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = accountViewModel.decryptZap(zapRequest)
if (decryptedContent != null) {
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
content = Pair(author, decryptedContent.content)
} else {
if (!zapRequest.event?.content().isNullOrBlank()) {
content = Pair(author, zapRequest.event?.content())
scope.launch(Dispatchers.IO) {
(zapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = accountViewModel.decryptZap(zapRequest)
if (decryptedContent != null) {
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
content = Pair(author, decryptedContent.content)
} else {
if (!zapRequest.event?.content().isNullOrBlank()) {
content = Pair(author, zapRequest.event?.content())
}
}
}
}
@@ -274,10 +252,14 @@ private fun AuthorPictureAndComment(
Modifier
}
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
Row(
modifier = modifier.clickable {
navController.navigate("User/${author.pubkeyHex}")
},
verticalAlignment = Alignment.CenterVertically
) {
FastNoteAuthorPicture(
author = author,
navController = navController,
userAccount = accountUser,
size = 35.dp
)
@@ -309,12 +291,16 @@ fun AuthorGallery(
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
authorNotes.forEach {
authorNotes.take(50).forEach {
val author = it.author
if (author != null) {
AuthorPictureAndComment(author, null, navController, accountUser, accountViewModel)
}
}
if (authorNotes.size > 50) {
Text(" and ${authorNotes.size - 50} others")
}
}
}
}
@@ -322,7 +308,6 @@ fun AuthorGallery(
@Composable
fun FastNoteAuthorPicture(
author: User,
navController: NavController,
userAccount: User,
size: Dp,
pictureModifier: Modifier = Modifier
@@ -331,14 +316,12 @@ fun FastNoteAuthorPicture(
val user = userState?.user ?: return
val showFollowingMark = userAccount.isFollowingCached(user) || user === userAccount
UserPicture(
userHex = user.pubkeyHex,
userPicture = user.profilePicture(),
showFollowingMark = showFollowingMark,
size = size,
modifier = pictureModifier,
onClick = {
navController.navigate("User/${user.pubkeyHex}")
}
modifier = pictureModifier
)
}
@@ -92,64 +92,75 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
val uri = LocalUriHandler.current
user.nip05()?.let { nip05 ->
if (nip05.split("@").size == 2) {
val parts = nip05.split("@")
if (parts.size == 2) {
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
Column(modifier = columnModifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05.split("@")[0] != "_") {
Text(
text = AnnotatedString(nip05.split("@")[0]),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
if (nip05Verified == null) {
Icon(
tint = Color.Yellow,
imageVector = Icons.Default.Downloading,
contentDescription = "Downloading",
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
} else if (nip05Verified == true) {
Icon(
painter = painterResource(R.drawable.ic_verified),
"NIP-05 Verified",
tint = Nip05.copy(0.52f),
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
} else {
Icon(
tint = Color.Red,
imageVector = Icons.Default.Report,
contentDescription = "Invalid Nip05",
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
}
ClickableText(
text = AnnotatedString(nip05.split("@")[1]),
onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)),
maxLines = 1,
overflow = TextOverflow.Visible
)
}
DisplayNIP05(parts[0], parts[1], nip05Verified)
}
}
}
}
@Composable
private fun DisplayNIP05(
user: String,
domain: String,
nip05Verified: Boolean?
) {
val uri = LocalUriHandler.current
Row(verticalAlignment = Alignment.CenterVertically) {
if (user != "_") {
Text(
text = AnnotatedString(user),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (nip05Verified == null) {
Icon(
tint = Color.Yellow,
imageVector = Icons.Default.Downloading,
contentDescription = "Downloading",
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
} else if (nip05Verified == true) {
Icon(
painter = painterResource(R.drawable.ic_verified),
"NIP-05 Verified",
tint = Nip05.copy(0.52f),
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
} else {
Icon(
tint = Color.Red,
imageVector = Icons.Default.Report,
contentDescription = "Invalid Nip05",
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
}
ClickableText(
text = AnnotatedString(domain),
onClick = { runCatching { uri.openUri("https://$domain") } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)),
maxLines = 1,
overflow = TextOverflow.Visible
)
}
}
@Composable
fun DisplayNip05ProfileStatus(user: User) {
val uri = LocalUriHandler.current
File diff suppressed because it is too large Load Diff
@@ -92,18 +92,20 @@ private fun OptionNote(
var optionTally by remember { mutableStateOf(Pair(BigDecimal.ZERO, defaultColor)) }
LaunchedEffect(key1 = optionNumber, key2 = pollViewModel) {
val myTally = pollViewModel.optionVoteTally(optionNumber)
val color = if (
pollViewModel.consensusThreshold != null &&
myTally >= pollViewModel.consensusThreshold!!
) {
Color.Green.copy(alpha = 0.32f)
} else {
defaultColor
}
withContext(Dispatchers.IO) {
val myTally = pollViewModel.optionVoteTally(optionNumber)
val color = if (
pollViewModel.consensusThreshold != null &&
myTally >= pollViewModel.consensusThreshold!!
) {
Color.Green.copy(alpha = 0.32f)
} else {
defaultColor
}
if (myTally > optionTally.first || color != optionTally.second) {
optionTally = Pair(myTally, color)
if (myTally > optionTally.first || color != optionTally.second) {
optionTally = Pair(myTally, color)
}
}
}
@@ -61,7 +61,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.math.RoundingMode
import kotlin.math.roundToInt
@@ -319,21 +318,22 @@ fun ZapReaction(
var zappingProgress by remember { mutableStateOf(0f) }
var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) }
var zapAmountTxt by remember { mutableStateOf<String>("") }
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
if (!wasZappedByLoggedInUser) {
wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
}
zapAmount = account.calculateZappedAmount(zappedNote)
zapAmountTxt = showAmount(account.calculateZappedAmount(zappedNote))
}
}
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.size(iconSize)
modifier = Modifier
.size(iconSize)
.combinedClickable(
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
@@ -456,7 +456,7 @@ fun ZapReaction(
}
Text(
showAmount(zapAmount),
zapAmountTxt,
fontSize = 14.sp,
color = grayTint,
modifier = textModifier
@@ -5,6 +5,7 @@ import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -17,13 +18,23 @@ import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun ReplyInformation(replyTo: List<Note>?, mentions: List<String>, account: Account, navController: NavController) {
val dupMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
var dupMentions by remember { mutableStateOf<List<User>?>(null) }
ReplyInformation(replyTo, dupMentions, account) {
navController.navigate("User/${it.pubkeyHex}")
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
dupMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
}
}
if (dupMentions != null) {
ReplyInformation(replyTo, dupMentions, account) {
navController.navigate("User/${it.pubkeyHex}")
}
}
}
@@ -102,6 +113,34 @@ fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Ac
}
}
@Composable
fun ReplyInformationChannel(replyTo: List<Note>?, mentions: List<String>, channel: Channel, account: Account, navController: NavController) {
var sortedMentions by remember { mutableStateOf<List<User>?>(null) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
sortedMentions = mentions
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()
.sortedBy { account.isFollowing(it) }
}
}
if (sortedMentions != null) {
ReplyInformationChannel(
replyTo,
sortedMentions,
channel,
onUserTagClick = {
navController.navigate("User/${it.pubkeyHex}")
},
onChannelTagClick = {
navController.navigate("Channel/${it.idHex}")
}
)
}
}
@Composable
fun ReplyInformationChannel(replyTo: List<Note>?, mentions: List<User>?, channel: Channel, navController: NavController) {
ReplyInformationChannel(
@@ -28,41 +28,55 @@ fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
if (user.bestUsername() != null && user.bestDisplayName() != null) {
val bestUserName = user.bestUsername()
val bestDisplayName = user.bestDisplayName()
val npubDisplay = user.pubkeyDisplayHex()
UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, weight)
}
@Composable
private fun UserNameDisplay(
bestUserName: String?,
bestDisplayName: String?,
npubDisplay: String,
modifier: Modifier
) {
if (bestUserName != null && bestDisplayName != null) {
Text(
user.bestDisplayName() ?: "",
bestDisplayName,
fontWeight = FontWeight.Bold
)
Text(
"@${(user.bestUsername() ?: "")}",
"@$bestUserName",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
modifier = modifier
)
} else if (user.bestDisplayName() != null) {
} else if (bestDisplayName != null) {
Text(
user.bestDisplayName() ?: "",
bestDisplayName,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
modifier = modifier
)
} else if (user.bestUsername() != null) {
} else if (bestUserName != null) {
Text(
"@${(user.bestUsername() ?: "")}",
"@$bestUserName",
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
modifier = modifier
)
} else {
Text(
user.pubkeyDisplayHex(),
npubDisplay,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
modifier = modifier
)
}
}
@@ -34,7 +34,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.UnfollowButton
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal
@Composable
@@ -95,7 +94,7 @@ fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewMode
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) }
LaunchedEffect(key1 = noteZap) {
withContext(Dispatchers.IO) {
coroutineScope.launch(Dispatchers.IO) {
zapAmount = (noteZap.event as? LnZapEvent)?.amount
}
}
@@ -29,7 +29,6 @@ import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.ui.screen.ZapSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@@ -70,15 +69,7 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, isInnerNote: Boolean = false, routeFor
Column(
modifier = Modifier.background(backgroundColor).combinedClickable(
onClick = {
if (noteEvent !is ChannelMessageEvent) {
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
} else {
note.channel()?.let {
navController.navigate("Channel/${it.idHex}")
}
}
routeFor(note, account.userProfile())?.let { navController.navigate(it) }
},
onLongClick = { popupExpanded = true }
)
@@ -113,7 +104,7 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, isInnerNote: Boolean = false, routeFor
FlowRow() {
zapSetCard.zapEvents.forEach {
NoteAuthorPicture(
note = it.key,
baseNote = it.key,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
@@ -94,7 +94,7 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false,
FlowRow() {
zapSetCard.zapEvents.forEach {
NoteAuthorPicture(
note = it.key,
baseNote = it.key,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.service.nip19.Nip19
import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.CoroutineScope
@@ -42,7 +42,7 @@ class AccountStateViewModel() : ViewModel() {
fun startUI(key: String) {
val pattern = Pattern.compile(".+@.+\\.[a-z]+")
val parsed = Nip19.uriToRoute(key)
val pubKeyParsed = parsed?.hex?.toByteArray()
val pubKeyParsed = parsed?.hex?.hexToByteArray()
val account =
if (key.startsWith("nsec")) {
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.screen
import android.util.Log
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
@@ -32,6 +33,8 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.ZapSetCompose
import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@OptIn(ExperimentalMaterialApi::class)
@Composable
@@ -85,6 +88,7 @@ fun CardFeedView(
}
}
@OptIn(ExperimentalTime::class)
@Composable
private fun FeedLoaded(
state: CardFeedState.Loaded,
@@ -114,61 +118,64 @@ private fun FeedLoaded(
state = listState
) {
itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item ->
when (item) {
is NoteCard -> NoteCompose(
item.note,
isBoostedNote = false,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
is ZapSetCard -> ZapSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
is ZapUserSetCard -> ZapUserSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
is LikeSetCard -> LikeSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
is BoostSetCard -> BoostSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
is MultiSetCard -> MultiSetCompose(
item,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
is BadgeCard -> BadgeCompose(
item,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
is MessageSetCard -> MessageSetCompose(
messageSetCard = item,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
navController = navController
)
val (value, elapsed) = measureTimedValue {
when (item) {
is NoteCard -> NoteCompose(
item.note,
isBoostedNote = false,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
is ZapSetCard -> ZapSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
is ZapUserSetCard -> ZapUserSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
is LikeSetCard -> LikeSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
is BoostSetCard -> BoostSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
is MultiSetCard -> MultiSetCompose(
item,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
is BadgeCard -> BadgeCompose(
item,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
is MessageSetCard -> MessageSetCompose(
messageSetCard = item,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
navController = navController
)
}
}
Log.d("Time", "${item.javaClass.simpleName} Feed in $elapsed ${item.id()}")
}
}
}
@@ -175,7 +175,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
fun refreshFromOldState(newItems: Set<Note>) {
private fun refreshFromOldState(newItems: Set<Note>) {
val oldNotesState = _feedContent.value
val thisAccount = (localFilter as? NotificationFeedFilter)?.account
@@ -104,7 +104,7 @@ private fun FeedLoaded(
} else {
val replyAuthorBase =
(note.event as? PrivateDmEvent)
?.recipientPubKey()
?.verifiedRecipientPubKey()
?.let { LocalCache.getOrCreateUser(it) }
var userToComposeOn = note.author!!
@@ -249,7 +249,7 @@ fun NoteMaster(
})
) {
NoteAuthorPicture(
note = baseNote,
baseNote = baseNote,
navController = navController,
userAccount = account.userProfile(),
size = 55.dp
@@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -50,9 +51,11 @@ fun HomeScreen(
) {
val coroutineScope = rememberCoroutineScope()
val account = accountViewModel.accountLiveData.value?.account ?: return
var wantsToAddNip47 by remember { mutableStateOf<String?>(nip47) }
var wantsToAddNip47 by remember { mutableStateOf(nip47) }
LaunchedEffect(accountViewModel) {
val accountState = account.live.observeAsState()
LaunchedEffect(accountViewModel, accountState.value?.account?.defaultHomeFollowList) {
HomeNewThreadFeedFilter.account = account
HomeConversationsFeedFilter.account = account
NostrHomeDataSource.resetFilters()
@@ -100,23 +100,42 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) {
if (userId == null) return
var userBase by remember { mutableStateOf<User?>(null) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
userBase = LocalCache.checkGetOrCreateUser(userId)
}
}
userBase?.let {
ProfileScreen(
user = it,
accountViewModel = accountViewModel,
navController = navController
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ProfileScreen(user: User, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
if (userId == null) return
UserProfileNewThreadFeedFilter.loadUserProfile(account, user)
UserProfileConversationsFeedFilter.loadUserProfile(account, user)
UserProfileFollowersFeedFilter.loadUserProfile(account, user)
UserProfileFollowsFeedFilter.loadUserProfile(account, user)
UserProfileZapsFeedFilter.loadUserProfile(user)
UserProfileReportsFeedFilter.loadUserProfile(user)
UserProfileBookmarksFeedFilter.loadUserProfile(account, user)
UserProfileNewThreadFeedFilter.loadUserProfile(account, userId)
UserProfileConversationsFeedFilter.loadUserProfile(account, userId)
UserProfileFollowersFeedFilter.loadUserProfile(account, userId)
UserProfileFollowsFeedFilter.loadUserProfile(account, userId)
UserProfileZapsFeedFilter.loadUserProfile(userId)
UserProfileReportsFeedFilter.loadUserProfile(userId)
UserProfileBookmarksFeedFilter.loadUserProfile(account, userId)
NostrUserProfileDataSource.loadUserProfile(userId)
NostrUserProfileDataSource.loadUserProfile(user)
val lifeCycleOwner = LocalLifecycleOwner.current
@@ -124,7 +143,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Profidle Start")
NostrUserProfileDataSource.loadUserProfile(userId)
NostrUserProfileDataSource.loadUserProfile(user)
NostrUserProfileDataSource.start()
}
if (event == Lifecycle.Event.ON_PAUSE) {
@@ -105,10 +105,14 @@ fun VideoScreen(
val lifeCycleOwner = LocalLifecycleOwner.current
val account = accountViewModel.accountLiveData.value?.account ?: return
val accountState = account.live.observeAsState()
NostrVideoDataSource.account = account
VideoFeedFilter.account = account
LaunchedEffect(accountViewModel) {
LaunchedEffect(accountViewModel, accountState.value?.account?.defaultStoriesFollowList) {
VideoFeedFilter.account = account
NostrVideoDataSource.account = account
NostrVideoDataSource.resetFilters()
videoFeedView.invalidateData()
}
@@ -118,6 +122,7 @@ fun VideoScreen(
if (event == Lifecycle.Event.ON_RESUME) {
println("Video Start")
VideoFeedFilter.account = account
NostrVideoDataSource.account = account
NostrVideoDataSource.start()
videoFeedView.invalidateData()
}
+6
View File
@@ -333,5 +333,11 @@
<string name="upload_server_relays_nip95">Saját csomópontjaid (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">A fájlokat a csomópontokra töltik fel és ott tárolják. Rögzített URL-től mentesek (harmadik féltől való függőség). Győződj meg róla, hogy legalább egy NIP-95 csomópont a csomópontlistában szerepel</string>
<string name="follow_list_selection">Követek Lista</string>
<string name="follow_list_kind3follows">Mindenki akit követek</string>
<string name="follow_list_global">Globális</string>
</resources>
+14
View File
@@ -336,6 +336,10 @@
<string name="upload_server_nostrbuild">nostr.build - trusted</string>
<string name="upload_server_nostrbuild_explainer">Nostr.build can modify the file</string>
<string name="upload_server_nostrfilesdev">nostrfiles.dev - trusted</string>
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev can modify the file</string>
<string name="upload_server_imgur_nip94">Verifiable Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Checks if Imgur modified the file. New NIP: other clients might not see it</string>
@@ -345,7 +349,17 @@
<string name="upload_server_nostrbuild_nip94">Verifiable Nostr.build (NIP-94)</string>
<string name="upload_server_nostrbuild_nip94_explainer">Checks if Nostr.build modified the file. New NIP: other clients might not see it</string>
<string name="upload_server_nostrfilesdev_nip94">Verifiable Nostrfiles.dev (NIP-94)</string>
<string name="upload_server_nostrfilesdev_nip94_explainer">Checks if Nostrfiles.dev modified the file. New NIP: other clients might not see it</string>
<string name="upload_server_relays_nip95">Your relays (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Files are hosted by your relays. New NIP: check if they support</string>
<string name="follow_list_selection">Follow List</string>
<string name="follow_list_kind3follows">All Follows</string>
<string name="follow_list_global">Global</string>
</resources>
@@ -4,6 +4,7 @@ import android.util.LruCache
import com.google.android.gms.tasks.Task
import com.google.android.gms.tasks.Tasks
import com.google.mlkit.nl.languageid.LanguageIdentification
import com.google.mlkit.nl.languageid.LanguageIdentificationOptions
import com.google.mlkit.nl.translate.TranslateLanguage
import com.google.mlkit.nl.translate.Translation
import com.google.mlkit.nl.translate.Translator
@@ -20,7 +21,8 @@ class ResultOrError(
)
object LanguageTranslatorService {
private val languageIdentification = LanguageIdentification.getClient()
private val options = LanguageIdentificationOptions.Builder().setConfidenceThreshold(0.6f).build()
private val languageIdentification = LanguageIdentification.getClient(options)
val lnRegex = Pattern.compile("\\blnbc[a-z0-9]+\\b", Pattern.CASE_INSENSITIVE)
val tagRegex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)", Pattern.CASE_INSENSITIVE)
@@ -18,6 +18,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -34,7 +35,7 @@ import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.lang.ResultOrError
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
import java.util.Locale
@Composable
@@ -57,8 +58,10 @@ fun TranslatableRichTextViewer(
val accountState by accountViewModel.accountLanguagesLiveData.observeAsState()
val account = accountState?.account ?: return
val scope = rememberCoroutineScope()
LaunchedEffect(accountState) {
withContext(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
LanguageTranslatorService.autoTranslate(
content,
account.dontTranslateFrom,
@@ -70,8 +73,6 @@ fun TranslatableRichTextViewer(
showOriginal = preference == task.result.sourceLang
}
translatedTextState.value = task.result
} else {
translatedTextState.value = ResultOrError(content, null, null, null)
}
}
}