Simplifies report feed in the user profile

This commit is contained in:
Vitor Pamplona
2026-03-24 18:30:52 -04:00
parent be4a6d4fc4
commit fb976d168a
7 changed files with 93 additions and 137 deletions
@@ -45,7 +45,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.sample
@@ -426,41 +425,6 @@ fun observeUserIsFollowingChannel(
return flow.collectAsStateWithLifecycle(channel.roomId in account.ephemeralChatList.liveEphemeralChatList.value)
}
@Composable
fun observeUserReports(
user: User,
accountViewModel: AccountViewModel,
onUpdate: () -> Unit,
) {
// 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, onUpdate) {
user
.reports()
.receivedReportsByAuthor
.onEach { onUpdate() }
.onStart { onUpdate() }
}.collectAsStateWithLifecycle(emptyMap())
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeUserReportCount(
user: User,
accountViewModel: AccountViewModel,
): State<Int> {
// 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.reports().countFlow() }
return flow.collectAsStateWithLifecycle(0)
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeUserContactCardsScore(
@@ -229,6 +229,7 @@ fun PrepareViewModels(
factory =
UserProfileReportFeedViewModel.Factory(
baseUser,
accountViewModel.account,
),
)
@@ -273,7 +274,6 @@ fun ProfileScreen(
WatchLifecycleAndUpdateModel(appRecommendations)
WatchLifecycleAndUpdateModel(bookmarksFeedViewModel)
WatchLifecycleAndUpdateModel(galleryFeedViewModel)
WatchLifecycleAndUpdateModel(reportsFeedViewModel)
UserProfileFilterAssemblerSubscription(baseUser, accountViewModel.dataSources().profile)
@@ -521,7 +521,7 @@ private fun CreateAndRenderTabs(
{ ZapTabHeader(zapFeedViewModel, accountViewModel) },
{ BookmarkTabHeader(baseUser, accountViewModel) },
{ FollowedTagsTabHeader(baseUser, accountViewModel) },
{ ReportsTabHeader(baseUser, accountViewModel) },
{ ReportsTabHeader(baseUser, reportsFeedViewModel, accountViewModel) },
{ RelaysTabHeader(baseUser, accountViewModel) },
)
@@ -23,18 +23,20 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports
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.observeUserReportCount
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun ReportsTabHeader(
baseUser: User,
reportsFeedViewModel: UserProfileReportFeedViewModel,
accountViewModel: AccountViewModel,
) {
val reportCount by observeUserReportCount(baseUser, accountViewModel)
val reportCount by reportsFeedViewModel.followerCount.collectAsStateWithLifecycle()
if (reportCount > 0) {
Text(text = stringRes(R.string.number_reports, reportCount))
@@ -21,14 +21,25 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@Composable
fun TabReports(
@@ -37,15 +48,36 @@ fun TabReports(
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchReportsAndUpdateFeed(baseUser, feedViewModel, accountViewModel)
Column(Modifier.fillMaxHeight()) {
RefresheableFeedView(
feedViewModel,
null,
enablePullRefresh = false,
accountViewModel = accountViewModel,
nav = nav,
)
val items by feedViewModel.followersFlow.collectAsStateWithLifecycle()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = FeedPadding,
state = rememberLazyListState(),
) {
itemsIndexed(
items,
key = { _, item -> item.idHex },
contentType = { _, item -> item.event?.kind ?: -1 },
) { _, item ->
Row(Modifier.fillMaxWidth().animateItem()) {
NoteCompose(
item,
modifier = Modifier.fillMaxWidth(),
routeForLastRead = null,
isBoostedNote = false,
isHiddenFeed = false,
quotesLeft = 3,
accountViewModel = accountViewModel,
nav = nav,
)
}
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
}
@@ -1,36 +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.reports
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserReports
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel
@Composable
fun WatchReportsAndUpdateFeed(
baseUser: User,
feedViewModel: UserProfileReportFeedViewModel,
accountViewModel: AccountViewModel,
) {
observeUserReports(baseUser, accountViewModel) { feedViewModel.invalidateData() }
}
@@ -23,17 +23,59 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal
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.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
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
@Stable
class UserProfileReportFeedViewModel(
val user: User,
) : AndroidFeedViewModel(UserProfileReportsFeedFilter(user)) {
val account: Account,
) : ViewModel() {
val sortingModel: Comparator<Note> =
compareBy(
{ it.author?.let { !account.isFollowing(it) } },
{ it.idHex },
)
@OptIn(kotlinx.coroutines.FlowPreview::class)
val followersFlow: StateFlow<List<Note>> =
user
.reports()
.receivedReportsByAuthor
.map {
it.values.flatten().sortedWith(sortingModel)
}.sample(500)
.flowOn(Dispatchers.IO)
.stateIn(
viewModelScope,
initialValue = emptyList(),
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,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = UserProfileReportFeedViewModel(user) as T
override fun <T : ViewModel> create(modelClass: Class<T>): T = UserProfileReportFeedViewModel(user, account) as T
}
}
@@ -1,48 +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.reports.dal
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
class UserProfileReportsFeedFilter(
val user: User,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = user.pubkeyHex
override fun feed(): List<Note> = sort(innerApplyFilter(user.reportsOrNull()?.all() ?: emptyList()))
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> =
collection
.filterTo(mutableSetOf()) {
it.event is ReportEvent && it.event?.isTaggedUser(user.pubkeyHex) == true
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
override fun limit() = 400
}