Lightning Zaps.
This commit is contained in:
@@ -13,7 +13,7 @@ object RoboHashCache {
|
|||||||
fun get(context: Context, hash: String): Bitmap {
|
fun get(context: Context, hash: String): Bitmap {
|
||||||
if (!this::robots.isInitialized) {
|
if (!this::robots.isInitialized) {
|
||||||
robots = RoboHash(context)
|
robots = RoboHash(context)
|
||||||
robots.useCache(LruCache(100));
|
robots.useCache(LruCache(1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
return robots.imageForHandle(robots.calculateHandleFromUUID(UUID.nameUUIDFromBytes(hash.toByteArray())))
|
return robots.imageForHandle(robots.calculateHandleFromUUID(UUID.nameUUIDFromBytes(hash.toByteArray())))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.vitorpamplona.amethyst.lnurl
|
package com.vitorpamplona.amethyst.lnurl
|
||||||
|
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import kotlin.math.ln
|
import java.net.URLEncoder
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -27,15 +27,20 @@ class LightningAddressResolver {
|
|||||||
return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}"
|
return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetchLightningAddressJson(lnaddress: String, onSucess: (String) -> Unit) {
|
fun fetchLightningAddressJson(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
fetchLightningAddressJsonSuspend(lnaddress, onSucess)
|
fetchLightningAddressJsonSuspend(lnaddress, onSuccess, onError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSucess: (String) -> Unit) {
|
private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||||
val url = assembleUrl(lnaddress) ?: return
|
val url = assembleUrl(lnaddress)
|
||||||
|
|
||||||
|
if (url == null) {
|
||||||
|
onError("Could not assemble LNUrl from Lightning Address \"${lnaddress}\". Check the user's setup")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val request: Request = Request.Builder().url(url).build()
|
val request: Request = Request.Builder().url(url).build()
|
||||||
@@ -43,64 +48,101 @@ class LightningAddressResolver {
|
|||||||
client.newCall(request).enqueue(object : Callback {
|
client.newCall(request).enqueue(object : Callback {
|
||||||
override fun onResponse(call: Call, response: Response) {
|
override fun onResponse(call: Call, response: Response) {
|
||||||
response.use {
|
response.use {
|
||||||
onSucess(response.body.string())
|
onSuccess(response.body.string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||||
|
onError("Could not resolve User's LNURL address from ${url}. Check if the server up and if the lightning address ${lnaddress} is correct")
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, onSucess: (String) -> Unit) {
|
fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
fetchLightningInvoiceSuspend(lnCallback, milliSats, message, onSucess)
|
fetchLightningInvoiceSuspend(lnCallback, milliSats, message, nostrRequest, onSuccess, onError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchLightningInvoiceSuspend(lnCallback: String, milliSats: Long, message: String, onSucess: (String) -> Unit) {
|
private suspend fun fetchLightningInvoiceSuspend(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
||||||
|
|
||||||
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
|
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
|
||||||
val url = "$lnCallback${urlBinder}amount=$milliSats&comment=$message"
|
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
|
||||||
|
|
||||||
|
if (nostrRequest != null) {
|
||||||
|
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
|
||||||
|
url += "&nostr=$encodedNostrRequest"
|
||||||
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val request: Request = Request.Builder().url(url).build()
|
val request: Request = Request.Builder().url(url).build()
|
||||||
|
|
||||||
client.newCall(request).enqueue(object : Callback {
|
client.newCall(request).enqueue(object : Callback {
|
||||||
override fun onResponse(call: Call, response: Response) {
|
override fun onResponse(call: Call, response: Response) {
|
||||||
response.use {
|
response.use {
|
||||||
onSucess(response.body.string())
|
onSuccess(response.body.string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||||
|
onError("Could not fetch an invoice from ${lnCallback}. Message ${e.message}")
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun lnAddressToLnUrl(lnaddress: String, onSucess: (String) -> Unit) {
|
fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||||
fetchLightningAddressJson(lnaddress) {
|
fetchLightningAddressJson(lnaddress,
|
||||||
onSucess(Bech32.encodeBytes("lnurl",it.toByteArray(), Bech32.Encoding.Bech32))
|
onSuccess = {
|
||||||
}
|
onSuccess(Bech32.encodeBytes("lnurl",it.toByteArray(), Bech32.Encoding.Bech32))
|
||||||
|
},
|
||||||
|
onError = onError
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, onSucess: (String) -> Unit) {
|
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||||
val mapper = jacksonObjectMapper()
|
val mapper = jacksonObjectMapper()
|
||||||
|
|
||||||
fetchLightningAddressJson(lnaddress) {
|
fetchLightningAddressJson(lnaddress,
|
||||||
mapper.readTree(it)?.get("callback")?.asText()?.let { callback ->
|
onSuccess = {
|
||||||
fetchLightningInvoice(callback, milliSats, message,
|
val lnurlp = try {
|
||||||
onSucess = {
|
mapper.readTree(it)
|
||||||
mapper.readTree(it)?.get("pr")?.asText()?.let { pr ->
|
} catch (t: Throwable) {
|
||||||
onSucess(pr)
|
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
|
||||||
|
null
|
||||||
}
|
}
|
||||||
|
val callback = lnurlp?.get("callback")?.asText()
|
||||||
|
|
||||||
|
if (callback == null) {
|
||||||
|
onError("Callback URL not found in the User's lightning address server configuration")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false
|
||||||
|
|
||||||
|
callback?.let { callback ->
|
||||||
|
fetchLightningInvoice(callback, milliSats, message, if (allowsNostr) nostrRequest else null,
|
||||||
|
onSuccess = {
|
||||||
|
val lnInvoice = try {
|
||||||
|
mapper.readTree(it)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
onError("Error Parsing JSON from Lightning Address's invoice fetch. Check the user's lightning setup")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
lnInvoice?.get("pr")?.asText()?.let { pr ->
|
||||||
|
onSuccess(pr)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError = onError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError = onError
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@ import com.vitorpamplona.amethyst.service.relays.Constants
|
|||||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||||
@@ -119,6 +120,27 @@ class Account(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun createZapRequestFor(note: Note): LnZapRequestEvent? {
|
||||||
|
if (!isWriteable()) return null
|
||||||
|
|
||||||
|
if (note.hasZapped(userProfile())) {
|
||||||
|
// has already liked this note
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
note.event?.let {
|
||||||
|
return LnZapRequestEvent.create(it, userProfile().relays?.keys ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createZapRequestFor(user: User): LnZapRequestEvent? {
|
||||||
|
if (!isWriteable()) return null
|
||||||
|
|
||||||
|
return LnZapRequestEvent.create(user.pubkeyHex, userProfile().relays?.keys ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
|
||||||
|
}
|
||||||
|
|
||||||
fun report(note: Note, type: ReportEvent.ReportType) {
|
fun report(note: Note, type: ReportEvent.ReportType) {
|
||||||
if (!isWriteable()) return
|
if (!isWriteable()) return
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
|
|||||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
|
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||||
@@ -405,6 +407,72 @@ object LocalCache {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun consume(event: LnZapEvent) {
|
||||||
|
val note = getOrCreateNote(event.id.toHexKey())
|
||||||
|
|
||||||
|
// Already processed this event.
|
||||||
|
if (note.event != null) return
|
||||||
|
|
||||||
|
val author = getOrCreateUser(event.pubKey.toHexKey())
|
||||||
|
val mentions = event.zappedAuthor.map { getOrCreateUser(it) }
|
||||||
|
val repliesTo = event.zappedPost.map { getOrCreateNote(it) }
|
||||||
|
|
||||||
|
note.loadEvent(event, author, mentions, repliesTo)
|
||||||
|
|
||||||
|
val zapRequest = event.containedPost?.id?.toHexKey()?.let { getOrCreateNote(it) }
|
||||||
|
if (zapRequest == null) {
|
||||||
|
Log.e("ZP","Zap Request not found. Unable to process Zap {${event.toJson()}}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d("ZP", "New ZapEvent ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
|
||||||
|
|
||||||
|
// Adds notifications to users.
|
||||||
|
mentions.forEach {
|
||||||
|
it.addTaggedPost(note)
|
||||||
|
}
|
||||||
|
repliesTo.forEach {
|
||||||
|
it.author?.addTaggedPost(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
repliesTo.forEach {
|
||||||
|
it.addZap(zapRequest, note)
|
||||||
|
}
|
||||||
|
mentions.forEach {
|
||||||
|
it.addZap(zapRequest, note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consume(event: LnZapRequestEvent) {
|
||||||
|
val note = getOrCreateNote(event.id.toHexKey())
|
||||||
|
|
||||||
|
// Already processed this event.
|
||||||
|
if (note.event != null) return
|
||||||
|
|
||||||
|
val author = getOrCreateUser(event.pubKey.toHexKey())
|
||||||
|
val mentions = event.zappedAuthor.map { getOrCreateUser(it) }
|
||||||
|
val repliesTo = event.zappedPost.map { getOrCreateNote(it) }
|
||||||
|
|
||||||
|
note.loadEvent(event, author, mentions, repliesTo)
|
||||||
|
|
||||||
|
Log.d("ZP", "New Zap Request ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
|
||||||
|
|
||||||
|
// Adds notifications to users.
|
||||||
|
mentions.forEach {
|
||||||
|
it.addTaggedPost(note)
|
||||||
|
}
|
||||||
|
repliesTo.forEach {
|
||||||
|
it.author?.addTaggedPost(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
repliesTo.forEach {
|
||||||
|
it.addZap(note, null)
|
||||||
|
}
|
||||||
|
mentions.forEach {
|
||||||
|
it.addZap(note, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun findUsersStartingWith(username: String): List<User> {
|
fun findUsersStartingWith(username: String): List<User> {
|
||||||
return users.values.filter {
|
return users.values.filter {
|
||||||
it.info.anyNameStartsWith(username)
|
it.info.anyNameStartsWith(username)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.vitorpamplona.amethyst.model
|
package com.vitorpamplona.amethyst.model
|
||||||
|
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
|
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
|
||||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||||
|
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||||
@@ -16,6 +18,7 @@ import kotlinx.coroutines.Job
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||||
|
import java.math.BigDecimal
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import java.util.regex.Matcher
|
import java.util.regex.Matcher
|
||||||
@@ -46,6 +49,8 @@ class Note(val idHex: String) {
|
|||||||
private set
|
private set
|
||||||
var reports = setOf<Note>()
|
var reports = setOf<Note>()
|
||||||
private set
|
private set
|
||||||
|
var zaps = mapOf<Note, Note?>()
|
||||||
|
private set
|
||||||
|
|
||||||
var relays = setOf<String>()
|
var relays = setOf<String>()
|
||||||
private set
|
private set
|
||||||
@@ -104,6 +109,16 @@ class Note(val idHex: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun addZap(zapRequest: Note, zap: Note?) {
|
||||||
|
if (zapRequest !in zaps.keys) {
|
||||||
|
zaps = zaps + Pair(zapRequest, zap)
|
||||||
|
liveZaps.invalidateData()
|
||||||
|
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
|
||||||
|
zaps = zaps + Pair(zapRequest, zap)
|
||||||
|
liveZaps.invalidateData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun addReaction(note: Note) {
|
fun addReaction(note: Note) {
|
||||||
if (note !in reactions) {
|
if (note !in reactions) {
|
||||||
reactions = reactions + note
|
reactions = reactions + note
|
||||||
@@ -125,6 +140,11 @@ class Note(val idHex: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isZappedBy(user: User): Boolean {
|
||||||
|
// Zaps who the requester was the user
|
||||||
|
return zaps.any { println(it.key.author?.toBestDisplayName()); it.key.author == user }
|
||||||
|
}
|
||||||
|
|
||||||
fun isReactedBy(user: User): Boolean {
|
fun isReactedBy(user: User): Boolean {
|
||||||
return reactions.any { it.author == user }
|
return reactions.any { it.author == user }
|
||||||
}
|
}
|
||||||
@@ -141,6 +161,14 @@ class Note(val idHex: String) {
|
|||||||
return reports.filter { it.author in users }
|
return reports.filter { it.author in users }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun zappedAmount(): BigDecimal {
|
||||||
|
return zaps.mapNotNull { it.value?.event }
|
||||||
|
.filterIsInstance<LnZapEvent>()
|
||||||
|
.mapNotNull {
|
||||||
|
it.amount
|
||||||
|
}.sumOf { it }
|
||||||
|
}
|
||||||
|
|
||||||
fun hasAnyReports(): Boolean {
|
fun hasAnyReports(): Boolean {
|
||||||
val dayAgo = Date().time / 1000 - 24*60*60
|
val dayAgo = Date().time / 1000 - 24*60*60
|
||||||
return author?.reports?.filter { it.event?.createdAt ?: 0 > dayAgo }?.isNotEmpty() ?: false
|
return author?.reports?.filter { it.event?.createdAt ?: 0 > dayAgo }?.isNotEmpty() ?: false
|
||||||
@@ -174,6 +202,10 @@ class Note(val idHex: String) {
|
|||||||
return event is RepostEvent || replyTo == null || replyTo?.size == 0
|
return event is RepostEvent || replyTo == null || replyTo?.size == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun hasZapped(loggedIn: User): Boolean {
|
||||||
|
return zaps.any { it.key.author == loggedIn }
|
||||||
|
}
|
||||||
|
|
||||||
fun hasReacted(loggedIn: User, content: String): Boolean {
|
fun hasReacted(loggedIn: User, content: String): Boolean {
|
||||||
return reactions.firstOrNull { it.author == loggedIn && it.event?.content == content } != null
|
return reactions.firstOrNull { it.author == loggedIn && it.event?.content == content } != null
|
||||||
}
|
}
|
||||||
@@ -191,6 +223,7 @@ class Note(val idHex: String) {
|
|||||||
val liveReplies: NoteLiveData = NoteLiveData(this)
|
val liveReplies: NoteLiveData = NoteLiveData(this)
|
||||||
val liveReports: NoteLiveData = NoteLiveData(this)
|
val liveReports: NoteLiveData = NoteLiveData(this)
|
||||||
val liveRelays: NoteLiveData = NoteLiveData(this)
|
val liveRelays: NoteLiveData = NoteLiveData(this)
|
||||||
|
val liveZaps: NoteLiveData = NoteLiveData(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
|
class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
package com.vitorpamplona.amethyst.model
|
package com.vitorpamplona.amethyst.model
|
||||||
|
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
|
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
|
||||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||||
|
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||||
import com.vitorpamplona.amethyst.service.relays.Client
|
import com.vitorpamplona.amethyst.service.relays.Client
|
||||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||||
import fr.acinq.secp256k1.Hex
|
import fr.acinq.secp256k1.Hex
|
||||||
|
import java.math.BigDecimal
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
@@ -48,6 +51,9 @@ class User(val pubkeyHex: String) {
|
|||||||
var reports = setOf<Note>()
|
var reports = setOf<Note>()
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
var zaps = mapOf<Note, Note?>()
|
||||||
|
private set
|
||||||
|
|
||||||
var relays: Map<String, ContactListEvent.ReadWrite>? = null
|
var relays: Map<String, ContactListEvent.ReadWrite>? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@@ -159,6 +165,24 @@ class User(val pubkeyHex: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun addZap(zapRequest: Note, zap: Note?) {
|
||||||
|
if (zapRequest !in zaps.keys) {
|
||||||
|
zaps = zaps + Pair(zapRequest, zap)
|
||||||
|
liveZaps.invalidateData()
|
||||||
|
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
|
||||||
|
zaps = zaps + Pair(zapRequest, zap)
|
||||||
|
liveZaps.invalidateData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun zappedAmount(): BigDecimal {
|
||||||
|
return zaps.mapNotNull { it.value?.event }
|
||||||
|
.filterIsInstance<LnZapEvent>()
|
||||||
|
.mapNotNull {
|
||||||
|
it.amount
|
||||||
|
}.sumOf { it }
|
||||||
|
}
|
||||||
|
|
||||||
fun reportsBy(user: User): List<Note> {
|
fun reportsBy(user: User): List<Note> {
|
||||||
return reports.filter { it.author == user }
|
return reports.filter { it.author == user }
|
||||||
}
|
}
|
||||||
@@ -300,6 +324,7 @@ class User(val pubkeyHex: String) {
|
|||||||
val liveRelays: UserLiveData = UserLiveData(this)
|
val liveRelays: UserLiveData = UserLiveData(this)
|
||||||
val liveRelayInfo: UserLiveData = UserLiveData(this)
|
val liveRelayInfo: UserLiveData = UserLiveData(this)
|
||||||
val liveMetadata: UserLiveData = UserLiveData(this)
|
val liveMetadata: UserLiveData = UserLiveData(this)
|
||||||
|
val liveZaps: UserLiveData = UserLiveData(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
class UserMetadata {
|
class UserMetadata {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
|
|||||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
|
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||||
@@ -68,6 +70,14 @@ abstract class NostrDataSource<T>(val debugName: String) {
|
|||||||
ReactionEvent.kind -> LocalCache.consume(ReactionEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
ReactionEvent.kind -> LocalCache.consume(ReactionEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||||
ReportEvent.kind -> LocalCache.consume(ReportEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
ReportEvent.kind -> LocalCache.consume(ReportEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||||
|
|
||||||
|
LnZapEvent.kind -> {
|
||||||
|
val zapEvent = LnZapEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)
|
||||||
|
|
||||||
|
zapEvent.containedPost?.let { onEvent(it, subscriptionId, relay) }
|
||||||
|
LocalCache.consume(zapEvent)
|
||||||
|
}
|
||||||
|
LnZapRequestEvent.kind -> LocalCache.consume(LnZapRequestEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||||
|
|
||||||
ChannelCreateEvent.kind -> LocalCache.consume(ChannelCreateEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
ChannelCreateEvent.kind -> LocalCache.consume(ChannelCreateEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||||
ChannelMetadataEvent.kind -> LocalCache.consume(ChannelMetadataEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
ChannelMetadataEvent.kind -> LocalCache.consume(ChannelMetadataEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||||
ChannelMessageEvent.kind -> LocalCache.consume(ChannelMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig), relay)
|
ChannelMessageEvent.kind -> LocalCache.consume(ChannelMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig), relay)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.model.User
|
|||||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||||
import nostr.postr.JsonFilter
|
import nostr.postr.JsonFilter
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ object NostrNotificationDataSource: NostrDataSource<Note>("NotificationFeed") {
|
|||||||
.filter {
|
.filter {
|
||||||
it.event !is ChannelCreateEvent
|
it.event !is ChannelCreateEvent
|
||||||
&& it.event !is ChannelMetadataEvent
|
&& it.event !is ChannelMetadataEvent
|
||||||
|
&& it.event !is LnZapRequestEvent
|
||||||
}
|
}
|
||||||
.sortedBy { it.event?.createdAt }
|
.sortedBy { it.event?.createdAt }
|
||||||
.reversed()
|
.reversed()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.model.Note
|
|||||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||||
@@ -34,7 +35,7 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
|
|||||||
types = FeedType.values().toSet(),
|
types = FeedType.values().toSet(),
|
||||||
filter = JsonFilter(
|
filter = JsonFilter(
|
||||||
kinds = listOf(
|
kinds = listOf(
|
||||||
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind
|
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind
|
||||||
),
|
),
|
||||||
tags = mapOf("e" to listOf(it.idHex)),
|
tags = mapOf("e" to listOf(it.idHex)),
|
||||||
since = it.lastReactionsDownloadTime
|
since = it.lastReactionsDownloadTime
|
||||||
@@ -68,7 +69,7 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
|
|||||||
types = FeedType.values().toSet(),
|
types = FeedType.values().toSet(),
|
||||||
filter = JsonFilter(
|
filter = JsonFilter(
|
||||||
kinds = listOf(
|
kinds = listOf(
|
||||||
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind,
|
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind,
|
||||||
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind
|
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind
|
||||||
),
|
),
|
||||||
ids = interestedEvents.toList()
|
ids = interestedEvents.toList()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.vitorpamplona.amethyst.model.Account
|
|||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
|
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||||
import nostr.postr.JsonFilter
|
import nostr.postr.JsonFilter
|
||||||
@@ -42,6 +43,17 @@ object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun createUserReceivedZapsFilter(): TypedFilter {
|
||||||
|
return TypedFilter(
|
||||||
|
types = FeedType.values().toSet(),
|
||||||
|
filter = JsonFilter(
|
||||||
|
kinds = listOf(LnZapEvent.kind),
|
||||||
|
tags = mapOf("e" to listOf(user!!.pubkeyHex)),
|
||||||
|
limit = 100
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun createFollowFilter(): TypedFilter {
|
fun createFollowFilter(): TypedFilter {
|
||||||
return TypedFilter(
|
return TypedFilter(
|
||||||
types = FeedType.values().toSet(),
|
types = FeedType.values().toSet(),
|
||||||
@@ -76,7 +88,8 @@ object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
|
|||||||
createUserInfoFilter(),
|
createUserInfoFilter(),
|
||||||
createUserPostsFilter(),
|
createUserPostsFilter(),
|
||||||
createFollowFilter(),
|
createFollowFilter(),
|
||||||
createFollowersFilter()
|
createFollowersFilter(),
|
||||||
|
createUserReceivedZapsFilter()
|
||||||
).ifEmpty { null }
|
).ifEmpty { null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service
|
||||||
|
|
||||||
|
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.LnZapEvent
|
||||||
|
import nostr.postr.JsonFilter
|
||||||
|
import nostr.postr.events.ContactListEvent
|
||||||
|
|
||||||
|
object NostrUserProfileZapsDataSource: NostrDataSource<Pair<Note,Note>>("UserProfileZapsFeed") {
|
||||||
|
lateinit var account: Account
|
||||||
|
var user: User? = null
|
||||||
|
|
||||||
|
fun loadUserProfile(userId: String) {
|
||||||
|
user = LocalCache.users[userId]
|
||||||
|
resetFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun feed(): List<Pair<Note,Note>> {
|
||||||
|
return (user?.zaps?.filter { it.value != null }?.toList()?.sortedBy { (it.second?.event as? LnZapEvent)?.amount }?.reversed() ?: emptyList()) as List<Pair<Note, Note>>
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateChannelFilters() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service.model
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
|
||||||
|
import com.vitorpamplona.amethyst.service.relays.Client
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.util.Date
|
||||||
|
import nostr.postr.Utils
|
||||||
|
import nostr.postr.events.Event
|
||||||
|
import nostr.postr.toHex
|
||||||
|
|
||||||
|
class LnZapEvent (
|
||||||
|
id: ByteArray,
|
||||||
|
pubKey: ByteArray,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: List<List<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: ByteArray
|
||||||
|
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||||
|
|
||||||
|
@Transient val zappedPost: List<String>
|
||||||
|
@Transient val zappedAuthor: List<String>
|
||||||
|
@Transient val containedPost: Event?
|
||||||
|
@Transient val lnInvoice: String?
|
||||||
|
@Transient val preimage: String?
|
||||||
|
@Transient val amount: BigDecimal?
|
||||||
|
|
||||||
|
init {
|
||||||
|
zappedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||||
|
zappedAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||||
|
|
||||||
|
lnInvoice = tags.filter { it.firstOrNull() == "bolt11" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||||
|
amount = lnInvoice?.let { LnInvoiceUtil.getAmountInSats(lnInvoice) }
|
||||||
|
preimage = tags.filter { it.firstOrNull() == "preimage" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||||
|
|
||||||
|
val description = tags.filter { it.firstOrNull() == "description" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||||
|
|
||||||
|
containedPost = try {
|
||||||
|
if (description == null)
|
||||||
|
null
|
||||||
|
else
|
||||||
|
fromJson(description, Client.lenient)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val kind = 9735
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service.model
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.service.relays.Client
|
||||||
|
import java.util.Date
|
||||||
|
import nostr.postr.Utils
|
||||||
|
import nostr.postr.events.ContactListEvent
|
||||||
|
import nostr.postr.events.Event
|
||||||
|
import nostr.postr.toHex
|
||||||
|
|
||||||
|
class LnZapRequestEvent (
|
||||||
|
id: ByteArray,
|
||||||
|
pubKey: ByteArray,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: List<List<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: ByteArray
|
||||||
|
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||||
|
|
||||||
|
@Transient val zappedPost: List<String>
|
||||||
|
@Transient val zappedAuthor: List<String>
|
||||||
|
|
||||||
|
init {
|
||||||
|
zappedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||||
|
zappedAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val kind = 9734
|
||||||
|
|
||||||
|
fun create(originalNote: Event, relays: Set<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent {
|
||||||
|
val content = ""
|
||||||
|
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||||
|
val tags = listOf(
|
||||||
|
listOf("e", originalNote.id.toHex()),
|
||||||
|
listOf("p", originalNote.pubKey.toHex()),
|
||||||
|
listOf("relays") + relays
|
||||||
|
)
|
||||||
|
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||||
|
val sig = Utils.sign(id, privateKey)
|
||||||
|
return LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun create(userHex: String, relays: Set<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent {
|
||||||
|
val content = ""
|
||||||
|
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||||
|
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, pubKey, createdAt, tags, content, sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
*/
|
||||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components
|
|||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -21,6 +22,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -36,10 +38,12 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.core.content.ContextCompat.startActivity
|
import androidx.core.content.ContextCompat.startActivity
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.lnurl.LightningAddressResolver
|
import com.vitorpamplona.amethyst.lnurl.LightningAddressResolver
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InvoiceRequest(lud16: String, onClose: () -> Unit ) {
|
fun InvoiceRequest(lud16: String, onClose: () -> Unit ) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
Column(modifier = Modifier
|
Column(modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -124,13 +128,21 @@ fun InvoiceRequest(lud16: String, onClose: () -> Unit ) {
|
|||||||
Button(
|
Button(
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||||
onClick = {
|
onClick = {
|
||||||
LightningAddressResolver().lnAddressInvoice(lud16, amount * 1000, message) {
|
LightningAddressResolver().lnAddressInvoice(lud16, amount * 1000, message,
|
||||||
|
onSuccess = {
|
||||||
runCatching {
|
runCatching {
|
||||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
|
||||||
startActivity(context, intent, null)
|
startActivity(context, intent, null)
|
||||||
}
|
}
|
||||||
onClose()
|
onClose()
|
||||||
|
},
|
||||||
|
onError = {
|
||||||
|
scope.launch {
|
||||||
|
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
|
||||||
|
onClose()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
},
|
},
|
||||||
shape = RoundedCornerShape(15.dp),
|
shape = RoundedCornerShape(15.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.model.Note
|
|||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
import com.vitorpamplona.amethyst.model.toNote
|
import com.vitorpamplona.amethyst.model.toNote
|
||||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||||
@@ -97,6 +98,9 @@ fun NoteCompose(
|
|||||||
|
|
||||||
val context = LocalContext.current.applicationContext
|
val context = LocalContext.current.applicationContext
|
||||||
|
|
||||||
|
var moreActionsExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
|
||||||
if (note?.event == null) {
|
if (note?.event == null) {
|
||||||
BlankNote(modifier.combinedClickable(
|
BlankNote(modifier.combinedClickable(
|
||||||
onClick = { },
|
onClick = { },
|
||||||
@@ -231,6 +235,20 @@ fun NoteCompose(
|
|||||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||||
maxLines = 1
|
maxLines = 1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||||
|
onClick = { moreActionsExpanded = true }
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.MoreVert,
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(15.dp),
|
||||||
|
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||||
|
)
|
||||||
|
|
||||||
|
NoteDropDownMenu(baseNote, moreActionsExpanded, { moreActionsExpanded = false }, accountViewModel)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
" boosted",
|
" boosted",
|
||||||
@@ -314,7 +332,10 @@ private fun RelayBadges(baseNote: Note) {
|
|||||||
FlowRow(Modifier.padding(top = 10.dp, start = 5.dp, end = 4.dp)) {
|
FlowRow(Modifier.padding(top = 10.dp, start = 5.dp, end = 4.dp)) {
|
||||||
relaysToDisplay.forEach {
|
relaysToDisplay.forEach {
|
||||||
val url = it.removePrefix("wss://")
|
val url = it.removePrefix("wss://")
|
||||||
Box(Modifier.size(15.dp).padding(1.dp)) {
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(15.dp)
|
||||||
|
.padding(1.dp)) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = "https://${url}/favicon.ico",
|
model = "https://${url}/favicon.ico",
|
||||||
placeholder = rememberAsyncImagePainter(RoboHashCache.get(ctx, url)),
|
placeholder = rememberAsyncImagePainter(RoboHashCache.get(ctx, url)),
|
||||||
@@ -333,7 +354,10 @@ private fun RelayBadges(baseNote: Note) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (noteRelays.size > 3 && !expanded) {
|
if (noteRelays.size > 3 && !expanded) {
|
||||||
Row(Modifier.fillMaxWidth().height(25.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.Top) {
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(25.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.Top) {
|
||||||
IconButton(
|
IconButton(
|
||||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||||
onClick = { expanded = true }
|
onClick = { expanded = true }
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.note
|
package com.vitorpamplona.amethyst.ui.note
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material.Icon
|
import androidx.compose.material.Icon
|
||||||
import androidx.compose.material.IconButton
|
import androidx.compose.material.IconButton
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Download
|
import androidx.compose.material.icons.filled.Bolt
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.outlined.BarChart
|
import androidx.compose.material.icons.outlined.BarChart
|
||||||
import androidx.compose.material.icons.outlined.Visibility
|
import androidx.compose.material.icons.outlined.Bolt
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -34,15 +34,19 @@ import androidx.compose.ui.platform.LocalUriHandler
|
|||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.imageLoader
|
|
||||||
import coil.request.CachePolicy
|
import coil.request.CachePolicy
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.lnurl.LightningAddressResolver
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
|
fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
|
||||||
@@ -55,13 +59,16 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
|
|||||||
val boostsState by baseNote.liveBoosts.observeAsState()
|
val boostsState by baseNote.liveBoosts.observeAsState()
|
||||||
val boostedNote = boostsState?.note
|
val boostedNote = boostsState?.note
|
||||||
|
|
||||||
|
val zapsState by baseNote.liveZaps.observeAsState()
|
||||||
|
val zappedNote = zapsState?.note
|
||||||
|
|
||||||
val repliesState by baseNote.liveReplies.observeAsState()
|
val repliesState by baseNote.liveReplies.observeAsState()
|
||||||
val replies = repliesState?.note?.replies ?: emptySet()
|
val replies = repliesState?.note?.replies ?: emptySet()
|
||||||
|
|
||||||
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
|
|
||||||
var popupExpanded by remember { mutableStateOf(false) }
|
|
||||||
val uri = LocalUriHandler.current
|
val uri = LocalUriHandler.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var wantsToReplyTo by remember {
|
var wantsToReplyTo by remember {
|
||||||
mutableStateOf<Note?>(null)
|
mutableStateOf<Note?>(null)
|
||||||
@@ -78,8 +85,19 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
modifier = Modifier.then(Modifier.size(20.dp)),
|
||||||
onClick = { if (account.isWriteable()) wantsToReplyTo = baseNote }
|
onClick = {
|
||||||
|
if (account.isWriteable())
|
||||||
|
wantsToReplyTo = baseNote
|
||||||
|
else
|
||||||
|
scope.launch {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
"Login with a Private key to be able to reply",
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(R.drawable.ic_comment),
|
painter = painterResource(R.drawable.ic_comment),
|
||||||
@@ -97,8 +115,19 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
modifier = Modifier.then(Modifier.size(20.dp)),
|
||||||
onClick = { if (account.isWriteable()) accountViewModel.boost(baseNote) }
|
onClick = {
|
||||||
|
if (account.isWriteable())
|
||||||
|
accountViewModel.boost(baseNote)
|
||||||
|
else
|
||||||
|
scope.launch {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
"Login with a Private key to be able to boost posts",
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
if (boostedNote?.isBoostedBy(account.userProfile()) == true) {
|
if (boostedNote?.isBoostedBy(account.userProfile()) == true) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -125,8 +154,19 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
modifier = Modifier.then(Modifier.size(20.dp)),
|
||||||
onClick = { if (account.isWriteable()) accountViewModel.reactTo(baseNote) }
|
onClick = {
|
||||||
|
if (account.isWriteable())
|
||||||
|
accountViewModel.reactTo(baseNote)
|
||||||
|
else
|
||||||
|
scope.launch {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
"Login with a Private key to like Posts",
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
if (reactedNote?.isReactedBy(account.userProfile()) == true) {
|
if (reactedNote?.isReactedBy(account.userProfile()) == true) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -152,9 +192,53 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
|
|||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier.then(Modifier.size(20.dp)),
|
||||||
|
onClick = {
|
||||||
|
if (account.isWriteable()) {
|
||||||
|
accountViewModel.zap(baseNote, 1000 * 1000, "", context) {
|
||||||
|
scope.launch {
|
||||||
|
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
scope.launch {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
"Login with a Private key to be able to send Zaps",
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (zappedNote?.isZappedBy(account.userProfile()) == true) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Bolt,
|
||||||
|
contentDescription = "Zaps",
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = BitcoinOrange
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Bolt,
|
||||||
|
contentDescription = "Zaps",
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = grayTint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
showAmount(zappedNote?.zappedAmount()),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
modifier = Modifier.then(Modifier.size(20.dp)),
|
||||||
onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") }
|
onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") }
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -178,25 +262,33 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
|
|||||||
colorFilter = ColorFilter.tint(grayTint)
|
colorFilter = ColorFilter.tint(grayTint)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
IconButton(
|
|
||||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
|
||||||
onClick = { popupExpanded = true }
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.MoreVert,
|
|
||||||
null,
|
|
||||||
modifier = Modifier.size(15.dp),
|
|
||||||
tint = grayTint,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
NoteDropDownMenu(baseNote, popupExpanded, { popupExpanded = false }, accountViewModel)
|
fun showCount(count: Int?): String {
|
||||||
|
if (count == null) return " "
|
||||||
|
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"
|
||||||
|
else -> "$count"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showCount(size: Int?): String {
|
val OneGiga = BigDecimal(1000000000)
|
||||||
if (size == null) return " "
|
val OneMega = BigDecimal(1000000)
|
||||||
return if (size == 0) return " " else "$size"
|
val OneKilo = BigDecimal(1000)
|
||||||
|
|
||||||
|
fun showAmount(amount: BigDecimal?): String {
|
||||||
|
if (amount == null) return " "
|
||||||
|
if (amount.abs() < BigDecimal(0.01)) return " "
|
||||||
|
|
||||||
|
return when {
|
||||||
|
amount >= OneGiga -> "%.1fG".format(amount.div(OneGiga).setScale(1, RoundingMode.HALF_UP))
|
||||||
|
amount >= OneMega -> "%.1fM".format(amount.div(OneMega).setScale(1, RoundingMode.HALF_UP))
|
||||||
|
amount >= OneKilo -> "%.1fk".format(amount.div(OneKilo).setScale(1, RoundingMode.HALF_UP))
|
||||||
|
else -> "%.0f".format(amount)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,8 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
|
|||||||
.padding(
|
.padding(
|
||||||
start = 12.dp,
|
start = 12.dp,
|
||||||
end = 12.dp,
|
end = 12.dp,
|
||||||
top = 10.dp)
|
top = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
|
||||||
UserPicture(baseUser, navController, account.userProfile(), 55.dp)
|
UserPicture(baseUser, navController, account.userProfile(), 55.dp)
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.note
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.Divider
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.vitorpamplona.amethyst.LocalPreferences
|
||||||
|
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.FollowButton
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.ShowUserButton
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.UnfollowButton
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewModel, navController: NavController) {
|
||||||
|
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||||
|
val account = accountState?.account ?: return
|
||||||
|
|
||||||
|
val userState by account.userProfile().liveFollows.observeAsState()
|
||||||
|
val userFollows = userState?.user ?: return
|
||||||
|
|
||||||
|
val noteState by baseNote.second.live.observeAsState()
|
||||||
|
val noteZap = noteState?.note ?: return
|
||||||
|
|
||||||
|
val baseNoteRequest by baseNote.first.live.observeAsState()
|
||||||
|
val noteZapRequest = baseNoteRequest?.note ?: return
|
||||||
|
|
||||||
|
val baseAuthor = noteZapRequest.author
|
||||||
|
|
||||||
|
val ctx = LocalContext.current.applicationContext
|
||||||
|
|
||||||
|
if (baseAuthor == null) {
|
||||||
|
BlankNote()
|
||||||
|
} else {
|
||||||
|
Column(modifier =
|
||||||
|
Modifier.clickable(
|
||||||
|
onClick = { navController.navigate("User/${baseAuthor.pubkeyHex}") }
|
||||||
|
),
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(
|
||||||
|
start = 12.dp,
|
||||||
|
end = 12.dp,
|
||||||
|
top = 10.dp
|
||||||
|
),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
|
||||||
|
UserPicture(baseAuthor, navController, account.userProfile(), 55.dp)
|
||||||
|
|
||||||
|
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
UsernameDisplay(baseAuthor)
|
||||||
|
}
|
||||||
|
|
||||||
|
val userState by baseAuthor.liveMetadata.observeAsState()
|
||||||
|
val user = userState?.user ?: return
|
||||||
|
|
||||||
|
Text(
|
||||||
|
user.info.about ?: "",
|
||||||
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val amount =
|
||||||
|
(noteZap.event as? LnZapEvent)?.amount
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(start = 10.dp),
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
showAmount(amount) + " sats",
|
||||||
|
color = BitcoinOrange,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.W500,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||||
|
if (account.isHidden(baseAuthor)) {
|
||||||
|
ShowUserButton {
|
||||||
|
account.showUser(baseAuthor.pubkeyHex)
|
||||||
|
LocalPreferences(ctx).saveToEncryptedStorage(account)
|
||||||
|
}
|
||||||
|
} else if (userFollows.isFollowing(baseAuthor)) {
|
||||||
|
UnfollowButton { account.unfollow(baseAuthor) }
|
||||||
|
} else {
|
||||||
|
FollowButton { account.follow(baseAuthor) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider(
|
||||||
|
modifier = Modifier.padding(top = 10.dp),
|
||||||
|
thickness = 0.25.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.note
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
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.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.LikeSetCard
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.ZapSetCard
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ZapSetCompose(zapSetCard: ZapSetCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
|
||||||
|
val noteState by zapSetCard.note.live.observeAsState()
|
||||||
|
val note = noteState?.note
|
||||||
|
|
||||||
|
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||||
|
val account = accountState?.account ?: return
|
||||||
|
|
||||||
|
val context = LocalContext.current.applicationContext
|
||||||
|
|
||||||
|
if (note == null) {
|
||||||
|
BlankNote(Modifier, isInnerNote)
|
||||||
|
} else {
|
||||||
|
val isNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
|
||||||
|
NotificationCache.markAsRead(routeForLastRead, zapSetCard.createdAt, context)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.background(
|
||||||
|
if (isNew) MaterialTheme.colors.primary.copy(0.12f) else MaterialTheme.colors.background
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
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 = "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,
|
||||||
|
navController = navController,
|
||||||
|
userAccount = account.userProfile(),
|
||||||
|
size = 35.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NoteCompose(note, null, Modifier.padding(top = 5.dp), true, accountViewModel, navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,14 @@ class LikeSetCard(val note: Note, val likeEvents: List<Note>): Card() {
|
|||||||
override fun id() = note.idHex + "L" + createdAt
|
override fun id() = note.idHex + "L" + createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ZapSetCard(val note: Note, val zapEvents: List<Note>): Card() {
|
||||||
|
val createdAt = zapEvents.maxOf { it.event?.createdAt ?: 0 }
|
||||||
|
override fun createdAt(): Long {
|
||||||
|
return createdAt
|
||||||
|
}
|
||||||
|
override fun id() = note.idHex + "Z" + createdAt
|
||||||
|
}
|
||||||
|
|
||||||
class BoostSetCard(val note: Note, val boostEvents: List<Note>): Card() {
|
class BoostSetCard(val note: Note, val boostEvents: List<Note>): Card() {
|
||||||
val createdAt = boostEvents.maxOf { it.event?.createdAt ?: 0 }
|
val createdAt = boostEvents.maxOf { it.event?.createdAt ?: 0 }
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
|||||||
import com.vitorpamplona.amethyst.ui.note.BoostSetCompose
|
import com.vitorpamplona.amethyst.ui.note.BoostSetCompose
|
||||||
import com.vitorpamplona.amethyst.ui.note.LikeSetCompose
|
import com.vitorpamplona.amethyst.ui.note.LikeSetCompose
|
||||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.ZapSetCompose
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -100,6 +101,13 @@ private fun FeedLoaded(
|
|||||||
navController = navController,
|
navController = navController,
|
||||||
routeForLastRead = routeForLastRead
|
routeForLastRead = routeForLastRead
|
||||||
)
|
)
|
||||||
|
is ZapSetCard -> ZapSetCompose(
|
||||||
|
item,
|
||||||
|
isInnerNote = false,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
navController = navController,
|
||||||
|
routeForLastRead = routeForLastRead
|
||||||
|
)
|
||||||
is LikeSetCard -> LikeSetCompose(
|
is LikeSetCard -> LikeSetCompose(
|
||||||
item,
|
item,
|
||||||
isInnerNote = false,
|
isInnerNote = false,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.model.Note
|
|||||||
import com.vitorpamplona.amethyst.service.NostrDataSource
|
import com.vitorpamplona.amethyst.service.NostrDataSource
|
||||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
@@ -68,6 +69,21 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
|
|||||||
|
|
||||||
val reactionCards = reactionsPerEvent.map { LikeSetCard(it.key, it.value) }
|
val reactionCards = reactionsPerEvent.map { LikeSetCard(it.key, it.value) }
|
||||||
|
|
||||||
|
val zapsPerEvent = mutableMapOf<Note, MutableList<Note>>()
|
||||||
|
notes
|
||||||
|
.filter { it.event is LnZapEvent}
|
||||||
|
.forEach { zapEvent ->
|
||||||
|
val zappedPost = zapEvent.replyTo?.lastOrNull() { it.event !is ChannelMetadataEvent && it.event !is ChannelCreateEvent }
|
||||||
|
if (zappedPost != null) {
|
||||||
|
val key = zappedPost.zaps.filter { it.value == zapEvent }.keys.firstOrNull()
|
||||||
|
if (key != null) {
|
||||||
|
zapsPerEvent.getOrPut(zappedPost, { mutableListOf() }).add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val zapCards = zapsPerEvent.map { ZapSetCard(it.key, it.value) }
|
||||||
|
|
||||||
val boostsPerEvent = mutableMapOf<Note, MutableList<Note>>()
|
val boostsPerEvent = mutableMapOf<Note, MutableList<Note>>()
|
||||||
notes
|
notes
|
||||||
.filter { it.event is RepostEvent }
|
.filter { it.event is RepostEvent }
|
||||||
@@ -81,7 +97,7 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
|
|||||||
|
|
||||||
val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent }.map { NoteCard(it) }
|
val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent }.map { NoteCard(it) }
|
||||||
|
|
||||||
return (reactionCards + boostCards + textNoteCards).sortedBy { it.createdAt() }.reversed()
|
return (reactionCards + boostCards + zapCards + textNoteCards).sortedBy { it.createdAt() }.reversed()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateFeed(notes: List<Card>) {
|
private fun updateFeed(notes: List<Card>) {
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navCo
|
|||||||
navController
|
navController
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
FeedState.Loading -> {
|
is FeedState.Loading -> {
|
||||||
LoadingFeed()
|
LoadingFeed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
|
|||||||
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||||
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrUserProfileZapsDataSource
|
||||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
|
||||||
|
sealed class LnZapFeedState {
|
||||||
|
object Loading : LnZapFeedState()
|
||||||
|
class Loaded(val feed: MutableState<List<Pair<Note, Note>>>): LnZapFeedState()
|
||||||
|
object Empty : LnZapFeedState()
|
||||||
|
class FeedError(val errorMessage: String) : LnZapFeedState()
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||||
|
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.UserCompose
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.ZapNoteCompose
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LnZapFeedView(viewModel: LnZapFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||||
|
val feedState by viewModel.feedContent.collectAsState()
|
||||||
|
|
||||||
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
|
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
|
||||||
|
|
||||||
|
LaunchedEffect(isRefreshing) {
|
||||||
|
if (isRefreshing) {
|
||||||
|
viewModel.refresh()
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SwipeRefresh(
|
||||||
|
state = swipeRefreshState,
|
||||||
|
onRefresh = {
|
||||||
|
isRefreshing = true
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column() {
|
||||||
|
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
|
||||||
|
when (state) {
|
||||||
|
is LnZapFeedState.Empty -> {
|
||||||
|
FeedEmpty {
|
||||||
|
isRefreshing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is LnZapFeedState.FeedError -> {
|
||||||
|
FeedError(state.errorMessage) {
|
||||||
|
isRefreshing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is LnZapFeedState.Loaded -> {
|
||||||
|
LnZapFeedLoaded(state, accountViewModel, navController)
|
||||||
|
}
|
||||||
|
is LnZapFeedState.Loading -> {
|
||||||
|
LoadingFeed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LnZapFeedLoaded(
|
||||||
|
state: LnZapFeedState.Loaded,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
navController: NavController
|
||||||
|
) {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(
|
||||||
|
top = 10.dp,
|
||||||
|
bottom = 10.dp
|
||||||
|
),
|
||||||
|
state = listState
|
||||||
|
) {
|
||||||
|
itemsIndexed(state.feed.value, key = { _, item -> item.second.idHex }) { index, item ->
|
||||||
|
ZapNoteCompose(item, accountViewModel = accountViewModel, navController = navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
|
import com.vitorpamplona.amethyst.model.LocalCacheState
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrDataSource
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrUserProfileZapsDataSource
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
class NostrUserProfileZapsFeedViewModel: LnZapFeedViewModel(NostrUserProfileZapsDataSource)
|
||||||
|
|
||||||
|
open class LnZapFeedViewModel(val dataSource: NostrDataSource<Pair<Note, Note>>): ViewModel() {
|
||||||
|
private val _feedContent = MutableStateFlow<LnZapFeedState>(LnZapFeedState.Loading)
|
||||||
|
val feedContent = _feedContent.asStateFlow()
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||||
|
scope.launch {
|
||||||
|
refreshSuspended()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun refreshSuspended() {
|
||||||
|
val notes = dataSource.loadTop()
|
||||||
|
|
||||||
|
val oldNotesState = feedContent.value
|
||||||
|
if (oldNotesState is LnZapFeedState.Loaded) {
|
||||||
|
if (notes != oldNotesState.feed) {
|
||||||
|
updateFeed(notes)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateFeed(notes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateFeed(notes: List<Pair<Note,Note>>) {
|
||||||
|
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||||
|
scope.launch {
|
||||||
|
val currentState = feedContent.value
|
||||||
|
if (notes.isEmpty()) {
|
||||||
|
_feedContent.update { LnZapFeedState.Empty }
|
||||||
|
} else if (currentState is LnZapFeedState.Loaded) {
|
||||||
|
// updates the current list
|
||||||
|
currentState.feed.value = notes
|
||||||
|
} else {
|
||||||
|
_feedContent.update { LnZapFeedState.Loaded(mutableStateOf(notes)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var handlerWaiting = AtomicBoolean()
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun invalidateData() {
|
||||||
|
if (handlerWaiting.getAndSet(true)) return
|
||||||
|
|
||||||
|
handlerWaiting.set(true)
|
||||||
|
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||||
|
scope.launch {
|
||||||
|
delay(100)
|
||||||
|
refresh()
|
||||||
|
handlerWaiting.set(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val cacheListener: (LocalCacheState) -> Unit = {
|
||||||
|
invalidateData()
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
LocalCache.live.observeForever(cacheListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
LocalCache.live.removeObserver(cacheListener)
|
||||||
|
|
||||||
|
dataSource.stop()
|
||||||
|
viewModelScope.cancel()
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.screen
|
package com.vitorpamplona.amethyst.ui.screen
|
||||||
|
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
|
|
||||||
sealed class UserFeedState {
|
sealed class UserFeedState {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewMode
|
|||||||
is UserFeedState.Loaded -> {
|
is UserFeedState.Loaded -> {
|
||||||
FeedLoaded(state, accountViewModel, navController)
|
FeedLoaded(state, accountViewModel, navController)
|
||||||
}
|
}
|
||||||
UserFeedState.Loading -> {
|
is UserFeedState.Loading -> {
|
||||||
LoadingFeed()
|
LoadingFeed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.map
|
import androidx.lifecycle.map
|
||||||
import com.vitorpamplona.amethyst.LocalPreferences
|
import com.vitorpamplona.amethyst.LocalPreferences
|
||||||
|
import com.vitorpamplona.amethyst.lnurl.LightningAddressResolver
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
import com.vitorpamplona.amethyst.model.AccountState
|
import com.vitorpamplona.amethyst.model.AccountState
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
@@ -20,6 +24,27 @@ class AccountViewModel(private val account: Account): ViewModel() {
|
|||||||
account.reactTo(note)
|
account.reactTo(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun zap(note: Note, amount: Long, message: String, context: Context, onError: (String) -> Unit) {
|
||||||
|
val lud16 = note.author?.info?.lud16
|
||||||
|
|
||||||
|
if (lud16.isNullOrBlank()) {
|
||||||
|
onError("User does not have a lightning address setup to receive sats")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val zapRequest = account.createZapRequestFor(note)
|
||||||
|
|
||||||
|
LightningAddressResolver().lnAddressInvoice(lud16, amount, message, zapRequest?.toJson(),
|
||||||
|
onSuccess = {
|
||||||
|
runCatching {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
|
||||||
|
ContextCompat.startActivity(context, intent, null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError = onError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun report(note: Note, type: ReportEvent.ReportType) {
|
fun report(note: Note, type: ReportEvent.ReportType) {
|
||||||
account.report(note, type)
|
account.report(note, type)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,10 +55,12 @@ import com.vitorpamplona.amethyst.model.User
|
|||||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||||
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
|
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
|
||||||
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
|
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrUserProfileZapsDataSource
|
||||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||||
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
|
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
|
||||||
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
|
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
|
||||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -77,11 +79,13 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
|
|||||||
NostrUserProfileDataSource.loadUserProfile(userId)
|
NostrUserProfileDataSource.loadUserProfile(userId)
|
||||||
NostrUserProfileFollowersDataSource.loadUserProfile(userId)
|
NostrUserProfileFollowersDataSource.loadUserProfile(userId)
|
||||||
NostrUserProfileFollowsDataSource.loadUserProfile(userId)
|
NostrUserProfileFollowsDataSource.loadUserProfile(userId)
|
||||||
|
NostrUserProfileZapsDataSource.loadUserProfile(userId)
|
||||||
|
|
||||||
onDispose {
|
onDispose {
|
||||||
NostrUserProfileDataSource.stop()
|
NostrUserProfileDataSource.stop()
|
||||||
NostrUserProfileFollowsDataSource.stop()
|
NostrUserProfileFollowsDataSource.stop()
|
||||||
NostrUserProfileFollowersDataSource.stop()
|
NostrUserProfileFollowersDataSource.stop()
|
||||||
|
NostrUserProfileZapsDataSource.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +177,17 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
|
|||||||
Tab(
|
Tab(
|
||||||
selected = pagerState.currentPage == 3,
|
selected = pagerState.currentPage == 3,
|
||||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(3) } },
|
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(3) } },
|
||||||
|
text = {
|
||||||
|
val userState by baseUser.liveZaps.observeAsState()
|
||||||
|
val userZaps = userState?.user?.zappedAmount()
|
||||||
|
|
||||||
|
Text(text = "${showAmount(userZaps)} Zaps")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Tab(
|
||||||
|
selected = pagerState.currentPage == 4,
|
||||||
|
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(4) } },
|
||||||
text = {
|
text = {
|
||||||
val userState by baseUser.liveRelays.observeAsState()
|
val userState by baseUser.liveRelays.observeAsState()
|
||||||
val userRelaysBeingUsed =
|
val userRelaysBeingUsed =
|
||||||
@@ -186,7 +201,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
HorizontalPager(
|
HorizontalPager(
|
||||||
count = 4,
|
count = 5,
|
||||||
state = pagerState,
|
state = pagerState,
|
||||||
modifier = with(LocalDensity.current) {
|
modifier = with(LocalDensity.current) {
|
||||||
Modifier.height((columnSize.height - tabsSize.height).toDp())
|
Modifier.height((columnSize.height - tabsSize.height).toDp())
|
||||||
@@ -196,7 +211,8 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
|
|||||||
0 -> TabNotes(baseUser, accountViewModel, navController)
|
0 -> TabNotes(baseUser, accountViewModel, navController)
|
||||||
1 -> TabFollows(baseUser, accountViewModel, navController)
|
1 -> TabFollows(baseUser, accountViewModel, navController)
|
||||||
2 -> TabFollowers(baseUser, accountViewModel, navController)
|
2 -> TabFollowers(baseUser, accountViewModel, navController)
|
||||||
3 -> TabRelays(baseUser, accountViewModel, navController)
|
3 -> TabReceivedZaps(baseUser, accountViewModel, navController)
|
||||||
|
4 -> TabRelays(baseUser, accountViewModel, navController)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -452,6 +468,22 @@ fun TabFollowers(user: User, accountViewModel: AccountViewModel, navController:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TabReceivedZaps(user: User, accountViewModel: AccountViewModel, navController: NavController) {
|
||||||
|
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||||
|
if (accountState != null) {
|
||||||
|
val feedViewModel: NostrUserProfileZapsFeedViewModel = viewModel()
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxHeight()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(vertical = 0.dp)
|
||||||
|
) {
|
||||||
|
LnZapFeedView(feedViewModel, accountViewModel, navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TabRelays(user: User, accountViewModel: AccountViewModel, navController: NavController) {
|
fun TabRelays(user: User, accountViewModel: AccountViewModel, navController: NavController) {
|
||||||
val feedViewModel: RelayFeedViewModel = viewModel()
|
val feedViewModel: RelayFeedViewModel = viewModel()
|
||||||
|
|||||||
Reference in New Issue
Block a user