Groups zaps in profile by user

Moves author's zap cache to the Profile viewmodel
Redesigns composables to match.
Updates Notifications to not use the old zap cache.
This commit is contained in:
Vitor Pamplona
2026-02-07 17:52:26 -05:00
parent 452d549e43
commit 838fb3ed7e
25 changed files with 382 additions and 807 deletions
@@ -98,8 +98,6 @@ fun debugState(context: Context) {
Log.d(
STATE_DUMP_TAG,
"Users: " +
LocalCache.users.filter { _, it -> it.flowSet != null }.size +
" / " +
LocalCache.users.filter { _, it -> it.metadataOrNull() != null }.size +
" / " +
LocalCache.users.size(),
@@ -1852,13 +1852,11 @@ object LocalCache : ILocalCache, ICacheProvider {
}
val author = getOrCreateUser(event.pubKey)
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = computeReplyTo(event)
note.loadEvent(event, author, repliesTo)
repliesTo.forEach { it.addZap(zapRequest, note) }
mentions.forEach { it.addZap(zapRequest, note) }
refreshNewNoteObservers(note)
@@ -1886,7 +1884,6 @@ object LocalCache : ILocalCache, ICacheProvider {
note.loadEvent(event, author, repliesTo)
repliesTo.forEach { it.addZap(note, null) }
mentions.forEach { it.addZap(note, null) }
refreshNewNoteObservers(note)
@@ -2365,7 +2362,6 @@ object LocalCache : ILocalCache, ICacheProvider {
fun cleanObservers() {
notes.forEach { _, it -> it.clearFlow() }
addressables.forEach { _, it -> it.clearFlow() }
users.forEach { _, it -> it.clearFlow() }
}
fun pruneHiddenMessagesChannel(
@@ -2559,18 +2555,6 @@ object LocalCache : ILocalCache, ICacheProvider {
val noteEvent = note.event
if (noteEvent is LnZapEvent) {
noteEvent.zappedAuthor().forEach {
val author = getUserIfExists(it)
author?.removeZap(note)
}
}
if (noteEvent is LnZapRequestEvent) {
noteEvent.zappedAuthor().mapNotNull {
val author = getUserIfExists(it)
author?.removeZap(note)
}
}
if (noteEvent is ReportEvent) {
noteEvent.reportedAuthor().forEach {
getUserIfExists(it.pubkey)?.reportsOrNull()?.removeReport(note)
@@ -2792,7 +2776,7 @@ object LocalCache : ILocalCache, ICacheProvider {
return justConsumeAndUpdateIndexes(event, relay?.url, wasVerified)
}
private fun checkDeletionAndConsume(
fun checkDeletionAndConsume(
event: Event,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
@@ -22,5 +22,3 @@ package com.vitorpamplona.amethyst.model
// Re-export from commons for backwards compatibility
typealias User = com.vitorpamplona.amethyst.commons.model.User
typealias RelayInfo = com.vitorpamplona.amethyst.commons.model.nip01Core.RelayInfo
typealias UserState = com.vitorpamplona.amethyst.commons.model.UserState
@@ -32,7 +32,6 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
@@ -51,7 +50,6 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.sample
import java.math.BigDecimal
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
@@ -178,37 +176,6 @@ fun observeUserPicture(
)
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeUserFollowCount(
user: User,
accountViewModel: AccountViewModel,
): State<Int> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
val note =
remember(user) {
accountViewModel.follows(user)
}
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
note
.flow()
.metadata.stateFlow
.mapLatest { noteState ->
(noteState.note.event as? ContactListEvent)?.followCount() ?: 0
}.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
return flow.collectAsStateWithLifecycle(
(note.event as? ContactListEvent)?.followCount() ?: 0,
)
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeUserTagFollowCount(
@@ -440,46 +407,6 @@ fun observeUserIsFollowingChannel(
return flow.collectAsStateWithLifecycle(channel.roomId in account.ephemeralChatList.liveEphemeralChatList.value)
}
@Composable
fun observeUserZaps(
user: User,
accountViewModel: AccountViewModel,
): State<UserState?> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.flow()
.zaps.stateFlow
.collectAsStateWithLifecycle()
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeUserZapAmount(
user: User,
accountViewModel: AccountViewModel,
): State<BigDecimal> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
.flow()
.zaps.stateFlow
.sample(1000)
.mapLatest { userState ->
userState.user.zappedAmount()
}.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
return flow.collectAsStateWithLifecycle(BigDecimal.ZERO)
}
@Composable
fun observeUserReports(
user: User,
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ButtonDefaults
@@ -24,15 +24,19 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenUser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserAboutMe
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowing
import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -42,9 +46,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.FollowButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ListButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ShowUserButton
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
fun UserCompose(
@@ -141,3 +145,18 @@ fun UserComposeNoAction(
}
}
}
@Composable
fun AboutDisplay(
baseAuthor: User,
accountViewModel: AccountViewModel,
) {
val aboutMe by observeUserAboutMe(baseAuthor, accountViewModel)
Text(
aboutMe,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@@ -1,234 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
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.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserAboutMe
import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ZapReqResponse
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun ZapNoteCompose(
baseReqResponse: ZapReqResponse,
accountViewModel: AccountViewModel,
nav: INav,
) {
val baseNoteRequest by observeNote(baseReqResponse.zapRequest, accountViewModel)
var baseAuthor by remember { mutableStateOf<User?>(baseReqResponse.zapRequest.author) }
LaunchedEffect(baseNoteRequest) {
accountViewModel.decryptAmountMessage(baseNoteRequest.note, baseReqResponse.zapEvent) {
baseAuthor = it?.user
}
}
if (baseAuthor == null) {
BlankNote()
} else {
Column(
modifier =
Modifier.clickable(
onClick = { baseAuthor?.let { nav.nav(routeFor(it)) } },
),
verticalArrangement = Arrangement.Center,
) {
baseAuthor?.let { RenderZapNoteSlim(it, baseReqResponse.zapEvent, accountViewModel, nav) }
}
}
}
@Preview
@Composable
fun RenderZapNotePreview() {
val accountViewModel = mockAccountViewModel()
val user1: User = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")
val note1: Note = LocalCache.getOrCreateNote("ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b")
ThemeComparisonColumn {
RenderZapNote(
user1,
note1,
accountViewModel,
EmptyNav(),
)
}
}
@Preview
@Composable
fun RenderZapNoteSlimPreview() {
val accountViewModel = mockAccountViewModel()
val user1: User = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")
val note1: Note = LocalCache.getOrCreateNote("ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b")
ThemeComparisonColumn {
RenderZapNoteSlim(
user1,
note1,
accountViewModel,
EmptyNav(),
)
}
}
@Composable
private fun RenderZapNote(
baseAuthor: User,
zapNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
ListItem(
leadingContent = {
UserPicture(baseAuthor, Size55dp, accountViewModel = accountViewModel, nav = nav)
},
headlineContent = {
UsernameDisplay(baseAuthor, accountViewModel = accountViewModel)
},
supportingContent = {
ZapAmount(zapNote, accountViewModel)
},
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
UserActionOptions(baseAuthor, accountViewModel, nav)
}
},
)
}
@Composable
private fun RenderZapNoteSlim(
baseAuthor: User,
zapNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
SlimListItem(
leadingContent = {
UserPicture(baseAuthor, Size55dp, accountViewModel = accountViewModel, nav = nav)
},
headlineContent = {
UsernameDisplay(baseAuthor, accountViewModel = accountViewModel)
},
supportingContent = {
ZapAmount(zapNote, accountViewModel)
},
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
UserActionOptions(baseAuthor, accountViewModel, nav)
}
},
)
}
@Composable
private fun ZapAmount(
zapEventNote: Note,
accountViewModel: AccountViewModel,
) {
val noteState by observeNote(zapEventNote, accountViewModel)
var zapAmount by remember { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = noteState) {
launch(Dispatchers.IO) {
val newZapAmount = showAmountInteger((noteState?.note?.event as? LnZapEvent)?.amount)
if (zapAmount != newZapAmount) {
zapAmount = newZapAmount
}
}
}
zapAmount?.let {
Text(
text = it,
color = BitcoinOrange,
fontSize = 20.sp,
fontWeight = FontWeight.W500,
)
}
}
@Composable
fun AboutDisplayNoFormat(
baseAuthor: User,
accountViewModel: AccountViewModel,
) {
val aboutMe by observeUserAboutMe(baseAuthor, accountViewModel)
Text(
aboutMe,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
fun AboutDisplay(
baseAuthor: User,
accountViewModel: AccountViewModel,
) {
val aboutMe by observeUserAboutMe(baseAuthor, accountViewModel)
Text(
aboutMe,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCache.getNoteIfExists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.BundledInsert
@@ -201,15 +202,22 @@ class CardFeedContentState(
LocalCache.getUserIfExists(it) // don't create user if it doesn't exist
}
if (author != null) {
val zapRequest =
author.zaps
.filter { it.value == zapEvent }
.keys
.firstOrNull()
val existingZapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) }
if (existingZapRequest == null || existingZapRequest.event == null) {
// tries to add it
event.zapRequest?.let {
LocalCache.checkDeletionAndConsume(it, null, false)
}
}
val zapRequest = event.zapRequest
if (zapRequest != null) {
zapsPerUser
.getOrPut(author, { mutableListOf() })
.add(CombinedZap(zapRequest, zapEvent))
val zapRequestNote = LocalCache.getNoteIfExists(zapRequest.id)
if (zapRequestNote != null) {
zapsPerUser
.getOrPut(author, { mutableListOf() })
.add(CombinedZap(zapRequestNote, zapEvent))
}
}
}
}
@@ -93,7 +93,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.TabReports
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.TabReceivedZaps
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ZapTabHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal.UserProfileZapsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal.UserProfileZapsViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size8dp
@@ -169,13 +169,11 @@ fun PrepareViewModels(
factory = UserAppRecommendationsFeedViewModel.Factory(baseUser),
)
val zapFeedViewModel: UserProfileZapsFeedViewModel =
val zapFeedViewModel: UserProfileZapsViewModel =
viewModel(
key = baseUser.pubkeyHex + "UserProfileZapsFeedViewModel",
factory =
UserProfileZapsFeedViewModel.Factory(
baseUser,
),
UserProfileZapsViewModel.Factory(baseUser, accountViewModel.account),
)
val threadsViewModel: UserProfileNewThreadsFeedViewModel =
@@ -253,7 +251,7 @@ fun ProfileScreen(
followsFeedViewModel: UserProfileFollowsUserFeedViewModel,
followersFeedViewModel: UserProfileFollowersUserFeedViewModel,
appRecommendations: UserAppRecommendationsFeedViewModel,
zapFeedViewModel: UserProfileZapsFeedViewModel,
zapFeedViewModel: UserProfileZapsViewModel,
bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel,
galleryFeedViewModel: UserProfileGalleryFeedViewModel,
reportsFeedViewModel: UserProfileReportFeedViewModel,
@@ -374,7 +372,7 @@ private fun RenderScreen(
appRecommendations: UserAppRecommendationsFeedViewModel,
followsFeedViewModel: UserProfileFollowsUserFeedViewModel,
followersFeedViewModel: UserProfileFollowersUserFeedViewModel,
zapFeedViewModel: UserProfileZapsFeedViewModel,
zapFeedViewModel: UserProfileZapsViewModel,
bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel,
galleryFeedViewModel: UserProfileGalleryFeedViewModel,
reportsFeedViewModel: UserProfileReportFeedViewModel,
@@ -396,6 +394,15 @@ private fun RenderScreen(
CreateAndRenderTabs(
baseUser,
pagerState,
threadsViewModel,
repliesViewModel,
mutualViewModel,
followsFeedViewModel,
followersFeedViewModel,
zapFeedViewModel,
bookmarksFeedViewModel,
galleryFeedViewModel,
reportsFeedViewModel,
accountViewModel,
)
}
@@ -431,7 +438,7 @@ private fun CreateAndRenderPages(
mutualViewModel: UserProfileMutualFeedViewModel,
followsFeedViewModel: UserProfileFollowsUserFeedViewModel,
followersFeedViewModel: UserProfileFollowersUserFeedViewModel,
zapFeedViewModel: UserProfileZapsFeedViewModel,
zapFeedViewModel: UserProfileZapsViewModel,
bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel,
galleryFeedViewModel: UserProfileGalleryFeedViewModel,
reportsFeedViewModel: UserProfileReportFeedViewModel,
@@ -479,6 +486,15 @@ fun UpdateThreadsAndRepliesWhenBlockUnblock(
private fun CreateAndRenderTabs(
baseUser: User,
pagerState: PagerState,
threadsViewModel: UserProfileNewThreadsFeedViewModel,
repliesViewModel: UserProfileConversationsFeedViewModel,
mutualViewModel: UserProfileMutualFeedViewModel,
followsFeedViewModel: UserProfileFollowsUserFeedViewModel,
followersFeedViewModel: UserProfileFollowersUserFeedViewModel,
zapFeedViewModel: UserProfileZapsViewModel,
bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel,
galleryFeedViewModel: UserProfileGalleryFeedViewModel,
reportsFeedViewModel: UserProfileReportFeedViewModel,
accountViewModel: AccountViewModel,
) {
val coroutineScope = rememberCoroutineScope()
@@ -489,9 +505,9 @@ private fun CreateAndRenderTabs(
{ Text(text = stringRes(R.string.replies)) },
{ Text(text = stringRes(R.string.mutual)) },
{ Text(text = stringRes(R.string.gallery)) },
{ FollowTabHeader(baseUser, accountViewModel) },
{ FollowersTabHeader(baseUser, accountViewModel) },
{ ZapTabHeader(baseUser, accountViewModel) },
{ FollowTabHeader(followsFeedViewModel, accountViewModel) },
{ FollowersTabHeader(baseUser, followersFeedViewModel, accountViewModel) },
{ ZapTabHeader(zapFeedViewModel, accountViewModel) },
{ BookmarkTabHeader(baseUser, accountViewModel) },
{ FollowedTagsTabHeader(baseUser, accountViewModel) },
{ ReportsTabHeader(baseUser, accountViewModel) },
@@ -23,18 +23,35 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsFollowerCount
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.dal.UserProfileFollowersUserFeedViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun FollowersTabHeader(
baseUser: User,
followersFeedViewModel: UserProfileFollowersUserFeedViewModel,
accountViewModel: AccountViewModel,
) {
val followerCount by observeUserContactCardsFollowerCount(baseUser, accountViewModel)
if (followerCount == "--") {
FollowersTabHeaderLocal(followersFeedViewModel, accountViewModel)
} else {
Text(text = stringRes(R.string.number_followers, followerCount))
}
}
@Composable
fun FollowersTabHeaderLocal(
followersFeedViewModel: UserProfileFollowersUserFeedViewModel,
accountViewModel: AccountViewModel,
) {
val followerCount by followersFeedViewModel.followerCount.collectAsStateWithLifecycle()
Text(text = stringRes(R.string.number_followers, followerCount))
}
@@ -76,6 +76,16 @@ class UserProfileFollowersUserFeedViewModel(
started = SharingStarted.Lazily,
)
val followerCount =
followersFlow
.map { it.size }
.flowOn(Dispatchers.IO)
.stateIn(
viewModelScope,
initialValue = 0,
started = SharingStarted.Lazily,
)
class Factory(
val user: User,
val account: Account,
@@ -23,18 +23,18 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollowCount
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows.dal.UserProfileFollowsUserFeedViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun FollowTabHeader(
baseUser: User,
followsFeedViewModel: UserProfileFollowsUserFeedViewModel,
accountViewModel: AccountViewModel,
) {
val followCount by observeUserFollowCount(baseUser, accountViewModel)
val followCount by followsFeedViewModel.followCount.collectAsStateWithLifecycle()
val text =
if (followCount > 0) {
@@ -28,9 +28,9 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenUser
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ShowUserButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ListButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ShowUserButton
@Composable
fun ProfileActions(
@@ -80,7 +80,6 @@ class RelayFeedViewModel :
.map { relay ->
val info = relayState?.get(relay)
if (info != null) {
println("AABBCC convert() relays: ${relay.url} ${info.counter}")
MyRelayInfo(relay, info.lastEvent, info.counter)
} else {
MyRelayInfo(relay, 0, 0)
@@ -1,47 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.Note
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
@Immutable data class ZapReqResponse(
val zapRequest: Note,
val zapEvent: Note,
)
@Stable
sealed class LnZapFeedState {
object Loading : LnZapFeedState()
class Loaded(
val feed: MutableStateFlow<ImmutableList<ZapReqResponse>>,
) : LnZapFeedState()
object Empty : LnZapFeedState()
class FeedError(
val errorMessage: String,
) : LnZapFeedState()
}
@@ -1,85 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps
import androidx.compose.animation.core.tween
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ZapNoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@Composable
fun LnZapFeedView(
viewModel: LnZapFeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
CrossfadeIfEnabled(targetState = feedState, animationSpec = tween(durationMillis = 100), accountViewModel = accountViewModel) { state ->
when (state) {
is LnZapFeedState.Empty -> {
FeedEmpty { viewModel.invalidateData() }
}
is LnZapFeedState.FeedError -> {
FeedError(state.errorMessage) { viewModel.invalidateData() }
}
is LnZapFeedState.Loaded -> {
LnZapFeedLoaded(state, accountViewModel, nav)
}
is LnZapFeedState.Loading -> {
LoadingFeed()
}
}
}
}
@Composable
private fun LnZapFeedLoaded(
state: LnZapFeedState.Loaded,
accountViewModel: AccountViewModel,
nav: INav,
) {
val items by state.feed.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
LazyColumn(
contentPadding = FeedPadding,
state = listState,
) {
itemsIndexed(items, key = { _, item -> item.zapEvent.idHex }) { _, item ->
ZapNoteCompose(item, accountViewModel = accountViewModel, nav = nav)
HorizontalDivider(thickness = DividerThickness)
}
}
}
@@ -1,106 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.BundledUpdate
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@Stable
open class LnZapFeedViewModel(
val dataSource: FeedFilter<ZapReqResponse>,
) : ViewModel() {
private val _feedContent = MutableStateFlow<LnZapFeedState>(LnZapFeedState.Loading)
val feedContent = _feedContent.asStateFlow()
private fun refresh() {
viewModelScope.launch(Dispatchers.IO) { refreshSuspended() }
}
private fun refreshSuspended() {
checkNotInMainThread()
val notes = dataSource.loadTop().toImmutableList()
val oldNotesState = _feedContent.value
if (oldNotesState is LnZapFeedState.Loaded) {
// Using size as a proxy for has changed.
if (!equalImmutableLists(notes, oldNotesState.feed.value)) {
updateFeed(notes)
}
} else {
updateFeed(notes)
}
}
private fun updateFeed(notes: ImmutableList<ZapReqResponse>) {
val currentState = _feedContent.value
if (notes.isEmpty()) {
_feedContent.tryEmit(LnZapFeedState.Empty)
} else if (currentState is LnZapFeedState.Loaded) {
// updates the current list
currentState.feed.tryEmit(notes)
} else {
_feedContent.tryEmit(LnZapFeedState.Loaded(MutableStateFlow(notes)))
}
}
private val bundler = BundledUpdate(250, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
}
}
init {
Log.d("Init", "${this.javaClass.simpleName}")
viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
invalidateData()
}
}
viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.deletedEventBundles.collect { newNotes ->
invalidateData()
}
}
}
override fun onCleared() {
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
bundler.cancel()
super.onCleared()
}
}
@@ -22,23 +22,39 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal.UserProfileZapsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal.UserProfileZapsViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@Composable
fun TabReceivedZaps(
baseUser: User,
zapFeedViewModel: UserProfileZapsFeedViewModel,
zapFeedViewModel: UserProfileZapsViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchZapsAndUpdateFeed(baseUser, zapFeedViewModel, accountViewModel)
Column(Modifier.fillMaxHeight()) {
LnZapFeedView(zapFeedViewModel, accountViewModel, nav)
val feedState by zapFeedViewModel.receivedZapAmountsByUser.collectAsStateWithLifecycle()
LazyColumn(
contentPadding = FeedPadding,
state = rememberLazyListState(),
) {
itemsIndexed(feedState, key = { _, item -> item.user.pubkeyHex }) { _, item ->
ZapNoteCompose(item, accountViewModel = accountViewModel, nav = nav)
HorizontalDivider(thickness = DividerThickness)
}
}
}
}
@@ -1,40 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserZaps
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal.UserProfileZapsFeedViewModel
@Composable
fun WatchZapsAndUpdateFeed(
baseUser: User,
feedViewModel: UserProfileZapsFeedViewModel,
accountViewModel: AccountViewModel,
) {
val userState by observeUserZaps(baseUser, accountViewModel)
LaunchedEffect(userState) { feedViewModel.invalidateData() }
}
@@ -0,0 +1,115 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps
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.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.UserActionOptions
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal.ZapAmount
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.utils.BigDecimal
@Composable
fun ZapNoteCompose(
zap: ZapAmount,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column(
modifier =
Modifier.clickable(
onClick = { zap.user.let { nav.nav(routeFor(it)) } },
),
verticalArrangement = Arrangement.Center,
) {
RenderZapNoteSlim(zap, accountViewModel, nav)
}
}
@Preview
@Composable
fun RenderZapNoteSlimPreview() {
val accountViewModel = mockAccountViewModel()
val user1: User = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")
ThemeComparisonColumn {
RenderZapNoteSlim(
ZapAmount(user1, BigDecimal.TEN),
accountViewModel,
EmptyNav(),
)
}
}
@Composable
private fun RenderZapNoteSlim(
zap: ZapAmount,
accountViewModel: AccountViewModel,
nav: INav,
) {
SlimListItem(
leadingContent = {
UserPicture(zap.user, Size55dp, accountViewModel = accountViewModel, nav = nav)
},
headlineContent = {
UsernameDisplay(zap.user, accountViewModel = accountViewModel)
},
supportingContent = {
Text(
text = remember { showAmountInteger(zap.amount) },
color = BitcoinOrange,
fontSize = 20.sp,
fontWeight = FontWeight.W500,
)
},
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
UserActionOptions(zap.user, accountViewModel, nav)
}
},
)
}
@@ -23,19 +23,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserZapAmount
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal.UserProfileZapsViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun ZapTabHeader(
baseUser: User,
zapsViewModel: UserProfileZapsViewModel,
accountViewModel: AccountViewModel,
) {
val zapAmount by observeUserZapAmount(baseUser, accountViewModel)
val zapAmount by zapsViewModel.totalReceivedZaps.collectAsStateWithLifecycle()
Text(text = "${showAmountInteger(zapAmount)} ${stringRes(id = R.string.zaps)}")
}
@@ -1,50 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ZapReqResponse
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
class UserProfileZapsFeedFilter(
val user: User,
) : FeedFilter<ZapReqResponse>() {
override fun feedKey(): String = user.pubkeyHex
override fun feed(): List<ZapReqResponse> = forProfileFeed(user.zaps)
override fun limit() = 400
companion object {
fun forProfileFeed(zaps: Map<Note, Note?>?): List<ZapReqResponse> {
if (zaps == null) return emptyList()
return (
zaps
.mapNotNull { entry -> entry.value?.let { ZapReqResponse(entry.key, it) } }
.sortedBy { (it.zapEvent.event as? LnZapEvent)?.amount() }
.reversed()
)
}
}
}
@@ -1,37 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.LnZapFeedViewModel
class UserProfileZapsFeedViewModel(
user: User,
) : LnZapFeedViewModel(UserProfileZapsFeedFilter(user)) {
class Factory(
val user: User,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = UserProfileZapsFeedViewModel(user) as T
}
}
@@ -0,0 +1,145 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.utils.BigDecimal
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.stateIn
@Immutable
data class ZapAmount(
val user: User,
val amount: BigDecimal,
)
@Stable
class UserProfileZapsViewModel(
val user: User,
val account: Account,
) : ViewModel() {
val zapsToUser =
Filter(
kinds = listOf(LnZapEvent.KIND),
tags = mapOf("p" to listOf(user.pubkeyHex)),
)
val sortingModel: Comparator<ZapAmount> =
compareByDescending<ZapAmount> { it.amount }.thenBy { it.user.pubkeyHex }
suspend fun mapRequest(zapEvent: LnZapEvent): ZapAmount? {
val zapRequest =
zapEvent.zapRequest ?: return ZapAmount(
LocalCache.getOrCreateUser(zapEvent.pubKey),
zapEvent.amount ?: BigDecimal.ZERO,
)
return if (zapRequest.isPrivateZap()) {
// if user is not the logged in, we cannot decrypt.
if (user.pubkeyHex == account.pubKey) {
val cachedPrivateRequest = account.privateZapsDecryptionCache.decryptPrivateZap(zapRequest)
if (cachedPrivateRequest != null) {
ZapAmount(
LocalCache.getOrCreateUser(cachedPrivateRequest.pubKey),
zapEvent.amount ?: BigDecimal.ZERO,
)
} else {
ZapAmount(
LocalCache.getOrCreateUser(zapRequest.pubKey),
zapEvent.amount ?: BigDecimal.ZERO,
)
}
} else {
ZapAmount(
LocalCache.getOrCreateUser(zapRequest.pubKey),
zapEvent.amount ?: BigDecimal.ZERO,
)
}
} else {
ZapAmount(
LocalCache.getOrCreateUser(zapRequest.pubKey),
zapEvent.amount ?: BigDecimal.ZERO,
)
}
}
suspend fun List<Event>.sumAmountsByUser(): List<ZapAmount> {
val results = mutableMapOf<User, BigDecimal>()
this.forEach { zapEvent ->
if (zapEvent is LnZapEvent) {
val zapAmount = mapRequest(zapEvent)
if (zapAmount != null) {
val existingAmount = results[zapAmount.user] ?: BigDecimal.ZERO
results[zapAmount.user] = existingAmount + zapAmount.amount
}
}
}
return results.map { (user, amount) -> ZapAmount(user, amount) }.sortedWith(sortingModel)
}
val receivedZapAmountsByUser: StateFlow<List<ZapAmount>> =
account.cache
.observeEvents(zapsToUser)
.sample(500)
.map { zapEvents ->
zapEvents.sumAmountsByUser()
}.flowOn(Dispatchers.IO)
.stateIn(
viewModelScope,
initialValue = emptyList(),
started = SharingStarted.Lazily,
)
val totalReceivedZaps =
receivedZapAmountsByUser
.map { it.sumOf { it.amount } }
.flowOn(Dispatchers.IO)
.stateIn(
viewModelScope,
initialValue = BigDecimal.ZERO,
started = SharingStarted.Lazily,
)
class Factory(
val user: User,
val account: Account,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = UserProfileZapsViewModel(user, account) as T
}
}
@@ -35,11 +35,9 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Hex
import java.math.BigDecimal
interface UserDependencies
@@ -58,11 +56,6 @@ class User(
private var status: UserStatusCache? = null
private var relays: UserRelaysCache? = null
var zaps = mapOf<Note, Note?>()
private set
var flowSet: UserFlowSet? = null
fun pubkey() = Hex.decode(pubkeyHex)
fun pubkeyNpub() = pubkey().toNpub()
@@ -101,38 +94,6 @@ class User(
fun lnAddress(): String? = metadataOrNull()?.lnAddress()
fun addZap(
zapRequest: Note,
zap: Note?,
) {
if (zaps[zapRequest] == null) {
zaps = zaps + Pair(zapRequest, zap)
flowSet?.zaps?.invalidateData()
}
}
fun removeZap(zapRequestOrZapEvent: Note) {
if (zaps.containsKey(zapRequestOrZapEvent)) {
zaps = zaps.minus(zapRequestOrZapEvent)
flowSet?.zaps?.invalidateData()
} else if (zaps.containsValue(zapRequestOrZapEvent)) {
zaps = zaps.filter { it.value != zapRequestOrZapEvent }
flowSet?.zaps?.invalidateData()
}
}
fun zappedAmount(): BigDecimal {
var amount = BigDecimal.ZERO
zaps.forEach {
val itemValue = (it.value?.event as? LnZapEvent)?.amount
if (itemValue != null) {
amount += itemValue
}
}
return amount
}
fun addRelayBeingUsed(
relay: NormalizedRelayUrl,
eventTime: Long,
@@ -186,47 +147,8 @@ class User(
return metadataOrNull()?.containsAny(hiddenWordsCase) == true
}
@Synchronized
fun createOrDestroyFlowSync(create: Boolean) {
if (create) {
if (flowSet == null) {
flowSet = UserFlowSet(this)
}
} else {
if (flowSet != null && flowSet?.isInUse() == false) {
flowSet = null
}
}
}
fun flow(): UserFlowSet {
if (flowSet == null) {
createOrDestroyFlowSync(true)
}
return flowSet!!
}
fun clearFlow() {
if (flowSet != null && flowSet?.isInUse() == false) {
createOrDestroyFlowSync(false)
}
}
}
@Stable
class UserFlowSet(
u: User,
) {
val zaps = UserBundledRefresherFlow(u)
fun isInUse(): Boolean = zaps.hasObservers()
}
// Re-export from commons.state for backwards compatibility
typealias UserBundledRefresherFlow = com.vitorpamplona.amethyst.commons.state.UserMetadataState
typealias UserState = com.vitorpamplona.amethyst.commons.state.UserState
fun Set<User>.toHexSet() = mapTo(LinkedHashSet(size)) { it.pubkeyHex }
fun Set<User>.toSortedHexes() = map { it.pubkeyHex }.sorted()