Lightning Zaps.
This commit is contained in:
@@ -13,7 +13,7 @@ object RoboHashCache {
|
||||
fun get(context: Context, hash: String): Bitmap {
|
||||
if (!this::robots.isInitialized) {
|
||||
robots = RoboHash(context)
|
||||
robots.useCache(LruCache(100));
|
||||
robots.useCache(LruCache(1000));
|
||||
}
|
||||
|
||||
return robots.imageForHandle(robots.calculateHandleFromUUID(UUID.nameUUIDFromBytes(hash.toByteArray())))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.lnurl
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import kotlin.math.ln
|
||||
import java.net.URLEncoder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -27,15 +27,20 @@ class LightningAddressResolver {
|
||||
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)
|
||||
scope.launch {
|
||||
fetchLightningAddressJsonSuspend(lnaddress, onSucess)
|
||||
fetchLightningAddressJsonSuspend(lnaddress, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSucess: (String) -> Unit) {
|
||||
val url = assembleUrl(lnaddress) ?: return
|
||||
private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
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) {
|
||||
val request: Request = Request.Builder().url(url).build()
|
||||
@@ -43,64 +48,101 @@ class LightningAddressResolver {
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
onSucess(response.body.string())
|
||||
onSuccess(response.body.string())
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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) {
|
||||
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
|
||||
val url = "$lnCallback${urlBinder}amount=$milliSats&comment=$message"
|
||||
|
||||
private suspend fun fetchLightningInvoiceSuspend(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
||||
|
||||
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
|
||||
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
|
||||
|
||||
if (nostrRequest != null) {
|
||||
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
|
||||
url += "&nostr=$encodedNostrRequest"
|
||||
}
|
||||
|
||||
val request: Request = Request.Builder().url(url).build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
onSucess(response.body.string())
|
||||
onSuccess(response.body.string())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not fetch an invoice from ${lnCallback}. Message ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun lnAddressToLnUrl(lnaddress: String, onSucess: (String) -> Unit) {
|
||||
fetchLightningAddressJson(lnaddress) {
|
||||
onSucess(Bech32.encodeBytes("lnurl",it.toByteArray(), Bech32.Encoding.Bech32))
|
||||
}
|
||||
fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
fetchLightningAddressJson(lnaddress,
|
||||
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()
|
||||
|
||||
fetchLightningAddressJson(lnaddress) {
|
||||
mapper.readTree(it)?.get("callback")?.asText()?.let { callback ->
|
||||
fetchLightningInvoice(callback, milliSats, message,
|
||||
onSucess = {
|
||||
mapper.readTree(it)?.get("pr")?.asText()?.let { pr ->
|
||||
onSucess(pr)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
fetchLightningAddressJson(lnaddress,
|
||||
onSuccess = {
|
||||
val lnurlp = try {
|
||||
mapper.readTree(it)
|
||||
} catch (t: Throwable) {
|
||||
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.ChannelMessageEvent
|
||||
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.ReportEvent
|
||||
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) {
|
||||
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.ChannelMetadataEvent
|
||||
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.ReportEvent
|
||||
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> {
|
||||
return users.values.filter {
|
||||
it.info.anyNameStartsWith(username)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
|
||||
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.RepostEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
@@ -16,6 +18,7 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import java.math.BigDecimal
|
||||
import java.util.Date
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.regex.Matcher
|
||||
@@ -46,6 +49,8 @@ class Note(val idHex: String) {
|
||||
private set
|
||||
var reports = setOf<Note>()
|
||||
private set
|
||||
var zaps = mapOf<Note, Note?>()
|
||||
private set
|
||||
|
||||
var relays = setOf<String>()
|
||||
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) {
|
||||
if (note !in reactions) {
|
||||
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 {
|
||||
return reactions.any { it.author == user }
|
||||
}
|
||||
@@ -141,6 +161,14 @@ class Note(val idHex: String) {
|
||||
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 {
|
||||
val dayAgo = Date().time / 1000 - 24*60*60
|
||||
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
|
||||
}
|
||||
|
||||
fun hasZapped(loggedIn: User): Boolean {
|
||||
return zaps.any { it.key.author == loggedIn }
|
||||
}
|
||||
|
||||
fun hasReacted(loggedIn: User, content: String): Boolean {
|
||||
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 liveReports: NoteLiveData = NoteLiveData(this)
|
||||
val liveRelays: NoteLiveData = NoteLiveData(this)
|
||||
val liveZaps: NoteLiveData = NoteLiveData(this)
|
||||
}
|
||||
|
||||
class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
|
||||
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.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import java.math.BigDecimal
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -48,6 +51,9 @@ class User(val pubkeyHex: String) {
|
||||
var reports = setOf<Note>()
|
||||
private set
|
||||
|
||||
var zaps = mapOf<Note, Note?>()
|
||||
private set
|
||||
|
||||
var relays: Map<String, ContactListEvent.ReadWrite>? = null
|
||||
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> {
|
||||
return reports.filter { it.author == user }
|
||||
}
|
||||
@@ -300,6 +324,7 @@ class User(val pubkeyHex: String) {
|
||||
val liveRelays: UserLiveData = UserLiveData(this)
|
||||
val liveRelayInfo: UserLiveData = UserLiveData(this)
|
||||
val liveMetadata: UserLiveData = UserLiveData(this)
|
||||
val liveZaps: UserLiveData = UserLiveData(this)
|
||||
}
|
||||
|
||||
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.ChannelMetadataEvent
|
||||
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.ReportEvent
|
||||
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))
|
||||
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))
|
||||
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)
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.model.User
|
||||
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.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import nostr.postr.JsonFilter
|
||||
|
||||
@@ -19,6 +20,7 @@ object NostrNotificationDataSource: NostrDataSource<Note>("NotificationFeed") {
|
||||
.filter {
|
||||
it.event !is ChannelCreateEvent
|
||||
&& it.event !is ChannelMetadataEvent
|
||||
&& it.event !is LnZapRequestEvent
|
||||
}
|
||||
.sortedBy { it.event?.createdAt }
|
||||
.reversed()
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
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.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
@@ -34,7 +35,7 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
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)),
|
||||
since = it.lastReactionsDownloadTime
|
||||
@@ -68,7 +69,7 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind,
|
||||
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind,
|
||||
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind
|
||||
),
|
||||
ids = interestedEvents.toList()
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
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 {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
@@ -76,7 +88,8 @@ object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
|
||||
createUserInfoFilter(),
|
||||
createUserPostsFilter(),
|
||||
createFollowFilter(),
|
||||
createFollowersFilter()
|
||||
createFollowersFilter(),
|
||||
createUserReceivedZapsFilter()
|
||||
).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.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -21,6 +22,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -36,10 +38,12 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.lnurl.LightningAddressResolver
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun InvoiceRequest(lud16: String, onClose: () -> Unit ) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -124,13 +128,21 @@ fun InvoiceRequest(lud16: String, onClose: () -> Unit ) {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||
onClick = {
|
||||
LightningAddressResolver().lnAddressInvoice(lud16, amount * 1000, message) {
|
||||
runCatching {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
|
||||
startActivity(context, intent, null)
|
||||
LightningAddressResolver().lnAddressInvoice(lud16, amount * 1000, message,
|
||||
onSuccess = {
|
||||
runCatching {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
|
||||
startActivity(context, intent, null)
|
||||
}
|
||||
onClose()
|
||||
},
|
||||
onError = {
|
||||
scope.launch {
|
||||
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(15.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
|
||||
@@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.toNote
|
||||
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.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
@@ -97,6 +98,9 @@ fun NoteCompose(
|
||||
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
var moreActionsExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
|
||||
if (note?.event == null) {
|
||||
BlankNote(modifier.combinedClickable(
|
||||
onClick = { },
|
||||
@@ -231,6 +235,20 @@ fun NoteCompose(
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
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 {
|
||||
Text(
|
||||
" boosted",
|
||||
@@ -314,7 +332,10 @@ private fun RelayBadges(baseNote: Note) {
|
||||
FlowRow(Modifier.padding(top = 10.dp, start = 5.dp, end = 4.dp)) {
|
||||
relaysToDisplay.forEach {
|
||||
val url = it.removePrefix("wss://")
|
||||
Box(Modifier.size(15.dp).padding(1.dp)) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(15.dp)
|
||||
.padding(1.dp)) {
|
||||
AsyncImage(
|
||||
model = "https://${url}/favicon.ico",
|
||||
placeholder = rememberAsyncImagePainter(RoboHashCache.get(ctx, url)),
|
||||
@@ -326,14 +347,17 @@ private fun RelayBadges(baseNote: Note) {
|
||||
.fillMaxSize(1f)
|
||||
.clip(shape = CircleShape)
|
||||
.background(MaterialTheme.colors.background)
|
||||
.clickable(onClick = { uri.openUri("https://" + url) } )
|
||||
.clickable(onClick = { uri.openUri("https://" + url) })
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||
onClick = { expanded = true }
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
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.Row
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
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.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.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
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.graphics.ColorFilter
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import coil.compose.AsyncImage
|
||||
import coil.imageLoader
|
||||
import coil.request.CachePolicy
|
||||
import coil.request.ImageRequest
|
||||
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.ui.actions.NewPostView
|
||||
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
|
||||
fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
|
||||
@@ -55,13 +59,16 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
|
||||
val boostsState by baseNote.liveBoosts.observeAsState()
|
||||
val boostedNote = boostsState?.note
|
||||
|
||||
val zapsState by baseNote.liveZaps.observeAsState()
|
||||
val zappedNote = zapsState?.note
|
||||
|
||||
val repliesState by baseNote.liveReplies.observeAsState()
|
||||
val replies = repliesState?.note?.replies ?: emptySet()
|
||||
|
||||
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
val uri = LocalUriHandler.current
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var wantsToReplyTo by remember {
|
||||
mutableStateOf<Note?>(null)
|
||||
@@ -78,8 +85,19 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||
onClick = { if (account.isWriteable()) wantsToReplyTo = baseNote }
|
||||
modifier = Modifier.then(Modifier.size(20.dp)),
|
||||
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(
|
||||
painter = painterResource(R.drawable.ic_comment),
|
||||
@@ -97,8 +115,19 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
|
||||
)
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||
onClick = { if (account.isWriteable()) accountViewModel.boost(baseNote) }
|
||||
modifier = Modifier.then(Modifier.size(20.dp)),
|
||||
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) {
|
||||
Icon(
|
||||
@@ -125,8 +154,19 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
|
||||
)
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||
onClick = { if (account.isWriteable()) accountViewModel.reactTo(baseNote) }
|
||||
modifier = Modifier.then(Modifier.size(20.dp)),
|
||||
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) {
|
||||
Icon(
|
||||
@@ -152,9 +192,53 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
|
||||
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(
|
||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||
modifier = Modifier.then(Modifier.size(20.dp)),
|
||||
onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") }
|
||||
) {
|
||||
Icon(
|
||||
@@ -178,25 +262,33 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
|
||||
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(size: Int?): String {
|
||||
if (size == null) return " "
|
||||
return if (size == 0) return " " else "$size"
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
val OneGiga = BigDecimal(1000000000)
|
||||
val OneMega = BigDecimal(1000000)
|
||||
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(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp)
|
||||
top = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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() {
|
||||
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.LikeSetCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapSetCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
@@ -100,6 +101,13 @@ private fun FeedLoaded(
|
||||
navController = navController,
|
||||
routeForLastRead = routeForLastRead
|
||||
)
|
||||
is ZapSetCard -> ZapSetCompose(
|
||||
item,
|
||||
isInnerNote = false,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
routeForLastRead = routeForLastRead
|
||||
)
|
||||
is LikeSetCard -> LikeSetCompose(
|
||||
item,
|
||||
isInnerNote = false,
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.NostrDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
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.RepostEvent
|
||||
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 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>>()
|
||||
notes
|
||||
.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) }
|
||||
|
||||
return (reactionCards + boostCards + textNoteCards).sortedBy { it.createdAt() }.reversed()
|
||||
return (reactionCards + boostCards + zapCards + textNoteCards).sortedBy { it.createdAt() }.reversed()
|
||||
}
|
||||
|
||||
private fun updateFeed(notes: List<Card>) {
|
||||
|
||||
@@ -77,7 +77,7 @@ fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navCo
|
||||
navController
|
||||
)
|
||||
}
|
||||
FeedState.Loading -> {
|
||||
is FeedState.Loading -> {
|
||||
LoadingFeed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileZapsDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
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
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
sealed class UserFeedState {
|
||||
|
||||
@@ -59,7 +59,7 @@ fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewMode
|
||||
is UserFeedState.Loaded -> {
|
||||
FeedLoaded(state, accountViewModel, navController)
|
||||
}
|
||||
UserFeedState.Loading -> {
|
||||
is UserFeedState.Loading -> {
|
||||
LoadingFeed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountState
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -20,6 +24,27 @@ class AccountViewModel(private val account: Account): ViewModel() {
|
||||
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) {
|
||||
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.NostrUserProfileFollowersDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileZapsDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
|
||||
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
|
||||
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.theme.BitcoinOrange
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -77,11 +79,13 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
|
||||
NostrUserProfileDataSource.loadUserProfile(userId)
|
||||
NostrUserProfileFollowersDataSource.loadUserProfile(userId)
|
||||
NostrUserProfileFollowsDataSource.loadUserProfile(userId)
|
||||
NostrUserProfileZapsDataSource.loadUserProfile(userId)
|
||||
|
||||
onDispose {
|
||||
NostrUserProfileDataSource.stop()
|
||||
NostrUserProfileFollowsDataSource.stop()
|
||||
NostrUserProfileFollowersDataSource.stop()
|
||||
NostrUserProfileZapsDataSource.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +177,17 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
|
||||
Tab(
|
||||
selected = pagerState.currentPage == 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 = {
|
||||
val userState by baseUser.liveRelays.observeAsState()
|
||||
val userRelaysBeingUsed =
|
||||
@@ -186,7 +201,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
|
||||
)
|
||||
}
|
||||
HorizontalPager(
|
||||
count = 4,
|
||||
count = 5,
|
||||
state = pagerState,
|
||||
modifier = with(LocalDensity.current) {
|
||||
Modifier.height((columnSize.height - tabsSize.height).toDp())
|
||||
@@ -196,7 +211,8 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
|
||||
0 -> TabNotes(baseUser, accountViewModel, navController)
|
||||
1 -> TabFollows(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
|
||||
fun TabRelays(user: User, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val feedViewModel: RelayFeedViewModel = viewModel()
|
||||
|
||||
Reference in New Issue
Block a user