Compare commits

..
27 Commits
Author SHA1 Message Date
Vitor Pamplona f224fe6b40 v0.32.2 2023-04-06 12:23:35 -04:00
Vitor Pamplona 403a1723de Previews Notes that are not a citation using #[n] 2023-04-06 11:59:21 -04:00
Vitor Pamplona 6cf1960df4 Adds a New Message Tagger to Chats (tags Notes and Users by default) and Private Message (doesn't tag Users and Notes) 2023-04-06 11:58:49 -04:00
Vitor Pamplona 8676752f19 Fixes Channel's reply to filtration (removes citations and the channel id from replies) 2023-04-06 11:57:28 -04:00
Vitor Pamplona 7012d949c2 Fixes formatting 2023-04-06 11:56:23 -04:00
Vitor Pamplona 8344274011 Adds Invoice Creation to New Posts. 2023-04-06 11:54:28 -04:00
Vitor Pamplona b0953310c2 Making the poll click animation wider. 2023-04-05 11:10:14 -04:00
Vitor Pamplona 65910295db Adds notification for Zaps that don't have a Note attached to them. 2023-04-05 08:46:25 -04:00
Vitor Pamplona 7a957bd6ec Initializing indexes with large sizes from the start. 2023-04-05 08:45:58 -04:00
Vitor Pamplona 292ce779a1 Lazy initialization of the json mapper (300ms) 2023-04-05 08:45:33 -04:00
Vitor Pamplona 924b21cdfc Reducing the use of Slow disk access of Local Preferences. 2023-04-05 08:44:48 -04:00
Vitor PamplonaandGitHub 7b539e63bd Merge pull request #334 from Poussinou/patch-1
Update README.md
2023-04-05 08:21:06 -04:00
PoussinouandGitHub d37fef94fe Update README.md 2023-04-05 21:24:47 +09:30
Vitor PamplonaandGitHub 93a1853317 Merge pull request #332 from braydonf/readme
Update readme gradlew install commands
2023-04-04 19:48:16 -04:00
Braydon 9944f98bde Update readme gradlew install commands 2023-04-04 16:24:44 -07:00
Vitor Pamplona 05138232f4 Fixes missing function 2023-04-04 16:12:20 -04:00
Vitor Pamplona 83d7085375 Hides Poll option when replying to channel or DMs. 2023-04-04 16:10:55 -04:00
Vitor Pamplona a8a3915c8d Allowing replies of PMs from Notifications. 2023-04-04 16:09:54 -04:00
Vitor Pamplona 83bcf53583 v0.32.1 2023-04-04 14:11:05 -04:00
Vitor Pamplona 875d9544ec Fixes Poll Color for Light mode. 2023-04-04 13:59:02 -04:00
Vitor Pamplona 7ce2a0bbea Makes sure before and after voting the padding is the same. 2023-04-04 13:57:06 -04:00
Vitor Pamplona a3b9ea6a42 Fixes format 2023-04-04 13:51:27 -04:00
Vitor Pamplona ca2c811859 Fixes Voted option highlight. 2023-04-04 13:49:41 -04:00
Vitor Pamplona b09bb0c3c5 Fixes margins when images are used in the poll 2023-04-04 13:39:12 -04:00
Vitor Pamplona a68ea1abe5 Adds margin between the reply note and the text 2023-04-04 12:35:42 -04:00
Vitor Pamplona 02a9aaab60 Removes the 5dp space on the generic RichText Viewer 2023-04-04 12:29:25 -04:00
Vitor Pamplona efd2ddba71 Adds payment animation to Polls 2023-04-04 12:25:32 -04:00
34 changed files with 637 additions and 285 deletions
+17 -1
View File
@@ -4,6 +4,15 @@
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting.
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/com.vitorpamplona.amethyst/)
[<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png"
alt="Get it on Google Play"
height="80">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
Or get the latest APK from the [Releases Section](https://github.com/vitorpamplona/amethyst/releases/latest).
# Current Features
- [x] Event Builders / WebSocket Subscriptions (NIP-01, NIP-15)
@@ -106,8 +115,15 @@ Build the app:
```
## Installing on device
For the F-Droid build:
```bash
./gradlew installDebug
./gradlew installFdroidDebug
```
For the Play build:
```bash
./gradlew installPlayDebug
```
## How to Deploy
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 115
versionName "0.32.0"
versionCode 117
versionName "0.32.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -27,10 +27,7 @@ private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
data class AccountInfo(
val npub: String,
val hasPrivKey: Boolean,
val current: Boolean,
val displayName: String?,
val profilePicture: String?
val hasPrivKey: Boolean = false
)
private object PrefKeys {
@@ -38,8 +35,6 @@ private object PrefKeys {
const val SAVED_ACCOUNTS = "all_saved_accounts"
const val NOSTR_PRIVKEY = "nostr_privkey"
const val NOSTR_PUBKEY = "nostr_pubkey"
const val DISPLAY_NAME = "display_name"
const val PROFILE_PICTURE_URL = "profile_picture"
const val FOLLOWING_CHANNELS = "following_channels"
const val HIDDEN_USERS = "hidden_users"
const val RELAYS = "relays"
@@ -59,53 +54,73 @@ private val gson = GsonBuilder().create()
object LocalPreferences {
private const val comma = ","
private var currentAccount: String?
get() = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
set(npub) {
val prefs = encryptedPreferences()
prefs.edit().apply {
private var _currentAccount: String? = null
private fun currentAccount(): String? {
if (_currentAccount == null) {
_currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
}
return _currentAccount
}
private fun updateCurrentAccount(npub: String) {
if (_currentAccount != npub) {
_currentAccount = npub
encryptedPreferences().edit().apply {
putString(PrefKeys.CURRENT_ACCOUNT, npub)
}.apply()
}
}
private val savedAccounts: List<String>
get() = encryptedPreferences()
.getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
private var _savedAccounts: List<String>? = null
private fun savedAccounts(): List<String> {
if (_savedAccounts == null) {
_savedAccounts = encryptedPreferences()
.getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
}
return _savedAccounts!!
}
private fun updateSavedAccounts(accounts: List<String>) {
if (_savedAccounts != accounts) {
_savedAccounts = accounts
encryptedPreferences().edit().apply {
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
}.apply()
}
}
private val prefsDirPath: String
get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/"
private fun addAccount(npub: String) {
val accounts = savedAccounts.toMutableList()
val accounts = savedAccounts().toMutableList()
if (npub !in accounts) {
accounts.add(npub)
updateSavedAccounts(accounts)
}
val prefs = encryptedPreferences()
prefs.edit().apply {
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
}.apply()
}
private fun setCurrentAccount(account: Account) {
val npub = account.userProfile().pubkeyNpub()
currentAccount = npub
updateCurrentAccount(npub)
addAccount(npub)
}
fun switchToAccount(npub: String) {
currentAccount = npub
updateCurrentAccount(npub)
}
/**
* Removes the account from the app level shared preferences
*/
private fun removeAccount(npub: String) {
val accounts = savedAccounts.toMutableList()
val accounts = savedAccounts().toMutableList()
if (accounts.remove(npub)) {
val prefs = encryptedPreferences()
prefs.edit().apply {
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
}.apply()
updateSavedAccounts(accounts)
}
}
@@ -145,11 +160,11 @@ object LocalPreferences {
removeAccount(npub)
deleteUserPreferenceFile(npub)
if (savedAccounts.isEmpty()) {
if (savedAccounts().isEmpty()) {
val appPrefs = encryptedPreferences()
appPrefs.edit().clear().apply()
} else if (currentAccount == npub) {
currentAccount = savedAccounts.elementAt(0)
} else if (currentAccount() == npub) {
updateCurrentAccount(savedAccounts().elementAt(0))
}
}
@@ -159,17 +174,8 @@ object LocalPreferences {
}
fun allSavedAccounts(): List<AccountInfo> {
return savedAccounts.map { npub ->
val prefs = encryptedPreferences(npub)
val hasPrivKey = prefs.getString(PrefKeys.NOSTR_PRIVKEY, null) != null
AccountInfo(
npub = npub,
hasPrivKey = hasPrivKey,
current = npub == currentAccount,
displayName = prefs.getString(PrefKeys.DISPLAY_NAME, null),
profilePicture = prefs.getString(PrefKeys.PROFILE_PICTURE_URL, null)
)
return savedAccounts().map { npub ->
AccountInfo(npub = npub)
}
}
@@ -189,13 +195,11 @@ object LocalPreferences {
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
putString(PrefKeys.DISPLAY_NAME, account.userProfile().toBestDisplayName())
putString(PrefKeys.PROFILE_PICTURE_URL, account.userProfile().profilePicture())
}.apply()
}
fun loadFromEncryptedStorage(): Account? {
encryptedPreferences(currentAccount).apply {
encryptedPreferences(currentAccount()).apply {
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
@@ -247,7 +251,7 @@ object LocalPreferences {
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
return Account(
val a = Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
followingChannels,
hiddenUsers,
@@ -261,23 +265,25 @@ object LocalPreferences {
hideBlockAlertDialog,
latestContactList
)
return a
}
}
fun saveLastRead(route: String, timestampInSecs: Long) {
encryptedPreferences(currentAccount).edit().apply {
encryptedPreferences(currentAccount()).edit().apply {
putLong(PrefKeys.LAST_READ(route), timestampInSecs)
}.apply()
}
fun loadLastRead(route: String): Long {
encryptedPreferences(currentAccount).run {
encryptedPreferences(currentAccount()).run {
return getLong(PrefKeys.LAST_READ(route), 0)
}
}
fun migrateSingleUserPrefs() {
if (currentAccount != null) return
if (currentAccount() != null) return
val pubkey = encryptedPreferences().getString(PrefKeys.NOSTR_PUBKEY, null) ?: return
val npub = Hex.decode(pubkey).toNpub()
@@ -314,6 +320,6 @@ object LocalPreferences {
encryptedPreferences().edit().clear().apply()
addAccount(npub)
currentAccount = npub
updateCurrentAccount(npub)
}
}
@@ -402,10 +402,11 @@ class Account(
LocalCache.consume(signedEvent)
}
fun sendChannelMessage(message: String, toChannel: String, replyingTo: Note? = null, mentions: List<User>?) {
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?) {
if (!isWriteable()) return
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val repliesToHex = replyTo?.map { it.idHex }
val mentionsHex = mentions?.map { it.pubkeyHex }
val signedEvent = ChannelMessageEvent.create(
@@ -419,12 +420,12 @@ class Account(
LocalCache.consume(signedEvent, null)
}
fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null) {
fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List<User>?) {
if (!isWriteable()) return
val user = LocalCache.users[toUser] ?: return
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val mentionsHex = emptyList<String>()
val mentionsHex = mentions?.map { it.pubkeyHex }
val signedEvent = PrivateDmEvent.create(
recipientPubKey = user.pubkey(),
@@ -17,16 +17,18 @@ import java.time.format.DateTimeFormatter
import java.util.concurrent.ConcurrentHashMap
object LocalCache {
val metadataParser = jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.readerFor(UserMetadata::class.java)
val metadataParser by lazy {
jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.readerFor(UserMetadata::class.java)
}
val antiSpam = AntiSpamFilter()
val users = ConcurrentHashMap<HexKey, User>()
val notes = ConcurrentHashMap<HexKey, Note>()
val users = ConcurrentHashMap<HexKey, User>(5000)
val notes = ConcurrentHashMap<HexKey, Note>(5000)
val channels = ConcurrentHashMap<HexKey, Channel>()
val addressables = ConcurrentHashMap<String, AddressableNote>()
val addressables = ConcurrentHashMap<String, AddressableNote>(100)
fun checkGetOrCreateUser(key: String): User? {
if (isValidHexNpub(key)) {
@@ -563,9 +565,9 @@ object LocalCache {
return
}
val replyTo = event.replyTos()
val replyTo = event.tagsWithoutCitations()
.filter { it != event.channel() }
.mapNotNull { checkGetOrCreateNote(it) }
.filter { it.event !is ChannelCreateEvent }
note.loadEvent(event, author, replyTo)
@@ -600,8 +602,7 @@ object LocalCache {
val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) } +
(
(zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()
?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>()
(zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>()
)
note.loadEvent(event, author, repliesTo)
@@ -387,6 +387,10 @@ class UserMetadata {
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
.any { it.startsWith(prefix, true) }
}
fun lnAddress(): String? {
return (lud16?.trim() ?: lud06?.trim())?.ifBlank { null }
}
}
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
@@ -13,7 +13,7 @@ open class BaseTextNoteEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun mentions() = taggedUsers()
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
open fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun findCitations(): Set<String> {
var citations = mutableSetOf<String>()
@@ -12,11 +12,10 @@ class ChannelMessageEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun channel() = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
fun replyTos() = tags.filter { it.getOrNull(1) != channel() }.mapNotNull { it.getOrNull(1) }
fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
override fun replyTos() = tags.filter { it.firstOrNull() == "e" && it.getOrNull(1) != channel() }.mapNotNull { it.getOrNull(1) }
companion object {
const val kind = 42
@@ -27,6 +27,8 @@ class LnZapEvent(
.filter { it.firstOrNull() == "p" }
.mapNotNull { it.getOrNull(1) }
override fun zappedRequestAuthor(): String? = containedPost()?.pubKey()
override fun amount(): BigDecimal? {
return amount
}
@@ -10,6 +10,8 @@ interface LnZapEventInterface : EventInterface {
fun zappedAuthor(): List<String>
fun zappedRequestAuthor(): String?
fun taggedAddresses(): List<ATag>
fun amount(): BigDecimal?
@@ -0,0 +1,81 @@
package com.vitorpamplona.amethyst.ui.actions
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.parseDirtyWordForKey
import com.vitorpamplona.amethyst.service.nip19.Nip19
class NewMessageTagger(var channel: Channel?, var mentions: List<User>?, var replyTos: List<Note>?, var message: String) {
open fun addUserToMentions(user: User) {
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
}
open fun addNoteToReplyTos(note: Note) {
note.author?.let { addUserToMentions(it) }
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
}
open fun tagIndex(user: User): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (channel != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
}
open fun tagIndex(note: Note): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (channel != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
}
fun run() {
// adds all references to mentions and reply tos
message.split('\n').forEach { paragraph: String ->
paragraph.split(' ').forEach { word: String ->
val results = parseDirtyWordForKey(word)
if (results?.key?.type == Nip19.Type.USER) {
addUserToMentions(LocalCache.getOrCreateUser(results.key.hex))
} else if (results?.key?.type == Nip19.Type.NOTE) {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
} else if (results?.key?.type == Nip19.Type.EVENT) {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
if (note != null) {
addNoteToReplyTos(note)
}
}
}
}
// Tags the text in the correct order.
message = message.split('\n').map { paragraph: String ->
paragraph.split(' ').map { word: String ->
val results = parseDirtyWordForKey(word)
if (results?.key?.type == Nip19.Type.USER) {
val user = LocalCache.getOrCreateUser(results.key.hex)
"#[${tagIndex(user)}]${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.NOTE) {
val note = LocalCache.getOrCreateNote(results.key.hex)
"#[${tagIndex(note)}]${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.EVENT) {
val note = LocalCache.getOrCreateNote(results.key.hex)
"#[${tagIndex(note)}]${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
if (note != null) {
"#[${tagIndex(note)}]${results.restOfWord}"
} else {
word
}
} else {
word
}
}.joinToString(" ")
}.joinToString("\n")
}
}
@@ -12,6 +12,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MonetizationOn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@@ -29,6 +31,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@@ -182,6 +185,28 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
}
val user = postViewModel.account?.userProfile()
val lud16 = user?.info?.lnAddress()
if (lud16 != null && user != null && postViewModel.wantsInvoice) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
InvoiceRequest(
lud16,
user.pubkeyHex,
account,
stringResource(id = R.string.lightning_invoice),
stringResource(id = R.string.lightning_create_and_add_invoice),
onSuccess = {
postViewModel.message = TextFieldValue(postViewModel.message.text + "\n\n" + it)
postViewModel.wantsInvoice = false
},
onClose = {
postViewModel.wantsInvoice = false
}
)
}
}
val myUrlPreview = postViewModel.urlPreview
if (myUrlPreview != null) {
Row(modifier = Modifier.padding(top = 5.dp)) {
@@ -244,8 +269,16 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
postViewModel.upload(it, context)
}
AddPollButton(postViewModel.wantsPoll) {
postViewModel.wantsPoll = !postViewModel.wantsPoll
if (postViewModel.canUsePoll()) {
AddPollButton(postViewModel.wantsPoll) {
postViewModel.wantsPoll = !postViewModel.wantsPoll
}
}
if (postViewModel.canAddLnInvoice()) {
AddLnInvoiceButton(postViewModel.wantsInvoice) {
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
}
}
}
}
@@ -269,14 +302,42 @@ private fun AddPollButton(
painter = painterResource(R.drawable.ic_poll),
null,
modifier = Modifier.size(20.dp),
tint = Color.White
tint = MaterialTheme.colors.onBackground
)
} else {
Icon(
painter = painterResource(R.drawable.ic_lists),
null,
modifier = Modifier.size(20.dp),
tint = Color.White
tint = MaterialTheme.colors.onBackground
)
}
}
}
@Composable
private fun AddLnInvoiceButton(
isLnInvoiceActive: Boolean,
onClick: () -> Unit
) {
IconButton(
onClick = {
onClick()
}
) {
if (!isLnInvoiceActive) {
Icon(
imageVector = Icons.Default.MonetizationOn,
null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onBackground
)
} else {
Icon(
imageVector = Icons.Default.MonetizationOn,
null,
modifier = Modifier.size(20.dp),
tint = Color.Green.copy(alpha = 0.52f)
)
}
}
@@ -13,8 +13,8 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import kotlinx.coroutines.Dispatchers
@@ -52,6 +52,9 @@ open class NewPostViewModel : ViewModel() {
var isValidConsensusThreshold = mutableStateOf(true)
var isValidClosedAt = mutableStateOf(true)
// Invoices
var wantsInvoice by mutableStateOf(false)
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
originalNote = replyingTo
replyingTo?.let { replyNote ->
@@ -79,81 +82,18 @@ open class NewPostViewModel : ViewModel() {
this.account = account
}
open fun addUserToMentions(user: User) {
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
}
open fun addNoteToReplyTos(note: Note) {
note.author?.let { addUserToMentions(it) }
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
}
open fun tagIndex(user: User): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (originalNote?.channel() != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
}
open fun tagIndex(note: Note): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (originalNote?.channel() != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
}
fun sendPost() {
// adds all references to mentions and reply tos
message.text.split('\n').forEach { paragraph: String ->
paragraph.split(' ').forEach { word: String ->
val results = parseDirtyWordForKey(word)
if (results?.key?.type == Nip19.Type.USER) {
addUserToMentions(LocalCache.getOrCreateUser(results.key.hex))
} else if (results?.key?.type == Nip19.Type.NOTE) {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
} else if (results?.key?.type == Nip19.Type.EVENT) {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
if (note != null) {
addNoteToReplyTos(note)
}
}
}
}
// Tags the text in the correct order.
val newMessage = message.text.split('\n').map { paragraph: String ->
paragraph.split(' ').map { word: String ->
val results = parseDirtyWordForKey(word)
if (results?.key?.type == Nip19.Type.USER) {
val user = LocalCache.getOrCreateUser(results.key.hex)
"#[${tagIndex(user)}]${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.NOTE) {
val note = LocalCache.getOrCreateNote(results.key.hex)
"#[${tagIndex(note)}]${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.EVENT) {
val note = LocalCache.getOrCreateNote(results.key.hex)
"#[${tagIndex(note)}]${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
if (note != null) {
"#[${tagIndex(note)}]${results.restOfWord}"
} else {
word
}
} else {
word
}
}.joinToString(" ")
}.joinToString("\n")
val tagger = NewMessageTagger(originalNote?.channel(), mentions, replyTos, message.text)
tagger.run()
if (wantsPoll) {
account?.sendPoll(newMessage, replyTos, mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt)
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt)
} else if (originalNote?.channel() != null) {
account?.sendChannelMessage(newMessage, originalNote!!.channel()!!.idHex, originalNote!!, mentions)
account?.sendChannelMessage(tagger.message, tagger.channel!!.idHex, tagger.replyTos, tagger.mentions)
} else if (originalNote?.event is PrivateDmEvent) {
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions)
} else {
account?.sendPost(newMessage, replyTos, mentions)
account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions)
}
cancel()
@@ -196,6 +136,8 @@ open class NewPostViewModel : ViewModel() {
valueMinimum = null
consensusThreshold = null
closedAt = null
wantsInvoice = false
}
open fun findUrlInMessage(): String? {
@@ -245,7 +187,15 @@ open class NewPostViewModel : ViewModel() {
}
fun canPost(): Boolean {
return message.text.isNotBlank() && !isUploadingImage &&
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice &&
(!wantsPoll || pollOptions.values.all { it.isNotEmpty() })
}
fun canUsePoll(): Boolean {
return originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null
}
fun canAddLnInvoice(): Boolean {
return account?.userProfile()?.info?.lnAddress() != null
}
}
@@ -1,7 +1,5 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
@@ -36,14 +34,21 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import kotlinx.coroutines.launch
@Composable
fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onClose: () -> Unit) {
fun InvoiceRequest(
lud16: String,
toUserPubKeyHex: String,
account: Account,
titleText: String? = null,
buttonText: String? = null,
onSuccess: (String) -> Unit,
onClose: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -73,7 +78,7 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC
)
Text(
text = stringResource(R.string.lightning_tips),
text = titleText ?: stringResource(R.string.lightning_tips),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp)
@@ -137,13 +142,7 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC
amount * 1000,
message,
zapRequest?.toJson(),
onSuccess = {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
startActivity(context, intent, null)
}
onClose()
},
onSuccess = onSuccess,
onError = {
scope.launch {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
@@ -159,7 +158,7 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = stringResource(R.string.send_sats), color = Color.White, fontSize = 20.sp)
Text(text = buttonText ?: stringResource(R.string.send_sats), color = Color.White, fontSize = 20.sp)
}
}
}
@@ -188,7 +188,13 @@ fun RichTextViewer(
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(word, navController)
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
@@ -239,7 +245,13 @@ fun RichTextViewer(
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(word, navController)
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
@@ -285,19 +297,48 @@ private fun isArabic(text: String): Boolean {
}
fun isBechLink(word: String): Boolean {
val cleaned = word.removePrefix("@").removePrefix("nostr:").removePrefix("@").take(7).lowercase()
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@").take(7)
return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) }
}
@Composable
fun BechLink(word: String, navController: NavController) {
fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
val nip19Route = Nip19.uriToRoute(word)
if (nip19Route == null) {
Text(text = "$word ")
} else {
ClickableRoute(nip19Route, navController)
if (nip19Route.type == Nip19.Type.NOTE || nip19Route.type == Nip19.Type.EVENT || nip19Route.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateNote(nip19Route.hex)
if (note != null) {
if (canPreview) {
NoteCompose(
baseNote = note,
accountViewModel = accountViewModel,
modifier = Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
),
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
.compositeOver(backgroundColor),
isQuotedNote = true,
navController = navController
)
} else {
ClickableRoute(nip19Route, navController)
}
} else {
ClickableRoute(nip19Route, navController)
}
} else {
ClickableRoute(nip19Route, navController)
}
}
}
@@ -69,7 +69,6 @@ fun ZoomableImageView(word: String, images: List<String> = listOf(word)) {
contentDescription = word,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
@@ -24,10 +23,8 @@ import androidx.compose.material.TextButton
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.RadioButtonChecked
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -45,14 +42,15 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage
import nostr.postr.bechToBytes
import nostr.postr.toHex
@Composable
fun AccountSwitchBottomSheet(
@@ -82,90 +80,101 @@ fun AccountSwitchBottomSheet(
accounts.forEach { acc ->
val current = accountUser.pubkeyNpub() == acc.npub
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
val baseUser = try {
LocalCache.getOrCreateUser(decodePublicKey(acc.npub).toHexKey())
} catch (e: Exception) {
null
}
if (baseUser != null) {
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
Row(
modifier = Modifier
.weight(1f)
.clickable {
accountStateViewModel.switchUser(acc.npub)
},
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier
.padding(16.dp, 16.dp)
.weight(1f),
.weight(1f)
.clickable {
accountStateViewModel.switchUser(acc.npub)
},
verticalAlignment = Alignment.CenterVertically
) {
Box(
Row(
modifier = Modifier
.width(55.dp)
.padding(0.dp)
.padding(16.dp, 16.dp)
.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
RobohashAsyncImageProxy(
robot = acc.npub.bechToBytes("npub").toHex(),
model = ResizeImage(acc.profilePicture, 55.dp),
contentDescription = stringResource(R.string.profile_image),
modifier = Modifier
.width(55.dp)
.height(55.dp)
.clip(shape = CircleShape)
)
Box(
modifier = Modifier
.size(20.dp)
.align(Alignment.TopEnd)
.width(55.dp)
.padding(0.dp)
) {
if (acc.hasPrivKey) {
RobohashAsyncImageProxy(
robot = user.pubkeyHex,
model = ResizeImage(user.profilePicture(), 55.dp),
contentDescription = stringResource(R.string.profile_image),
modifier = Modifier
.width(55.dp)
.height(55.dp)
.clip(shape = CircleShape)
)/*
Box(
modifier = Modifier
.size(20.dp)
.align(Alignment.TopEnd)
) {
if (acc.hasPrivKey) {
Icon(
imageVector = Icons.Default.Key,
contentDescription = stringResource(R.string.account_switch_has_private_key),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.primary
)
} else {
Icon(
imageVector = Icons.Default.Visibility,
contentDescription = stringResource(R.string.account_switch_pubkey_only),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.primary
)
}
}*/
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
val npubShortHex = acc.npub.toShortenHex()
user.bestDisplayName()?.let {
Text(it)
}
Text(npubShortHex)
}
Column(modifier = Modifier.width(32.dp)) {
if (current) {
Icon(
imageVector = Icons.Default.Key,
contentDescription = stringResource(R.string.account_switch_has_private_key),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.primary
)
} else {
Icon(
imageVector = Icons.Default.Visibility,
contentDescription = stringResource(R.string.account_switch_pubkey_only),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.primary
imageVector = Icons.Default.RadioButtonChecked,
contentDescription = stringResource(R.string.account_switch_active_account),
tint = MaterialTheme.colors.secondary
)
}
}
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
val npubShortHex = acc.npub.toShortenHex()
if (acc.displayName != null && acc.displayName != npubShortHex) {
Text(acc.displayName)
}
Text(npubShortHex)
}
Column(modifier = Modifier.width(32.dp)) {
if (current) {
Icon(
imageVector = Icons.Default.RadioButtonChecked,
contentDescription = stringResource(R.string.account_switch_active_account),
tint = MaterialTheme.colors.secondary
)
}
}
}
}
IconButton(
onClick = { accountStateViewModel.logOff(acc.npub) }
) {
Icon(
imageVector = Icons.Default.Logout,
contentDescription = stringResource(R.string.log_out),
tint = MaterialTheme.colors.onSurface
)
IconButton(
onClick = { accountStateViewModel.logOff(acc.npub) }
) {
Icon(
imageVector = Icons.Default.Logout,
contentDescription = stringResource(R.string.log_out),
tint = MaterialTheme.colors.onSurface
)
}
}
}
}
@@ -230,17 +230,15 @@ fun ChatroomMessageCompose(
if (!innerQuote && !replyTo.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
replyTo.toSet().mapIndexed { _, note ->
if (note.event != null) {
ChatroomMessageCompose(
note,
null,
innerQuote = true,
parentBackgroundColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
navController = navController,
onWantsToReply = onWantsToReply
)
}
ChatroomMessageCompose(
note,
null,
innerQuote = true,
parentBackgroundColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
navController = navController,
onWantsToReply = onWantsToReply
)
}
}
}
@@ -286,7 +284,7 @@ fun ChatroomMessageCompose(
TranslatableRichTextViewer(
eventContent,
canPreview,
Modifier,
Modifier.padding(top = 5.dp),
note.event?.tags(),
backgroundBubbleColor,
accountViewModel,
@@ -296,7 +294,7 @@ fun ChatroomMessageCompose(
TranslatableRichTextViewer(
stringResource(R.string.could_not_decrypt_the_message),
true,
Modifier,
Modifier.padding(top = 5.dp),
note.event?.tags(),
backgroundBubbleColor,
accountViewModel,
@@ -371,6 +371,7 @@ fun NoteComposeInner(
} else {
ReplyInformation(note.replyTo, sortedMentions, account, navController)
}
Spacer(modifier = Modifier.height(5.dp))
} else if (!makeItShort && noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions() != null)) {
val sortedMentions = noteEvent.mentions()
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
@@ -380,6 +381,7 @@ fun NoteComposeInner(
note.channel()?.let {
ReplyInformationChannel(note.replyTo, sortedMentions, it, navController)
}
Spacer(modifier = Modifier.height(5.dp))
}
if (noteEvent is ReactionEvent || noteEvent is RepostEvent) {
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.ui.note
import android.widget.Toast
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
@@ -10,7 +9,6 @@ import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.saveable.rememberSaveable
@@ -107,11 +105,11 @@ fun PollNote(
)
}
Column(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(15.dp)) {
TranslatableRichTextViewer(
poll_op.value,
canPreview,
Modifier.padding(vertical = 5.dp),
Modifier,
pollViewModel.pollEvent?.tags(),
backgroundColor,
accountViewModel,
@@ -144,7 +142,7 @@ fun PollNote(
TranslatableRichTextViewer(
poll_op.value,
canPreview,
Modifier.padding(vertical = 5.dp, horizontal = 10.dp),
Modifier.padding(15.dp),
pollViewModel.pollEvent?.tags(),
backgroundColor,
accountViewModel,
@@ -188,8 +186,8 @@ fun ZapVote(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.combinedClickable(
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false, radius = 24.dp),
// interactionSource = remember { MutableInteractionSource() },
// indication = rememberRipple(bounded = false, radius = 24.dp),
onClick = {
if (!accountViewModel.isWriteable()) {
scope.launch {
@@ -282,6 +280,7 @@ fun ZapVote(
clickablePrepend()
if (pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) {
zappingProgress = 1f
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
@@ -289,12 +288,21 @@ fun ZapVote(
tint = BitcoinOrange
)
} else {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
if (zappingProgress < 0.1 || zappingProgress > 0.99) {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
} else {
Spacer(Modifier.width(3.dp))
CircularProgressIndicator(
progress = zappingProgress,
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp
)
}
}
}
@@ -94,7 +94,7 @@ class PollNoteViewModel {
.filterIsInstance<LnZapEvent>()
.map {
val zappedOption = it.zappedPollOption()
if (zappedOption == option) {
if (zappedOption == option && it.zappedRequestAuthor() == user.pubkeyHex) {
return true
}
}
@@ -64,7 +64,6 @@ import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.math.RoundingMode
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -125,7 +124,7 @@ fun ReplyReaction(
val scope = rememberCoroutineScope()
IconButton(
modifier = Modifier.then(Modifier.size(20.dp)),
modifier = Modifier.size(20.dp),
onClick = {
if (accountViewModel.isWriteable()) {
onPress()
@@ -460,7 +459,7 @@ private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier)
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
IconButton(
modifier = Modifier.then(Modifier.size(20.dp)),
modifier = Modifier.size(20.dp),
onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") }
) {
Icon(
@@ -475,7 +474,6 @@ private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier)
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("https://counter.amethyst.social/${baseNote.idHex}.svg?label=+&color=00000000")
.crossfade(true)
.diskCachePolicy(CachePolicy.DISABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build(),
@@ -0,0 +1,109 @@
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.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.ZapUserSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = zapSetCard) {
withContext(Dispatchers.IO) {
isNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, zapSetCard.createdAt)
}
}
var backgroundColor = if (isNew) {
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}
Column(
modifier = Modifier
.background(backgroundColor)
.clickable {
navController.navigate("User/${zapSetCard.user.pubkeyHex}")
}
) {
Row(
modifier = Modifier
.padding(
start = if (!isInnerNote) 12.dp else 0.dp,
end = if (!isInnerNote) 12.dp else 0.dp,
top = 10.dp
)
) {
// Draws the like picture outside the boosted card.
if (!isInnerNote) {
Box(
modifier = Modifier
.width(55.dp)
.padding(0.dp)
) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(id = R.string.zaps),
tint = BitcoinOrange,
modifier = Modifier
.size(25.dp)
.align(Alignment.TopEnd)
)
}
}
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
FlowRow() {
zapSetCard.zapEvents.forEach {
NoteAuthorPicture(
note = it.key,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
UserCompose(baseUser = zapSetCard.user, accountViewModel = accountViewModel, navController = navController)
}
}
}
}
@@ -35,11 +35,11 @@ class AccountStateViewModel() : ViewModel() {
private fun tryLoginExistingAccount() {
LocalPreferences.loadFromEncryptedStorage()?.let {
login(it)
startUI(it)
}
}
fun login(key: String) {
fun startUI(key: String) {
val pattern = Pattern.compile(".+@.+\\.[a-z]+")
val parsed = Nip19.uriToRoute(key)
val pubKeyParsed = parsed?.hex?.toByteArray()
@@ -56,7 +56,8 @@ class AccountStateViewModel() : ViewModel() {
Account(Persona(Hex.decode(key)))
}
login(account)
LocalPreferences.updatePrefsForLogin(account)
startUI(account)
}
fun switchUser(npub: String) {
@@ -67,24 +68,22 @@ class AccountStateViewModel() : ViewModel() {
fun newKey() {
val account = Account(Persona())
login(account)
// saves to local preferences
LocalPreferences.updatePrefsForLogin(account)
startUI(account)
}
@OptIn(DelicateCoroutinesApi::class)
fun login(account: Account) {
LocalPreferences.updatePrefsForLogin(account)
fun startUI(account: Account) {
if (account.loggedIn.privKey != null) {
_accountContent.update { AccountState.LoggedIn(account) }
} else {
_accountContent.update { AccountState.LoggedInViewOnly(account) }
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
ServiceManager.start(account)
}
GlobalScope.launch(Dispatchers.Main) {
account.saveable.observeForever(saveListener)
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.MutableState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
abstract class Card() {
abstract fun createdAt(): Long
@@ -40,6 +41,14 @@ class ZapSetCard(val note: Note, val zapEvents: Map<Note, Note>) : Card() {
override fun id() = note.idHex + "Z" + createdAt
}
class ZapUserSetCard(val user: User, val zapEvents: Map<Note, Note>) : Card() {
val createdAt = zapEvents.maxOf { it.value.createdAt() ?: 0 }
override fun createdAt(): Long {
return createdAt
}
override fun id() = user.pubkeyHex + "U" + createdAt
}
class MultiSetCard(val note: Note, val boostEvents: List<Note>, val likeEvents: List<Note>, val zapEvents: Map<Note, Note>) : Card() {
val createdAt = maxOf(
zapEvents.maxOfOrNull { it.value.createdAt() ?: 0 } ?: 0,
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.ui.note.MessageSetCompose
import com.vitorpamplona.amethyst.ui.note.MultiSetCompose
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
@OptIn(ExperimentalMaterialApi::class)
@@ -128,6 +129,13 @@ private fun FeedLoaded(
navController = navController,
routeForLastRead = routeForLastRead
)
is ZapUserSetCard -> ZapUserSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
is LikeSetCard -> LikeSetCompose(
item,
isInnerNote = false,
@@ -7,6 +7,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
@@ -78,7 +79,7 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
}
// val reactionCards = reactionsPerEvent.map { LikeSetCard(it.key, it.value) }
val zapsPerUser = mutableMapOf<User, MutableMap<Note, Note>>()
val zapsPerEvent = mutableMapOf<Note, MutableMap<Note, Note>>()
notes
.filter { it.event is LnZapEvent }
@@ -89,6 +90,20 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
if (zapRequest != null) {
zapsPerEvent.getOrPut(zappedPost, { mutableMapOf() }).put(zapRequest, zapEvent)
}
} else {
val event = (zapEvent.event as LnZapEvent)
val author = event.zappedAuthor().mapNotNull {
LocalCache.checkGetOrCreateUser(
it
)
}.firstOrNull()
if (author != null) {
val zapRequest = author.zaps.filter { it.value == zapEvent }.keys.firstOrNull()
if (zapRequest != null) {
zapsPerUser.getOrPut(author, { mutableMapOf() })
.put(zapRequest, zapEvent)
}
}
}
}
@@ -121,6 +136,13 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
}
}.flatten()
val userZaps = zapsPerUser.map {
ZapUserSetCard(
it.key,
it.value
)
}
val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent && it.event !is LnZapEvent }.map {
if (it.event is PrivateDmEvent) {
MessageSetCard(it)
@@ -131,7 +153,7 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
}
}
return (multiCards + textNoteCards).sortedBy { it.createdAt() }.reversed()
return (multiCards + textNoteCards + userZaps).sortedBy { it.createdAt() }.reversed()
}
private fun updateFeed(notes: List<Card>) {
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
@@ -296,8 +297,9 @@ fun NoteMaster(
}
}
Spacer(modifier = Modifier.height(10.dp))
if (noteEvent is BadgeDefinitionEvent) {
Spacer(modifier = Modifier.padding(top = 10.dp))
BadgeDisplay(baseNote = note)
} else if (noteEvent is LongTextNoteEvent) {
Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)) {
@@ -66,6 +66,7 @@ import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
@@ -213,7 +214,9 @@ fun ChannelScreen(
trailingIcon = {
PostButton(
onPost = {
account.sendChannelMessage(channelScreenModel.message.text, channel.idHex, replyTo.value, null)
val tagger = NewMessageTagger(channel, listOfNotNull(replyTo.value?.author), listOfNotNull(replyTo.value), channelScreenModel.message.text)
tagger.run()
account.sendChannelMessage(tagger.message, channel.idHex, tagger.replyTos, tagger.mentions)
channelScreenModel.message = TextFieldValue("")
replyTo.value = null
feedViewModel.invalidateData() // Don't wait a full second before updating
@@ -177,7 +177,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
trailingIcon = {
PostButton(
onPost = {
account.sendPrivateMessage(chatRoomScreenModel.message.text, userId, replyTo.value)
account.sendPrivateMessage(chatRoomScreenModel.message.text, userId, replyTo.value, null)
chatRoomScreenModel.message = TextFieldValue("")
replyTo.value = null
feedViewModel.invalidateData() // Don't wait a full second before updating
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.*
@@ -27,6 +29,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalUriHandler
@@ -39,6 +42,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -421,6 +425,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
val uri = LocalUriHandler.current
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
Row(verticalAlignment = Alignment.Bottom) {
user.bestDisplayName()?.let {
@@ -555,9 +560,25 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
if (zapExpanded) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
InvoiceRequest(lud16, baseUser.pubkeyHex, account) {
zapExpanded = false
}
InvoiceRequest(
lud16,
baseUser.pubkeyHex,
account,
onSuccess = {
// pay directly
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(it)
} else {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
ContextCompat.startActivity(context, intent, null)
}
}
},
onClose = {
zapExpanded = false
}
)
}
}
}
@@ -158,7 +158,7 @@ fun LoginPage(
keyboardActions = KeyboardActions(
onGo = {
try {
accountViewModel.login(key.value.text)
accountViewModel.startUI(key.value.text)
} catch (e: Exception) {
errorMessage = context.getString(R.string.invalid_key)
}
@@ -237,7 +237,7 @@ fun LoginPage(
if (acceptedTerms.value && key.value.text.isNotBlank()) {
try {
accountViewModel.login(key.value.text)
accountViewModel.startUI(key.value.text)
} catch (e: Exception) {
errorMessage = context.getString(R.string.invalid_key)
}
+2
View File
@@ -288,4 +288,6 @@
<string name="custom_zaps_add_a_message">Add a public message</string>
<string name="custom_zaps_add_a_message_example">Thank you for all your work!</string>
<string name="lightning_create_and_add_invoice">Create and Add</string>
</resources>
@@ -79,7 +79,7 @@ fun TranslatableRichTextViewer(
val toBeViewed = if (showOriginal) content else translatedTextState.value.result ?: content
Column(modifier = Modifier.padding(top = 5.dp)) {
Column() {
ExpandableRichTextViewer(
toBeViewed,
canPreview,