diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 6e6e2910b6..e7ef182e0d 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -14,10 +14,10 @@ jobs:
steps:
- uses: actions/checkout@v2
- - name: Set up JDK 11
+ - name: Set up JDK 17
uses: actions/setup-java@v1
with:
- java-version: 11
+ java-version: 17
- name: Cache gradle
uses: actions/cache@v1
diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml
index 853a6ad2b1..e788e06bfc 100644
--- a/.github/workflows/create-release.yml
+++ b/.github/workflows/create-release.yml
@@ -12,10 +12,10 @@ jobs:
- name: Checkout code
uses: actions/checkout@v2
- - name: Set up JDK 11
+ - name: Set up JDK 17
uses: actions/setup-java@v1
with:
- java-version: 11
+ java-version: 17
- name: Cache gradle
uses: actions/cache@v1
diff --git a/.gitignore b/.gitignore
index d67bb1a415..a167f9389e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/androidTestResultsUserPreferences.xml
+/.idea/deploymentTargetDropDown.xml
.DS_Store
/build
/captures
diff --git a/README.md b/README.md
index e31320ea18..7d2eb1ce50 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,15 @@
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting.
+[
](https://f-droid.org/packages/com.vitorpamplona.amethyst/)
+[
](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)
@@ -50,6 +59,7 @@ Amethyst brings the best social network to your Android phone. Just insert your
- [ ] Delegated Event Signing (NIP-26)
- [ ] Account Creation / Backup Guidance (NIP-06)
- [ ] Message Sent feedback (NIP-20)
+- [ ] Polls (NIP-69)
# Development Overview
@@ -75,7 +85,7 @@ Lastly, the user's account information (priv key/pub key) is stored in the Andro
## Setup
Make sure to have the following pre-requisites installed:
-1. Java 11
+1. Java 17
2. Android Studio
3. Android 8.0+ Phone or Emulation setup
@@ -105,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
@@ -150,6 +167,12 @@ Information shared on nostr should be assumed permanent for privacy purposes. Th
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) are very welcome.
+## Contributors
+
+
+
+
+
# MIT License
Copyright (c) 2023 Vitor Pamplona
diff --git a/app/build.gradle b/app/build.gradle
index 3f269ebef3..c31919d7da 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
- versionCode 113
- versionName "0.31.3"
+ versionCode 118
+ versionName "0.32.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -57,12 +57,12 @@ android {
}
compileOptions {
- sourceCompatibility JavaVersion.VERSION_11
- targetCompatibility JavaVersion.VERSION_11
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
- jvmTarget = '11'
+ jvmTarget = '17'
}
buildFeatures {
@@ -78,6 +78,10 @@ android {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
+
+ lintOptions {
+ disable 'MissingTranslation'
+ }
}
dependencies {
diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslateableRichTextViewer.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt
similarity index 91%
rename from app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslateableRichTextViewer.kt
rename to app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt
index 920b0b0775..5f4659d445 100644
--- a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslateableRichTextViewer.kt
+++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt
@@ -1,26 +1,26 @@
-package com.vitorpamplona.amethyst.ui.components
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.navigation.NavController
-import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
-
-@Composable
-fun TranslateableRichTextViewer(
- content: String,
- canPreview: Boolean,
- modifier: Modifier = Modifier,
- tags: List>?,
- backgroundColor: Color,
- accountViewModel: AccountViewModel,
- navController: NavController
-) = ExpandableRichTextViewer(
- content,
- canPreview,
- modifier,
- tags,
- backgroundColor,
- accountViewModel,
- navController
-)
+package com.vitorpamplona.amethyst.ui.components
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.navigation.NavController
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+
+@Composable
+fun TranslatableRichTextViewer(
+ content: String,
+ canPreview: Boolean,
+ modifier: Modifier = Modifier,
+ tags: List>?,
+ backgroundColor: Color,
+ accountViewModel: AccountViewModel,
+ navController: NavController
+) = ExpandableRichTextViewer(
+ content,
+ canPreview,
+ modifier,
+ tags,
+ backgroundColor,
+ accountViewModel,
+ navController
+)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 518e6a5397..3735c465f9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,8 @@
+
+
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt
index 1726474863..40b6d817ea 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt
@@ -17,6 +17,8 @@ import nostr.postr.Persona
import nostr.postr.toHex
import nostr.postr.toNpub
import java.io.File
+import java.net.InetSocketAddress
+import java.net.Proxy
import java.util.Locale
// Release mode (!BuildConfig.DEBUG) always uses encrypted preferences
@@ -27,10 +29,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 +37,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"
@@ -51,6 +48,7 @@ private object PrefKeys {
const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
+ const val USE_PROXY = "use_proxy"
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
}
@@ -59,53 +57,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
- get() = encryptedPreferences()
- .getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
+ private var _savedAccounts: List? = null
+
+ private fun savedAccounts(): List {
+ if (_savedAccounts == null) {
+ _savedAccounts = encryptedPreferences()
+ .getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
+ }
+ return _savedAccounts!!
+ }
+
+ private fun updateSavedAccounts(accounts: List) {
+ 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 +163,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 +177,8 @@ object LocalPreferences {
}
fun allSavedAccounts(): List {
- 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 +198,13 @@ 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())
+ println(account.proxy != null)
+ putBoolean(PrefKeys.USE_PROXY, account.proxy != null)
}.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()
@@ -246,8 +255,10 @@ object LocalPreferences {
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
+ val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
+ var proxy = if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", 9050)) else null
- return Account(
+ val a = Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
followingChannels,
hiddenUsers,
@@ -259,25 +270,28 @@ object LocalPreferences {
zapPaymentRequestServer,
hideDeleteRequestDialog,
hideBlockAlertDialog,
- latestContactList
+ latestContactList,
+ proxy
)
+
+ 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 +328,6 @@ object LocalPreferences {
encryptedPreferences().edit().clear().apply()
addAccount(npub)
- currentAccount = npub
+ updateCurrentAccount(npub)
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
index 7c121402c1..fadefa7902 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
@@ -1,810 +1,852 @@
-package com.vitorpamplona.amethyst.model
-
-import android.content.res.Resources
-import androidx.core.os.ConfigurationCompat
-import androidx.lifecycle.LiveData
-import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
-import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
-import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
-import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
-import com.vitorpamplona.amethyst.service.model.Contact
-import com.vitorpamplona.amethyst.service.model.ContactListEvent
-import com.vitorpamplona.amethyst.service.model.DeletionEvent
-import com.vitorpamplona.amethyst.service.model.IdentityClaim
-import com.vitorpamplona.amethyst.service.model.LnZapPaymentRequestEvent
-import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
-import com.vitorpamplona.amethyst.service.model.MetadataEvent
-import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
-import com.vitorpamplona.amethyst.service.model.ReactionEvent
-import com.vitorpamplona.amethyst.service.model.ReportEvent
-import com.vitorpamplona.amethyst.service.model.RepostEvent
-import com.vitorpamplona.amethyst.service.model.TextNoteEvent
-import com.vitorpamplona.amethyst.service.relays.Client
-import com.vitorpamplona.amethyst.service.relays.Constants
-import com.vitorpamplona.amethyst.service.relays.FeedType
-import com.vitorpamplona.amethyst.service.relays.Relay
-import com.vitorpamplona.amethyst.service.relays.RelayPool
-import com.vitorpamplona.amethyst.ui.components.BundledUpdate
-import com.vitorpamplona.amethyst.ui.note.Nip47URI
-import kotlinx.coroutines.DelicateCoroutinesApi
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.launch
-import nostr.postr.Persona
-import java.util.Locale
-
-val DefaultChannels = setOf(
- "25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", // -> Anigma's Nostr
- "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group
-)
-
-fun getLanguagesSpokenByUser(): Set {
- val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
- val codedList = mutableSetOf()
- for (i in 0 until languageList.size()) {
- languageList.get(i)?.let { codedList.add(it.language) }
- }
- return codedList
-}
-
-@OptIn(DelicateCoroutinesApi::class)
-class Account(
- val loggedIn: Persona,
- var followingChannels: Set = DefaultChannels,
- var hiddenUsers: Set = setOf(),
- var localRelays: Set = Constants.defaultRelays.toSet(),
- var dontTranslateFrom: Set = getLanguagesSpokenByUser(),
- var languagePreferences: Map = mapOf(),
- var translateTo: String = Locale.getDefault().language,
- var zapAmountChoices: List = listOf(500L, 1000L, 5000L),
- var zapPaymentRequest: Nip47URI? = null,
- var hideDeleteRequestDialog: Boolean = false,
- var hideBlockAlertDialog: Boolean = false,
- var backupContactList: ContactListEvent? = null
-) {
- var transientHiddenUsers: Set = setOf()
-
- // Observers line up here.
- val live: AccountLiveData = AccountLiveData(this)
- val liveLanguages: AccountLiveData = AccountLiveData(this)
- val saveable: AccountLiveData = AccountLiveData(this)
-
- fun userProfile(): User {
- return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
- }
-
- fun followingChannels(): List {
- return followingChannels.map { LocalCache.getOrCreateChannel(it) }
- }
-
- fun hiddenUsers(): List {
- return (hiddenUsers + transientHiddenUsers).map { LocalCache.getOrCreateUser(it) }
- }
-
- fun isWriteable(): Boolean {
- return loggedIn.privKey != null
- }
-
- fun sendNewRelayList(relays: Map) {
- if (!isWriteable()) return
-
- val contactList = userProfile().latestContactList
- val follows = contactList?.follows() ?: emptyList()
- val followsTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
-
- if (contactList != null && follows.isNotEmpty()) {
- val event = ContactListEvent.create(
- follows,
- followsTags,
- relays,
- loggedIn.privKey!!
- )
-
- Client.send(event)
- LocalCache.consume(event)
- } else {
- val event = ContactListEvent.create(listOf(), listOf(), relays, loggedIn.privKey!!)
-
- // Keep this local to avoid erasing a good contact list.
- // Client.send(event)
- LocalCache.consume(event)
- }
- }
-
- fun sendNewUserMetadata(toString: String, identities: List) {
- if (!isWriteable()) return
-
- loggedIn.privKey?.let {
- val event = MetadataEvent.create(toString, identities, loggedIn.privKey!!)
- Client.send(event)
- LocalCache.consume(event)
- }
- }
-
- fun reactionTo(note: Note): List {
- return note.reactedBy(userProfile(), "+")
- }
-
- fun hasBoosted(note: Note): Boolean {
- return boostsTo(note).isNotEmpty()
- }
-
- fun boostsTo(note: Note): List {
- return note.boostedBy(userProfile())
- }
-
- fun hasReacted(note: Note): Boolean {
- return note.hasReacted(userProfile(), "+")
- }
-
- fun reactTo(note: Note) {
- if (!isWriteable()) return
-
- if (hasReacted(note)) {
- // has already liked this note
- return
- }
-
- note.event?.let {
- val event = ReactionEvent.createLike(it, loggedIn.privKey!!)
- Client.send(event)
- LocalCache.consume(event)
- }
- }
-
- fun createZapRequestFor(note: Note, message: String = ""): LnZapRequestEvent? {
- if (!isWriteable()) return null
-
- note.event?.let {
- return LnZapRequestEvent.create(it, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!, message)
- }
-
- return null
- }
-
- fun hasWalletConnectSetup(): Boolean {
- return zapPaymentRequest != null
- }
-
- fun sendZapPaymentRequestFor(lnInvoice: String) {
- if (!isWriteable()) return
-
- zapPaymentRequest?.let {
- val event = LnZapPaymentRequestEvent.create(lnInvoice, it.pubKeyHex, it.secret?.toByteArray() ?: loggedIn.privKey!!)
-
- Client.send(event, it.relayUri)
- }
- }
-
- fun createZapRequestFor(user: User): LnZapRequestEvent? {
- return createZapRequestFor(user.pubkeyHex)
- }
-
- fun createZapRequestFor(userPubKeyHex: String, message: String = ""): LnZapRequestEvent? {
- if (!isWriteable()) return null
-
- return LnZapRequestEvent.create(userPubKeyHex, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!, message)
- }
-
- fun report(note: Note, type: ReportEvent.ReportType, content: String = "") {
- if (!isWriteable()) return
-
- if (note.hasReacted(userProfile(), "⚠️")) {
- // has already liked this note
- return
- }
-
- note.event?.let {
- val event = ReactionEvent.createWarning(it, loggedIn.privKey!!)
- Client.send(event)
- LocalCache.consume(event)
- }
-
- note.event?.let {
- val event = ReportEvent.create(it, type, loggedIn.privKey!!, content = content)
- Client.send(event)
- LocalCache.consume(event, null)
- }
- }
-
- fun report(user: User, type: ReportEvent.ReportType) {
- if (!isWriteable()) return
-
- if (user.hasReport(userProfile(), type)) {
- // has already reported this note
- return
- }
-
- val event = ReportEvent.create(user.pubkeyHex, type, loggedIn.privKey!!)
- Client.send(event)
- LocalCache.consume(event, null)
- }
-
- fun delete(note: Note) {
- delete(listOf(note))
- }
-
- fun delete(notes: List) {
- if (!isWriteable()) return
-
- val myNotes = notes.filter { it.author == userProfile() }.map { it.idHex }
-
- if (myNotes.isNotEmpty()) {
- val event = DeletionEvent.create(myNotes, loggedIn.privKey!!)
- Client.send(event)
- LocalCache.consume(event)
- }
- }
-
- fun boost(note: Note) {
- if (!isWriteable()) return
-
- if (note.hasBoostedInTheLast5Minutes(userProfile())) {
- // has already bosted in the past 5mins
- return
- }
-
- note.event?.let {
- val event = RepostEvent.create(it, loggedIn.privKey!!)
- Client.send(event)
- LocalCache.consume(event)
- }
- }
-
- fun broadcast(note: Note) {
- note.event?.let {
- Client.send(it)
- }
- }
-
- fun follow(user: User) {
- if (!isWriteable()) return
-
- val contactList = userProfile().latestContactList
- val followingUsers = contactList?.follows() ?: emptyList()
- val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
-
- val event = if (contactList != null) {
- ContactListEvent.create(
- followingUsers.plus(Contact(user.pubkeyHex, null)),
- followingTags,
- contactList.relays(),
- loggedIn.privKey!!
- )
- } else {
- val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
- ContactListEvent.create(
- listOf(Contact(user.pubkeyHex, null)),
- followingTags,
- relays,
- loggedIn.privKey!!
- )
- }
-
- Client.send(event)
- LocalCache.consume(event)
- }
-
- fun follow(tag: String) {
- if (!isWriteable()) return
-
- val contactList = userProfile().latestContactList
- val followingUsers = contactList?.follows() ?: emptyList()
- val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
-
- val event = if (contactList != null) {
- ContactListEvent.create(
- followingUsers,
- followingTags.plus(tag),
- contactList.relays(),
- loggedIn.privKey!!
- )
- } else {
- val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
- ContactListEvent.create(
- followingUsers,
- followingTags.plus(tag),
- relays,
- loggedIn.privKey!!
- )
- }
-
- Client.send(event)
- LocalCache.consume(event)
- }
-
- fun unfollow(user: User) {
- if (!isWriteable()) return
-
- val contactList = userProfile().latestContactList
- val followingUsers = contactList?.follows() ?: emptyList()
- val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
-
- if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) {
- val event = ContactListEvent.create(
- followingUsers.filter { it.pubKeyHex != user.pubkeyHex },
- followingTags,
- contactList.relays(),
- loggedIn.privKey!!
- )
-
- Client.send(event)
- LocalCache.consume(event)
- }
- }
-
- fun unfollow(tag: String) {
- if (!isWriteable()) return
-
- val contactList = userProfile().latestContactList
- val followingUsers = contactList?.follows() ?: emptyList()
- val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
-
- if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) {
- val event = ContactListEvent.create(
- followingUsers,
- followingTags.filter { !it.equals(tag, ignoreCase = true) },
- contactList.relays(),
- loggedIn.privKey!!
- )
-
- Client.send(event)
- LocalCache.consume(event)
- }
- }
-
- fun sendPost(message: String, replyTo: List?, mentions: List?, tags: List? = null) {
- if (!isWriteable()) return
-
- val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex }
- val mentionsHex = mentions?.map { it.pubkeyHex }
- val addresses = replyTo?.mapNotNull { it.address() }
-
- val signedEvent = TextNoteEvent.create(
- msg = message,
- replyTos = repliesToHex,
- mentions = mentionsHex,
- addresses = addresses,
- extraTags = tags,
- privateKey = loggedIn.privKey!!
- )
-
- Client.send(signedEvent)
- LocalCache.consume(signedEvent)
- }
-
- fun sendChannelMessage(message: String, toChannel: String, replyingTo: Note? = null, mentions: List?) {
- if (!isWriteable()) return
-
- val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
- val mentionsHex = mentions?.map { it.pubkeyHex }
-
- val signedEvent = ChannelMessageEvent.create(
- message = message,
- channel = toChannel,
- replyTos = repliesToHex,
- mentions = mentionsHex,
- privateKey = loggedIn.privKey!!
- )
- Client.send(signedEvent)
- LocalCache.consume(signedEvent, null)
- }
-
- fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null) {
- if (!isWriteable()) return
- val user = LocalCache.users[toUser] ?: return
-
- val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
- val mentionsHex = emptyList()
-
- val signedEvent = PrivateDmEvent.create(
- recipientPubKey = user.pubkey(),
- publishedRecipientPubKey = user.pubkey(),
- msg = message,
- replyTos = repliesToHex,
- mentions = mentionsHex,
- privateKey = loggedIn.privKey!!,
- advertiseNip18 = false
- )
- Client.send(signedEvent)
- LocalCache.consume(signedEvent, null)
- }
-
- fun sendCreateNewChannel(name: String, about: String, picture: String) {
- if (!isWriteable()) return
-
- val metadata = ChannelCreateEvent.ChannelData(
- name,
- about,
- picture
- )
-
- val event = ChannelCreateEvent.create(
- channelInfo = metadata,
- privateKey = loggedIn.privKey!!
- )
-
- Client.send(event)
- LocalCache.consume(event)
-
- joinChannel(event.id)
- }
-
- fun addPrivateBookmark(note: Note) {
- if (!isWriteable()) return
-
- val bookmarks = userProfile().latestBookmarkList
-
- val event = BookmarkListEvent.create(
- "bookmark",
- bookmarks?.taggedEvents() ?: emptyList(),
- bookmarks?.taggedUsers() ?: emptyList(),
- bookmarks?.taggedAddresses() ?: emptyList(),
-
- bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.plus(note.idHex) ?: listOf(note.idHex),
- bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
- bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
-
- loggedIn.privKey!!
- )
-
- Client.send(event)
- LocalCache.consume(event)
- }
-
- fun addPublicBookmark(note: Note) {
- if (!isWriteable()) return
-
- val bookmarks = userProfile().latestBookmarkList
-
- val event = BookmarkListEvent.create(
- "bookmark",
- bookmarks?.taggedEvents()?.plus(note.idHex) ?: listOf(note.idHex),
- bookmarks?.taggedUsers() ?: emptyList(),
- bookmarks?.taggedAddresses() ?: emptyList(),
-
- bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
- bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
- bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
-
- loggedIn.privKey!!
- )
-
- Client.send(event)
- LocalCache.consume(event)
- }
-
- fun removePrivateBookmark(note: Note) {
- if (!isWriteable()) return
-
- val bookmarks = userProfile().latestBookmarkList
-
- val event = BookmarkListEvent.create(
- "bookmark",
- bookmarks?.taggedEvents() ?: emptyList(),
- bookmarks?.taggedUsers() ?: emptyList(),
- bookmarks?.taggedAddresses() ?: emptyList(),
-
- bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.minus(note.idHex) ?: listOf(),
- bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
- bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
-
- loggedIn.privKey!!
- )
-
- Client.send(event)
- LocalCache.consume(event)
- }
-
- fun removePublicBookmark(note: Note) {
- if (!isWriteable()) return
-
- val bookmarks = userProfile().latestBookmarkList
-
- val event = BookmarkListEvent.create(
- "bookmark",
- bookmarks?.taggedEvents()?.minus(note.idHex),
- bookmarks?.taggedUsers() ?: emptyList(),
- bookmarks?.taggedAddresses() ?: emptyList(),
-
- bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
- bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
- bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
-
- loggedIn.privKey!!
- )
-
- Client.send(event)
- LocalCache.consume(event)
- }
-
- fun isInPrivateBookmarks(note: Note): Boolean {
- if (!isWriteable()) return false
-
- if (note is AddressableNote) {
- return userProfile().latestBookmarkList?.privateTaggedAddresses(loggedIn.privKey!!)
- ?.contains(note.address) == true
- } else {
- return userProfile().latestBookmarkList?.privateTaggedEvents(loggedIn.privKey!!)
- ?.contains(note.idHex) == true
- }
- }
-
- fun isInPublicBookmarks(note: Note): Boolean {
- if (!isWriteable()) return false
-
- if (note is AddressableNote) {
- return userProfile().latestBookmarkList?.taggedAddresses()?.contains(note.address) == true
- } else {
- return userProfile().latestBookmarkList?.taggedEvents()?.contains(note.idHex) == true
- }
- }
-
- fun joinChannel(idHex: String) {
- followingChannels = followingChannels + idHex
- live.invalidateData()
-
- saveable.invalidateData()
- }
-
- fun leaveChannel(idHex: String) {
- followingChannels = followingChannels - idHex
- live.invalidateData()
-
- saveable.invalidateData()
- }
-
- fun hideUser(pubkeyHex: String) {
- hiddenUsers = hiddenUsers + pubkeyHex
- live.invalidateData()
- saveable.invalidateData()
- }
-
- fun showUser(pubkeyHex: String) {
- hiddenUsers = hiddenUsers - pubkeyHex
- transientHiddenUsers = transientHiddenUsers - pubkeyHex
- live.invalidateData()
- saveable.invalidateData()
- }
-
- fun changeZapAmounts(newAmounts: List) {
- zapAmountChoices = newAmounts
- live.invalidateData()
- saveable.invalidateData()
- }
-
- fun changeZapPaymentRequest(newServer: Nip47URI?) {
- zapPaymentRequest = newServer
- live.invalidateData()
- saveable.invalidateData()
- }
-
- fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
- if (!isWriteable()) return
-
- val metadata = ChannelCreateEvent.ChannelData(
- name,
- about,
- picture
- )
-
- val event = ChannelMetadataEvent.create(
- newChannelInfo = metadata,
- originalChannelIdHex = channel.idHex,
- privateKey = loggedIn.privKey!!
- )
-
- Client.send(event)
- LocalCache.consume(event)
-
- joinChannel(event.id)
- }
-
- fun decryptContent(note: Note): String? {
- val event = note.event
- return if (event is PrivateDmEvent && loggedIn.privKey != null) {
- var pubkeyToUse = event.pubKey
-
- val recepientPK = event.recipientPubKey()
-
- if (note.author == userProfile() && recepientPK != null) {
- pubkeyToUse = recepientPK
- }
-
- event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray())
- } else {
- event?.content()
- }
- }
-
- fun addDontTranslateFrom(languageCode: String) {
- dontTranslateFrom = dontTranslateFrom.plus(languageCode)
- liveLanguages.invalidateData()
-
- saveable.invalidateData()
- }
-
- fun updateTranslateTo(languageCode: String) {
- translateTo = languageCode
- liveLanguages.invalidateData()
-
- saveable.invalidateData()
- }
-
- fun prefer(source: String, target: String, preference: String) {
- languagePreferences = languagePreferences + Pair("$source,$target", preference)
- saveable.invalidateData()
- }
-
- fun preferenceBetween(source: String, target: String): String? {
- return languagePreferences.get("$source,$target")
- }
-
- private fun updateContactListTo(newContactList: ContactListEvent?) {
- if (newContactList?.unverifiedFollowKeySet().isNullOrEmpty()) return
-
- // Events might be different objects, we have to compare their ids.
- if (backupContactList?.id != newContactList?.id) {
- backupContactList = newContactList
- saveable.invalidateData()
- }
- }
-
- // Takes a User's relay list and adds the types of feeds they are active for.
- fun activeRelays(): Array? {
- var usersRelayList = userProfile().latestContactList?.relays()?.map {
- val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet()
- Relay(it.key, it.value.read, it.value.write, localFeedTypes)
- } ?: return null
-
- // Ugly, but forces nostr.band as the only search-supporting relay today.
- // TODO: Remove when search becomes more available.
- if (usersRelayList.none { it.activeTypes.contains(FeedType.SEARCH) }) {
- usersRelayList = usersRelayList + Relay(
- Constants.forcedRelayForSearch.url,
- Constants.forcedRelayForSearch.read,
- Constants.forcedRelayForSearch.write,
- Constants.forcedRelayForSearch.feedTypes
- )
- }
-
- return usersRelayList.toTypedArray()
- }
-
- fun convertLocalRelays(): Array {
- return localRelays.map {
- Relay(it.url, it.read, it.write, it.feedTypes)
- }.toTypedArray()
- }
-
- fun reconnectIfRelaysHaveChanged() {
- val newRelaySet = activeRelays() ?: convertLocalRelays()
- if (!Client.isSameRelaySetConfig(newRelaySet)) {
- Client.disconnect()
- Client.connect(newRelaySet)
- RelayPool.requestAndWatch()
- }
- }
-
- fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers
-
- fun followingKeySet(): Set {
- return userProfile().cachedFollowingKeySet() ?: emptySet()
- }
-
- fun followingTagSet(): Set {
- return userProfile().cachedFollowingTagSet() ?: emptySet()
- }
-
- fun isAcceptable(user: User): Boolean {
- return !isHidden(user) && // if user hasn't hided this author
- user.reportsBy(userProfile()).isEmpty() && // if user has not reported this post
- user.countReportAuthorsBy(followingKeySet()) < 5
- }
-
- fun isAcceptableDirect(note: Note): Boolean {
- return note.reportsBy(userProfile()).isEmpty() && // if user has not reported this post
- note.countReportAuthorsBy(followingKeySet()) < 5 // if it has 5 reports by reliable users
- }
-
- fun isFollowing(user: User): Boolean {
- return user.pubkeyHex in followingKeySet()
- }
-
- fun isAcceptable(note: Note): Boolean {
- return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author
- isAcceptableDirect(note) &&
- (
- note.event !is RepostEvent ||
- (note.event is RepostEvent && note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null)
- ) // is not a reaction about a blocked post
- }
-
- fun getRelevantReports(note: Note): Set {
- val followsPlusMe = userProfile().latestContactList?.verifiedFollowKeySetAndMe ?: emptySet()
-
- val innerReports = if (note.event is RepostEvent) {
- note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList()
- } else {
- emptyList()
- }
-
- return (
- note.reportsBy(followsPlusMe) +
- (
- note.author?.reportsBy(followsPlusMe) ?: emptyList()
- ) + innerReports
- ).toSet()
- }
-
- fun saveRelayList(value: List) {
- localRelays = value.toSet()
- sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) })
-
- saveable.invalidateData()
- }
-
- fun setHideDeleteRequestDialog() {
- hideDeleteRequestDialog = true
- saveable.invalidateData()
- }
-
- fun setHideBlockAlertDialog() {
- hideBlockAlertDialog = true
- saveable.invalidateData()
- }
-
- init {
- backupContactList?.let {
- println("Loading saved contacts ${it.toJson()}")
- if (userProfile().latestContactList == null) {
- LocalCache.consume(it)
- }
- }
-
- // Observes relays to restart connections
- userProfile().live().relays.observeForever {
- GlobalScope.launch(Dispatchers.IO) {
- reconnectIfRelaysHaveChanged()
- }
- }
-
- // saves contact list for the next time.
- userProfile().live().follows.observeForever {
- updateContactListTo(userProfile().latestContactList)
- }
-
- // imports transient blocks due to spam.
- LocalCache.antiSpam.liveSpam.observeForever {
- GlobalScope.launch(Dispatchers.IO) {
- it.cache.spamMessages.snapshot().values.forEach {
- if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) {
- val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex)
- if (userToBlock != userProfile() && userToBlock.pubkeyHex !in followingKeySet()) {
- transientHiddenUsers = transientHiddenUsers + it.pubkeyHex
- }
- }
- }
- }
- }
- }
-}
-
-class AccountLiveData(private val account: Account) : LiveData(AccountState(account)) {
- // Refreshes observers in batches.
- private val bundler = BundledUpdate(300, Dispatchers.Default) {
- if (hasActiveObservers()) {
- refresh()
- }
- }
-
- fun invalidateData() {
- bundler.invalidate()
- }
-
- fun refresh() {
- postValue(AccountState(account))
- }
-}
-
-class AccountState(val account: Account)
+package com.vitorpamplona.amethyst.model
+
+import android.content.res.Resources
+import androidx.core.os.ConfigurationCompat
+import androidx.lifecycle.LiveData
+import com.vitorpamplona.amethyst.service.model.*
+import com.vitorpamplona.amethyst.service.relays.Client
+import com.vitorpamplona.amethyst.service.relays.Constants
+import com.vitorpamplona.amethyst.service.relays.FeedType
+import com.vitorpamplona.amethyst.service.relays.Relay
+import com.vitorpamplona.amethyst.service.relays.RelayPool
+import com.vitorpamplona.amethyst.ui.components.BundledUpdate
+import com.vitorpamplona.amethyst.ui.note.Nip47URI
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import nostr.postr.Persona
+import java.net.Proxy
+import java.util.Locale
+
+val DefaultChannels = setOf(
+ "25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", // -> Anigma's Nostr
+ "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group
+)
+
+fun getLanguagesSpokenByUser(): Set {
+ val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
+ val codedList = mutableSetOf()
+ for (i in 0 until languageList.size()) {
+ languageList.get(i)?.let { codedList.add(it.language) }
+ }
+ return codedList
+}
+
+@OptIn(DelicateCoroutinesApi::class)
+class Account(
+ val loggedIn: Persona,
+ var followingChannels: Set = DefaultChannels,
+ var hiddenUsers: Set = setOf(),
+ var localRelays: Set = Constants.defaultRelays.toSet(),
+ var dontTranslateFrom: Set = getLanguagesSpokenByUser(),
+ var languagePreferences: Map = mapOf(),
+ var translateTo: String = Locale.getDefault().language,
+ var zapAmountChoices: List = listOf(500L, 1000L, 5000L),
+ var zapPaymentRequest: Nip47URI? = null,
+ var hideDeleteRequestDialog: Boolean = false,
+ var hideBlockAlertDialog: Boolean = false,
+ var backupContactList: ContactListEvent? = null,
+ var proxy: Proxy?
+) {
+ var transientHiddenUsers: Set = setOf()
+
+ // Observers line up here.
+ val live: AccountLiveData = AccountLiveData(this)
+ val liveLanguages: AccountLiveData = AccountLiveData(this)
+ val saveable: AccountLiveData = AccountLiveData(this)
+
+ var userProfileCache: User? = null
+
+ fun userProfile(): User {
+ return userProfileCache ?: run {
+ val myUser: User = LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
+ userProfileCache = myUser
+ myUser
+ }
+ }
+
+ fun followingChannels(): List {
+ return followingChannels.map { LocalCache.getOrCreateChannel(it) }
+ }
+
+ fun hiddenUsers(): List {
+ return (hiddenUsers + transientHiddenUsers).map { LocalCache.getOrCreateUser(it) }
+ }
+
+ fun isWriteable(): Boolean {
+ return loggedIn.privKey != null
+ }
+
+ fun sendNewRelayList(relays: Map) {
+ if (!isWriteable()) return
+
+ val contactList = userProfile().latestContactList
+ val follows = contactList?.follows() ?: emptyList()
+ val followsTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
+
+ if (contactList != null && follows.isNotEmpty()) {
+ val event = ContactListEvent.create(
+ follows,
+ followsTags,
+ relays,
+ loggedIn.privKey!!
+ )
+
+ Client.send(event)
+ LocalCache.consume(event)
+ } else {
+ val event = ContactListEvent.create(listOf(), listOf(), relays, loggedIn.privKey!!)
+
+ // Keep this local to avoid erasing a good contact list.
+ // Client.send(event)
+ LocalCache.consume(event)
+ }
+ }
+
+ fun sendNewUserMetadata(toString: String, identities: List) {
+ if (!isWriteable()) return
+
+ loggedIn.privKey?.let {
+ val event = MetadataEvent.create(toString, identities, loggedIn.privKey!!)
+ Client.send(event)
+ LocalCache.consume(event)
+ }
+ }
+
+ fun reactionTo(note: Note): List {
+ return note.reactedBy(userProfile(), "+")
+ }
+
+ fun hasBoosted(note: Note): Boolean {
+ return boostsTo(note).isNotEmpty()
+ }
+
+ fun boostsTo(note: Note): List {
+ return note.boostedBy(userProfile())
+ }
+
+ fun hasReacted(note: Note): Boolean {
+ return note.hasReacted(userProfile(), "+")
+ }
+
+ fun reactTo(note: Note) {
+ if (!isWriteable()) return
+
+ if (hasReacted(note)) {
+ // has already liked this note
+ return
+ }
+
+ note.event?.let {
+ val event = ReactionEvent.createLike(it, loggedIn.privKey!!)
+ Client.send(event)
+ LocalCache.consume(event)
+ }
+ }
+
+ fun createZapRequestFor(note: Note, pollOption: Int?, message: String = "", zapType: LnZapEvent.ZapType): LnZapRequestEvent? {
+ if (!isWriteable()) return null
+
+ note.event?.let { event ->
+ return LnZapRequestEvent.create(
+ event,
+ userProfile().latestContactList?.relays()?.keys?.ifEmpty { null }
+ ?: localRelays.map { it.url }.toSet(),
+ loggedIn.privKey!!,
+ pollOption,
+ message,
+ zapType
+ )
+ }
+ return null
+ }
+
+ fun hasWalletConnectSetup(): Boolean {
+ return zapPaymentRequest != null
+ }
+
+ fun sendZapPaymentRequestFor(lnInvoice: String) {
+ if (!isWriteable()) return
+
+ zapPaymentRequest?.let {
+ val event = LnZapPaymentRequestEvent.create(lnInvoice, it.pubKeyHex, it.secret?.toByteArray() ?: loggedIn.privKey!!)
+
+ Client.send(event, it.relayUri)
+ }
+ }
+
+ fun createZapRequestFor(user: User): LnZapRequestEvent? {
+ return createZapRequestFor(user)
+ }
+
+ fun createZapRequestFor(userPubKeyHex: String, message: String = "", zapType: LnZapEvent.ZapType): LnZapRequestEvent? {
+ if (!isWriteable()) return null
+
+ return LnZapRequestEvent.create(
+ userPubKeyHex,
+ userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(),
+ loggedIn.privKey!!,
+ message,
+ zapType
+ )
+ }
+
+ fun report(note: Note, type: ReportEvent.ReportType, content: String = "") {
+ if (!isWriteable()) return
+
+ if (note.hasReacted(userProfile(), "⚠️")) {
+ // has already liked this note
+ return
+ }
+
+ note.event?.let {
+ val event = ReactionEvent.createWarning(it, loggedIn.privKey!!)
+ Client.send(event)
+ LocalCache.consume(event)
+ }
+
+ note.event?.let {
+ val event = ReportEvent.create(it, type, loggedIn.privKey!!, content = content)
+ Client.send(event)
+ LocalCache.consume(event, null)
+ }
+ }
+
+ fun report(user: User, type: ReportEvent.ReportType) {
+ if (!isWriteable()) return
+
+ if (user.hasReport(userProfile(), type)) {
+ // has already reported this note
+ return
+ }
+
+ val event = ReportEvent.create(user.pubkeyHex, type, loggedIn.privKey!!)
+ Client.send(event)
+ LocalCache.consume(event, null)
+ }
+
+ fun delete(note: Note) {
+ delete(listOf(note))
+ }
+
+ fun delete(notes: List) {
+ if (!isWriteable()) return
+
+ val myNotes = notes.filter { it.author == userProfile() }.map { it.idHex }
+
+ if (myNotes.isNotEmpty()) {
+ val event = DeletionEvent.create(myNotes, loggedIn.privKey!!)
+ Client.send(event)
+ LocalCache.consume(event)
+ }
+ }
+
+ fun boost(note: Note) {
+ if (!isWriteable()) return
+
+ if (note.hasBoostedInTheLast5Minutes(userProfile())) {
+ // has already bosted in the past 5mins
+ return
+ }
+
+ note.event?.let {
+ val event = RepostEvent.create(it, loggedIn.privKey!!)
+ Client.send(event)
+ LocalCache.consume(event)
+ }
+ }
+
+ fun broadcast(note: Note) {
+ note.event?.let {
+ Client.send(it)
+ }
+ }
+
+ fun follow(user: User) {
+ if (!isWriteable()) return
+
+ val contactList = userProfile().latestContactList
+ val followingUsers = contactList?.follows() ?: emptyList()
+ val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
+
+ val event = if (contactList != null) {
+ ContactListEvent.create(
+ followingUsers.plus(Contact(user.pubkeyHex, null)),
+ followingTags,
+ contactList.relays(),
+ loggedIn.privKey!!
+ )
+ } else {
+ val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
+ ContactListEvent.create(
+ listOf(Contact(user.pubkeyHex, null)),
+ followingTags,
+ relays,
+ loggedIn.privKey!!
+ )
+ }
+
+ Client.send(event)
+ LocalCache.consume(event)
+ }
+
+ fun follow(tag: String) {
+ if (!isWriteable()) return
+
+ val contactList = userProfile().latestContactList
+ val followingUsers = contactList?.follows() ?: emptyList()
+ val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
+
+ val event = if (contactList != null) {
+ ContactListEvent.create(
+ followingUsers,
+ followingTags.plus(tag),
+ contactList.relays(),
+ loggedIn.privKey!!
+ )
+ } else {
+ val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
+ ContactListEvent.create(
+ followingUsers,
+ followingTags.plus(tag),
+ relays,
+ loggedIn.privKey!!
+ )
+ }
+
+ Client.send(event)
+ LocalCache.consume(event)
+ }
+
+ fun unfollow(user: User) {
+ if (!isWriteable()) return
+
+ val contactList = userProfile().latestContactList
+ val followingUsers = contactList?.follows() ?: emptyList()
+ val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
+
+ if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) {
+ val event = ContactListEvent.create(
+ followingUsers.filter { it.pubKeyHex != user.pubkeyHex },
+ followingTags,
+ contactList.relays(),
+ loggedIn.privKey!!
+ )
+
+ Client.send(event)
+ LocalCache.consume(event)
+ }
+ }
+
+ fun unfollow(tag: String) {
+ if (!isWriteable()) return
+
+ val contactList = userProfile().latestContactList
+ val followingUsers = contactList?.follows() ?: emptyList()
+ val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
+
+ if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) {
+ val event = ContactListEvent.create(
+ followingUsers,
+ followingTags.filter { !it.equals(tag, ignoreCase = true) },
+ contactList.relays(),
+ loggedIn.privKey!!
+ )
+
+ Client.send(event)
+ LocalCache.consume(event)
+ }
+ }
+
+ fun sendPost(message: String, replyTo: List?, mentions: List?, tags: List? = null) {
+ if (!isWriteable()) return
+
+ val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex }
+ val mentionsHex = mentions?.map { it.pubkeyHex }
+ val addresses = replyTo?.mapNotNull { it.address() }
+
+ val signedEvent = TextNoteEvent.create(
+ msg = message,
+ replyTos = repliesToHex,
+ mentions = mentionsHex,
+ addresses = addresses,
+ extraTags = tags,
+ privateKey = loggedIn.privKey!!
+ )
+
+ Client.send(signedEvent)
+ LocalCache.consume(signedEvent)
+ }
+
+ fun sendPoll(
+ message: String,
+ replyTo: List?,
+ mentions: List?,
+ pollOptions: Map,
+ valueMaximum: Int?,
+ valueMinimum: Int?,
+ consensusThreshold: Int?,
+ closedAt: Int?
+ ) {
+ if (!isWriteable()) return
+
+ val repliesToHex = replyTo?.map { it.idHex }
+ val mentionsHex = mentions?.map { it.pubkeyHex }
+ val addresses = replyTo?.mapNotNull { it.address() }
+
+ val signedEvent = PollNoteEvent.create(
+ msg = message,
+ replyTos = repliesToHex,
+ mentions = mentionsHex,
+ addresses = addresses,
+ privateKey = loggedIn.privKey!!,
+ pollOptions = pollOptions,
+ valueMaximum = valueMaximum,
+ valueMinimum = valueMinimum,
+ consensusThreshold = consensusThreshold,
+ closedAt = closedAt
+ )
+ // println("Sending new PollNoteEvent: %s".format(signedEvent.toJson()))
+ Client.send(signedEvent)
+ LocalCache.consume(signedEvent)
+ }
+
+ fun sendChannelMessage(message: String, toChannel: String, replyTo: List?, mentions: List?) {
+ if (!isWriteable()) return
+
+ // val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
+ val repliesToHex = replyTo?.map { it.idHex }
+ val mentionsHex = mentions?.map { it.pubkeyHex }
+
+ val signedEvent = ChannelMessageEvent.create(
+ message = message,
+ channel = toChannel,
+ replyTos = repliesToHex,
+ mentions = mentionsHex,
+ privateKey = loggedIn.privKey!!
+ )
+ Client.send(signedEvent)
+ LocalCache.consume(signedEvent, null)
+ }
+
+ fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List?) {
+ if (!isWriteable()) return
+ val user = LocalCache.users[toUser] ?: return
+
+ val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
+ val mentionsHex = mentions?.map { it.pubkeyHex }
+
+ val signedEvent = PrivateDmEvent.create(
+ recipientPubKey = user.pubkey(),
+ publishedRecipientPubKey = user.pubkey(),
+ msg = message,
+ replyTos = repliesToHex,
+ mentions = mentionsHex,
+ privateKey = loggedIn.privKey!!,
+ advertiseNip18 = false
+ )
+ Client.send(signedEvent)
+ LocalCache.consume(signedEvent, null)
+ }
+
+ fun sendCreateNewChannel(name: String, about: String, picture: String) {
+ if (!isWriteable()) return
+
+ val metadata = ChannelCreateEvent.ChannelData(
+ name,
+ about,
+ picture
+ )
+
+ val event = ChannelCreateEvent.create(
+ channelInfo = metadata,
+ privateKey = loggedIn.privKey!!
+ )
+
+ Client.send(event)
+ LocalCache.consume(event)
+
+ joinChannel(event.id)
+ }
+
+ fun addPrivateBookmark(note: Note) {
+ if (!isWriteable()) return
+
+ val bookmarks = userProfile().latestBookmarkList
+
+ val event = BookmarkListEvent.create(
+ "bookmark",
+ bookmarks?.taggedEvents() ?: emptyList(),
+ bookmarks?.taggedUsers() ?: emptyList(),
+ bookmarks?.taggedAddresses() ?: emptyList(),
+
+ bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.plus(note.idHex) ?: listOf(note.idHex),
+ bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
+ bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
+
+ loggedIn.privKey!!
+ )
+
+ Client.send(event)
+ LocalCache.consume(event)
+ }
+
+ fun addPublicBookmark(note: Note) {
+ if (!isWriteable()) return
+
+ val bookmarks = userProfile().latestBookmarkList
+
+ val event = BookmarkListEvent.create(
+ "bookmark",
+ bookmarks?.taggedEvents()?.plus(note.idHex) ?: listOf(note.idHex),
+ bookmarks?.taggedUsers() ?: emptyList(),
+ bookmarks?.taggedAddresses() ?: emptyList(),
+
+ bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
+ bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
+ bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
+
+ loggedIn.privKey!!
+ )
+
+ Client.send(event)
+ LocalCache.consume(event)
+ }
+
+ fun removePrivateBookmark(note: Note) {
+ if (!isWriteable()) return
+
+ val bookmarks = userProfile().latestBookmarkList
+
+ val event = BookmarkListEvent.create(
+ "bookmark",
+ bookmarks?.taggedEvents() ?: emptyList(),
+ bookmarks?.taggedUsers() ?: emptyList(),
+ bookmarks?.taggedAddresses() ?: emptyList(),
+
+ bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.minus(note.idHex) ?: listOf(),
+ bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
+ bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
+
+ loggedIn.privKey!!
+ )
+
+ Client.send(event)
+ LocalCache.consume(event)
+ }
+
+ fun removePublicBookmark(note: Note) {
+ if (!isWriteable()) return
+
+ val bookmarks = userProfile().latestBookmarkList
+
+ val event = BookmarkListEvent.create(
+ "bookmark",
+ bookmarks?.taggedEvents()?.minus(note.idHex),
+ bookmarks?.taggedUsers() ?: emptyList(),
+ bookmarks?.taggedAddresses() ?: emptyList(),
+
+ bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
+ bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
+ bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
+
+ loggedIn.privKey!!
+ )
+
+ Client.send(event)
+ LocalCache.consume(event)
+ }
+
+ fun isInPrivateBookmarks(note: Note): Boolean {
+ if (!isWriteable()) return false
+
+ if (note is AddressableNote) {
+ return userProfile().latestBookmarkList?.privateTaggedAddresses(loggedIn.privKey!!)
+ ?.contains(note.address) == true
+ } else {
+ return userProfile().latestBookmarkList?.privateTaggedEvents(loggedIn.privKey!!)
+ ?.contains(note.idHex) == true
+ }
+ }
+
+ fun isInPublicBookmarks(note: Note): Boolean {
+ if (!isWriteable()) return false
+
+ if (note is AddressableNote) {
+ return userProfile().latestBookmarkList?.taggedAddresses()?.contains(note.address) == true
+ } else {
+ return userProfile().latestBookmarkList?.taggedEvents()?.contains(note.idHex) == true
+ }
+ }
+
+ fun joinChannel(idHex: String) {
+ followingChannels = followingChannels + idHex
+ live.invalidateData()
+
+ saveable.invalidateData()
+ }
+
+ fun leaveChannel(idHex: String) {
+ followingChannels = followingChannels - idHex
+ live.invalidateData()
+
+ saveable.invalidateData()
+ }
+
+ fun hideUser(pubkeyHex: String) {
+ hiddenUsers = hiddenUsers + pubkeyHex
+ live.invalidateData()
+ saveable.invalidateData()
+ }
+
+ fun showUser(pubkeyHex: String) {
+ hiddenUsers = hiddenUsers - pubkeyHex
+ transientHiddenUsers = transientHiddenUsers - pubkeyHex
+ live.invalidateData()
+ saveable.invalidateData()
+ }
+
+ fun changeZapAmounts(newAmounts: List) {
+ zapAmountChoices = newAmounts
+ live.invalidateData()
+ saveable.invalidateData()
+ }
+
+ fun changeZapPaymentRequest(newServer: Nip47URI?) {
+ zapPaymentRequest = newServer
+ live.invalidateData()
+ saveable.invalidateData()
+ }
+
+ fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
+ if (!isWriteable()) return
+
+ val metadata = ChannelCreateEvent.ChannelData(
+ name,
+ about,
+ picture
+ )
+
+ val event = ChannelMetadataEvent.create(
+ newChannelInfo = metadata,
+ originalChannelIdHex = channel.idHex,
+ privateKey = loggedIn.privKey!!
+ )
+
+ Client.send(event)
+ LocalCache.consume(event)
+
+ joinChannel(event.id)
+ }
+
+ fun decryptContent(note: Note): String? {
+ val event = note.event
+ return if (event is PrivateDmEvent && loggedIn.privKey != null) {
+ var pubkeyToUse = event.pubKey
+
+ val recepientPK = event.recipientPubKey()
+
+ if (note.author == userProfile() && recepientPK != null) {
+ pubkeyToUse = recepientPK
+ }
+
+ event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray())
+ } else {
+ event?.content()
+ }
+ }
+
+ fun addDontTranslateFrom(languageCode: String) {
+ dontTranslateFrom = dontTranslateFrom.plus(languageCode)
+ liveLanguages.invalidateData()
+
+ saveable.invalidateData()
+ }
+
+ fun updateTranslateTo(languageCode: String) {
+ translateTo = languageCode
+ liveLanguages.invalidateData()
+
+ saveable.invalidateData()
+ }
+
+ fun prefer(source: String, target: String, preference: String) {
+ languagePreferences = languagePreferences + Pair("$source,$target", preference)
+ saveable.invalidateData()
+ }
+
+ fun preferenceBetween(source: String, target: String): String? {
+ return languagePreferences.get("$source,$target")
+ }
+
+ private fun updateContactListTo(newContactList: ContactListEvent?) {
+ if (newContactList?.unverifiedFollowKeySet().isNullOrEmpty()) return
+
+ // Events might be different objects, we have to compare their ids.
+ if (backupContactList?.id != newContactList?.id) {
+ backupContactList = newContactList
+ saveable.invalidateData()
+ }
+ }
+
+ // Takes a User's relay list and adds the types of feeds they are active for.
+ fun activeRelays(): Array? {
+ var usersRelayList = userProfile().latestContactList?.relays()?.map {
+ val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet()
+ Relay(it.key, it.value.read, it.value.write, localFeedTypes, proxy)
+ } ?: return null
+
+ // Ugly, but forces nostr.band as the only search-supporting relay today.
+ // TODO: Remove when search becomes more available.
+ if (usersRelayList.none { it.activeTypes.contains(FeedType.SEARCH) }) {
+ usersRelayList = usersRelayList + Relay(
+ Constants.forcedRelayForSearch.url,
+ Constants.forcedRelayForSearch.read,
+ Constants.forcedRelayForSearch.write,
+ Constants.forcedRelayForSearch.feedTypes,
+ proxy
+ )
+ }
+
+ return usersRelayList.toTypedArray()
+ }
+
+ fun convertLocalRelays(): Array {
+ return localRelays.map {
+ Relay(it.url, it.read, it.write, it.feedTypes, proxy)
+ }.toTypedArray()
+ }
+
+ fun reconnectIfRelaysHaveChanged() {
+ val newRelaySet = activeRelays() ?: convertLocalRelays()
+ if (!Client.isSameRelaySetConfig(newRelaySet)) {
+ Client.disconnect()
+ Client.connect(newRelaySet)
+ RelayPool.requestAndWatch()
+ }
+ }
+
+ fun isHidden(user: User) = isHidden(user.pubkeyHex)
+ fun isHidden(userHex: String) = userHex in hiddenUsers || userHex in transientHiddenUsers
+
+ fun followingKeySet(): Set {
+ return userProfile().cachedFollowingKeySet() ?: emptySet()
+ }
+
+ fun followingTagSet(): Set {
+ return userProfile().cachedFollowingTagSet() ?: emptySet()
+ }
+
+ fun isAcceptable(user: User): Boolean {
+ return !isHidden(user) && // if user hasn't hided this author
+ user.reportsBy(userProfile()).isEmpty() && // if user has not reported this post
+ user.countReportAuthorsBy(followingKeySet()) < 5
+ }
+
+ fun isAcceptableDirect(note: Note): Boolean {
+ return note.reportsBy(userProfile()).isEmpty() && // if user has not reported this post
+ note.countReportAuthorsBy(followingKeySet()) < 5 // if it has 5 reports by reliable users
+ }
+
+ fun isFollowing(user: User): Boolean {
+ return user.pubkeyHex in followingKeySet()
+ }
+
+ fun isAcceptable(note: Note): Boolean {
+ return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author
+ isAcceptableDirect(note) &&
+ (
+ note.event !is RepostEvent ||
+ (note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null)
+ ) // is not a reaction about a blocked post
+ }
+
+ fun getRelevantReports(note: Note): Set {
+ val followsPlusMe = userProfile().latestContactList?.verifiedFollowKeySetAndMe ?: emptySet()
+
+ val innerReports = if (note.event is RepostEvent) {
+ note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList()
+ } else {
+ emptyList()
+ }
+
+ return (
+ note.reportsBy(followsPlusMe) +
+ (
+ note.author?.reportsBy(followsPlusMe) ?: emptyList()
+ ) + innerReports
+ ).toSet()
+ }
+
+ fun saveRelayList(value: List) {
+ localRelays = value.toSet()
+ sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) })
+
+ saveable.invalidateData()
+ }
+
+ fun setHideDeleteRequestDialog() {
+ hideDeleteRequestDialog = true
+ saveable.invalidateData()
+ }
+
+ fun setHideBlockAlertDialog() {
+ hideBlockAlertDialog = true
+ saveable.invalidateData()
+ }
+
+ init {
+ backupContactList?.let {
+ println("Loading saved contacts ${it.toJson()}")
+ if (userProfile().latestContactList == null) {
+ LocalCache.consume(it)
+ }
+ }
+
+ // Observes relays to restart connections
+ userProfile().live().relays.observeForever {
+ GlobalScope.launch(Dispatchers.IO) {
+ reconnectIfRelaysHaveChanged()
+ }
+ }
+
+ // saves contact list for the next time.
+ userProfile().live().follows.observeForever {
+ updateContactListTo(userProfile().latestContactList)
+ }
+
+ // imports transient blocks due to spam.
+ LocalCache.antiSpam.liveSpam.observeForever {
+ GlobalScope.launch(Dispatchers.IO) {
+ it.cache.spamMessages.snapshot().values.forEach {
+ if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) {
+ val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex)
+ if (userToBlock != userProfile() && userToBlock.pubkeyHex !in followingKeySet()) {
+ transientHiddenUsers = transientHiddenUsers + it.pubkeyHex
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+class AccountLiveData(private val account: Account) : LiveData(AccountState(account)) {
+ // Refreshes observers in batches.
+ private val bundler = BundledUpdate(300, Dispatchers.Default) {
+ if (hasActiveObservers()) {
+ refresh()
+ }
+ }
+
+ fun invalidateData() {
+ bundler.invalidate()
+ }
+
+ fun refresh() {
+ postValue(AccountState(account))
+ }
+}
+
+class AccountState(val account: Account)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt
index c50ff57816..7e16f77d80 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt
@@ -38,14 +38,14 @@ fun HexKey.toDisplayHexKey(): String {
}
fun decodePublicKey(key: String): ByteArray {
+ val parsed = Nip19.uriToRoute(key)
+ val pubKeyParsed = parsed?.hex?.toByteArray()
+
return if (key.startsWith("nsec")) {
Persona(privKey = key.bechToBytes()).pubKey
- } else if (key.startsWith("npub")) {
- key.bechToBytes()
- } else if (key.startsWith("note")) {
- key.bechToBytes()
- } else { // if (pattern.matcher(key).matches()) {
- // } else {
+ } else if (pubKeyParsed != null) {
+ pubKeyParsed
+ } else {
Hex.decode(key)
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
index 90e260cd27..ee46362889 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
@@ -4,30 +4,9 @@ import android.util.Log
import androidx.lifecycle.LiveData
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
-import com.vitorpamplona.amethyst.service.model.ATag
-import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
-import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
-import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
-import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
-import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
-import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
-import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
-import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
-import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
-import com.vitorpamplona.amethyst.service.model.ContactListEvent
-import com.vitorpamplona.amethyst.service.model.DeletionEvent
-import com.vitorpamplona.amethyst.service.model.LnZapEvent
-import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
-import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
-import com.vitorpamplona.amethyst.service.model.MetadataEvent
-import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
-import com.vitorpamplona.amethyst.service.model.ReactionEvent
-import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent
-import com.vitorpamplona.amethyst.service.model.ReportEvent
-import com.vitorpamplona.amethyst.service.model.RepostEvent
-import com.vitorpamplona.amethyst.service.model.TextNoteEvent
+import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.Relay
-import com.vitorpamplona.amethyst.ui.components.BundledUpdate
+import com.vitorpamplona.amethyst.ui.components.BundledInsert
import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.*
import nostr.postr.toNpub
@@ -38,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()
- val notes = ConcurrentHashMap()
+ val users = ConcurrentHashMap(5000)
+ val notes = ConcurrentHashMap(5000)
val channels = ConcurrentHashMap()
- val addressables = ConcurrentHashMap()
+ val addressables = ConcurrentHashMap(100)
fun checkGetOrCreateUser(key: String): User? {
if (isValidHexNpub(key)) {
@@ -208,7 +189,7 @@ object LocalCache {
it.addReply(note)
}
- refreshObservers()
+ refreshObservers(note)
}
fun consume(event: LongTextNoteEvent, relay: Relay?) {
@@ -237,10 +218,46 @@ object LocalCache {
author.addNote(note)
- refreshObservers()
+ refreshObservers(note)
}
}
+ fun consume(event: PollNoteEvent, relay: Relay? = null) {
+ val note = getOrCreateNote(event.id)
+ val author = getOrCreateUser(event.pubKey)
+
+ if (relay != null) {
+ author.addRelayBeingUsed(relay, event.createdAt)
+ note.addRelay(relay)
+ }
+
+ // Already processed this event.
+ if (note.event != null) return
+
+ if (antiSpam.isSpam(event, relay)) {
+ relay?.let {
+ it.spamCounter++
+ }
+ return
+ }
+
+ val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
+
+ note.loadEvent(event, author, replyTo)
+
+ // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.take(100)} ${formattedDateTime(event.createdAt)}")
+
+ // Prepares user's profile view.
+ author.addNote(note)
+
+ // Counts the replies
+ replyTo.forEach {
+ it.addReply(note)
+ }
+
+ refreshObservers(note)
+ }
+
fun consume(event: BadgeDefinitionEvent) {
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
@@ -251,7 +268,7 @@ object LocalCache {
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
- refreshObservers()
+ refreshObservers(note)
}
}
@@ -269,8 +286,6 @@ object LocalCache {
note.loadEvent(event, author, replyTo)
author.updateAcceptedBadges(note)
-
- refreshObservers()
}
}
@@ -292,7 +307,7 @@ object LocalCache {
it.addReply(note)
}
- refreshObservers()
+ refreshObservers(note)
}
@Suppress("UNUSED_PARAMETER")
@@ -336,7 +351,7 @@ object LocalCache {
recipient.addMessage(author, note)
}
- refreshObservers()
+ refreshObservers(note)
}
fun consume(event: DeletionEvent) {
@@ -384,7 +399,7 @@ object LocalCache {
}
if (deletedAtLeastOne) {
- live.invalidateData()
+ // refreshObservers()
}
}
@@ -410,7 +425,7 @@ object LocalCache {
it.addBoost(note)
}
- refreshObservers()
+ refreshObservers(note)
}
fun consume(event: ReactionEvent) {
@@ -448,6 +463,8 @@ object LocalCache {
it.addReport(note)
}
}
+
+ refreshObservers(note)
}
fun consume(event: ReportEvent, relay: Relay?) {
@@ -478,6 +495,8 @@ object LocalCache {
repliesTo.forEach {
it.addReport(note)
}
+
+ refreshObservers(note)
}
fun consume(event: ChannelCreateEvent) {
@@ -494,7 +513,7 @@ object LocalCache {
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList())
- refreshObservers()
+ refreshObservers(note)
}
}
@@ -514,7 +533,7 @@ object LocalCache {
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList())
- refreshObservers()
+ refreshObservers(note)
}
} else {
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
@@ -548,9 +567,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)
@@ -561,7 +580,7 @@ object LocalCache {
it.addReply(note)
}
- refreshObservers()
+ refreshObservers(note)
}
@Suppress("UNUSED_PARAMETER")
@@ -578,15 +597,14 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
- val zapRequest = event.containedPost()?.id?.let { getOrCreateNote(it) }
+ val zapRequest = event.zapRequest?.id?.let { getOrCreateNote(it) }
val author = getOrCreateUser(event.pubKey)
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) } +
(
- (zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()
- ?.map { getOrCreateAddressableNote(it) } ?: emptySet()
+ (zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet()
)
note.loadEvent(event, author, repliesTo)
@@ -604,6 +622,8 @@ object LocalCache {
mentions.forEach {
it.addZap(zapRequest, note)
}
+
+ refreshObservers(note)
}
fun consume(event: LnZapRequestEvent) {
@@ -627,6 +647,8 @@ object LocalCache {
mentions.forEach {
it.addZap(note, null)
}
+
+ refreshObservers(note)
}
fun findUsersStartingWith(username: String): List {
@@ -640,6 +662,7 @@ object LocalCache {
fun findNotesStartingWith(text: String): List {
return notes.values.filter {
(it.event is TextNoteEvent && it.event?.content()?.contains(text, true) ?: false) ||
+ (it.event is PollNoteEvent && it.event?.content()?.contains(text, true) ?: false) ||
(it.event is ChannelMessageEvent && it.event?.content()?.contains(text, true) ?: false) ||
it.idHex.startsWith(text, true) ||
it.idNote().startsWith(text, true)
@@ -732,30 +755,23 @@ object LocalCache {
}
// Observers line up here.
- val live: LocalCacheLiveData = LocalCacheLiveData(this)
+ val live: LocalCacheLiveData = LocalCacheLiveData()
- private fun refreshObservers() {
- live.invalidateData()
+ private fun refreshObservers(newNote: Note) {
+ live.invalidateData(newNote)
}
}
-class LocalCacheLiveData(val cache: LocalCache) :
- LiveData(LocalCacheState(cache)) {
+class LocalCacheLiveData : LiveData>(setOf()) {
// Refreshes observers in batches.
- private val bundler = BundledUpdate(300, Dispatchers.Main) {
- if (hasActiveObservers()) {
- refresh()
+ private val bundler = BundledInsert(300, Dispatchers.Main)
+
+ fun invalidateData(newNote: Note) {
+ bundler.invalidateList(newNote) { bundledNewNotes ->
+ if (hasActiveObservers()) {
+ postValue(bundledNewNotes)
+ }
}
}
-
- fun invalidateData() {
- bundler.invalidate()
- }
-
- private fun refresh() {
- postValue(LocalCacheState(cache))
- }
}
-
-class LocalCacheState(val cache: LocalCache)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt
index d8f633116d..7bf4537f7e 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt
@@ -53,13 +53,14 @@ open class Note(val idHex: String) {
open fun idNote() = id().toNote()
open fun idDisplayNote() = idNote().toShortenHex()
- fun channel(): Channel? {
- val channelHex =
- (event as? ChannelMessageEvent)?.channel()
- ?: (event as? ChannelMetadataEvent)?.channel()
- ?: (event as? ChannelCreateEvent)?.let { it.id }
+ fun channelHex(): HexKey? {
+ return (event as? ChannelMessageEvent)?.channel()
+ ?: (event as? ChannelMetadataEvent)?.channel()
+ ?: (event as? ChannelCreateEvent)?.id
+ }
- return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) }
+ fun channel(): Channel? {
+ return channelHex()?.let { LocalCache.checkGetOrCreateChannel(it) }
}
open fun address(): ATag? = null
@@ -194,15 +195,15 @@ open class Note(val idHex: String) {
fun isZappedBy(user: User): Boolean {
// Zaps who the requester was the user
- return zaps.any { it.key.author == user }
+ return zaps.any { it.key.author === user }
}
fun isReactedBy(user: User): Boolean {
- return reactions.any { it.author == user }
+ return reactions.any { it.author === user }
}
fun isBoostedBy(user: User): Boolean {
- return boosts.any { it.author == user }
+ return boosts.any { it.author === user }
}
fun reportsBy(user: User): Set {
@@ -270,45 +271,6 @@ open class Note(val idHex: String) {
)
}
- fun directlyCiteUsersHex(): Set {
- val matcher = tagSearch.matcher(event?.content() ?: "")
- val returningList = mutableSetOf()
- while (matcher.find()) {
- try {
- val tag = matcher.group(1)?.let { event?.tags()?.get(it.toInt()) }
- if (tag != null && tag[0] == "p") {
- returningList.add(tag[1])
- }
- } catch (e: Exception) {
- }
- }
- return returningList
- }
-
- fun directlyCiteUsers(): Set {
- val matcher = tagSearch.matcher(event?.content() ?: "")
- val returningList = mutableSetOf()
- while (matcher.find()) {
- try {
- val tag = matcher.group(1)?.let { event?.tags()?.get(it.toInt()) }
- if (tag != null && tag[0] == "p") {
- LocalCache.checkGetOrCreateUser(tag[1])?.let {
- returningList.add(it)
- }
- }
- } catch (e: Exception) {
- }
- }
- return returningList
- }
-
- fun directlyCites(userProfile: User): Boolean {
- return author == userProfile ||
- (userProfile in directlyCiteUsers()) ||
- (event is ReactionEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true) ||
- (event is RepostEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true)
- }
-
fun isNewThread(): Boolean {
return event is RepostEvent || replyTo == null || replyTo?.size == 0
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt
index 909a4d7050..476f4ef7d0 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt
@@ -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(user)) {
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt
index 9317680eb3..cb867066c0 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
+import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
@@ -82,6 +83,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind,
+ PollNoteEvent.kind,
ReactionEvent.kind,
RepostEvent.kind,
ReportEvent.kind,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt
index b2b4e851f8..6d4fbadf99 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
import android.util.Log
import com.vitorpamplona.amethyst.model.LocalCache
+import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
@@ -75,7 +76,7 @@ abstract class NostrDataSource(val debugName: String) {
is DeletionEvent -> LocalCache.consume(event)
is LnZapEvent -> {
- event.containedPost()?.let { onEvent(it, subscriptionId, relay) }
+ event.zapRequest?.let { onEvent(it, subscriptionId, relay) }
LocalCache.consume(event)
}
is LnZapRequestEvent -> LocalCache.consume(event)
@@ -90,6 +91,7 @@ abstract class NostrDataSource(val debugName: String) {
LocalCache.consume(event)
}
is TextNoteEvent -> LocalCache.consume(event, relay)
+ is PollNoteEvent -> LocalCache.consume(event, relay)
else -> {
Log.w("Event Not Supported", event.toJson())
}
@@ -152,7 +154,7 @@ abstract class NostrDataSource(val debugName: String) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(250, Dispatchers.IO) {
- println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
+ // println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt
index 4a8830deae..dbcb987e63 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
+import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
@@ -11,7 +12,7 @@ object NostrGlobalDataSource : NostrDataSource("GlobalFeed") {
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
- kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
+ kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
limit = 200
)
)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt
index 7b3428a687..b07eab060b 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
+import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
@@ -54,7 +55,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
- kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
+ kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
authors = followSet,
limit = 400,
since = latestEOSEs.users[account.userProfile()]?.relayList
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt
index c481131cde..fda096ec5e 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt
@@ -1,12 +1,7 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.decodePublicKey
-import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
-import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
-import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
-import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
-import com.vitorpamplona.amethyst.service.model.MetadataEvent
-import com.vitorpamplona.amethyst.service.model.TextNoteEvent
+import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -65,7 +60,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
- kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind),
+ kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind),
search = mySearchString,
limit = 20
)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt
index d4dcccc88c..29f6cf38cd 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt
@@ -2,19 +2,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
-import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
-import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
-import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
-import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
-import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
-import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
-import com.vitorpamplona.amethyst.service.model.LnZapEvent
-import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
-import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
-import com.vitorpamplona.amethyst.service.model.ReactionEvent
-import com.vitorpamplona.amethyst.service.model.ReportEvent
-import com.vitorpamplona.amethyst.service.model.RepostEvent
-import com.vitorpamplona.amethyst.service.model.TextNoteEvent
+import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
@@ -43,7 +31,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
TextNoteEvent.kind, LongTextNoteEvent.kind,
ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind,
LnZapEvent.kind, LnZapRequestEvent.kind,
- BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind
+ BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind,
+ PollNoteEvent.kind
),
tags = mapOf("a" to listOf(aTag.toTag())),
since = it.lastReactionsDownloadTime
@@ -94,7 +83,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
RepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
- LnZapRequestEvent.kind
+ LnZapRequestEvent.kind,
+ PollNoteEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime
@@ -127,7 +117,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind,
- ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind
+ ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
+ PollNoteEvent.kind, PrivateDmEvent.kind
),
ids = interestedEvents.toList()
)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt
index 7ce4626eab..a4ad8a5fcc 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt
@@ -2,15 +2,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
-import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
-import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
-import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
-import com.vitorpamplona.amethyst.service.model.ContactListEvent
-import com.vitorpamplona.amethyst.service.model.LnZapEvent
-import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
-import com.vitorpamplona.amethyst.service.model.MetadataEvent
-import com.vitorpamplona.amethyst.service.model.RepostEvent
-import com.vitorpamplona.amethyst.service.model.TextNoteEvent
+import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -43,7 +35,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
- kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind),
+ kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 200
)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt
index 4583e39d82..031c2b1cf7 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt
@@ -13,7 +13,27 @@ 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) }
+
+ private var citedUsersCache: Set? = null
+
+ fun citedUsers(): Set {
+ citedUsersCache?.let { return it }
+
+ val matcher = tagSearch.matcher(content)
+ val returningList = mutableSetOf()
+ while (matcher.find()) {
+ try {
+ val tag = matcher.group(1)?.let { tags[it.toInt()] }
+ if (tag != null && tag.size > 1 && tag[0] == "p") {
+ returningList.add(tag[1])
+ }
+ } catch (e: Exception) {
+ }
+ }
+ citedUsersCache = returningList
+ return returningList
+ }
fun findCitations(): Set {
var citations = mutableSetOf()
@@ -22,10 +42,10 @@ open class BaseTextNoteEvent(
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { tags[it.toInt()] }
- if (tag != null && tag[0] == "e") {
+ if (tag != null && tag.size > 1 && tag[0] == "e") {
citations.add(tag[1])
}
- if (tag != null && tag[0] == "a") {
+ if (tag != null && tag.size > 1 && tag[0] == "a") {
citations.add(tag[1])
}
} catch (e: Exception) {
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt
index 76a05c6cd5..e2db58fbdf 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt
@@ -12,11 +12,10 @@ class ChannelMessageEvent(
tags: List>,
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
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt
index 9b7e232aec..818a2d3b24 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt
@@ -38,23 +38,23 @@ open class Event(
override fun toJson(): String = gson.toJson(this)
- fun taggedUsers() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
- fun taggedEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
+ 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] }
- fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
- val aTagValue = it.getOrNull(1)
+ fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
+ val aTagValue = it[1]
val relay = it.getOrNull(2)
- if (aTagValue != null) ATag.parse(aTagValue, relay) else null
+ ATag.parse(aTagValue, relay)
}
- override fun hashtags() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
+ override fun hashtags() = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] }
- override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex }
+ override fun isTaggedUser(idHex: String) = tags.any { it.size > 1 && it[0] == "p" && it[1] == idHex }
- override fun isTaggedHash(hashtag: String) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1).equals(hashtag, true) }
- override fun isTaggedHashes(hashtags: Set) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }
- override fun firstIsTaggedHashes(hashtags: Set) = tags.firstOrNull { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }?.getOrNull(1)
+ override fun isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) }
+ override fun isTaggedHashes(hashtags: Set) = tags.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }
+ override fun firstIsTaggedHashes(hashtags: Set) = tags.firstOrNull { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }?.getOrNull(1)
override fun getPoWRank(): Int {
var rank = 0
@@ -79,7 +79,7 @@ open class Event(
override fun getReward(): BigDecimal? {
return try {
- tags.filter { it.firstOrNull() == "reward" }.mapNotNull { BigDecimal(it.getOrNull(1)) }.firstOrNull()
+ tags.filter { it.firstOrNull() == "reward" }.mapNotNull { it.getOrNull(1)?.let { BigDecimal(it) } }.firstOrNull()
} catch (e: Exception) {
null
}
@@ -227,6 +227,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)
+ 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)
RecommendRelayEvent.kind -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig, lenient)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt
index 07194a0bf5..709a7dacd1 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt
@@ -24,11 +24,11 @@ interface EventInterface {
fun hasValidSignature(): Boolean
- fun isTaggedUser(loggedInUser: String): Boolean
+ fun isTaggedUser(idHex: String): Boolean
fun isTaggedHash(hashtag: String): Boolean
- fun isTaggedHashes(hashtag: Set): Boolean
- fun firstIsTaggedHashes(hashtag: Set): String?
+ fun isTaggedHashes(hashtags: Set): Boolean
+ fun firstIsTaggedHashes(hashtags: Set): String?
fun hashtags(): List
fun getReward(): BigDecimal?
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt
index f1796fe9a7..5f08c6a6e3 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt
@@ -1,62 +1,75 @@
-package com.vitorpamplona.amethyst.service.model
-
-import android.util.Log
-import com.vitorpamplona.amethyst.model.HexKey
-import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
-import com.vitorpamplona.amethyst.service.relays.Client
-import java.math.BigDecimal
-
-class LnZapEvent(
- id: HexKey,
- pubKey: HexKey,
- createdAt: Long,
- tags: List>,
- content: String,
- sig: HexKey
-) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) {
-
- override fun zappedPost() = tags
- .filter { it.firstOrNull() == "e" }
- .mapNotNull { it.getOrNull(1) }
-
- override fun zappedAuthor() = tags
- .filter { it.firstOrNull() == "p" }
- .mapNotNull { it.getOrNull(1) }
-
- override fun amount(): BigDecimal? {
- return amount
- }
-
- // Keeps this as a field because it's a heavier function used everywhere.
- val amount by lazy {
- try {
- lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) }
- } catch (e: Exception) {
- Log.e("LnZapEvent", "Failed to Parse LnInvoice ${description()}", e)
- null
- }
- }
-
- override fun containedPost(): Event? = try {
- description()?.ifBlank { null }?.let {
- fromJson(it, Client.lenient)
- }
- } catch (e: Exception) {
- Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e)
- null
- }
-
- private fun lnInvoice(): String? = tags
- .filter { it.firstOrNull() == "bolt11" }
- .mapNotNull { it.getOrNull(1) }
- .firstOrNull()
-
- private fun description(): String? = tags
- .filter { it.firstOrNull() == "description" }
- .mapNotNull { it.getOrNull(1) }
- .firstOrNull()
-
- companion object {
- const val kind = 9735
- }
-}
+package com.vitorpamplona.amethyst.service.model
+
+import android.util.Log
+import com.vitorpamplona.amethyst.model.HexKey
+import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
+import com.vitorpamplona.amethyst.service.relays.Client
+
+class LnZapEvent(
+ id: HexKey,
+ pubKey: HexKey,
+ createdAt: Long,
+ tags: List>,
+ content: String,
+ sig: HexKey
+) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) {
+ // This event is also kept in LocalCache (same object)
+ @Transient val zapRequest: LnZapRequestEvent?
+
+ private fun containedPost(): LnZapRequestEvent? = try {
+ description()?.ifBlank { null }?.let {
+ fromJson(it, Client.lenient)
+ } as? LnZapRequestEvent
+ } catch (e: Exception) {
+ Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e)
+ null
+ }
+
+ init {
+ zapRequest = containedPost()
+ }
+
+ override fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
+
+ override fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
+
+ override fun zappedPollOption(): Int? = try {
+ zapRequest?.tags?.firstOrNull { it.size > 1 && it[0] == POLL_OPTION }?.get(1)?.toInt()
+ } catch (e: Exception) {
+ Log.e("LnZapEvent", "ZappedPollOption failed to parse", e)
+ null
+ }
+
+ override fun zappedRequestAuthor(): String? = zapRequest?.pubKey()
+
+ override fun amount() = amount
+
+ // Keeps this as a field because it's a heavier function used everywhere.
+ val amount by lazy {
+ try {
+ lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) }
+ } catch (e: Exception) {
+ Log.e("LnZapEvent", "Failed to Parse LnInvoice ${description()}", e)
+ null
+ }
+ }
+
+ override fun message(): String {
+ return content
+ }
+
+ private fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
+
+ private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
+
+ companion object {
+ const val kind = 9735
+ }
+
+ enum class ZapType() {
+ PUBLIC,
+ PRIVATE, // not yet implemented
+ ANONYMOUS,
+ NONZAP
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt
index cc95d34b32..ada0d609d3 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt
@@ -6,11 +6,15 @@ interface LnZapEventInterface : EventInterface {
fun zappedPost(): List
+ fun zappedPollOption(): Int?
+
fun zappedAuthor(): List
+ fun zappedRequestAuthor(): String?
+
fun taggedAddresses(): List
fun amount(): BigDecimal?
- fun containedPost(): Event?
+ fun message(): String
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt
index 23dc9d6b68..eba7a18a9f 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt
@@ -1,97 +1,121 @@
-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 LnZapRequestEvent(
- id: HexKey,
- pubKey: HexKey,
- createdAt: Long,
- tags: List>,
- content: String,
- sig: HexKey
-) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
- fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
- fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
-
- companion object {
- const val kind = 9734
-
- fun create(
- originalNote: EventInterface,
- relays: Set,
- privateKey: ByteArray,
- message: String,
- createdAt: Long = Date().time / 1000
- ): LnZapRequestEvent {
- val content = message
- val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
- var tags = listOf(
- listOf("e", originalNote.id()),
- listOf("p", originalNote.pubKey()),
- listOf("relays") + relays
- )
- if (originalNote is LongTextNoteEvent) {
- tags = tags + listOf(listOf("a", originalNote.address().toTag()))
- }
-
- val id = generateId(pubKey, createdAt, kind, tags, content)
- val sig = Utils.sign(id, privateKey)
- return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
- }
-
- fun create(
- userHex: String,
- relays: Set,
- privateKey: ByteArray,
- message: String,
- createdAt: Long = Date().time / 1000
- ): LnZapRequestEvent {
- val content = message
- val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
- val tags = listOf(
- listOf("p", userHex),
- listOf("relays") + relays
- )
- val id = generateId(pubKey, createdAt, kind, tags, content)
- val sig = Utils.sign(id, privateKey)
- return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
- }
- }
-}
-
-/*
-{
- "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"
- ]
- ]
-}
-*/
+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 LnZapRequestEvent(
+ id: HexKey,
+ pubKey: HexKey,
+ createdAt: Long,
+ tags: List>,
+ content: String,
+ sig: HexKey
+) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
+
+ 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] }
+
+ companion object {
+ const val kind = 9734
+
+ fun create(
+ originalNote: EventInterface,
+ relays: Set,
+ privateKey: ByteArray,
+ pollOption: Int?,
+ message: String,
+ zapType: LnZapEvent.ZapType,
+ createdAt: Long = Date().time / 1000
+ ): LnZapRequestEvent {
+ val content = message
+ var privkey = privateKey
+ var pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
+ var tags = listOf(
+ listOf("e", originalNote.id()),
+ listOf("p", originalNote.pubKey()),
+ listOf("relays") + relays
+ )
+ if (originalNote is LongTextNoteEvent) {
+ tags = tags + listOf(listOf("a", originalNote.address().toTag()))
+ }
+ if (pollOption != null && pollOption >= 0) {
+ tags = tags + listOf(listOf(POLL_OPTION, pollOption.toString()))
+ }
+ if (zapType == LnZapEvent.ZapType.ANONYMOUS) {
+ tags = tags + listOf(listOf("anon", ""))
+ privkey = Utils.privkeyCreate()
+ pubKey = Utils.pubkeyCreate(privkey).toHexKey()
+ }
+ val id = generateId(pubKey, createdAt, kind, tags, content)
+ val sig = Utils.sign(id, privkey)
+ return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
+ }
+
+ fun create(
+ userHex: String,
+ relays: Set,
+ privateKey: ByteArray,
+ message: String,
+ zapType: LnZapEvent.ZapType,
+ createdAt: Long = Date().time / 1000
+ ): LnZapRequestEvent {
+ val content = message
+ var privkey = privateKey
+ var pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
+ var tags = listOf(
+ listOf("p", userHex),
+ listOf("relays") + relays
+ )
+ if (zapType == LnZapEvent.ZapType.ANONYMOUS) {
+ tags = tags + listOf(listOf("anon", ""))
+ privkey = Utils.privkeyCreate()
+ pubKey = Utils.pubkeyCreate(privkey).toHexKey()
+ }
+
+ val id = generateId(pubKey, createdAt, kind, tags, content)
+ val sig = Utils.sign(id, privkey)
+ return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
+ }
+ }
+}
+
+/*
+{
+ "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": // TODO
+}
+*/
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt
new file mode 100644
index 0000000000..c91cd4c7a3
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt
@@ -0,0 +1,99 @@
+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
+
+const val POLL_OPTION = "poll_option"
+const val VALUE_MAXIMUM = "value_maximum"
+const val VALUE_MINIMUM = "value_minimum"
+const val CONSENSUS_THRESHOLD = "consensus_threshold"
+const val CLOSED_AT = "closed_at"
+
+class PollNoteEvent(
+ id: HexKey,
+ pubKey: HexKey,
+ createdAt: Long,
+ tags: List>,
+ // ots: String?, TODO implement OTS: https://github.com/opentimestamps/java-opentimestamps
+ content: String,
+ sig: HexKey
+) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
+ fun pollOptions() =
+ tags.filter { it.size > 2 && it[0] == POLL_OPTION }
+ .associate { it[1].toInt() to it[2] }
+
+ fun getTagInt(property: String): Int? {
+ val number = tags.firstOrNull() { it.size > 1 && it[0] == property }?.get(1)
+
+ return if (number.isNullOrBlank() || number == "null") {
+ null
+ } else {
+ number.toInt()
+ }
+ }
+
+ companion object {
+ const val kind = 6969
+
+ fun create(
+ msg: String,
+ replyTos: List?,
+ mentions: List?,
+ addresses: List?,
+ privateKey: ByteArray,
+ createdAt: Long = Date().time / 1000,
+ pollOptions: Map,
+ valueMaximum: Int?,
+ valueMinimum: Int?,
+ consensusThreshold: Int?,
+ closedAt: Int?
+ ): PollNoteEvent {
+ val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
+ val tags = mutableListOf>()
+ replyTos?.forEach {
+ tags.add(listOf("e", it))
+ }
+ mentions?.forEach {
+ tags.add(listOf("p", it))
+ }
+ addresses?.forEach {
+ tags.add(listOf("a", it.toTag()))
+ }
+ pollOptions.forEach { poll_op ->
+ tags.add(listOf(POLL_OPTION, poll_op.key.toString(), poll_op.value))
+ }
+ tags.add(listOf(VALUE_MAXIMUM, valueMaximum.toString()))
+ tags.add(listOf(VALUE_MINIMUM, valueMinimum.toString()))
+ tags.add(listOf(CONSENSUS_THRESHOLD, consensusThreshold.toString()))
+ tags.add(listOf(CLOSED_AT, closedAt.toString()))
+ val id = generateId(pubKey, createdAt, kind, tags, msg)
+ val sig = Utils.sign(id, privateKey)
+ return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
+ }
+ }
+}
+
+/*
+{
+ "id": <32-bytes lowercase hex-encoded sha256 of the serialized event data>
+ "pubkey": <32-bytes lowercase hex-encoded public key of the event creator>,
+ "created_at": ,
+ "kind": 6969,
+ "tags": [
+ ["e", <32-bytes hex of the id of the poll event>, ],
+ ["p", <32-bytes hex of the key>, ],
+ ["poll_option", "0", "poll option 0 description string"],
+ ["poll_option", "1", "poll option 1 description string"],
+ ["poll_option", "n", "poll option description string"],
+ ["value_maximum", "maximum satoshi value for inclusion in tally"],
+ ["value_minimum", "minimum satoshi value for inclusion in tally"],
+ ["consensus_threshold", "required percentage to attain consensus <0..100>"],
+ ["closed_at", "unix timestamp in seconds"],
+ ],
+ "ots":
+ "content": ,
+ "sig": <64-bytes hex of the signature of the sha256 hash of the serialized event data, which is the same as the "id" field>
+}
+ */
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt
index 89e1d98e67..c820d0ca2f 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt
@@ -21,7 +21,7 @@ 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.firstOrNull() == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one
+ fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one
/**
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
@@ -29,7 +29,7 @@ class PrivateDmEvent(
* Nip-18 messages should refer to other events by inline references in the content like
* `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506).
*/
- fun replyTo() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
+ fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
return try {
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt
index f89bb29775..13188520c4 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt
@@ -14,8 +14,8 @@ class ReactionEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
- fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
- fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
+ fun originalPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
+ fun originalAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
companion object {
const val kind = 7
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt
index 42f33c3c52..d3bd9dd97a 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt
@@ -30,7 +30,7 @@ class ReportEvent(
}
fun reportedPost() = tags
- .filter { it.firstOrNull() == "e" && it.getOrNull(1) != null }
+ .filter { it.size > 1 && it[0] == "e" }
.map {
ReportedKey(
it[1],
@@ -39,7 +39,7 @@ class ReportEvent(
}
fun reportedAuthor() = tags
- .filter { it.firstOrNull() == "p" && it.getOrNull(1) != null }
+ .filter { it.size > 1 && it[0] == "p" }
.map {
ReportedKey(
it[1],
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt
index b4bdda2039..87be07093c 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt
@@ -19,7 +19,10 @@ object Nip19 {
try {
val matcher = nip19regex.matcher(uri)
- matcher.find()
+ if (!matcher.find()) {
+ return null
+ }
+
val uriScheme = matcher.group(1) // nostr:
val type = matcher.group(2) // npub1
val key = matcher.group(3) // bech32
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt
index 6f841deb65..b1ef135a9b 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt
@@ -76,7 +76,8 @@ object Client : RelayPool.Listener {
}
} else {
/** temporary connection */
- Relay(relay, false, true, emptySet()).requestAndWatch() {
+ /** TODO: set the proxy for this temporary connection */
+ Relay(relay, false, true, emptySet(), null).requestAndWatch() {
it.send(signedEvent)
it.disconnect()
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt
index a167a2d99f..9f5477687a 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt
@@ -10,7 +10,8 @@ object Constants {
fun convertDefaultRelays(): Array {
return defaultRelays.map {
- Relay(it.url, it.read, it.write, it.feedTypes)
+ /** TODO: set the proxy */
+ Relay(it.url, it.read, it.write, it.feedTypes, null)
}.toTypedArray()
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt
index 87407844b2..1084f21c95 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt
@@ -10,6 +10,7 @@ import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
+import java.net.Proxy
import java.util.Date
enum class FeedType {
@@ -20,9 +21,11 @@ class Relay(
var url: String,
var read: Boolean = true,
var write: Boolean = true,
- var activeTypes: Set = FeedType.values().toSet()
+ var activeTypes: Set = FeedType.values().toSet(),
+ proxy: Proxy?
) {
private val httpClient = OkHttpClient.Builder()
+ .proxy(proxy)
.followRedirects(true)
.followSslRedirects(true)
.build()
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt
new file mode 100644
index 0000000000..516ac6e52e
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt
@@ -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?, var replyTos: List?, 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")
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt
new file mode 100644
index 0000000000..c6d1d1f3e5
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt
@@ -0,0 +1,79 @@
+package com.vitorpamplona.amethyst.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.OutlinedTextField
+import androidx.compose.material.Text
+import androidx.compose.material.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
+
+@Composable
+fun NewPollClosing(pollViewModel: NewPostViewModel) {
+ var text by rememberSaveable { mutableStateOf("") }
+
+ pollViewModel.isValidClosedAt.value = true
+ if (text.isNotEmpty()) {
+ try {
+ val int = text.toInt()
+ if (int < 0) {
+ pollViewModel.isValidClosedAt.value = false
+ } else { pollViewModel.closedAt = int }
+ } catch (e: Exception) { pollViewModel.isValidClosedAt.value = false }
+ }
+
+ val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
+ focusedBorderColor = MaterialTheme.colors.error,
+ unfocusedBorderColor = Color.Red
+ )
+ val colorValid = TextFieldDefaults.outlinedTextFieldColors(
+ focusedBorderColor = MaterialTheme.colors.primary,
+ unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ OutlinedTextField(
+ value = text,
+ onValueChange = { text = it },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ modifier = Modifier.width(150.dp),
+ colors = if (pollViewModel.isValidClosedAt.value) colorValid else colorInValid,
+ label = {
+ Text(
+ text = stringResource(R.string.poll_closing_time),
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+ },
+ placeholder = {
+ Text(
+ text = stringResource(R.string.poll_closing_time_days),
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+ }
+ )
+ }
+}
+
+@Preview
+@Composable
+fun NewPollClosingPreview() {
+ NewPollClosing(NewPostViewModel())
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt
new file mode 100644
index 0000000000..d4e4d22bbb
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt
@@ -0,0 +1,79 @@
+package com.vitorpamplona.amethyst.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.OutlinedTextField
+import androidx.compose.material.Text
+import androidx.compose.material.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
+
+@Composable
+fun NewPollConsensusThreshold(pollViewModel: NewPostViewModel) {
+ var text by rememberSaveable { mutableStateOf("") }
+
+ pollViewModel.isValidConsensusThreshold.value = true
+ if (text.isNotEmpty()) {
+ try {
+ val int = text.toInt()
+ if (int < 0 || int > 100) {
+ pollViewModel.isValidConsensusThreshold.value = false
+ } else { pollViewModel.consensusThreshold = int }
+ } catch (e: Exception) { pollViewModel.isValidConsensusThreshold.value = false }
+ }
+
+ val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
+ focusedBorderColor = MaterialTheme.colors.error,
+ unfocusedBorderColor = Color.Red
+ )
+ val colorValid = TextFieldDefaults.outlinedTextFieldColors(
+ focusedBorderColor = MaterialTheme.colors.primary,
+ unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ OutlinedTextField(
+ value = text,
+ onValueChange = { text = it },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ modifier = Modifier.width(150.dp),
+ colors = if (pollViewModel.isValidConsensusThreshold.value) colorValid else colorInValid,
+ label = {
+ Text(
+ text = stringResource(R.string.poll_consensus_threshold),
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+ },
+ placeholder = {
+ Text(
+ text = stringResource(R.string.poll_consensus_threshold_percent),
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+ }
+ )
+ }
+}
+
+@Preview
+@Composable
+fun NewPollConsensusThresholdPreview() {
+ NewPollConsensusThreshold(NewPostViewModel())
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt
new file mode 100644
index 0000000000..876134cff8
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt
@@ -0,0 +1,70 @@
+package com.vitorpamplona.amethyst.ui.actions
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.tooling.preview.Preview
+import com.vitorpamplona.amethyst.R
+
+@Composable
+fun NewPollOption(pollViewModel: NewPostViewModel, optionIndex: Int) {
+ val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
+ focusedBorderColor = MaterialTheme.colors.error,
+ unfocusedBorderColor = Color.Red
+ )
+ val colorValid = TextFieldDefaults.outlinedTextFieldColors(
+ focusedBorderColor = MaterialTheme.colors.primary,
+ unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+
+ Row {
+ val deleteIcon: @Composable (() -> Unit) = {
+ IconButton(
+ onClick = {
+ pollViewModel.pollOptions.remove(optionIndex)
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = stringResource(R.string.clear)
+ )
+ }
+ }
+
+ OutlinedTextField(
+ modifier = Modifier.weight(1F),
+ value = pollViewModel.pollOptions[optionIndex] ?: "",
+ onValueChange = { pollViewModel.pollOptions[optionIndex] = it },
+ label = {
+ Text(
+ text = stringResource(R.string.poll_option_index).format(optionIndex + 1),
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+ },
+ placeholder = {
+ Text(
+ text = stringResource(R.string.poll_option_description),
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+ },
+ keyboardOptions = KeyboardOptions.Default.copy(
+ capitalization = KeyboardCapitalization.Sentences
+ ),
+ // colors = if (pollViewModel.pollOptions[optionIndex]?.isNotEmpty() == true) colorValid else colorInValid,
+ trailingIcon = if (optionIndex > 1) deleteIcon else null
+ )
+ }
+}
+
+@Preview
+@Composable
+fun NewPollOptionPreview() {
+ NewPollOption(NewPostViewModel(), 0)
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt
new file mode 100644
index 0000000000..685eb2e8ff
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt
@@ -0,0 +1,77 @@
+package com.vitorpamplona.amethyst.ui.components
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.style.TextDirection
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
+import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+fun NewPollPrimaryDescription(pollViewModel: NewPostViewModel) {
+ // initialize focus reference to be able to request focus programmatically
+ val focusRequester = remember { FocusRequester() }
+ val keyboardController = LocalSoftwareKeyboardController.current
+
+ var isInputValid = true
+ if (pollViewModel.message.text.isEmpty()) {
+ isInputValid = false
+ }
+
+ val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
+ focusedBorderColor = MaterialTheme.colors.error,
+ unfocusedBorderColor = Color.Red
+ )
+ val colorValid = TextFieldDefaults.outlinedTextFieldColors(
+ focusedBorderColor = MaterialTheme.colors.primary,
+ unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+
+ OutlinedTextField(
+ value = pollViewModel.message,
+ onValueChange = {
+ pollViewModel.updateMessage(it)
+ },
+ label = {
+ Text(
+ text = stringResource(R.string.poll_primary_description),
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+ },
+ keyboardOptions = KeyboardOptions.Default.copy(
+ capitalization = KeyboardCapitalization.Sentences
+ ),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp)
+ .focusRequester(focusRequester)
+ .onFocusChanged {
+ if (it.isFocused) {
+ keyboardController?.show()
+ }
+ },
+ placeholder = {
+ Text(
+ text = stringResource(R.string.poll_primary_description),
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+ },
+ colors = if (isInputValid) colorValid else colorInValid,
+ visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
+ textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
+ )
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt
new file mode 100644
index 0000000000..b84ed0480d
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt
@@ -0,0 +1,43 @@
+package com.vitorpamplona.amethyst.ui.components
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.OutlinedTextField
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Account
+import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
+
+@Composable
+fun NewPollRecipientsField(pollViewModel: NewPostViewModel, account: Account) {
+ // if no recipients, add user's pubkey
+ if (pollViewModel.zapRecipients.isEmpty()) {
+ pollViewModel.zapRecipients.add(account.userProfile().pubkeyHex)
+ }
+
+ // TODO allow add multiple recipients and check input validity
+
+ OutlinedTextField(
+ modifier = Modifier
+ .fillMaxWidth(),
+ value = pollViewModel.zapRecipients[0],
+ onValueChange = { /* TODO */ },
+ enabled = false, // TODO enable add recipients
+ label = {
+ Text(
+ text = stringResource(R.string.poll_zap_recipients),
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+ },
+ placeholder = {
+ Text(
+ text = stringResource(R.string.poll_zap_recipients),
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+ }
+
+ )
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt
new file mode 100644
index 0000000000..12977d096a
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt
@@ -0,0 +1,197 @@
+package com.vitorpamplona.amethyst.ui.actions
+
+import android.widget.Toast
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Account
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.service.model.TextNoteEvent
+import com.vitorpamplona.amethyst.ui.components.*
+import com.vitorpamplona.amethyst.ui.note.ReplyInformation
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
+import kotlinx.coroutines.delay
+
+@Composable
+fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account) {
+ val pollViewModel: NewPostViewModel = viewModel()
+
+ val context = LocalContext.current
+
+ val scrollState = rememberScrollState()
+
+ LaunchedEffect(Unit) {
+ pollViewModel.load(account, baseReplyTo, quote)
+ delay(100)
+
+ pollViewModel.imageUploadingError.collect { error ->
+ Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ Dialog(
+ onDismissRequest = { onClose() },
+ properties = DialogProperties(
+ usePlatformDefaultWidth = false,
+ dismissOnClickOutside = false,
+ decorFitsSystemWindows = false
+ )
+ ) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight()
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight()
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(start = 10.dp, end = 10.dp, top = 10.dp)
+ .imePadding()
+ .weight(1f)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ CloseButton(onCancel = {
+ pollViewModel.cancel()
+ onClose()
+ })
+
+ PollButton(
+ onPost = {
+ pollViewModel.sendPost()
+ onClose()
+ },
+ isActive = pollViewModel.message.text.isNotBlank() &&
+ pollViewModel.pollOptions.values.all { it.isNotEmpty() } &&
+ pollViewModel.isValidRecipients.value &&
+ pollViewModel.isValidvalueMaximum.value &&
+ pollViewModel.isValidvalueMinimum.value &&
+ pollViewModel.isValidConsensusThreshold.value &&
+ pollViewModel.isValidClosedAt.value
+ )
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(scrollState)
+ ) {
+ if (pollViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) {
+ ReplyInformation(pollViewModel.replyTos, pollViewModel.mentions, account, "✖ ") {
+ pollViewModel.removeFromReplyList(it)
+ }
+ }
+
+ Text(stringResource(R.string.poll_heading_required))
+ // NewPollRecipientsField(pollViewModel, account)
+ NewPollPrimaryDescription(pollViewModel)
+ pollViewModel.pollOptions.values.forEachIndexed { index, element ->
+ NewPollOption(pollViewModel, index)
+ }
+ Button(
+ onClick = { pollViewModel.pollOptions[pollViewModel.pollOptions.size] = "" },
+ border = BorderStroke(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
+ colors = ButtonDefaults.outlinedButtonColors(
+ contentColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+ ) {
+ Image(
+ painterResource(id = android.R.drawable.ic_input_add),
+ contentDescription = "Add poll option button",
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ Text(stringResource(R.string.poll_heading_optional))
+ NewPollVoteValueRange(pollViewModel)
+ NewPollConsensusThreshold(pollViewModel)
+ NewPollClosing(pollViewModel)
+ }
+ }
+
+ val userSuggestions = pollViewModel.userSuggestions
+ if (userSuggestions.isNotEmpty()) {
+ LazyColumn(
+ contentPadding = PaddingValues(
+ top = 10.dp
+ ),
+ modifier = Modifier.heightIn(0.dp, 300.dp)
+ ) {
+ itemsIndexed(
+ userSuggestions,
+ key = { _, item -> item.pubkeyHex }
+ ) { index, item ->
+ UserLine(item, account) {
+ pollViewModel.autocompleteWithUser(item)
+ }
+ }
+ }
+ }
+
+ Row(modifier = Modifier.fillMaxWidth()) {
+ /*UploadFromGallery(
+ isUploading = pollViewModel.isUploadingImage
+ ) {
+ pollViewModel.upload(it, context)
+ }*/
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun PollButton(modifier: Modifier = Modifier, onPost: () -> Unit = {}, isActive: Boolean) {
+ Button(
+ modifier = modifier,
+ onClick = {
+ if (isActive) {
+ onPost()
+ }
+ },
+ shape = RoundedCornerShape(20.dp),
+ colors = ButtonDefaults
+ .buttonColors(
+ backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
+ )
+ ) {
+ Text(text = stringResource(R.string.post_poll), color = Color.White)
+ }
+}
+
+/*@Preview
+@Composable
+fun NewPollViewPreview() {
+ NewPollView(onClose = {}, account = Account(loggedIn = Persona()))
+}*/
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt
new file mode 100644
index 0000000000..ee6a3f4fec
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt
@@ -0,0 +1,126 @@
+package com.vitorpamplona.amethyst.ui.actions
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.OutlinedTextField
+import androidx.compose.material.Text
+import androidx.compose.material.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.R
+
+@Composable
+fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
+ var textMax by rememberSaveable { mutableStateOf("") }
+ var textMin by rememberSaveable { mutableStateOf("") }
+
+ // check for zapMax amounts < 1
+ pollViewModel.isValidvalueMaximum.value = true
+ if (textMax.isNotEmpty()) {
+ try {
+ val int = textMax.toInt()
+ if (int < 1) {
+ pollViewModel.isValidvalueMaximum.value = false
+ } else { pollViewModel.valueMaximum = int }
+ } catch (e: Exception) { pollViewModel.isValidvalueMaximum.value = false }
+ }
+
+ // check for minZap amounts < 1
+ pollViewModel.isValidvalueMinimum.value = true
+ if (textMin.isNotEmpty()) {
+ try {
+ val int = textMin.toInt()
+ if (int < 1) {
+ pollViewModel.isValidvalueMinimum.value = false
+ } else { pollViewModel.valueMinimum = int }
+ } catch (e: Exception) { pollViewModel.isValidvalueMinimum.value = false }
+ }
+
+ // check for zapMin > zapMax
+ if (textMin.isNotEmpty() && textMax.isNotEmpty()) {
+ try {
+ val intMin = textMin.toInt()
+ val intMax = textMax.toInt()
+
+ if (intMin > intMax) {
+ pollViewModel.isValidvalueMinimum.value = false
+ pollViewModel.isValidvalueMaximum.value = false
+ }
+ } catch (e: Exception) {
+ pollViewModel.isValidvalueMinimum.value = false
+ pollViewModel.isValidvalueMaximum.value = false
+ }
+ }
+
+ val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
+ focusedBorderColor = MaterialTheme.colors.error,
+ unfocusedBorderColor = Color.Red
+ )
+ val colorValid = TextFieldDefaults.outlinedTextFieldColors(
+ focusedBorderColor = MaterialTheme.colors.primary,
+ unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ OutlinedTextField(
+ value = textMin,
+ onValueChange = { textMin = it },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ modifier = Modifier.width(150.dp),
+ colors = if (pollViewModel.isValidvalueMinimum.value) colorValid else colorInValid,
+ label = {
+ Text(
+ text = stringResource(R.string.poll_zap_value_min),
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+ },
+ placeholder = {
+ Text(
+ text = stringResource(R.string.sats),
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+ }
+ )
+ OutlinedTextField(
+ value = textMax,
+ onValueChange = { textMax = it },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ modifier = Modifier.width(150.dp),
+ colors = if (pollViewModel.isValidvalueMaximum.value) colorValid else colorInValid,
+ label = {
+ Text(
+ text = stringResource(R.string.poll_zap_value_max),
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+ },
+ placeholder = {
+ Text(
+ text = stringResource(R.string.sats),
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+ }
+ )
+ }
+}
+
+@Preview
+@Composable
+fun NewPollVoteValueRangePreview() {
+ NewPollVoteValueRange(NewPostViewModel())
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt
index 8ef31d6983..213573b225 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt
@@ -1,6 +1,8 @@
package com.vitorpamplona.amethyst.ui.actions
import android.widget.Toast
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@@ -10,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.CurrencyBitcoin
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@@ -27,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
@@ -40,6 +45,7 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
+import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.delay
@OptIn(ExperimentalComposeUiApi::class)
@@ -104,8 +110,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
postViewModel.sendPost()
onClose()
},
- isActive = postViewModel.message.text.isNotBlank() &&
- !postViewModel.isUploadingImage
+ isActive = postViewModel.canPost()
)
}
@@ -161,6 +166,48 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
+ if (postViewModel.wantsPoll) {
+ postViewModel.pollOptions.values.forEachIndexed { index, element ->
+ NewPollOption(postViewModel, index)
+ }
+
+ Button(
+ onClick = { postViewModel.pollOptions[postViewModel.pollOptions.size] = "" },
+ border = BorderStroke(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
+ colors = ButtonDefaults.outlinedButtonColors(
+ contentColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+ ) {
+ Image(
+ painterResource(id = android.R.drawable.ic_input_add),
+ contentDescription = "Add poll option button",
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ }
+
+ 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)) {
@@ -217,11 +264,25 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
Row(modifier = Modifier.fillMaxWidth()) {
UploadFromGallery(
isUploading = postViewModel.isUploadingImage,
- tint = MaterialTheme.colors.primary,
+ tint = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(bottom = 10.dp)
) {
postViewModel.upload(it, context)
}
+
+ if (postViewModel.canUsePoll) {
+ val hashtag = stringResource(R.string.poll_hashtag)
+ AddPollButton(postViewModel.wantsPoll) {
+ postViewModel.wantsPoll = !postViewModel.wantsPoll
+ postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
+ }
+ }
+
+ if (postViewModel.canAddInvoice) {
+ AddLnInvoiceButton(postViewModel.wantsInvoice) {
+ postViewModel.wantsInvoice = !postViewModel.wantsInvoice
+ }
+ }
}
}
}
@@ -229,6 +290,62 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
}
+@Composable
+private fun AddPollButton(
+ isPollActive: Boolean,
+ onClick: () -> Unit
+) {
+ IconButton(
+ onClick = {
+ onClick()
+ }
+ ) {
+ if (!isPollActive) {
+ Icon(
+ painter = painterResource(R.drawable.ic_poll),
+ null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colors.onBackground
+ )
+ } else {
+ Icon(
+ painter = painterResource(R.drawable.ic_lists),
+ null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colors.onBackground
+ )
+ }
+ }
+}
+
+@Composable
+private fun AddLnInvoiceButton(
+ isLnInvoiceActive: Boolean,
+ onClick: () -> Unit
+) {
+ IconButton(
+ onClick = {
+ onClick()
+ }
+ ) {
+ if (!isLnInvoiceActive) {
+ Icon(
+ imageVector = Icons.Default.CurrencyBitcoin,
+ null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colors.onBackground
+ )
+ } else {
+ Icon(
+ imageVector = Icons.Default.CurrencyBitcoin,
+ null,
+ modifier = Modifier.size(20.dp),
+ tint = BitcoinOrange
+ )
+ }
+ }
+}
+
@Composable
fun CloseButton(onCancel: () -> Unit) {
Button(
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt
index 739ca540d0..8be28a4913 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt
@@ -3,15 +3,18 @@ package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.text.TextRange
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
@@ -19,9 +22,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
-class NewPostViewModel : ViewModel() {
- private var account: Account? = null
- private var originalNote: Note? = null
+open class NewPostViewModel : ViewModel() {
+ var account: Account? = null
+ var originalNote: Note? = null
var mentions by mutableStateOf?>(null)
var replyTos by mutableStateOf?>(null)
@@ -34,7 +37,27 @@ class NewPostViewModel : ViewModel() {
var userSuggestions by mutableStateOf>(emptyList())
var userSuggestionAnchor: TextRange? = null
- fun load(account: Account, replyingTo: Note?, quote: Note?) {
+ // Polls
+ var canUsePoll by mutableStateOf(false)
+ var wantsPoll by mutableStateOf(false)
+ var zapRecipients = mutableStateListOf()
+ var pollOptions = newStateMapPollOptions()
+ var valueMaximum: Int? = null
+ var valueMinimum: Int? = null
+ var consensusThreshold: Int? = null
+ var closedAt: Int? = null
+
+ var isValidRecipients = mutableStateOf(true)
+ var isValidvalueMaximum = mutableStateOf(true)
+ var isValidvalueMinimum = mutableStateOf(true)
+ var isValidConsensusThreshold = mutableStateOf(true)
+ var isValidClosedAt = mutableStateOf(true)
+
+ // Invoices
+ var canAddInvoice by mutableStateOf(false)
+ var wantsInvoice by mutableStateOf(false)
+
+ open fun load(account: Account, replyingTo: Note?, quote: Note?) {
originalNote = replyingTo
replyingTo?.let { replyNote ->
this.replyTos = (replyNote.replyTo ?: emptyList()).plus(replyNote)
@@ -49,7 +72,7 @@ class NewPostViewModel : ViewModel() {
this.mentions = currentMentions.plus(replyUser)
}
}
- } ?: {
+ } ?: run {
replyTos = null
mentions = null
}
@@ -58,88 +81,27 @@ class NewPostViewModel : ViewModel() {
message = TextFieldValue(message.text + "\n\n@${it.idNote()}")
}
+ canAddInvoice = account.userProfile().info?.lnAddress() != null
+ canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null
+
this.account = account
}
- fun addUserToMentions(user: User) {
- mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
- }
-
- fun addNoteToReplyTos(note: Note) {
- note.author?.let { addUserToMentions(it) }
- replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
- }
-
- 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)
- }
-
- 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)
+ val tagger = NewMessageTagger(originalNote?.channel(), mentions, replyTos, message.text)
+ tagger.run()
- 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")
-
- if (originalNote?.channel() != null) {
- account?.sendChannelMessage(newMessage, originalNote!!.channel()!!.idHex, originalNote!!, mentions)
+ if (wantsPoll) {
+ account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt)
+ } else if (originalNote?.channel() != null) {
+ 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)
}
- message = TextFieldValue("")
- urlPreview = null
- isUploadingImage = false
- mentions = null
+ cancel()
}
fun upload(it: Uri, context: Context) {
@@ -166,14 +128,24 @@ class NewPostViewModel : ViewModel() {
)
}
- fun cancel() {
+ open fun cancel() {
message = TextFieldValue("")
urlPreview = null
isUploadingImage = false
mentions = null
+
+ wantsPoll = false
+ zapRecipients = mutableStateListOf()
+ pollOptions = newStateMapPollOptions()
+ valueMaximum = null
+ valueMinimum = null
+ consensusThreshold = null
+ closedAt = null
+
+ wantsInvoice = false
}
- fun findUrlInMessage(): String? {
+ open fun findUrlInMessage(): String? {
return message.text.split('\n').firstNotNullOfOrNull { paragraph ->
paragraph.split(' ').firstOrNull { word: String ->
isValidURL(word) || noProtocolUrlValidator.matcher(word).matches()
@@ -181,11 +153,11 @@ class NewPostViewModel : ViewModel() {
}
}
- fun removeFromReplyList(it: User) {
+ open fun removeFromReplyList(it: User) {
mentions = mentions?.minus(it)
}
- fun updateMessage(it: TextFieldValue) {
+ open fun updateMessage(it: TextFieldValue) {
message = it
urlPreview = findUrlInMessage()
@@ -200,7 +172,7 @@ class NewPostViewModel : ViewModel() {
}
}
- fun autocompleteWithUser(item: User) {
+ open fun autocompleteWithUser(item: User) {
userSuggestionAnchor?.let {
val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
@@ -214,4 +186,26 @@ class NewPostViewModel : ViewModel() {
userSuggestions = emptyList()
}
}
+
+ private fun newStateMapPollOptions(): SnapshotStateMap {
+ return mutableStateMapOf(Pair(0, ""), Pair(1, ""))
+ }
+
+ fun canPost(): Boolean {
+ return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice &&
+ (!wantsPoll || pollOptions.values.all { it.isNotEmpty() })
+ }
+
+ fun includePollHashtagInMessage(include: Boolean, hashtag: String) {
+ if (include) {
+ updateMessage(TextFieldValue(message.text + " $hashtag"))
+ } else {
+ updateMessage(
+ TextFieldValue(
+ message.text.replace(" $hashtag", "")
+ .replace(hashtag, "")
+ )
+ )
+ }
+ }
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt
new file mode 100644
index 0000000000..1a0715ef29
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt
@@ -0,0 +1,97 @@
+package com.vitorpamplona.amethyst.ui.buttons
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.ButtonDefaults
+import androidx.compose.material.Icon
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.OutlinedButton
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Account
+import com.vitorpamplona.amethyst.service.NostrAccountDataSource
+import com.vitorpamplona.amethyst.ui.actions.NewPollView
+import com.vitorpamplona.amethyst.ui.actions.NewPostView
+
+@Composable
+fun FabColumn(account: Account) {
+ var isOpen by remember {
+ mutableStateOf(false)
+ }
+ var wantsToPoll by remember {
+ mutableStateOf(false)
+ }
+ var wantsToPost by remember {
+ mutableStateOf(false)
+ }
+
+ Column() {
+ if (isOpen) {
+ OutlinedButton(
+ onClick = {
+ wantsToPoll = true
+ isOpen = false
+ },
+ modifier = Modifier.size(45.dp),
+ shape = CircleShape,
+ colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
+ contentPadding = PaddingValues(0.dp)
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_poll),
+ null,
+ modifier = Modifier.size(26.dp),
+ tint = Color.White
+ )
+ }
+
+ Spacer(modifier = Modifier.height(20.dp))
+
+ OutlinedButton(
+ onClick = {
+ wantsToPost = true
+ isOpen = false
+ },
+ modifier = Modifier.size(45.dp),
+ shape = CircleShape,
+ colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
+ contentPadding = PaddingValues(0.dp)
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_lists),
+ null,
+ modifier = Modifier.size(26.dp),
+ tint = Color.White
+ )
+ }
+
+ Spacer(modifier = Modifier.height(20.dp))
+ }
+ OutlinedButton(
+ onClick = { isOpen = !isOpen },
+ modifier = Modifier.size(55.dp),
+ shape = CircleShape,
+ colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
+ contentPadding = PaddingValues(0.dp)
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_compose),
+ null,
+ modifier = Modifier.size(26.dp),
+ tint = Color.White
+ )
+ }
+ }
+
+ if (wantsToPost) {
+ NewPostView({ wantsToPost = false }, account = NostrAccountDataSource.account)
+ }
+
+ if (wantsToPoll) {
+ NewPollView({ wantsToPoll = false }, account = account)
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt
index b16534b28f..ece9c89e9b 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt
@@ -1,4 +1,4 @@
-package com.vitorpamplona.amethyst.buttons
+package com.vitorpamplona.amethyst.ui.buttons
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt
index d2172e560d..ea6dff88f1 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt
@@ -9,6 +9,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicReference
/**
* This class is designed to have a waiting time between two calls of invalidate
@@ -44,3 +45,37 @@ class BundledUpdate(
}
}
}
+
+/**
+ * This class is designed to have a waiting time between two calls of invalidate
+ */
+class BundledInsert(
+ val delay: Long,
+ val dispatcher: CoroutineDispatcher = Dispatchers.Default
+) {
+ private var onlyOneInBlock = AtomicBoolean()
+ private var atomicSet = AtomicReference>(setOf())
+
+ fun invalidateList(newObject: T, onUpdate: (Set) -> Unit) {
+ atomicSet.updateAndGet() {
+ it + newObject
+ }
+
+ if (onlyOneInBlock.getAndSet(true)) {
+ return
+ }
+
+ val scope = CoroutineScope(Job() + dispatcher)
+ scope.launch {
+ try {
+ onUpdate(atomicSet.getAndSet(emptySet()))
+ delay(delay)
+ onUpdate(atomicSet.getAndSet(emptySet()))
+ } finally {
+ withContext(NonCancellable) {
+ onlyOneInBlock.set(false)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt
index bb3c99d0a3..8962fc219d 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt
@@ -11,12 +11,12 @@ import com.vitorpamplona.amethyst.ui.note.toShortenHex
@Composable
fun ClickableNoteTag(
- baesNote: Note,
+ baseNote: Note,
navController: NavController
) {
ClickableText(
- text = AnnotatedString("@${baesNote.idNote().toShortenHex()}"),
- onClick = { navController.navigate("Note/${baesNote.idHex}") },
+ text = AnnotatedString("@${baseNote.idNote().toShortenHex()}"),
+ onClick = { navController.navigate("Note/${baseNote.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt
index 418e25b45a..51a335c3e0 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt
@@ -5,10 +5,34 @@ import android.net.Uri
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
-import androidx.compose.runtime.Composable
+import androidx.compose.material.Text
+import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.style.TextDirection
import androidx.core.content.ContextCompat
+import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+@Composable
+fun MayBeWithdrawal(lnurlWord: String) {
+ var lnWithdrawal by remember { mutableStateOf(null) }
+
+ LaunchedEffect(key1 = lnurlWord) {
+ withContext(Dispatchers.IO) {
+ lnWithdrawal = LnWithdrawalUtil.findWithdrawal(lnurlWord)
+ }
+ }
+
+ lnWithdrawal?.let {
+ ClickableWithdrawal(withdrawalString = it)
+ }
+ ?: Text(
+ text = "$lnurlWord ",
+ style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
+ )
+}
@Composable
fun ClickableWithdrawal(withdrawalString: String) {
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt
index f945164000..d86f81c55b 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt
@@ -9,13 +9,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.Button
-import androidx.compose.material.ButtonDefaults
-import androidx.compose.material.Divider
-import androidx.compose.material.Icon
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Text
-import androidx.compose.runtime.Composable
+import androidx.compose.material.*
+import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -24,22 +19,48 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextDirection
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.service.lnurl.LnInvoiceUtil
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.math.BigDecimal
import java.text.NumberFormat
@Composable
-fun InvoicePreview(lnInvoice: String) {
- val amount = try {
- LnInvoiceUtil.getAmountInSats(lnInvoice)
- } catch (e: Exception) {
- e.printStackTrace()
- null
+fun MayBeInvoicePreview(lnbcWord: String) {
+ var lnInvoice by remember { mutableStateOf?>(null) }
+
+ LaunchedEffect(key1 = lnbcWord) {
+ withContext(Dispatchers.IO) {
+ val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord)
+ if (myInvoice != null) {
+ val myInvoiceAmount = try {
+ LnInvoiceUtil.getAmountInSats(myInvoice)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ null
+ }
+
+ lnInvoice = Pair(myInvoice, myInvoiceAmount)
+ }
+ }
}
+ lnInvoice?.let {
+ InvoicePreview(it.first, it.second)
+ }
+ ?: Text(
+ text = "$lnbcWord ",
+ style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
+ )
+}
+
+@Composable
+fun InvoicePreview(lnInvoice: String, amount: BigDecimal?) {
val context = LocalContext.current
Column(
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt
index 2fff33f834..06e38c773c 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt
@@ -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,22 @@ 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 com.vitorpamplona.amethyst.service.model.LnZapEvent
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 +79,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)
@@ -130,20 +136,14 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC
Button(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
onClick = {
- val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message)
+ val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message, LnZapEvent.ZapType.PUBLIC)
LightningAddressResolver().lnAddressInvoice(
lud16,
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 +159,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)
}
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt
index 1dc0ce1548..474a1fea70 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.ui.components
import android.util.Log
import android.util.Patterns
-import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
@@ -14,7 +13,7 @@ import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
-import androidx.compose.runtime.Composable
+import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
@@ -34,12 +33,15 @@ import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material.MaterialRichText
import com.halilibo.richtext.ui.resolveDefaults
import com.vitorpamplona.amethyst.model.LocalCache
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
-import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
@@ -80,47 +82,47 @@ fun RichTextViewer(
accountViewModel: AccountViewModel,
navController: NavController
) {
- 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)
- )
- )
- )
-
- Column(modifier = modifier.animateContentSize()) {
+ Column(modifier = modifier) {
if (content.startsWith("# ") ||
content.contains("##") ||
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)
+ )
+ )
+ )
+
MaterialRichText(
style = myMarkDownStyle
) {
@@ -164,31 +166,21 @@ fun RichTextViewer(
UrlPreview(word, "$word ")
}
} else if (word.startsWith("lnbc", true)) {
- val lnInvoice = LnInvoiceUtil.findInvoice(word)
- if (lnInvoice != null) {
- InvoicePreview(lnInvoice)
- } else {
- Text(
- text = "$word ",
- style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
- )
- }
+ MayBeInvoicePreview(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)
- )
- }
+ 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, navController)
+ BechLink(
+ word,
+ canPreview,
+ backgroundColor,
+ accountViewModel,
+ navController
+ )
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
@@ -239,7 +231,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,45 +283,95 @@ 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("@")
return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) }
}
@Composable
-fun BechLink(word: String, navController: NavController) {
- val nip19Route = Nip19.uriToRoute(word)
+fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
+ var nip19Route by remember { mutableStateOf(null) }
+ var baseNotePair by remember { mutableStateOf?>(null) }
- if (nip19Route == null) {
- Text(text = "$word ")
+ LaunchedEffect(key1 = word) {
+ withContext(Dispatchers.IO) {
+ Nip19.uriToRoute(word)?.let {
+ if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
+ LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
+ baseNotePair = Pair(note, it.additionalChars)
+ }
+ } else {
+ nip19Route = it
+ }
+ }
+ }
+ }
+
+ if (canPreview) {
+ baseNotePair?.let {
+ NoteCompose(
+ baseNote = it.first,
+ 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
+ )
+ Text(
+ "${it.second} "
+ )
+ } ?: nip19Route?.let {
+ ClickableRoute(it, navController)
+ } ?: Text(text = "$word ")
} else {
- ClickableRoute(nip19Route, navController)
+ nip19Route?.let {
+ ClickableRoute(it, navController)
+ } ?: Text(text = "$word ")
}
}
@Composable
fun HashTag(word: String, accountViewModel: AccountViewModel, navController: NavController) {
- val hashtagMatcher = hashTagsPattern.matcher(word)
+ var tagSuffixPair by remember { mutableStateOf?>(null) }
- val (tag, suffix) = try {
- hashtagMatcher.find()
- Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
- } catch (e: Exception) {
- Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
- Pair(null, null)
+ LaunchedEffect(key1 = word) {
+ withContext(Dispatchers.IO) {
+ val hashtagMatcher = hashTagsPattern.matcher(word)
+
+ val (myTag, mySuffix) = try {
+ hashtagMatcher.find()
+ Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
+ } catch (e: Exception) {
+ Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
+ Pair(null, null)
+ }
+
+ if (myTag != null) {
+ tagSuffixPair = Pair(myTag, mySuffix)
+ }
+ }
}
- if (tag != null) {
- val hashtagIcon = checkForHashtagWithIcon(tag)
+ tagSuffixPair?.let { tagPair ->
+ val hashtagIcon = checkForHashtagWithIcon(tagPair.first)
ClickableText(
text = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
- append("#$tag")
+ append("#${tagPair.first}")
}
},
- onClick = { navController.navigate("Hashtag/$tag") }
+ onClick = { navController.navigate("Hashtag/${tagPair.first}") }
)
if (hashtagIcon != null) {
@@ -346,14 +394,12 @@ fun HashTag(word: String, accountViewModel: AccountViewModel, navController: Nav
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
- if (hashtagIcon != null) {
- Icon(
- painter = painterResource(hashtagIcon.icon),
- contentDescription = hashtagIcon.description,
- tint = hashtagIcon.color,
- modifier = hashtagIcon.modifier
- )
- }
+ Icon(
+ painter = painterResource(hashtagIcon.icon),
+ contentDescription = hashtagIcon.description,
+ tint = hashtagIcon.color,
+ modifier = hashtagIcon.modifier
+ )
}
)
)
@@ -364,69 +410,80 @@ fun HashTag(word: String, accountViewModel: AccountViewModel, navController: Nav
inlineContent = inlineContent
)
}
- Text(text = "$suffix ")
- } else {
- Text(text = "$word ")
- }
+ tagPair.second?.ifBlank { "" }?.let {
+ Text(text = "$it ")
+ }
+ } ?: Text(text = "$word ")
}
@Composable
fun TagLink(word: String, tags: List>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
- val matcher = tagIndex.matcher(word)
+ var baseUserPair by remember { mutableStateOf?>(null) }
+ var baseNotePair by remember { mutableStateOf?>(null) }
- val (index, extraCharacters) = try {
- matcher.find()
- Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
- } catch (e: Exception) {
- Log.w("Tag Parser", "Couldn't link tag $word", e)
- Pair(null, null)
- }
-
- if (index == null) {
- return Text(text = "$word ")
- }
-
- if (index >= 0 && index < tags.size) {
- if (tags[index][0] == "p") {
- val baseUser = LocalCache.checkGetOrCreateUser(tags[index][1])
- if (baseUser != null) {
- ClickableUserTag(baseUser, navController)
- Text(text = "$extraCharacters ")
- } else {
- // if here the tag is not a valid Nostr Hex
- Text(text = "$word ")
+ LaunchedEffect(key1 = word) {
+ withContext(Dispatchers.IO) {
+ val matcher = tagIndex.matcher(word)
+ val (index, suffix) = try {
+ matcher.find()
+ Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
+ } catch (e: Exception) {
+ Log.w("Tag Parser", "Couldn't link tag $word", e)
+ Pair(null, null)
}
- } else if (tags[index][0] == "e") {
- val note = LocalCache.checkGetOrCreateNote(tags[index][1])
- 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 {
- ClickableNoteTag(note, navController)
- Text(text = "$extraCharacters ")
+
+ if (index != null && index >= 0 && index < tags.size) {
+ val tag = tags[index]
+
+ if (tag.size > 1) {
+ if (tag[0] == "p") {
+ LocalCache.checkGetOrCreateUser(tag[1])?.let {
+ baseUserPair = Pair(it, suffix)
+ }
+ } else if (tag[0] == "e" || tag[0] == "a") {
+ LocalCache.checkGetOrCreateNote(tag[1])?.let {
+ baseNotePair = Pair(it, suffix)
+ }
+ }
}
- } else {
- // if here the tag is not a valid Nostr Hex
- Text(text = "$word ")
}
- } else {
- Text(text = "$word ")
}
}
+
+ baseUserPair?.let {
+ ClickableUserTag(it.first, navController)
+ Text(text = "${it.second} ")
+ }
+
+ baseNotePair?.let {
+ if (canPreview) {
+ NoteCompose(
+ baseNote = it.first,
+ 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
+ )
+ it.second?.ifBlank { null }?.let {
+ Text(text = "$it ")
+ }
+ } else {
+ ClickableNoteTag(it.first, navController)
+ Text(text = "${it.second} ")
+ }
+ }
+
+ if (baseNotePair == null && baseUserPair == null) {
+ Text(text = "$word ")
+ }
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt
index 19b3b2a245..e547da6bd5 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt
@@ -69,7 +69,6 @@ fun ZoomableImageView(word: String, images: List = listOf(word)) {
contentDescription = word,
contentScale = ContentScale.FillWidth,
modifier = Modifier
- .padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt
index 6e19217dd7..aa2921dceb 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt
@@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
-object ChannelFeedFilter : FeedFilter() {
+object ChannelFeedFilter : AdditiveFeedFilter() {
lateinit var account: Account
lateinit var channel: Channel
@@ -22,4 +22,14 @@ object ChannelFeedFilter : FeedFilter() {
.sortedBy { it.createdAt() }
.reversed()
}
+
+ override fun applyFilter(collection: Set): Set {
+ return collection
+ .filter { it.idHex in channel.notes.keys && account.isAcceptable(it) }
+ .toSet()
+ }
+
+ override fun sort(collection: Set): List {
+ return collection.sortedBy { it.createdAt() }.reversed()
+ }
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt
index 9d77e1ac41..f96c686817 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt
@@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
-object ChatroomFeedFilter : FeedFilter() {
+object ChatroomFeedFilter : AdditiveFeedFilter() {
var account: Account? = null
var withUser: User? = null
@@ -30,4 +30,23 @@ object ChatroomFeedFilter : FeedFilter() {
.sortedBy { it.createdAt() }
.reversed()
}
+
+ override fun applyFilter(collection: Set): Set {
+ val myAccount = account
+ val myUser = withUser
+
+ if (myAccount == null || myUser == null) return emptySet()
+
+ val messages = myAccount
+ .userProfile()
+ .privateChatrooms[myUser] ?: return emptySet()
+
+ return collection
+ .filter { it in messages.roomMessages && account?.isAcceptable(it) == true }
+ .toSet()
+ }
+
+ override fun sort(collection: Set): List {
+ return collection.sortedBy { it.createdAt() }.reversed()
+ }
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt
index e5f78f4e26..505a959ef9 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt
@@ -4,16 +4,37 @@ import android.util.Log
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
-abstract class FeedFilter() {
+abstract class FeedFilter {
@OptIn(ExperimentalTime::class)
fun loadTop(): List {
val (feed, elapsed) = measureTimedValue {
- feed().take(1000)
+ feed()
+ }
+
+ Log.d("Time", "${this.javaClass.simpleName} Feed in $elapsed with ${feed.size} objects")
+ return feed.take(1000)
+ }
+
+ abstract fun feed(): List
+}
+
+abstract class AdditiveFeedFilter : FeedFilter() {
+ abstract fun applyFilter(collection: Set): Set
+ abstract fun sort(collection: Set): List
+
+ @OptIn(ExperimentalTime::class)
+ fun updateListWith(oldList: List, newItems: Set): List {
+ val (feed, elapsed) = measureTimedValue {
+ val newItemsToBeAdded = applyFilter(newItems)
+ if (newItemsToBeAdded.isNotEmpty()) {
+ val newList = oldList.toSet() + newItemsToBeAdded
+ sort(newList).take(1000)
+ } else {
+ oldList
+ }
}
Log.d("Time", "${this.javaClass.simpleName} Feed in $elapsed with ${feed.size} objects")
return feed
}
-
- abstract fun feed(): List
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt
index ffed429cc3..f17d5ade58 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt
@@ -3,56 +3,48 @@ 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.ChannelMessageEvent
-import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
-import com.vitorpamplona.amethyst.service.model.TextNoteEvent
+import com.vitorpamplona.amethyst.service.model.*
-object GlobalFeedFilter : FeedFilter() {
+object GlobalFeedFilter : AdditiveFeedFilter() {
lateinit var account: Account
override fun feed(): List {
- val followChannels = account.followingChannels()
+ val notes = innerApplyFilter(LocalCache.notes.values)
+ val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
+
+ return sort(notes + longFormNotes)
+ }
+
+ override fun applyFilter(collection: Set): Set {
+ return innerApplyFilter(collection)
+ }
+
+ private fun innerApplyFilter(collection: Collection): Set {
+ val followChannels = account.followingChannels
val followUsers = account.followingKeySet()
+ val now = System.currentTimeMillis() / 1000
- val notes = LocalCache.notes.values
+ return collection
.asSequence()
.filter {
- (it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent) &&
- it.replyTo.isNullOrEmpty()
+ it.event is BaseTextNoteEvent && it.replyTo.isNullOrEmpty()
}
.filter {
+ val channel = it.channelHex()
// does not show events already in the public chat list
- (it.channel() == null || it.channel() !in followChannels) &&
+ (channel == null || channel !in followChannels) &&
// does not show people the user already follows
(it.author?.pubkeyHex !in followUsers)
}
.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()!! <= System.currentTimeMillis() / 1000
+ it.createdAt()!! <= now
}
- .toList()
+ .toSet()
+ }
- val longFormNotes = LocalCache.addressables.values
- .asSequence()
- .filter {
- (it.event is LongTextNoteEvent) && it.replyTo.isNullOrEmpty()
- }
- .filter {
- // does not show events already in the public chat list
- (it.channel() == null || it.channel() !in followChannels) &&
- // does not show people the user already follows
- (it.author?.pubkeyHex !in followUsers)
- }
- .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()!! <= System.currentTimeMillis() / 1000
- }
- .toList()
-
- return (notes + longFormNotes)
- .sortedBy { it.createdAt() }
- .reversed()
+ override fun sort(collection: Set): List {
+ return collection.sortedBy { it.createdAt() }.reversed()
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt
index 844505ff3f..d4fd4765e3 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt
@@ -8,14 +8,27 @@ import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
-object HashtagFeedFilter : FeedFilter() {
+object HashtagFeedFilter : AdditiveFeedFilter() {
lateinit var account: Account
var tag: String? = null
- override fun feed(): List {
- val myTag = tag ?: return emptyList()
+ fun loadHashtag(account: Account, tag: String?) {
+ this.account = account
+ this.tag = tag
+ }
- return LocalCache.notes.values
+ override fun feed(): List {
+ return sort(innerApplyFilter(LocalCache.notes.values))
+ }
+
+ override fun applyFilter(collection: Set): Set {
+ return applyFilter(collection)
+ }
+
+ private fun innerApplyFilter(collection: Collection): Set {
+ val myTag = tag ?: return emptySet()
+
+ return collection
.asSequence()
.filter {
(
@@ -27,13 +40,10 @@ object HashtagFeedFilter : FeedFilter() {
it.event?.isTaggedHash(myTag) == true
}
.filter { account.isAcceptable(it) }
- .sortedBy { it.createdAt() }
- .toList()
- .reversed()
+ .toSet()
}
- fun loadHashtag(account: Account, tag: String?) {
- this.account = account
- this.tag = tag
+ override fun sort(collection: Set): List {
+ return collection.sortedBy { it.createdAt() }.reversed()
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt
index 50385acfd7..6026e2669b 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt
@@ -3,25 +3,38 @@ 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.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
-object HomeConversationsFeedFilter : FeedFilter() {
+object HomeConversationsFeedFilter : AdditiveFeedFilter() {
lateinit var account: Account
override fun feed(): List {
+ return sort(innerApplyFilter(LocalCache.notes.values))
+ }
+
+ override fun applyFilter(collection: Set): Set {
+ return innerApplyFilter(collection)
+ }
+
+ private fun innerApplyFilter(collection: Collection): Set {
val user = account.userProfile()
val followingKeySet = user.cachedFollowingKeySet()
val followingTagSet = user.cachedFollowingTagSet()
- return LocalCache.notes.values
+ return collection
+ .asSequence()
.filter {
- (it.event is TextNoteEvent) &&
+ (it.event is TextNoteEvent || it.event is PollNoteEvent) &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true &&
!it.isNewThread()
}
- .sortedBy { it.createdAt() }
- .reversed()
+ .toSet()
+ }
+
+ override fun sort(collection: Set): List {
+ return collection.sortedBy { it.createdAt() }.reversed()
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt
index ca88aaa96f..d31a45c682 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt
@@ -4,37 +4,42 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
+import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
-object HomeNewThreadFeedFilter : FeedFilter() {
+object HomeNewThreadFeedFilter : AdditiveFeedFilter() {
lateinit var account: Account
override fun feed(): List {
+ val notes = innerApplyFilter(LocalCache.notes.values)
+ val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
+
+ return sort(notes + longFormNotes)
+ }
+
+ override fun applyFilter(collection: Set): Set {
+ return innerApplyFilter(collection)
+ }
+
+ private fun innerApplyFilter(collection: Collection): Set {
val user = account.userProfile()
val followingKeySet = user.cachedFollowingKeySet()
val followingTagSet = user.cachedFollowingTagSet()
- val notes = LocalCache.notes.values
+ return collection
+ .asSequence()
.filter { it ->
- (it.event is TextNoteEvent || it.event is RepostEvent) &&
+ (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent) &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
- it.author?.let { !account.isHidden(it) } ?: true &&
+ it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true &&
it.isNewThread()
}
+ .toSet()
+ }
- val longFormNotes = LocalCache.addressables.values
- .filter { it ->
- (it.event is LongTextNoteEvent) &&
- (it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
- // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
- it.author?.let { !account.isHidden(it) } ?: true &&
- it.isNewThread()
- }
-
- return (notes + longFormNotes)
- .sortedBy { it.createdAt() }
- .reversed()
+ override fun sort(collection: Set): List {
+ return collection.sortedBy { it.createdAt() }.reversed()
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt
index 1372f166d5..ba746ebbad 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt
@@ -3,47 +3,56 @@ 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
import com.vitorpamplona.amethyst.service.model.*
-object NotificationFeedFilter : FeedFilter() {
+object NotificationFeedFilter : AdditiveFeedFilter() {
lateinit var account: Account
override fun feed(): List {
- val loggedInUser = account.userProfile()
- return LocalCache.notes.values
- .asSequence()
- .filter {
- it.event !is ChannelCreateEvent &&
- it.event !is ChannelMetadataEvent &&
- it.event !is LnZapRequestEvent &&
- it.event !is BadgeDefinitionEvent &&
- it.event !is BadgeProfilesEvent &&
- it.event?.isTaggedUser(loggedInUser.pubkeyHex) ?: false &&
- (it.author == null || (!account.isHidden(it.author!!) && it.author != loggedInUser))
- }
- .filter { it ->
- it.event !is TextNoteEvent ||
- (it.event as? TextNoteEvent)?.taggedEvents()?.any {
- LocalCache.checkGetOrCreateNote(it)?.author == loggedInUser
- } == true ||
- loggedInUser in it.directlyCiteUsers()
- }
- .filter {
- it.event !is ReactionEvent ||
- it.replyTo?.lastOrNull()?.author == loggedInUser ||
- loggedInUser in it.directlyCiteUsers()
- }
- .filter {
- it.event !is RepostEvent ||
- it.replyTo?.lastOrNull()?.author == loggedInUser ||
- loggedInUser in it.directlyCiteUsers()
- }
- .sortedBy { it.createdAt() }
- .toList()
- .reversed()
+ return sort(innerApplyFilter(LocalCache.notes.values))
}
- fun isDifferentAccount(account: Account): Boolean {
- return this::account.isInitialized && this.account != account
+ override fun applyFilter(collection: Set): Set {
+ return innerApplyFilter(collection)
+ }
+
+ private fun innerApplyFilter(collection: Collection): Set {
+ val loggedInUser = account.userProfile()
+ val loggedInUserHex = loggedInUser.pubkeyHex
+
+ return collection.filter {
+ it.event !is ChannelCreateEvent &&
+ it.event !is ChannelMetadataEvent &&
+ it.event !is LnZapRequestEvent &&
+ it.event !is BadgeDefinitionEvent &&
+ it.event !is BadgeProfilesEvent &&
+ it.author !== loggedInUser &&
+ it.event?.isTaggedUser(loggedInUserHex) ?: false &&
+ (it.author == null || !account.isHidden(it.author!!.pubkeyHex)) &&
+ tagsAnEventByUser(it, loggedInUser)
+ }.toSet()
+ }
+
+ override fun sort(collection: Set): List {
+ return collection.sortedBy { it.createdAt() }.reversed()
+ }
+
+ fun tagsAnEventByUser(note: Note, author: User): Boolean {
+ val event = note.event
+
+ if (event is BaseTextNoteEvent) {
+ return (event.citedUsers().contains(author.pubkeyHex) || note.replyTo?.any { it.author === author } == true)
+ }
+
+ if (event is ReactionEvent) {
+ return note.replyTo?.lastOrNull()?.author === author
+ }
+
+ if (event is RepostEvent) {
+ return note.replyTo?.lastOrNull()?.author === author
+ }
+
+ return true
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt
index ceb6631922..d50ce32bcf 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt
@@ -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
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt
index defddc0c90..36e1ac835f 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt
@@ -46,6 +46,7 @@ import androidx.navigation.NavHostController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.ResizeImage
@@ -53,6 +54,8 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
+import java.net.InetSocketAddress
+import java.net.Proxy
@OptIn(ExperimentalMaterialApi::class)
@Composable
@@ -100,7 +103,12 @@ fun DrawerContent(
}
@Composable
-fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffoldState: ScaffoldState, navController: NavController) {
+fun ProfileContent(
+ baseAccountUser: User,
+ modifier: Modifier = Modifier,
+ scaffoldState: ScaffoldState,
+ navController: NavController
+) {
val coroutineScope = rememberCoroutineScope()
val accountUserState by baseAccountUser.live().metadata.observeAsState()
@@ -197,11 +205,17 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol
})
) {
Row() {
- Text("${accountUserFollows.cachedFollowCount() ?: "--"}", fontWeight = FontWeight.Bold)
+ Text(
+ "${accountUserFollows.cachedFollowCount() ?: "--"}",
+ fontWeight = FontWeight.Bold
+ )
Text(stringResource(R.string.following))
}
Row(modifier = Modifier.padding(start = 10.dp)) {
- Text("${accountUserFollows.cachedFollowerCount() ?: "--"}", fontWeight = FontWeight.Bold)
+ Text(
+ "${accountUserFollows.cachedFollowerCount() ?: "--"}",
+ fontWeight = FontWeight.Bold
+ )
Text(stringResource(R.string.followers))
}
}
@@ -221,6 +235,7 @@ fun ListContent(
) {
val coroutineScope = rememberCoroutineScope()
var backupDialogOpen by remember { mutableStateOf(false) }
+ var checked by remember { mutableStateOf(account.proxy != null) }
Column(modifier = modifier.fillMaxHeight()) {
if (accountUser != null) {
@@ -259,6 +274,19 @@ fun ListContent(
onClick = { backupDialogOpen = true }
)
+ IconRow(
+ title = "Enable Tor",
+ icon = R.drawable.ic_topics,
+ tint = MaterialTheme.colors.onBackground,
+ onClick = {
+ checked = !checked
+ println("changed tor to $checked")
+ account.proxy = if (checked) Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", 9050)) else null
+ ServiceManager.pause()
+ ServiceManager.start()
+ }
+ )
+
Spacer(modifier = Modifier.weight(1f))
IconRow(
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt
index 36e6423c5d..bee9980155 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt
@@ -60,7 +60,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
-import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
+import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -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
+ )
}
}
}
@@ -248,30 +246,32 @@ fun ChatroomMessageCompose(
Row(verticalAlignment = Alignment.CenterVertically) {
val event = note.event
if (event is ChannelCreateEvent) {
+ val channelInfo = event.channelInfo()
Text(
text = note.author?.toBestDisplayName()
.toString() + " ${stringResource(R.string.created)} " + (
- event.channelInfo().name
+ channelInfo.name
?: ""
) + " ${stringResource(R.string.with_description_of)} '" + (
- event.channelInfo().about
+ channelInfo.about
?: ""
) + "', ${stringResource(R.string.and_picture)} '" + (
- event.channelInfo().picture
+ channelInfo.picture
?: ""
) + "'"
)
} else if (event is ChannelMetadataEvent) {
+ val channelInfo = event.channelInfo()
Text(
text = note.author?.toBestDisplayName()
.toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + (
- event.channelInfo().name
+ channelInfo.name
?: ""
) + "$', {stringResource(R.string.description_to)} '" + (
- event.channelInfo().about
+ channelInfo.about
?: ""
) + "', ${stringResource(R.string.and_picture_to)} '" + (
- event.channelInfo().picture
+ channelInfo.picture
?: ""
) + "'"
)
@@ -283,20 +283,20 @@ fun ChatroomMessageCompose(
!noteForReports.hasAnyReports()
if (eventContent != null) {
- TranslateableRichTextViewer(
+ TranslatableRichTextViewer(
eventContent,
canPreview,
- Modifier,
+ Modifier.padding(top = 5.dp),
note.event?.tags(),
backgroundBubbleColor,
accountViewModel,
navController
)
} else {
- TranslateableRichTextViewer(
+ TranslatableRichTextViewer(
stringResource(R.string.could_not_decrypt_the_message),
true,
- Modifier,
+ Modifier.padding(top = 5.dp),
note.event?.tags(),
backgroundBubbleColor,
accountViewModel,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt
index bf0fa2cdb8..8deb5bcb1d 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt
@@ -26,7 +26,9 @@ 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
@@ -46,7 +48,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
} else {
var isNew by remember { mutableStateOf(false) }
- LaunchedEffect(key1 = messageSetCard) {
+ LaunchedEffect(key1 = messageSetCard.createdAt()) {
withContext(Dispatchers.IO) {
isNew =
messageSetCard.createdAt() > NotificationCache.load(routeForLastRead)
@@ -64,14 +66,29 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
Column(
modifier = Modifier.background(backgroundColor).combinedClickable(
onClick = {
- if (noteEvent !is ChannelMessageEvent) {
- navController.navigate("Note/${note.idHex}") {
- launchSingleTop = true
- }
- } else {
+ 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
+ }
}
},
onLongClick = { popupExpanded = true }
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt
index 2f262e9d4b..5376499053 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt
@@ -33,9 +33,11 @@ import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
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.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@@ -59,7 +61,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
} else {
var isNew by remember { mutableStateOf(false) }
- LaunchedEffect(key1 = multiSetCard) {
+ LaunchedEffect(key1 = multiSetCard.createdAt()) {
withContext(Dispatchers.IO) {
isNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead)
@@ -78,14 +80,29 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
.background(backgroundColor)
.combinedClickable(
onClick = {
- if (noteEvent !is ChannelMessageEvent) {
- navController.navigate("Note/${note.idHex}") {
- launchSingleTop = true
- }
- } else {
+ 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
+ }
}
},
onLongClick = { popupExpanded = true }
@@ -227,7 +244,7 @@ fun FastNoteAuthorPicture(
val userState by author.live().metadata.observeAsState()
val user = userState?.user ?: return
- val showFollowingMark = userAccount.isFollowingCached(user) || user == userAccount
+ val showFollowingMark = userAccount.isFollowingCached(user) || user === userAccount
UserPicture(
userHex = user.pubkeyHex,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt
index 53c5702141..cebfe1fdde 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt
@@ -96,7 +96,6 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie
user.nip05()?.let { nip05 ->
if (nip05.split("@").size == 2) {
- val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
Column(modifier = columnModifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05.split("@")[0] != "_") {
@@ -108,6 +107,7 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie
)
}
+ val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
if (nip05Verified == null) {
Icon(
tint = Color.Yellow,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt
index d7d18be4be..8400d174b3 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt
@@ -51,24 +51,8 @@ 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.BadgeAwardEvent
-import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
-import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
-import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
-import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
-import com.vitorpamplona.amethyst.service.model.EventInterface
-import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
-import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
-import com.vitorpamplona.amethyst.service.model.ReactionEvent
-import com.vitorpamplona.amethyst.service.model.ReportEvent
-import com.vitorpamplona.amethyst.service.model.RepostEvent
-import com.vitorpamplona.amethyst.service.model.TextNoteEvent
-import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
-import com.vitorpamplona.amethyst.ui.components.ResizeImage
-import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
-import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
-import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
-import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
+import com.vitorpamplona.amethyst.service.model.*
+import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
@@ -81,7 +65,7 @@ import kotlin.math.ceil
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
-@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class)
+@OptIn(ExperimentalTime::class)
@Composable
fun NoteCompose(
baseNote: Note,
@@ -112,7 +96,7 @@ fun NoteCompose(
)
}
- Log.d("Time", "Note Compose in $elapsed for ${baseNote.event?.kind()} ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}")
+ Log.d("Time", "Note Compose in $elapsed for ${baseNote.idHex} ${baseNote.event?.kind()} ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}")
}
@OptIn(ExperimentalFoundationApi::class)
@@ -132,6 +116,7 @@ fun NoteComposeInner(
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
+ val loggedIn = account.userProfile()
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note
@@ -146,6 +131,19 @@ fun NoteComposeInner(
var moreActionsExpanded by remember { mutableStateOf(false) }
+ var isAcceptable by remember { mutableStateOf(true) }
+ var canPreview by remember { mutableStateOf(true) }
+
+ LaunchedEffect(key1 = noteReportsState) {
+ withContext(Dispatchers.IO) {
+ canPreview = note?.author === loggedIn ||
+ (note?.author?.let { loggedIn.isFollowingCached(it) } ?: true) ||
+ !noteForReports.hasAnyReports()
+
+ isAcceptable = account.isAcceptable(noteForReports)
+ }
+ }
+
val noteEvent = note?.event
val baseChannel = note?.channel()
@@ -157,11 +155,11 @@ fun NoteComposeInner(
),
isBoostedNote
)
- } else if (!account.isAcceptable(noteForReports) && !showHiddenNote) {
+ } else if (!isAcceptable && !showHiddenNote) {
if (!account.isHidden(noteForReports.author!!)) {
HiddenNote(
account.getRelevantReports(noteForReports),
- account.userProfile(),
+ loggedIn,
modifier,
isBoostedNote,
navController,
@@ -209,7 +207,20 @@ fun NoteComposeInner(
navController.navigate("Channel/${it.idHex}")
}
} else if (noteEvent is PrivateDmEvent) {
- navController.navigate("Room/${note.author?.pubkeyHex}")
+ 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}")
}
@@ -234,7 +245,7 @@ fun NoteComposeInner(
.width(55.dp)
.padding(0.dp)
) {
- NoteAuthorPicture(note, navController, account.userProfile(), 55.dp)
+ NoteAuthorPicture(note, navController, loggedIn, 55.dp)
if (noteEvent is RepostEvent) {
note.replyTo?.lastOrNull()?.let {
@@ -247,7 +258,7 @@ fun NoteComposeInner(
NoteAuthorPicture(
it,
navController,
- account.userProfile(),
+ loggedIn,
35.dp,
pictureModifier = Modifier.border(2.dp, MaterialTheme.colors.background, CircleShape)
)
@@ -302,7 +313,7 @@ fun NoteComposeInner(
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (isQuotedNote) {
- NoteAuthorPicture(note, navController, account.userProfile(), 25.dp)
+ NoteAuthorPicture(note, navController, loggedIn, 25.dp)
Spacer(Modifier.padding(horizontal = 5.dp))
NoteUsernameDisplay(note, Modifier.weight(1f))
} else {
@@ -326,7 +337,7 @@ fun NoteComposeInner(
)
IconButton(
- modifier = Modifier.then(Modifier.size(24.dp)),
+ modifier = Modifier.size(24.dp),
onClick = { moreActionsExpanded = true }
) {
Icon(
@@ -359,11 +370,6 @@ fun NoteComposeInner(
Spacer(modifier = Modifier.height(3.dp))
if (!makeItShort && noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) {
- val sortedMentions = noteEvent.mentions()
- .mapNotNull { LocalCache.checkGetOrCreateUser(it) }
- .toSet()
- .sortedBy { account.userProfile().isFollowingCached(it) }
-
val replyingDirectlyTo = note.replyTo?.lastOrNull()
if (replyingDirectlyTo != null && unPackReply) {
NoteCompose(
@@ -385,17 +391,19 @@ fun NoteComposeInner(
navController = navController
)
} else {
- ReplyInformation(note.replyTo, sortedMentions, account, navController)
+ ReplyInformation(note.replyTo, noteEvent.mentions(), account, navController)
}
- } else if (!makeItShort && noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions() != null)) {
+ Spacer(modifier = Modifier.height(5.dp))
+ } else if (!makeItShort && noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) {
val sortedMentions = noteEvent.mentions()
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()
- .sortedBy { account.userProfile().isFollowingCached(it) }
+ .sortedBy { loggedIn.isFollowingCached(it) }
note.channel()?.let {
ReplyInformationChannel(note.replyTo, sortedMentions, it, navController)
}
+ Spacer(modifier = Modifier.height(5.dp))
}
if (noteEvent is ReactionEvent || noteEvent is RepostEvent) {
@@ -441,7 +449,7 @@ fun NoteComposeInner(
thickness = 0.25.dp
)
} else if (noteEvent is LongTextNoteEvent) {
- LongFormHeader(noteEvent, note, account.userProfile())
+ LongFormHeader(noteEvent, note, loggedIn)
ReactionsRow(note, accountViewModel)
@@ -459,7 +467,7 @@ fun NoteComposeInner(
UserPicture(
user = it,
navController = navController,
- userAccount = account.userProfile(),
+ userAccount = loggedIn,
size = 35.dp
)
}
@@ -485,12 +493,12 @@ fun NoteComposeInner(
thickness = 0.25.dp
)
} else if (noteEvent is PrivateDmEvent &&
- noteEvent.recipientPubKey() != account.userProfile().pubkeyHex &&
- note.author != account.userProfile()
+ noteEvent.recipientPubKey() != loggedIn.pubkeyHex &&
+ note.author !== loggedIn
) {
val recepient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) }
- TranslateableRichTextViewer(
+ TranslatableRichTextViewer(
stringResource(
id = R.string.private_conversation_notification,
"@${note.author?.pubkeyNpub()}",
@@ -515,12 +523,8 @@ fun NoteComposeInner(
} else {
val eventContent = accountViewModel.decrypt(note)
- val canPreview = note.author == account.userProfile() ||
- (note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
- !noteForReports.hasAnyReports()
-
if (eventContent != null) {
- if (makeItShort && note.author == account.userProfile()) {
+ if (makeItShort && note.author == loggedIn) {
Text(
text = eventContent,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
@@ -528,7 +532,7 @@ fun NoteComposeInner(
overflow = TextOverflow.Ellipsis
)
} else {
- TranslateableRichTextViewer(
+ TranslatableRichTextViewer(
eventContent,
canPreview = canPreview && !makeItShort,
Modifier.fillMaxWidth(),
@@ -538,7 +542,17 @@ fun NoteComposeInner(
navController
)
- DisplayUncitedHashtags(noteEvent, eventContent, navController)
+ DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController)
+ }
+
+ if (noteEvent is PollNoteEvent) {
+ PollNote(
+ note,
+ canPreview = canPreview && !makeItShort,
+ backgroundColor,
+ accountViewModel,
+ navController
+ )
}
}
@@ -565,11 +579,17 @@ fun DisplayFollowingHashtagsInPost(
account: Account,
navController: NavController
) {
+ var firstTag by remember { mutableStateOf(null) }
+
+ LaunchedEffect(key1 = noteEvent) {
+ withContext(Dispatchers.IO) {
+ firstTag = noteEvent.firstIsTaggedHashes(account.followingTagSet())
+ }
+ }
+
Column() {
Row(verticalAlignment = Alignment.CenterVertically) {
- val firstTag =
- noteEvent.firstIsTaggedHashes(account.followingTagSet())
- if (firstTag != null) {
+ firstTag?.let {
ClickableText(
text = AnnotatedString(" #$firstTag"),
onClick = { navController.navigate("Hashtag/$firstTag") },
@@ -586,11 +606,10 @@ fun DisplayFollowingHashtagsInPost(
@Composable
fun DisplayUncitedHashtags(
- noteEvent: EventInterface,
+ hashtags: List,
eventContent: String,
navController: NavController
) {
- val hashtags = noteEvent.hashtags()
if (hashtags.isNotEmpty()) {
FlowRow(
modifier = Modifier.padding(top = 5.dp)
@@ -872,7 +891,11 @@ private fun RelayBadges(baseNote: Note) {
items(relaysToDisplay.size) {
val url = relaysToDisplay[it].removePrefix("wss://").removePrefix("ws://")
- Box(Modifier.padding(1.dp).size(15.dp)) {
+ Box(
+ Modifier
+ .padding(1.dp)
+ .size(15.dp)
+ ) {
RobohashFallbackAsyncImage(
robot = "https://$url/favicon.ico",
model = "https://$url/favicon.ico",
@@ -1066,6 +1089,13 @@ fun UserPicture(
}
}
+data class DropDownParams(
+ val isFollowingAuthor: Boolean,
+ val isPrivateBookmarkNote: Boolean,
+ val isPublicBookmarkNote: Boolean,
+ val isLoggedUser: Boolean
+)
+
@Composable
fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) {
val clipboardManager = LocalClipboardManager.current
@@ -1073,11 +1103,28 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
val actContext = LocalContext.current
var reportDialogShowing by remember { mutableStateOf(false) }
+ var state by remember {
+ mutableStateOf(
+ DropDownParams(false, false, false, false)
+ )
+ }
+
+ LaunchedEffect(key1 = note) {
+ withContext(Dispatchers.IO) {
+ state = DropDownParams(
+ accountViewModel.isFollowing(note.author),
+ accountViewModel.isInPrivateBookmarks(note),
+ accountViewModel.isInPublicBookmarks(note),
+ accountViewModel.isLoggedUser(note.author)
+ )
+ }
+ }
+
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = onDismiss
) {
- if (!accountViewModel.isFollowing(note.author)) {
+ if (!state.isFollowingAuthor) {
DropdownMenuItem(onClick = {
accountViewModel.follow(
note.author ?: return@DropdownMenuItem
@@ -1114,7 +1161,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
Text(stringResource(R.string.quick_action_share))
}
Divider()
- if (accountViewModel.isInPrivateBookmarks(note)) {
+ if (state.isPrivateBookmarkNote) {
DropdownMenuItem(onClick = { accountViewModel.removePrivateBookmark(note); onDismiss() }) {
Text(stringResource(R.string.remove_from_private_bookmarks))
}
@@ -1123,7 +1170,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
Text(stringResource(R.string.add_to_private_bookmarks))
}
}
- if (accountViewModel.isInPublicBookmarks(note)) {
+ if (state.isPublicBookmarkNote) {
DropdownMenuItem(onClick = { accountViewModel.removePublicBookmark(note); onDismiss() }) {
Text(stringResource(R.string.remove_from_public_bookmarks))
}
@@ -1137,7 +1184,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
Text(stringResource(R.string.broadcast))
}
Divider()
- if (accountViewModel.isLoggedUser(note.author)) {
+ if (state.isLoggedUser) {
DropdownMenuItem(onClick = { accountViewModel.delete(note); onDismiss() }) {
Text(stringResource(R.string.request_deletion))
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt
index b6a8a54964..ca98d7048b 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt
@@ -109,8 +109,6 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
var showDeleteAlertDialog by remember { mutableStateOf(false) }
var showBlockAlertDialog by remember { mutableStateOf(false) }
var showReportDialog by remember { mutableStateOf(false) }
- val isOwnNote = accountViewModel.isLoggedUser(note.author)
- val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author!!)
val backgroundColor = if (MaterialTheme.colors.isLight) {
MaterialTheme.colors.primary
@@ -129,6 +127,9 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
}
if (popupExpanded) {
+ val isOwnNote = accountViewModel.isLoggedUser(note.author)
+ val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author!!)
+
Popup(onDismissRequest = onDismiss) {
Card(
modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape),
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt
new file mode 100644
index 0000000000..26e83cf916
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt
@@ -0,0 +1,538 @@
+package com.vitorpamplona.amethyst.ui.note
+
+import android.widget.Toast
+import androidx.compose.foundation.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+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.runtime.*
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.compose.ui.window.Popup
+import androidx.navigation.NavController
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.service.model.LnZapEvent
+import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import java.util.*
+import kotlin.math.roundToInt
+
+@Composable
+fun PollNote(
+ baseNote: Note,
+ canPreview: Boolean,
+ backgroundColor: Color,
+ accountViewModel: AccountViewModel,
+ navController: NavController
+) {
+ val zapsState by baseNote.live().zaps.observeAsState()
+ val zappedNote = zapsState?.note ?: return
+
+ val pollViewModel = PollNoteViewModel()
+ pollViewModel.load(zappedNote)
+
+ pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op ->
+ val optionTally = pollViewModel.optionVoteTally(poll_op.key)
+ val color = if (
+ pollViewModel.consensusThreshold != null &&
+ optionTally >= pollViewModel.consensusThreshold!!
+ ) {
+ Color.Green.copy(alpha = 0.32f)
+ } else {
+ MaterialTheme.colors.primary.copy(alpha = 0.32f)
+ }
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(vertical = 3.dp)
+ ) {
+ if (accountViewModel.isLoggedUser(zappedNote.author) || zappedNote.isZappedBy(accountViewModel.userProfile())) {
+ ZapVote(
+ baseNote,
+ accountViewModel,
+ pollViewModel,
+ poll_op.key,
+ nonClickablePrepend = {
+ Box(
+ Modifier.fillMaxWidth(0.75f).clip(shape = RoundedCornerShape(15.dp))
+ .border(
+ 2.dp,
+ color,
+ RoundedCornerShape(15.dp)
+ )
+ ) {
+ LinearProgressIndicator(
+ modifier = Modifier.matchParentSize(),
+ color = color,
+ progress = optionTally.toFloat()
+ )
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ horizontalAlignment = Alignment.End,
+ modifier = Modifier.padding(horizontal = 10.dp).width(40.dp)
+ ) {
+ Text(
+ text = "${(optionTally.toFloat() * 100).roundToInt()}%",
+ fontWeight = FontWeight.Bold
+ )
+ }
+
+ Column(modifier = Modifier.fillMaxWidth().padding(15.dp)) {
+ TranslatableRichTextViewer(
+ poll_op.value,
+ canPreview,
+ Modifier,
+ pollViewModel.pollEvent?.tags(),
+ backgroundColor,
+ accountViewModel,
+ navController
+ )
+ }
+ }
+ }
+ },
+ clickablePrepend = {
+ }
+ )
+ } else {
+ ZapVote(
+ baseNote,
+ accountViewModel,
+ pollViewModel,
+ poll_op.key,
+ nonClickablePrepend = {},
+ clickablePrepend = {
+ Box(
+ Modifier.fillMaxWidth(0.75f)
+ .clip(shape = RoundedCornerShape(15.dp))
+ .border(
+ 2.dp,
+ MaterialTheme.colors.primary,
+ RoundedCornerShape(15.dp)
+ )
+ ) {
+ TranslatableRichTextViewer(
+ poll_op.value,
+ canPreview,
+ Modifier.padding(15.dp),
+ pollViewModel.pollEvent?.tags(),
+ backgroundColor,
+ accountViewModel,
+ navController
+ )
+ }
+ }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+@OptIn(ExperimentalFoundationApi::class)
+fun ZapVote(
+ baseNote: Note,
+ accountViewModel: AccountViewModel,
+ pollViewModel: PollNoteViewModel,
+ pollOption: Int,
+ modifier: Modifier = Modifier,
+ nonClickablePrepend: @Composable () -> Unit,
+ clickablePrepend: @Composable () -> Unit
+) {
+ val zapsState by baseNote.live().zaps.observeAsState()
+ val zappedNote = zapsState?.note
+
+ var wantsToZap by remember { mutableStateOf(false) }
+
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ var zappingProgress by remember { mutableStateOf(0f) }
+
+ val accountState by accountViewModel.accountLiveData.observeAsState()
+ val account = accountState?.account ?: return
+
+ nonClickablePrepend()
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.combinedClickable(
+ role = Role.Button,
+ // interactionSource = remember { MutableInteractionSource() },
+ // indication = rememberRipple(bounded = false, radius = 24.dp),
+ onClick = {
+ if (!accountViewModel.isWriteable()) {
+ scope.launch {
+ Toast
+ .makeText(
+ context,
+ context.getString(R.string.login_with_a_private_key_to_be_able_to_send_zaps),
+ Toast.LENGTH_SHORT
+ )
+ .show()
+ }
+ } else if (pollViewModel.isPollClosed()) {
+ scope.launch {
+ Toast
+ .makeText(
+ context,
+ context.getString(R.string.poll_is_closed),
+ Toast.LENGTH_SHORT
+ )
+ .show()
+ }
+ } else if (accountViewModel.isLoggedUser(zappedNote?.author)) {
+ scope.launch {
+ Toast
+ .makeText(
+ context,
+ context.getString(R.string.poll_author_no_vote),
+ Toast.LENGTH_SHORT
+ )
+ .show()
+ }
+ } else if (pollViewModel.isVoteAmountAtomic() && pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) {
+ // only allow one vote per option when min==max, i.e. atomic vote amount specified
+ scope.launch {
+ Toast
+ .makeText(
+ context,
+ R.string.one_vote_per_user_on_atomic_votes,
+ Toast.LENGTH_SHORT
+ )
+ .show()
+ }
+ return@combinedClickable
+ } else if (account.zapAmountChoices.size == 1 && pollViewModel.isValidInputVoteAmount(account.zapAmountChoices.first())) {
+ scope.launch(Dispatchers.IO) {
+ accountViewModel.zap(
+ baseNote,
+ account.zapAmountChoices.first() * 1000,
+ pollOption,
+ "",
+ context,
+ onError = {
+ scope.launch {
+ zappingProgress = 0f
+ Toast
+ .makeText(context, it, Toast.LENGTH_SHORT)
+ .show()
+ }
+ },
+ onProgress = {
+ scope.launch(Dispatchers.Main) {
+ zappingProgress = it
+ }
+ },
+ zapType = LnZapEvent.ZapType.PUBLIC
+ )
+ }
+ } else {
+ wantsToZap = true
+ }
+ }
+ )
+ ) {
+ if (wantsToZap) {
+ FilteredZapAmountChoicePopup(
+ baseNote,
+ accountViewModel,
+ pollViewModel,
+ pollOption,
+ onDismiss = {
+ wantsToZap = false
+ zappingProgress = 0f
+ },
+ onChangeAmount = {
+ wantsToZap = false
+ },
+ onError = {
+ scope.launch {
+ zappingProgress = 0f
+ Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
+ }
+ },
+ onProgress = {
+ scope.launch(Dispatchers.Main) {
+ zappingProgress = it
+ }
+ }
+ )
+ }
+
+ clickablePrepend()
+
+ if (pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) {
+ zappingProgress = 1f
+ Icon(
+ imageVector = Icons.Default.Bolt,
+ contentDescription = stringResource(R.string.zaps),
+ modifier = Modifier.size(20.dp),
+ tint = BitcoinOrange
+ )
+ } else {
+ 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
+ )
+ }
+ }
+ }
+
+ // only show tallies after a user has zapped note
+ if (baseNote.author == accountViewModel.userProfile() || zappedNote?.isZappedBy(accountViewModel.userProfile()) == true) {
+ Text(
+ showAmount(pollViewModel.zappedPollOptionAmount(pollOption)),
+ fontSize = 14.sp,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
+ modifier = modifier
+ )
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
+@Composable
+fun FilteredZapAmountChoicePopup(
+ baseNote: Note,
+ accountViewModel: AccountViewModel,
+ pollViewModel: PollNoteViewModel,
+ pollOption: Int,
+ onDismiss: () -> Unit,
+ onChangeAmount: () -> Unit,
+ onError: (text: String) -> Unit,
+ onProgress: (percent: Float) -> Unit
+) {
+ val context = LocalContext.current
+
+ val accountState by accountViewModel.accountLiveData.observeAsState()
+ val account = accountState?.account ?: return
+ val zapMessage = ""
+ val scope = rememberCoroutineScope()
+
+ val options = account.zapAmountChoices.filter { pollViewModel.isValidInputVoteAmount(it) }.toMutableList()
+ if (options.isEmpty()) {
+ pollViewModel.valueMinimum?.let { minimum ->
+ pollViewModel.valueMaximum?.let { maximum ->
+ if (minimum != maximum) {
+ options.add(((minimum + maximum) / 2).toLong())
+ }
+ }
+ }
+ }
+ pollViewModel.valueMinimum?.let { options.add(it.toLong()) }
+ pollViewModel.valueMaximum?.let { options.add(it.toLong()) }
+ val sortedOptions = options.toSet().sorted()
+
+ Popup(
+ alignment = Alignment.BottomCenter,
+ offset = IntOffset(0, -100),
+ onDismissRequest = { onDismiss() }
+ ) {
+ FlowRow(horizontalArrangement = Arrangement.Center) {
+ sortedOptions.forEach { amountInSats ->
+ Button(
+ modifier = Modifier.padding(horizontal = 3.dp),
+ onClick = {
+ scope.launch(Dispatchers.IO) {
+ accountViewModel.zap(
+ baseNote,
+ amountInSats * 1000,
+ pollOption,
+ zapMessage,
+ context,
+ onError,
+ onProgress,
+ LnZapEvent.ZapType.PUBLIC
+ )
+ onDismiss()
+ }
+ },
+ shape = RoundedCornerShape(20.dp),
+ colors = ButtonDefaults
+ .buttonColors(
+ backgroundColor = MaterialTheme.colors.primary
+ )
+ ) {
+ Text(
+ "⚡ ${showAmount(amountInSats.toBigDecimal().setScale(1))}",
+ color = Color.White,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.combinedClickable(
+ onClick = {
+ scope.launch(Dispatchers.IO) {
+ accountViewModel.zap(
+ baseNote,
+ amountInSats * 1000,
+ pollOption,
+ zapMessage,
+ context,
+ onError,
+ onProgress,
+ LnZapEvent.ZapType.PUBLIC
+ )
+ onDismiss()
+ }
+ },
+ onLongClick = {
+ onChangeAmount()
+ }
+ )
+ )
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun ZapVoteAmountChoicePopup(
+ baseNote: Note,
+ accountViewModel: AccountViewModel,
+ pollViewModel: PollNoteViewModel,
+ pollOption: Int,
+ onDismiss: () -> Unit,
+ onError: (text: String) -> Unit,
+ onProgress: (percent: Float) -> Unit
+) {
+ val context = LocalContext.current
+
+ var inputAmountText by rememberSaveable { mutableStateOf("") }
+
+ val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
+ focusedBorderColor = MaterialTheme.colors.error,
+ unfocusedBorderColor = Color.Red
+ )
+ val colorValid = TextFieldDefaults.outlinedTextFieldColors(
+ focusedBorderColor = MaterialTheme.colors.primary,
+ unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+
+ Dialog(
+ onDismissRequest = { onDismiss() },
+ properties = DialogProperties(
+ dismissOnClickOutside = true,
+ usePlatformDefaultWidth = false
+ )
+ ) {
+ Surface {
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .padding(10.dp)
+ ) {
+ var amount = pollViewModel.inputVoteAmountLong(inputAmountText)
+
+ // only prompt for input amount if vote is not atomic
+ if (!pollViewModel.isVoteAmountAtomic()) {
+ OutlinedTextField(
+ value = inputAmountText,
+ onValueChange = { inputAmountText = it },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ modifier = Modifier.width(150.dp),
+ colors = if (pollViewModel.isValidInputVoteAmount(amount)) colorValid else colorInValid,
+ label = {
+ Text(
+ text = stringResource(R.string.poll_zap_amount),
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+ },
+ placeholder = {
+ Text(
+ text = pollViewModel.voteAmountPlaceHolderText(context.getString(R.string.sats)),
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+ }
+ )
+ } else { amount = pollViewModel.valueMaximum?.toLong() }
+
+ val isValidInputAmount = pollViewModel.isValidInputVoteAmount(amount)
+ Button(
+ modifier = Modifier.padding(horizontal = 3.dp),
+ enabled = isValidInputAmount,
+ onClick = {
+ if (amount != null && isValidInputAmount) {
+ accountViewModel.zap(
+ baseNote,
+ amount * 1000,
+ pollOption,
+ "",
+ context,
+ onError,
+ onProgress,
+ LnZapEvent.ZapType.PUBLIC
+ )
+ onDismiss()
+ }
+ },
+ shape = RoundedCornerShape(20.dp),
+ colors = ButtonDefaults
+ .buttonColors(
+ backgroundColor = MaterialTheme.colors.primary
+ )
+ ) {
+ Text(
+ "⚡ ${showAmount(amount?.toBigDecimal()?.setScale(1))}",
+ color = Color.White,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.combinedClickable(
+ onClick = {
+ if (amount != null && isValidInputAmount) {
+ accountViewModel.zap(
+ baseNote,
+ amount * 1000,
+ pollOption,
+ "",
+ context,
+ onError,
+ onProgress,
+ LnZapEvent.ZapType.PUBLIC
+ )
+ onDismiss()
+ }
+ },
+ onLongClick = {}
+ )
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt
new file mode 100644
index 0000000000..4ce21c3723
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt
@@ -0,0 +1,116 @@
+package com.vitorpamplona.amethyst.ui.note
+
+import com.vitorpamplona.amethyst.model.Account
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.model.User
+import com.vitorpamplona.amethyst.service.model.*
+import java.math.BigDecimal
+import java.math.RoundingMode
+import java.util.*
+
+class PollNoteViewModel {
+ var account: Account? = null
+ private var pollNote: Note? = null
+
+ var pollEvent: PollNoteEvent? = null
+ private var pollOptions: Map? = null
+ var valueMaximum: Int? = null
+ var valueMinimum: Int? = null
+ private var closedAt: Int? = null
+ var consensusThreshold: BigDecimal? = null
+
+ var totalZapped: BigDecimal = BigDecimal.ZERO
+
+ fun load(note: Note?) {
+ pollNote = note
+ pollEvent = pollNote?.event as PollNoteEvent
+ pollOptions = pollEvent?.pollOptions()
+ valueMaximum = pollEvent?.getTagInt(VALUE_MAXIMUM)
+ valueMinimum = pollEvent?.getTagInt(VALUE_MINIMUM)
+ consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal()
+ closedAt = pollEvent?.getTagInt(CLOSED_AT)
+
+ totalZapped = totalZapped()
+ }
+
+ fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum
+
+ fun isPollClosed(): Boolean = closedAt?.let { // allow 2 minute leeway for zap to propagate
+ pollNote?.createdAt()?.plus(it * (86400 + 120))!! < Date().time / 1000
+ } == true
+
+ fun voteAmountPlaceHolderText(sats: String): String = if (valueMinimum == null && valueMaximum == null) {
+ sats
+ } else if (valueMinimum == null) {
+ "1—$valueMaximum $sats"
+ } else if (valueMaximum == null) {
+ ">$valueMinimum $sats"
+ } else {
+ "$valueMinimum—$valueMaximum $sats"
+ }
+
+ fun inputVoteAmountLong(textAmount: String) = if (textAmount.isEmpty()) { null } else {
+ try {
+ textAmount.toLong()
+ } catch (e: Exception) { null }
+ }
+
+ fun isValidInputVoteAmount(amount: Long?): Boolean {
+ if (amount == null) {
+ return false
+ } else if (valueMinimum == null && valueMaximum == null) {
+ if (amount > 0) {
+ return true
+ }
+ } else if (valueMinimum == null) {
+ if (amount > 0 && amount <= valueMaximum!!) {
+ return true
+ }
+ } else if (valueMaximum == null) {
+ if (amount >= valueMinimum!!) {
+ return true
+ }
+ } else {
+ if ((valueMinimum!! <= amount) && (amount <= valueMaximum!!)) {
+ return true
+ }
+ }
+ return false
+ }
+
+ fun optionVoteTally(op: Int): BigDecimal {
+ return if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
+ zappedPollOptionAmount(op).divide(totalZapped, 2, RoundingMode.HALF_UP)
+ } else {
+ BigDecimal.ZERO
+ }
+ }
+
+ fun isPollOptionZappedBy(option: Int, user: User): Boolean {
+ if (pollNote?.zaps?.any { it.key.author === user } == true) {
+ pollNote!!.zaps
+ .any {
+ val event = it.value?.event as? LnZapEvent
+ event?.zappedPollOption() == option && event.zappedRequestAuthor() == user.pubkeyHex
+ }
+ }
+ return false
+ }
+
+ fun zappedPollOptionAmount(option: Int): BigDecimal {
+ return pollNote?.zaps?.values?.sumOf {
+ val event = it?.event as? LnZapEvent
+ if (event?.zappedPollOption() == option) {
+ event.amount ?: BigDecimal(0)
+ } else {
+ BigDecimal(0)
+ }
+ } ?: BigDecimal(0)
+ }
+
+ fun totalZapped(): BigDecimal {
+ return pollNote?.zaps?.values?.sumOf {
+ (it?.event as? LnZapEvent)?.amount ?: BigDecimal(0)
+ } ?: BigDecimal(0)
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt
index 991bdc6683..5a0d7a3b47 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt
@@ -55,6 +55,7 @@ import coil.request.CachePolicy
import coil.request.ImageRequest
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@@ -63,8 +64,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.math.RoundingMode
+import kotlin.math.roundToInt
-@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -125,7 +126,7 @@ fun ReplyReaction(
val scope = rememberCoroutineScope()
IconButton(
- modifier = Modifier.then(Modifier.size(20.dp)),
+ modifier = Modifier.size(20.dp),
onClick = {
if (accountViewModel.isWriteable()) {
onPress()
@@ -348,6 +349,7 @@ fun ZapReaction(
accountViewModel.zap(
baseNote,
account.zapAmountChoices.first() * 1000,
+ null,
zapMessage,
context,
onError = {
@@ -362,7 +364,8 @@ fun ZapReaction(
scope.launch(Dispatchers.Main) {
zappingProgress = it
}
- }
+ },
+ zapType = LnZapEvent.ZapType.PUBLIC
)
}
} else if (account.zapAmountChoices.size > 1) {
@@ -459,7 +462,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(
@@ -474,7 +477,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(),
@@ -555,10 +557,12 @@ fun ZapAmountChoicePopup(
accountViewModel.zap(
baseNote,
amountInSats * 1000,
+ null,
zapMessage,
context,
onError,
- onProgress
+ onProgress,
+ LnZapEvent.ZapType.PUBLIC
)
onDismiss()
}
@@ -579,10 +583,12 @@ fun ZapAmountChoicePopup(
accountViewModel.zap(
baseNote,
amountInSats * 1000,
+ null,
zapMessage,
context,
onError,
- onProgress
+ onProgress,
+ LnZapEvent.ZapType.PUBLIC
)
onDismiss()
}
@@ -603,9 +609,9 @@ fun showCount(count: Int?): String {
if (count == 0) return ""
return when {
- count >= 1000000000 -> "${Math.round(count / 1000000000f)}G"
- count >= 1000000 -> "${Math.round(count / 1000000f)}M"
- count >= 1000 -> "${Math.round(count / 1000f)}k"
+ count >= 1000000000 -> "${(count / 1000000000f).roundToInt()}G"
+ count >= 1000000 -> "${(count / 1000000f).roundToInt()}M"
+ count >= 1000 -> "${(count / 1000f).roundToInt()}k"
else -> "$count"
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt
index 72c92184e8..e755a8df17 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt
@@ -16,14 +16,15 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.R
-import com.vitorpamplona.amethyst.model.Account
-import com.vitorpamplona.amethyst.model.Channel
-import com.vitorpamplona.amethyst.model.Note
-import com.vitorpamplona.amethyst.model.User
+import com.vitorpamplona.amethyst.model.*
@Composable
-fun ReplyInformation(replyTo: List?, mentions: List?, account: Account, navController: NavController) {
- ReplyInformation(replyTo, mentions, account) {
+fun ReplyInformation(replyTo: List?, mentions: List, account: Account, navController: NavController) {
+ val sortedMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
+ .toSet()
+ .sortedBy { account.userProfile().isFollowingCached(it) }
+
+ ReplyInformation(replyTo, sortedMentions, account) {
navController.navigate("User/${it.pubkeyHex}")
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt
index 9cbb35b50d..5fe601a6ab 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt
@@ -1,193 +1,212 @@
-package com.vitorpamplona.amethyst.ui.note
-
-import android.widget.Toast
-import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material.*
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.input.KeyboardCapitalization
-import androidx.compose.ui.text.input.KeyboardType
-import androidx.compose.ui.text.input.TextFieldValue
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.window.Dialog
-import androidx.compose.ui.window.DialogProperties
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewmodel.compose.viewModel
-import com.vitorpamplona.amethyst.R
-import com.vitorpamplona.amethyst.model.Account
-import com.vitorpamplona.amethyst.model.Note
-import com.vitorpamplona.amethyst.ui.actions.CloseButton
-import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-
-class ZapOptionstViewModel : ViewModel() {
- private var account: Account? = null
-
- var customAmount by mutableStateOf(TextFieldValue("1000"))
- var customMessage by mutableStateOf(TextFieldValue(""))
-
- fun load(account: Account) {
- this.account = account
- }
-
- fun canSend(): Boolean {
- return value() != null
- }
-
- fun value(): Long? {
- return try {
- customAmount.text.trim().toLongOrNull()
- } catch (e: Exception) {
- null
- }
- }
-
- fun cancel() {
- }
-}
-
-@Composable
-fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: AccountViewModel, baseNote: Note) {
- val context = LocalContext.current
- val scope = rememberCoroutineScope()
- val postViewModel: ZapOptionstViewModel = viewModel()
-
- LaunchedEffect(account) {
- postViewModel.load(account)
- }
-
- Dialog(
- onDismissRequest = { onClose() },
- properties = DialogProperties(
- dismissOnClickOutside = false,
- usePlatformDefaultWidth = false
- )
- ) {
- Surface() {
- Column(modifier = Modifier.padding(10.dp)) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- CloseButton(onCancel = {
- postViewModel.cancel()
- onClose()
- })
-
- ZapButton(
- isActive = postViewModel.canSend()
- ) {
- scope.launch(Dispatchers.IO) {
- accountViewModel.zap(
- baseNote,
- postViewModel.value()!! * 1000L,
- postViewModel.customMessage.text,
- context,
- onError = {
- scope.launch {
- Toast
- .makeText(context, it, Toast.LENGTH_SHORT).show()
- }
- },
- onProgress = {
- scope.launch(Dispatchers.Main) {
- }
- }
- )
- }
- onClose()
- }
- }
-
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 5.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- OutlinedTextField(
- // stringResource(R.string.new_amount_in_sats
- label = { Text(text = stringResource(id = R.string.amount_in_sats)) },
- value = postViewModel.customAmount,
- onValueChange = {
- postViewModel.customAmount = it
- },
- keyboardOptions = KeyboardOptions.Default.copy(
- capitalization = KeyboardCapitalization.None,
- keyboardType = KeyboardType.Number
- ),
- placeholder = {
- Text(
- text = "100, 1000, 5000",
- color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
- )
- },
- singleLine = true,
- modifier = Modifier
- .padding(end = 10.dp)
- .weight(1f)
- )
- }
- Spacer(modifier = Modifier.height(5.dp))
-
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 5.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- OutlinedTextField(
- // stringResource(R.string.new_amount_in_sats
- label = { Text(text = stringResource(id = R.string.custom_zaps_add_a_message)) },
- value = postViewModel.customMessage,
- onValueChange = {
- postViewModel.customMessage = it
- },
- keyboardOptions = KeyboardOptions.Default.copy(
- capitalization = KeyboardCapitalization.None,
- keyboardType = KeyboardType.Text
- ),
- placeholder = {
- Text(
- text = stringResource(id = R.string.custom_zaps_add_a_message_example),
- color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
- )
- },
- singleLine = true,
- modifier = Modifier
- .padding(end = 10.dp)
- .weight(1f)
- )
- }
- }
- }
- }
-}
-
-@Composable
-fun ZapButton(isActive: Boolean, onPost: () -> Unit) {
- Button(
- onClick = { onPost() },
- shape = RoundedCornerShape(20.dp),
- colors = ButtonDefaults
- .buttonColors(
- backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
- )
- ) {
- Text(text = "⚡Zap ", color = Color.White)
- }
-}
+package com.vitorpamplona.amethyst.ui.note
+
+import android.widget.Toast
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Account
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.service.model.LnZapEvent
+import com.vitorpamplona.amethyst.ui.actions.CloseButton
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+class ZapOptionstViewModel : ViewModel() {
+ private var account: Account? = null
+ var customAmount by mutableStateOf(TextFieldValue("21"))
+ var customMessage by mutableStateOf(TextFieldValue(""))
+
+ fun load(account: Account) {
+ this.account = account
+ }
+
+ fun canSend(): Boolean {
+ return value() != null
+ }
+
+ fun value(): Long? {
+ return try {
+ customAmount.text.trim().toLongOrNull()
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ fun cancel() {
+ }
+}
+
+@Composable
+fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: AccountViewModel, baseNote: Note) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val postViewModel: ZapOptionstViewModel = viewModel()
+ LaunchedEffect(account) {
+ postViewModel.load(account)
+ }
+
+ var zappingProgress by remember { mutableStateOf(0f) }
+
+ val zapTypes = listOf(
+ Pair(LnZapEvent.ZapType.PUBLIC, "Public"),
+ Pair(LnZapEvent.ZapType.ANONYMOUS, "Anonymous"),
+ Pair(LnZapEvent.ZapType.NONZAP, "Non-Zap")
+ )
+
+ val zapOptions = zapTypes.map { it.second }
+ var selectedZapType by remember { mutableStateOf(zapTypes[0]) }
+
+ Dialog(
+ onDismissRequest = { onClose() },
+ properties = DialogProperties(
+ dismissOnClickOutside = false,
+ usePlatformDefaultWidth = false
+ )
+ ) {
+ Surface() {
+ Column(modifier = Modifier.padding(10.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ CloseButton(onCancel = {
+ postViewModel.cancel()
+ onClose()
+ })
+
+ ZapButton(
+ isActive = postViewModel.canSend()
+ ) {
+ scope.launch(Dispatchers.IO) {
+ accountViewModel.zap(
+ baseNote,
+ postViewModel.value()!! * 1000L,
+ null,
+ postViewModel.customMessage.text,
+ context,
+ onError = {
+ zappingProgress = 0f
+ scope.launch {
+ Toast
+ .makeText(context, it, Toast.LENGTH_SHORT).show()
+ }
+ },
+ onProgress = {
+ scope.launch(Dispatchers.Main) {
+ zappingProgress = it
+ }
+ },
+ zapType = selectedZapType.first
+ )
+ }
+ onClose()
+ }
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 5.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ OutlinedTextField(
+ // stringResource(R.string.new_amount_in_sats
+ label = { Text(text = stringResource(id = R.string.amount_in_sats)) },
+ value = postViewModel.customAmount,
+ onValueChange = {
+ postViewModel.customAmount = it
+ },
+ keyboardOptions = KeyboardOptions.Default.copy(
+ capitalization = KeyboardCapitalization.None,
+ keyboardType = KeyboardType.Number
+ ),
+ placeholder = {
+ Text(
+ text = "100, 1000, 5000",
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+ },
+ singleLine = true,
+ modifier = Modifier
+ .padding(end = 10.dp)
+ .weight(1f)
+ )
+ }
+ Spacer(modifier = Modifier.height(5.dp))
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 5.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ OutlinedTextField(
+ // stringResource(R.string.new_amount_in_sats
+ label = { Text(text = stringResource(id = R.string.custom_zaps_add_a_message)) },
+ value = postViewModel.customMessage,
+ onValueChange = {
+ postViewModel.customMessage = it
+ },
+ keyboardOptions = KeyboardOptions.Default.copy(
+ capitalization = KeyboardCapitalization.None,
+ keyboardType = KeyboardType.Text
+ ),
+ placeholder = {
+ Text(
+ text = stringResource(id = R.string.custom_zaps_add_a_message_example),
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+ },
+ singleLine = true,
+ modifier = Modifier
+ .padding(end = 10.dp)
+ .weight(1f)
+ )
+ }
+ TextSpinner(
+ label = "Zap Type",
+ placeholder = "Public",
+ options = zapOptions,
+ onSelect = {
+ selectedZapType = zapTypes[it]
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun ZapButton(isActive: Boolean, onPost: () -> Unit) {
+ Button(
+ onClick = { onPost() },
+ shape = RoundedCornerShape(20.dp),
+ colors = ButtonDefaults
+ .buttonColors(
+ backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
+ )
+ ) {
+ Text(text = "⚡Zap ", color = Color.White)
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt
new file mode 100644
index 0000000000..7d00942951
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt
@@ -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(false) }
+
+ LaunchedEffect(key1 = zapSetCard.createdAt()) {
+ 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)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt
index b78c33323e..30a7ba6b9a 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt
@@ -66,7 +66,7 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 10.dp),
- verticalArrangement = Arrangement.SpaceBetween
+ verticalArrangement = Arrangement.SpaceAround
) {
if (presenting) {
Column(modifier = Modifier.fillMaxWidth()) {
@@ -91,22 +91,22 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
fontSize = 18.sp
)
}
-
- Row(
- horizontalArrangement = Arrangement.Center,
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 35.dp, vertical = 10.dp)
- ) {
- QrCodeDrawer("nostr:${user.pubkeyNpub()}")
- }
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 30.dp, vertical = 10.dp)
+ .padding(horizontal = 35.dp)
+ ) {
+ QrCodeDrawer("nostr:${user.pubkeyNpub()}")
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 30.dp)
) {
Button(
onClick = { presenting = false },
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt
index 0cc9cb31f1..d6872f808c 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt
@@ -4,6 +4,8 @@ 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.service.nip19.Nip19
import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
@@ -16,6 +18,8 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import nostr.postr.Persona
import nostr.postr.bechToBytes
+import java.net.InetSocketAddress
+import java.net.Proxy
import java.util.regex.Pattern
class AccountStateViewModel() : ViewModel() {
@@ -33,26 +37,30 @@ class AccountStateViewModel() : ViewModel() {
private fun tryLoginExistingAccount() {
LocalPreferences.loadFromEncryptedStorage()?.let {
- login(it)
+ startUI(it)
}
}
- fun login(key: String) {
+ fun startUI(key: String, useProxy: Boolean) {
val pattern = Pattern.compile(".+@.+\\.[a-z]+")
+ val parsed = Nip19.uriToRoute(key)
+ val pubKeyParsed = parsed?.hex?.toByteArray()
+ var proxy = if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", 9050)) else null
val account =
if (key.startsWith("nsec")) {
- Account(Persona(privKey = key.bechToBytes()))
- } else if (key.startsWith("npub")) {
- Account(Persona(pubKey = key.bechToBytes()))
+ Account(Persona(privKey = key.bechToBytes()), proxy = proxy)
+ } else if (pubKeyParsed != null) {
+ Account(Persona(pubKey = pubKeyParsed), proxy = proxy)
} else if (pattern.matcher(key).matches()) {
// Evaluate NIP-5
- Account(Persona())
+ Account(Persona(), proxy = proxy)
} else {
- Account(Persona(Hex.decode(key)))
+ Account(Persona(Hex.decode(key)), proxy = proxy)
}
- login(account)
+ LocalPreferences.updatePrefsForLogin(account)
+ startUI(account)
}
fun switchUser(npub: String) {
@@ -61,26 +69,25 @@ class AccountStateViewModel() : ViewModel() {
tryLoginExistingAccount()
}
- fun newKey() {
- val account = Account(Persona())
- login(account)
+ fun newKey(useProxy: Boolean) {
+ var proxy = if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", 9050)) else null
+ val account = Account(Persona(), proxy = proxy)
+ // 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)
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt
index bdacb26395..3f374730c2 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt
@@ -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) : Card() {
override fun id() = note.idHex + "Z" + createdAt
}
+class ZapUserSetCard(val user: User, val zapEvents: Map) : 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, val likeEvents: List, val zapEvents: Map) : Card() {
val createdAt = maxOf(
zapEvents.maxOfOrNull { it.value.createdAt() ?: 0 } ?: 0,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt
index ed5e46c629..fb37977595 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt
@@ -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,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt
index 4e547522d3..f7e1a24c0f 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt
@@ -5,8 +5,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
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
@@ -14,7 +14,9 @@ import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
+import com.vitorpamplona.amethyst.ui.components.BundledInsert
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
+import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import kotlinx.coroutines.CoroutineScope
@@ -29,7 +31,7 @@ import kotlin.time.measureTimedValue
class NotificationViewModel : CardFeedViewModel(NotificationFeedFilter)
-open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() {
+open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() {
private val _feedContent = MutableStateFlow(CardFeedState.Loading)
val feedContent = _feedContent.asStateFlow()
@@ -45,28 +47,28 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() {
@Synchronized
private fun refreshSuspended() {
- val notes = dataSource.loadTop()
+ val notes = localFilter.loadTop()
- val thisAccount = (dataSource as? NotificationFeedFilter)?.account
+ val thisAccount = (localFilter as? NotificationFeedFilter)?.account
val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null
- val oldNotesState = feedContent.value
+ val oldNotesState = _feedContent.value
if (lastNotesCopy != null && oldNotesState is CardFeedState.Loaded) {
val newCards = convertToCard(notes.minus(lastNotesCopy))
if (newCards.isNotEmpty()) {
lastNotes = notes
- lastAccount = (dataSource as? NotificationFeedFilter)?.account
+ lastAccount = (localFilter as? NotificationFeedFilter)?.account
updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed())
}
} else {
val cards = convertToCard(notes)
lastNotes = notes
- lastAccount = (dataSource as? NotificationFeedFilter)?.account
+ lastAccount = (localFilter as? NotificationFeedFilter)?.account
updateFeed(cards)
}
}
- private fun convertToCard(notes: List): List {
+ private fun convertToCard(notes: Collection): List {
val reactionsPerEvent = mutableMapOf