From 64f2575228fc8acd4f221f03e9486f26056f0836 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 12 Feb 2023 18:23:02 -0500 Subject: [PATCH] Lightning Zaps. --- .../vitorpamplona/amethyst/RoboHashCache.kt | 2 +- .../lnurl/LightningAddressResolver.kt | 100 +++++++---- .../vitorpamplona/amethyst/model/Account.kt | 22 +++ .../amethyst/model/LocalCache.kt | 68 ++++++++ .../com/vitorpamplona/amethyst/model/Note.kt | 33 ++++ .../com/vitorpamplona/amethyst/model/User.kt | 25 +++ .../amethyst/service/NostrDataSource.kt | 10 ++ .../service/NostrNotificationDataSource.kt | 2 + .../service/NostrSingleEventDataSource.kt | 5 +- .../service/NostrUserProfileDataSource.kt | 15 +- .../service/NostrUserProfileZapsDataSource.kt | 25 +++ .../amethyst/service/model/LnZapEvent.kt | 50 ++++++ .../service/model/LnZapRequestEvent.kt | 89 ++++++++++ .../amethyst/ui/components/InvoiceRequest.kt | 24 ++- .../amethyst/ui/note/NoteCompose.kt | 30 +++- .../amethyst/ui/note/ReactionsRow.kt | 162 ++++++++++++++---- .../amethyst/ui/note/UserCompose.kt | 3 +- .../amethyst/ui/note/ZapNoteCompose.kt | 123 +++++++++++++ .../amethyst/ui/note/ZapSetCompose.kt | 91 ++++++++++ .../amethyst/ui/screen/CardFeedState.kt | 8 + .../amethyst/ui/screen/CardFeedView.kt | 8 + .../amethyst/ui/screen/CardFeedViewModel.kt | 18 +- .../amethyst/ui/screen/FeedView.kt | 2 +- .../amethyst/ui/screen/FeedViewModel.kt | 1 + .../amethyst/ui/screen/LnZapFeedState.kt | 11 ++ .../amethyst/ui/screen/LnZapFeedView.kt | 91 ++++++++++ .../amethyst/ui/screen/LnZapFeedViewModel.kt | 95 ++++++++++ .../amethyst/ui/screen/UserFeedState.kt | 1 + .../amethyst/ui/screen/UserFeedView.kt | 2 +- .../ui/screen/loggedIn/AccountViewModel.kt | 25 +++ .../ui/screen/loggedIn/ProfileScreen.kt | 36 +++- 31 files changed, 1094 insertions(+), 83 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileZapsDataSource.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapSetCompose.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/RoboHashCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/RoboHashCache.kt index fe2976e80..4d8f2c7e4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/RoboHashCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/RoboHashCache.kt @@ -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()))) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/lnurl/LightningAddressResolver.kt b/app/src/main/java/com/vitorpamplona/amethyst/lnurl/LightningAddressResolver.kt index 0e8f06fd8..7cc18d3da 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/lnurl/LightningAddressResolver.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/lnurl/LightningAddressResolver.kt @@ -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 + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 3d36d081a..f8176a340 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index a08193a4d..87d95204a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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 { return users.values.filter { it.info.anyNameStartsWith(username) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 43e5579eb..f38c6e2c8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -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() private set + var zaps = mapOf() + private set var relays = setOf() 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() + .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(note)) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 1fa6f6f44..5bde472bc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -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() private set + var zaps = mapOf() + private set + var relays: Map? = 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() + .mapNotNull { + it.amount + }.sumOf { it } + } + fun reportsBy(user: User): List { 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 { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index bdbc47909..076ba5e65 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -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(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) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrNotificationDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrNotificationDataSource.kt index 9a10941ba..e629d704e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrNotificationDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrNotificationDataSource.kt @@ -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("NotificationFeed") { .filter { it.event !is ChannelCreateEvent && it.event !is ChannelMetadataEvent + && it.event !is LnZapRequestEvent } .sortedBy { it.event?.createdAt } .reversed() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index 84bf97c69..df57ab200 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -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("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("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() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt index 451adf915..a237c86fe 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -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("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("UserProfileFeed") { createUserInfoFilter(), createUserPostsFilter(), createFollowFilter(), - createFollowersFilter() + createFollowersFilter(), + createUserReceivedZapsFilter() ).ifEmpty { null } } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileZapsDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileZapsDataSource.kt new file mode 100644 index 000000000..3e01b7409 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileZapsDataSource.kt @@ -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>("UserProfileZapsFeed") { + lateinit var account: Account + var user: User? = null + + fun loadUserProfile(userId: String) { + user = LocalCache.users[userId] + resetFilters() + } + + override fun feed(): List> { + return (user?.zaps?.filter { it.value != null }?.toList()?.sortedBy { (it.second?.event as? LnZapEvent)?.amount }?.reversed() ?: emptyList()) as List> + } + + override fun updateChannelFilters() {} +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt new file mode 100644 index 000000000..bd9a476e7 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt @@ -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>, + content: String, + sig: ByteArray +): Event(id, pubKey, createdAt, kind, tags, content, sig) { + + @Transient val zappedPost: List + @Transient val zappedAuthor: List + @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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt new file mode 100644 index 000000000..9a888a9fc --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt @@ -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>, + content: String, + sig: ByteArray +): Event(id, pubKey, createdAt, kind, tags, content, sig) { + + @Transient val zappedPost: List + @Transient val zappedAuthor: List + + 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, 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, 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" + ] + ] +} +*/ \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt index 9d5485eda..66b40dd84 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt @@ -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( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 3665901ea..ca388f580 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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 } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index a8d872b1a..3818bc761 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -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(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) + } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt index 97ef52ed9..203d617b3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt @@ -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) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt new file mode 100644 index 000000000..4b442023c --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt @@ -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, 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 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapSetCompose.kt new file mode 100644 index 000000000..937cc6ee8 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapSetCompose.kt @@ -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) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt index 911f47377..c943f3001 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt @@ -24,6 +24,14 @@ class LikeSetCard(val note: Note, val likeEvents: List): Card() { override fun id() = note.idHex + "L" + createdAt } +class ZapSetCard(val note: Note, val zapEvents: List): 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): Card() { val createdAt = boostEvents.maxOf { it.event?.createdAt ?: 0 } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt index cc7310e49..5aef2e038 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt @@ -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, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt index 2c5ed4370..65abd4f1a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt @@ -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): ViewModel() { val reactionCards = reactionsPerEvent.map { LikeSetCard(it.key, it.value) } + val zapsPerEvent = mutableMapOf>() + 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>() notes .filter { it.event is RepostEvent } @@ -81,7 +97,7 @@ class CardFeedViewModel(val dataSource: NostrDataSource): 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) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index 7b62ca311..2652f6a2b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -77,7 +77,7 @@ fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navCo navController ) } - FeedState.Loading -> { + is FeedState.Loading -> { LoadingFeed() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index 033ff7834..c645d5ca0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt new file mode 100644 index 000000000..bedef2bf8 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt @@ -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>>): LnZapFeedState() + object Empty : LnZapFeedState() + class FeedError(val errorMessage: String) : LnZapFeedState() +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt new file mode 100644 index 000000000..173ce73c3 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt new file mode 100644 index 000000000..8a4662837 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt @@ -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>): ViewModel() { + private val _feedContent = MutableStateFlow(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>) { + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt index f5cd706e0..f0062da9f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt @@ -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 { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt index 7e06c54ed..c80ef0cb9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt @@ -59,7 +59,7 @@ fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewMode is UserFeedState.Loaded -> { FeedLoaded(state, accountViewModel, navController) } - UserFeedState.Loading -> { + is UserFeedState.Loading -> { LoadingFeed() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index cc89bff06..d28e5ebb2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index dac3962b9..29408a543 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -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()